Initial commit
This commit is contained in:
0
qbit_ci/__init__.py
Normal file
0
qbit_ci/__init__.py
Normal file
53
qbit_ci/__main__.py
Normal file
53
qbit_ci/__main__.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import typing
|
||||
|
||||
import dotenv
|
||||
import qbittorrentapi
|
||||
import yaml
|
||||
dotenv.load_dotenv()
|
||||
from qbit_ci.torrent_dict import TorrentStateStore
|
||||
from qbit_ci.pipeline import Pipeline
|
||||
|
||||
|
||||
async def real_main(_: typing.Sequence[str]):
|
||||
with open(".qbit-ci.yaml", mode="r", encoding="utf8") as stream:
|
||||
pipeline_cfgs = [*yaml.load_all(stream, yaml.FullLoader)]
|
||||
|
||||
client = qbittorrentapi.Client(
|
||||
host=os.getenv('QBIT_HOST', 'localhost'),
|
||||
port=int(os.getenv('QBIT_PORT', '8080')),
|
||||
username=os.getenv('QBIT_USERNAME', 'admin'),
|
||||
password=os.getenv('QBIT_PASSWORD', 'adminadmin')
|
||||
)
|
||||
|
||||
client.auth_log_in()
|
||||
|
||||
pipelines: typing.List[Pipeline] = []
|
||||
for cfg in pipeline_cfgs:
|
||||
if cfg["type"] == "pipeline":
|
||||
pipelines.append(Pipeline(cfg))
|
||||
|
||||
torrent_dict = TorrentStateStore()
|
||||
|
||||
while True:
|
||||
for torrent in client.torrents_info():
|
||||
torrent: qbittorrentapi.TorrentDictionary
|
||||
changes = torrent_dict.update(torrent)
|
||||
|
||||
for pipeline in pipelines:
|
||||
pipeline.execute(torrent, changes)
|
||||
|
||||
time.sleep(10)
|
||||
|
||||
|
||||
|
||||
def main(args: typing.Sequence[str]) -> int:
|
||||
asyncio.run(real_main(args))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
10
qbit_ci/change_map.py
Normal file
10
qbit_ci/change_map.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import typing
|
||||
|
||||
class ChangeMap:
|
||||
def __init__(self, changes: typing.Dict[typing.Any, typing.Any]) -> None:
|
||||
self._changes: typing.Dict[typing.Any, typing.Any] = changes
|
||||
|
||||
def __getattr__(self, __name: str) -> typing.Any:
|
||||
return self._changes.get(__name)
|
||||
|
||||
__all__ = ("ChangeMap",)
|
4
qbit_ci/errors.py
Normal file
4
qbit_ci/errors.py
Normal file
@@ -0,0 +1,4 @@
|
||||
class PipelineNonZeroExit(Exception):
|
||||
pass
|
||||
|
||||
__all__ = ("PipelineNonZeroExit",)
|
34
qbit_ci/pipeline.py
Normal file
34
qbit_ci/pipeline.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import typing
|
||||
|
||||
import qbittorrentapi
|
||||
|
||||
from qbit_ci.change_map import ChangeMap
|
||||
from qbit_ci.errors import PipelineNonZeroExit
|
||||
from qbit_ci.pipeline_step import CommandStep
|
||||
from qbit_ci.pipeline_step import expr_to_step_condition
|
||||
from qbit_ci.pipeline_step import GenericStep
|
||||
|
||||
|
||||
AnyMap = typing.Mapping[str, typing.Any]
|
||||
|
||||
class Pipeline:
|
||||
name: str
|
||||
steps: typing.List[GenericStep] = []
|
||||
|
||||
def __init__(self, pipeline_config: AnyMap) -> None:
|
||||
self.name = pipeline_config['name']
|
||||
for step in pipeline_config['steps']:
|
||||
conds = [*map(expr_to_step_condition, step["when"])]
|
||||
self.steps.append(CommandStep(step["name"], step["commands"], conds))
|
||||
|
||||
def execute(self, torrent: qbittorrentapi.TorrentDictionary, changes: ChangeMap):
|
||||
state = {"torrent": torrent, "changes": changes}
|
||||
for step in self.steps:
|
||||
if not step.should_invoke(state):
|
||||
continue
|
||||
|
||||
exit_code = step.invoke(state)
|
||||
if exit_code != 0:
|
||||
raise PipelineNonZeroExit("Someone fucked up")
|
||||
|
||||
__all__ = ("Pipeline",)
|
49
qbit_ci/pipeline_step.py
Normal file
49
qbit_ci/pipeline_step.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import shlex
|
||||
import subprocess as sp
|
||||
import typing
|
||||
from dataclasses import dataclass
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
from qbit_ci.template import path_exists
|
||||
from qbit_ci.template import template_env
|
||||
|
||||
StateMap = typing.Mapping[str, typing.Any]
|
||||
StepCondition = typing.Callable[[StateMap], bool]
|
||||
|
||||
def expr_to_step_condition(expr: str) -> StepCondition:
|
||||
def inner(state_map: StateMap) -> bool:
|
||||
templ: Template = template_env.from_string(expr)
|
||||
result = templ.render(state_map)
|
||||
return result.lower() == "true"
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
class GenericStep(typing.Protocol):
|
||||
name: str
|
||||
|
||||
def should_invoke(self, state_map: StateMap) -> bool:
|
||||
...
|
||||
|
||||
def invoke(self, state_map: StateMap) -> int:
|
||||
...
|
||||
|
||||
@dataclass
|
||||
class CommandStep:
|
||||
name: str
|
||||
commands: typing.Sequence[str]
|
||||
conditions: typing.Sequence[StepCondition]
|
||||
|
||||
def should_invoke(self, state_map: StateMap) -> bool:
|
||||
return all(cond(state_map) for cond in self.conditions)
|
||||
|
||||
|
||||
def invoke(self, state_map: StateMap) -> int:
|
||||
cmd_fmt = " && ".join(self.commands)
|
||||
cmd_template: Template = template_env.from_string(cmd_fmt)
|
||||
cmd = cmd_template.render(state_map)
|
||||
resp = sp.Popen(shlex.split(cmd, posix=True))
|
||||
return resp.wait()
|
||||
|
||||
__all__ = ("expr_to_step_condition", "GenericStep", "CommandStep", "StepCondition")
|
18
qbit_ci/template.py
Normal file
18
qbit_ci/template.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import os
|
||||
|
||||
from jinja2 import BaseLoader
|
||||
from jinja2 import Environment
|
||||
|
||||
MNT_PREFIX = os.getenv("MNT_PREFIX", ".tmp")
|
||||
|
||||
def real_path(path: str):
|
||||
return os.path.join(MNT_PREFIX, path[1:])
|
||||
|
||||
def path_exists(path: str):
|
||||
return os.path.exists(path)
|
||||
|
||||
template_env = Environment(loader=BaseLoader())
|
||||
template_env.filters["real_path"] = real_path
|
||||
template_env.filters["path_exists"] = path_exists
|
||||
|
||||
__all__ = ("real_path", "path_exists", "template_env")
|
23
qbit_ci/torrent_dict.py
Normal file
23
qbit_ci/torrent_dict.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import typing
|
||||
|
||||
from qbittorrentapi import TorrentDictionary
|
||||
|
||||
from qbit_ci.change_map import ChangeMap
|
||||
|
||||
class TorrentStateStore:
|
||||
_torrents: typing.Dict[str, typing.Any] = {}
|
||||
|
||||
def update(self, torrent: TorrentDictionary) -> ChangeMap:
|
||||
torrent_name: str = torrent.hash
|
||||
old_torrent: typing.Optional[TorrentDictionary] = self._torrents.get(torrent_name)
|
||||
changes: typing.Any
|
||||
if old_torrent:
|
||||
changes = old_torrent.items() ^ torrent.items()
|
||||
else:
|
||||
changes = torrent.items()
|
||||
|
||||
change_map = ChangeMap({k: v for k, v in changes})
|
||||
self._torrents[torrent_name] = torrent
|
||||
return change_map
|
||||
|
||||
__all__ = ("TorrentStateStore",)
|
Reference in New Issue
Block a user