Initial commit

This commit is contained in:
2022-10-02 19:25:41 +02:00
commit 03fd9ae389
17 changed files with 1151 additions and 0 deletions

0
qbit_ci/__init__.py Normal file
View File

53
qbit_ci/__main__.py Normal file
View 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
View 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
View File

@@ -0,0 +1,4 @@
class PipelineNonZeroExit(Exception):
pass
__all__ = ("PipelineNonZeroExit",)

34
qbit_ci/pipeline.py Normal file
View 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
View 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
View 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
View 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",)