From 5143dc3af380f65536ef624472e27ca8c86b65df Mon Sep 17 00:00:00 2001 From: vg Date: Fri, 16 Sep 2022 14:51:12 +0200 Subject: git-sync on seele --- Makefile | 3 + gamechestcli/__init__.py | 0 gamechestcli/cli.py | 1 - gamechestcli/extract.py | 109 ----------------- gamechestcli/gamechest/cli.py | 1 + gamechestcli/gamechest/cliactions/install.py | 31 +++++ gamechestcli/gamechest/cliactions/remove.py | 38 ++++++ gamechestcli/gamechest/cliactions/run.py | 0 gamechestcli/gamechest/config.py | 50 ++++++++ gamechestcli/gamechest/consts.py | 2 + gamechestcli/gamechest/gamedb.py | 16 +++ gamechestcli/gamechest/paths.py | 23 ++++ gamechestcli/gamechest/processor.py | 41 +++++++ gamechestcli/gamechest/runners/download.py | 68 +++++++++++ gamechestcli/gamechest/runners/extract.py | 96 +++++++++++++++ gamechestcli/gamechest/runners/install.py | 42 +++++++ gamechestcli/gamechest/runners/remove.py | 150 +++++++++++++++++++++++ gamechestcli/gamechest/runners/runnerbase.py | 44 +++++++ gamechestcli/gamechest/runners/runnermulti.py | 91 ++++++++++++++ gamechestcli/gamechest/statusdb.py | 93 ++++++++++++++ gamechestcli/gamechest/structures.py | 38 ++++++ gamechestcli/multitest.py | 10 -- gamechestcli/remove.py | 170 -------------------------- gamechestcli/requirements-dev.txt | 2 + gamechestcli/requirements.txt | 3 + gamechestcli/rsync.py | 82 ------------- gamechestcli/structures.py | 9 -- 27 files changed, 832 insertions(+), 381 deletions(-) create mode 100644 Makefile delete mode 100644 gamechestcli/__init__.py delete mode 100644 gamechestcli/cli.py delete mode 100644 gamechestcli/extract.py create mode 100644 gamechestcli/gamechest/cli.py create mode 100644 gamechestcli/gamechest/cliactions/install.py create mode 100644 gamechestcli/gamechest/cliactions/remove.py create mode 100644 gamechestcli/gamechest/cliactions/run.py create mode 100644 gamechestcli/gamechest/config.py create mode 100644 gamechestcli/gamechest/consts.py create mode 100644 gamechestcli/gamechest/gamedb.py create mode 100644 gamechestcli/gamechest/paths.py create mode 100644 gamechestcli/gamechest/processor.py create mode 100644 gamechestcli/gamechest/runners/download.py create mode 100644 gamechestcli/gamechest/runners/extract.py create mode 100644 gamechestcli/gamechest/runners/install.py create mode 100644 gamechestcli/gamechest/runners/remove.py create mode 100644 gamechestcli/gamechest/runners/runnerbase.py create mode 100644 gamechestcli/gamechest/runners/runnermulti.py create mode 100644 gamechestcli/gamechest/statusdb.py create mode 100644 gamechestcli/gamechest/structures.py delete mode 100644 gamechestcli/multitest.py delete mode 100644 gamechestcli/remove.py create mode 100644 gamechestcli/requirements-dev.txt delete mode 100644 gamechestcli/rsync.py delete mode 100644 gamechestcli/structures.py diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..567e21a --- /dev/null +++ b/Makefile @@ -0,0 +1,3 @@ +static-fix: + isort -rc gamechestcli + diff --git a/gamechestcli/__init__.py b/gamechestcli/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/gamechestcli/cli.py b/gamechestcli/cli.py deleted file mode 100644 index 8b13789..0000000 --- a/gamechestcli/cli.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/gamechestcli/extract.py b/gamechestcli/extract.py deleted file mode 100644 index 2ac7fde..0000000 --- a/gamechestcli/extract.py +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/python3 - -import os -import re -import subprocess - -import humanfriendly - -from structures import Progress - -class Extract: - - _progress_re = re.compile(r'^\s*(\S+)\s+\[([^]]+)/s\]\s+ETA\s+(\S+)\s*$') - - def __init__(self, src, dst): - common_parameters = dict( - encoding='utf8', - env={**os.environ, - **{'LC_ALL':'C.UTF-8', - 'LANG':'C.UTF-8', - 'LANGUAGE':'C.UTF-8', - }}, - ) - self.src_size = os.stat(src).st_size - self.pv_proc = subprocess.Popen( - [ - 'pv', - '--force', - '--format', '%b %a %e', - src, - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - **common_parameters, - ) - lzip_command = '/usr/bin/lzip' - plzip_command = '/usr/bin/plzip' - lzip_command_to_use = lzip_command - if os.path.exists(plzip_command): - lzip_command_to_use = plzip_command - self.zip_proc = subprocess.Popen( - [ - lzip_command_to_use, - '--decompress', - ], - stdin=self.pv_proc.stdout, - stdout=subprocess.PIPE, - **common_parameters, - ) - self.tar_proc = subprocess.Popen( - [ - 'tar', - '-C', dst, - '-xf', '-' - ], - stdin=self.zip_proc.stdout, - **common_parameters, - ) - self.last_progress = Progress() - - def select_fd(self): - 'useful to use selectors with the process most meaningful fd' - return self.pv_proc.stderr - - def progress_read(self): - line = self.pv_proc.stderr.readline() - if match := self._progress_re.search(line): - written_bytes = humanfriendly.parse_size(match.group(1)) - self.last_progress = Progress( - written_bytes, - int(100 * written_bytes / self.src_size), - humanfriendly.parse_size(match.group(2)), - match.group(3), - ) - return self.last_progress - - def terminate(self): - for proc in (self.pv_proc, self.zip_proc, self.tar_proc): - proc.terminate() - - def poll(self): - 'returns None if not terminated, otherwise return returncode' - return self.tar_proc.poll() - - def wait(self, timeout=None): - self.pv_proc.wait(timeout) - self.zip_proc.wait(timeout) - return self.tar_proc.wait(timeout) - - def __enter__(self): - return self - - def __exit__(self, exc_type, value, traceback): - self.terminate() - self.wait() - - -if __name__ == '__main__': - import sys - import contextlib - with contextlib.suppress(KeyboardInterrupt): - with Extract(sys.argv[1], sys.argv[2]) as extract: - while extract.poll() is None: - progress = extract.progress_read() - print(f'{progress.bytes}b {progress.percent}% ' - f'{progress.speed}b/s {progress.eta}') - rc = extract.poll() - print(f'ended with code: {rc}') - print('test main ended') diff --git a/gamechestcli/gamechest/cli.py b/gamechestcli/gamechest/cli.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/gamechestcli/gamechest/cli.py @@ -0,0 +1 @@ + diff --git a/gamechestcli/gamechest/cliactions/install.py b/gamechestcli/gamechest/cliactions/install.py new file mode 100644 index 0000000..ed62760 --- /dev/null +++ b/gamechestcli/gamechest/cliactions/install.py @@ -0,0 +1,31 @@ +import functools + +from .. import gamedb +from .. import paths +from .. import processor +from ..runners.install import Install +from ..statusdb import StatusDB + + +def install_game(status_db, game_info): + remote_basedir = paths.get_remote_basedir() + source = f'{remote_basedir}/{game_info["package_name"]}' + dest = paths.get_games_install_basedir() + title = 'Installing game...' + task = functools.partial(Install, source, dest) + processor.process_task(title, task) + status_db.set_installed(game_info) + + +def install(game_id): + game_info = gamedb.get_game_info(game_id) + status_db = StatusDB() + if status_db.is_installed(game_info): + # games is already installed + return + install_game(status_db, game_info) + + +if __name__ == "__main__": + import sys + install(sys.argv[1]) diff --git a/gamechestcli/gamechest/cliactions/remove.py b/gamechestcli/gamechest/cliactions/remove.py new file mode 100644 index 0000000..c187ae1 --- /dev/null +++ b/gamechestcli/gamechest/cliactions/remove.py @@ -0,0 +1,38 @@ +import contextlib +import functools +import selectors + +from rich.progress import Progress as RichProgress +from rich import print + +from .. import gamedb +from .. import paths +from .. import processor +from ..runners.remove import Remove +from ..statusdb import StatusDB + + +def remove_game(status_db, game_info): + remote_basedir = paths.get_remote_basedir() + path = ( + paths.get_games_install_basedir() + / status_db.get_installed_game_name(game_info['id']) + ) + title = 'Removing game...' + task = functools.partial(Remove, path) + processor.process_task(title, task) + status_db.set_uninstalled(game_info) + + +def remove(game_id): + game_info = gamedb.get_game_info(game_id) + status_db = StatusDB() + if status_db.is_installed(game_info): + remove_game(status_db, game_info) + else: + print('Game not installed') + + +if __name__ == "__main__": + import sys + remove(sys.argv[1]) diff --git a/gamechestcli/gamechest/cliactions/run.py b/gamechestcli/gamechest/cliactions/run.py new file mode 100644 index 0000000..e69de29 diff --git a/gamechestcli/gamechest/config.py b/gamechestcli/gamechest/config.py new file mode 100644 index 0000000..38b02fd --- /dev/null +++ b/gamechestcli/gamechest/config.py @@ -0,0 +1,50 @@ +from pathlib import Path +from dataclasses import dataclass, asdict + +import yaml +from xdg.BaseDirectory import load_first_config, save_config_path, xdg_data_home + +CONFIG_PATH_TUPLE = ('org.devys.gamechest', 'config.yaml') + + +@dataclass +class GamechestConfig: + + # TODO issue: having a Path() here raise yaml dump error when saving + games_path: Path = Path(xdg_data_home) + #games_path: str + + @classmethod + def load_config_from_path(cls, path): + #import dataconf + #conf = dataconf.file('test.yaml', GamechestConfig) + #print(conf.games_path) + with Path(path).open() as fdin: + config_yaml = yaml.safe_load(fdin) + for key in ('games_path', ): + config_yaml[key] = Path(config_yaml[key]).expanduser() + return cls(**config_yaml) + + @classmethod + def load_config(cls): + first_config_path = load_first_config(*CONFIG_PATH_TUPLE) + return_config = None + if first_config_path is None: + return_config = GamechestConfig() + return_config.save_config() + else: + return_config = cls.load_config_from_path(first_config_path) + return return_config + + def save_config(self): + save_path = ( + Path(save_config_path(*CONFIG_PATH_TUPLE[:-1])) + / CONFIG_PATH_TUPLE[-1] + ) + with save_path.open('w', encoding='utf8') as fdout: + yaml.safe_dump(asdict(self), fdout) + + +if __name__ == "__main__": + #print(GamechestConfig.load_config_from_path('test.yaml')) + print(GamechestConfig.load_config()) diff --git a/gamechestcli/gamechest/consts.py b/gamechestcli/gamechest/consts.py new file mode 100644 index 0000000..091fd70 --- /dev/null +++ b/gamechestcli/gamechest/consts.py @@ -0,0 +1,2 @@ +XDG_RESOURCE_NAME = 'org.devys.gamechest' +STATUS_DB_NAME = 'status.sqlite' diff --git a/gamechestcli/gamechest/gamedb.py b/gamechestcli/gamechest/gamedb.py new file mode 100644 index 0000000..1e3967c --- /dev/null +++ b/gamechestcli/gamechest/gamedb.py @@ -0,0 +1,16 @@ +import yaml + +from . import paths + + +def load_games_database(): + database_path = paths.get_games_database_path() + with open(database_path, 'rb') as fdin: + return yaml.safe_load(fdin) + + +def get_game_info(game_id): + db = load_games_database() + return next(game_info + for game_info in db['games'] + if game_info['id'] == game_id) diff --git a/gamechestcli/gamechest/paths.py b/gamechestcli/gamechest/paths.py new file mode 100644 index 0000000..53cafe0 --- /dev/null +++ b/gamechestcli/gamechest/paths.py @@ -0,0 +1,23 @@ +import os + +from xdg import xdg_data_home + +from . import consts + + +def get_games_database_path(): + # TODO: unhardcode this + #return os.path.expanduser('~/games/.saves/gamedata.yaml') + return os.path.expanduser('~/game-saves/gamedata.yaml') + + +def get_remote_basedir(): + # TODO: unhardcode this + return 'jibril:/storage/games' + + +def get_games_install_basedir(): + games_install_path = xdg_data_home() / consts.XDG_RESOURCE_NAME / 'games' + games_install_path.mkdir(parents=True, exist_ok=True) + return games_install_path + diff --git a/gamechestcli/gamechest/processor.py b/gamechestcli/gamechest/processor.py new file mode 100644 index 0000000..794deb8 --- /dev/null +++ b/gamechestcli/gamechest/processor.py @@ -0,0 +1,41 @@ +import contextlib +import selectors + +from rich.progress import Progress as RichProgress +from rich import print + + +def process_task(title, task): + with contextlib.ExitStack() as stack: + runner = stack.enter_context(task()) + selector = stack.enter_context(selectors.DefaultSelector()) + selector.register(runner.get_read_fd(), selectors.EVENT_READ) + rich_progress = stack.enter_context(RichProgress()) + known_total = 100 + global_id = rich_progress.add_task(title, total=known_total) + last_step = None + while (rc := runner.poll()) is None: + if selector.select(): + progress = runner.progress_read() + #rich_progress.console.log(progress) + known_total = progress.steps_count*100 + rich_progress.update(global_id, + completed=progress.step*100+progress.percent, + total=known_total) + if last_step != progress.step: + if last_step is not None: + rich_progress.update(step_id, completed=100) + else: + rich_progress.console.print('Total steps:', + progress.steps_count) + last_step = progress.step + step_id = rich_progress.add_task( + f'Step {progress.step}', total=100) + rich_progress.update(step_id, completed=progress.percent) + rich_progress.update(step_id, completed=100) + rich_progress.update(global_id, completed=known_total) + #rich_progress.console.print('installation ended with code:', rc) + if rc != 0: + # success, update db to say installed + status_db.set_installed(game_info) + print('ended with code:', rc) diff --git a/gamechestcli/gamechest/runners/download.py b/gamechestcli/gamechest/runners/download.py new file mode 100644 index 0000000..0dfbd05 --- /dev/null +++ b/gamechestcli/gamechest/runners/download.py @@ -0,0 +1,68 @@ +import os +import re +import subprocess + +import humanfriendly + +from ..structures import Progress +from .runnerbase import RunnerBase, neutral_locale_variables + + +class Download(RunnerBase): + + _rsync_progress_re = re.compile(r'^\s*(\S+)\s+(\d+)%\s+(\S+)\s+(\S+)\s+$') + + def __init__(self, src, dst): + self.proc = subprocess.Popen( + [ + 'rsync', + '--partial', + # --no-h: not human, easier to parse (but speed still appears + # in human form). + '--no-h', + '--info=progress2', + src, + dst, + ], + stdout=subprocess.PIPE, + encoding='utf8', + env={**os.environ, + **neutral_locale_variables, + }, + ) + self.last_progress = Progress() + + def get_read_fd(self): + return self.proc.stdout + + def progress_read(self): + line = self.proc.stdout.readline() + if match := self._rsync_progress_re.search(line): + self.last_progress = Progress( + nbbytes=int(match.group(1)), + percent=int(match.group(2)), + speed=humanfriendly.parse_size(match.group(3), binary=True), + eta=match.group(4), + ) + return self.last_progress + + def terminate(self): + self.proc.terminate() + + def poll(self): + return self.proc.poll() + + def close(self): + self.proc.wait() + + +if __name__ == '__main__': + import sys + import contextlib + with contextlib.ExitStack() as stack: + stack.enter_context(contextlib.suppress(KeyboardInterrupt)) + runner = stack.enter_context(Download(sys.argv[1], sys.argv[2])) + while (rc := runner.poll()) is None: + print(runner.progress_read()) + print('runner ended with code:', rc) + print('test main ended') diff --git a/gamechestcli/gamechest/runners/extract.py b/gamechestcli/gamechest/runners/extract.py new file mode 100644 index 0000000..e8011a7 --- /dev/null +++ b/gamechestcli/gamechest/runners/extract.py @@ -0,0 +1,96 @@ +import os +import re +import subprocess + +import humanfriendly + +from ..structures import Progress +from .runnerbase import RunnerBase, neutral_locale_variables + + +class Extract(RunnerBase): + + _progress_re = re.compile(r'^\s*(\S+)\s+\[([^]]+)/s\]\s+ETA\s+(\S+)\s*$') + + def __init__(self, src, dst): + common_parameters = dict( + encoding='utf8', + env={**os.environ, + **neutral_locale_variables, + }, + ) + self.src_size = os.stat(src).st_size + self.pv_proc = subprocess.Popen( + [ + 'pv', + '--force', + '--format', '%b %a %e', + src, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + **common_parameters, + ) + lzip_command = '/usr/bin/lzip' + plzip_command = '/usr/bin/plzip' + lzip_command_to_use = lzip_command + if os.path.exists(plzip_command): + lzip_command_to_use = plzip_command + self.zip_proc = subprocess.Popen( + [ + lzip_command_to_use, + '--decompress', + ], + stdin=self.pv_proc.stdout, + stdout=subprocess.PIPE, + **common_parameters, + ) + self.tar_proc = subprocess.Popen( + [ + 'tar', + '-C', dst, + '-xf', '-' + ], + stdin=self.zip_proc.stdout, + **common_parameters, + ) + self.last_progress = Progress() + + def get_read_fd(self): + return self.pv_proc.stderr + + def progress_read(self): + line = self.pv_proc.stderr.readline() + if match := self._progress_re.search(line): + written_bytes = humanfriendly.parse_size(match.group(1)) + self.last_progress = Progress( + nbbytes=written_bytes, + percent=int(100 * written_bytes / self.src_size), + speed=humanfriendly.parse_size(match.group(2)), + eta=match.group(3), + ) + return self.last_progress + + def terminate(self): + for proc in (self.pv_proc, self.zip_proc, self.tar_proc): + proc.terminate() + + def poll(self): + return self.tar_proc.poll() + + def close(self): + self.pv_proc.wait() + self.zip_proc.wait() + self.tar_proc.wait() + + +if __name__ == '__main__': + import sys + import contextlib + with contextlib.suppress(KeyboardInterrupt): + with Extract(sys.argv[1], sys.argv[2]) as runner: + while runner.poll() is None: + print(runner.progress_read()) + rc = runner.poll() + print('ended with code:', rc) + print('test main ended') diff --git a/gamechestcli/gamechest/runners/install.py b/gamechestcli/gamechest/runners/install.py new file mode 100644 index 0000000..a827e25 --- /dev/null +++ b/gamechestcli/gamechest/runners/install.py @@ -0,0 +1,42 @@ +import functools +import os + +from .runnermulti import MultiSequencialRunnerBase +from .download import Download +from .extract import Extract +from .remove import Remove + + +class Install(MultiSequencialRunnerBase): + + def __init__(self, source, dest): + filename = os.path.split(source)[1] + tmpdest = os.path.join(dest, f'{filename}.rsynctmp') + runners = [ + functools.partial(Download, source, tmpdest), + functools.partial(Extract, tmpdest, dest), + functools.partial(Remove, tmpdest), + ] + super().__init__(runners) + + +if __name__ == '__main__': + import sys + import contextlib + import time + import selectors + print('main test') + with contextlib.ExitStack() as stack: + stack.enter_context(contextlib.suppress(KeyboardInterrupt)) + runner = stack.enter_context(Install(sys.argv[1], sys.argv[2])) + selector = stack.enter_context(selectors.DefaultSelector()) + selector.register(runner.get_read_fd(), selectors.EVENT_READ) + last_progress = None + while (rc := runner.poll()) is None: + if selector.select(): + progress = runner.progress_read() + if progress != last_progress: + print(progress) + last_progress = progress + print('ended with code:', rc) + print('test main ended') diff --git a/gamechestcli/gamechest/runners/remove.py b/gamechestcli/gamechest/runners/remove.py new file mode 100644 index 0000000..99c4247 --- /dev/null +++ b/gamechestcli/gamechest/runners/remove.py @@ -0,0 +1,150 @@ +import os +import re +import subprocess +import threading + +import humanfriendly + +from ..structures import Progress +from .runnerbase import RunnerBase, neutral_locale_variables + + +class Remove(RunnerBase): + + _progress_re = re.compile(r'^\s*(\S+)\s+\[([^]]+)/s\]\s+ETA\s+(\S+)\s*$') + + def __init__(self, path): + self.last_progress = Progress(linemode=True) + self.path = path + self.rm_proc = None + self.pv_proc = None + self.pipe = os.pipe() + self.read_fd = open(self.pipe[0], 'r', encoding='utf8') + #self.read_fd = open(self.pipe[0], 'rb', buffering=0) + self._proc_started = False + self._filescount = 0 + self._counting_quit_event = threading.Event() + self._counting_thread = threading.Thread( + target=self._counting_worker, + args=(self._counting_quit_event, path), + ) + self._counting_thread.start() + + def _start_proc(self): + common_parameters = dict( + encoding='utf8', + env={**os.environ, + **neutral_locale_variables, + }, + ) + self.rm_proc = subprocess.Popen( + [ + 'rm', + '--verbose', + '--recursive', + '--one-file-system', + '--preserve-root=all', + '--interactive=never', + '--', + self.path, + ], + # [ # only for testing + # 'testtools/fake-rm', + # self.path, + # ], + stdout=subprocess.PIPE, + **common_parameters, + ) + self.pv_proc = subprocess.Popen( + [ + 'pv', + '--force', + '--size', str(self._filescount), + '--format', '%b %a %e', + '--line-mode', + ], + stdin=self.rm_proc.stdout, + stdout=subprocess.DEVNULL, + stderr=self.pipe[1], + **common_parameters, + ) + # close child's pipe fd on parent's side or selectors will hang if + # process closes. + os.close(self.pipe[1]) + self._proc_started = True + + def _counting_worker(self, event, path): + # as counting files for a deep directory can take some time, make it + # interruptible with a thread and event for cancelling. + # using functools.reduce(x+1+len(y[2]), os.walk) would have been much + # cleaner, but the code gets harder to read with the addition of the + # event checking. Fallback to accumalator and iterations. + total = 0 + for _, _, files in os.walk(path): + total += 1 + len(files) + if event.is_set(): + break + # set filescount with calculated total (even then interrupted, as it + # would be a better estimation than nothing). + if os.path.isfile(path): + self._filescount = 1 + else: + self._filescount = total + # start next processes (if event was not set) + if not event.is_set(): + self._start_proc() + + def get_read_fd(self): + return self.read_fd + + def progress_read(self): + if not self._proc_started: + return self.last_progress + line = self.read_fd.readline() + #line = self.read_fd.readline().decode('utf8') + if match := self._progress_re.search(line): + written_bytes = humanfriendly.parse_size(match.group(1)) + self.last_progress = Progress( + linemode=True, + nbbytes=written_bytes, + percent=int(100 * written_bytes / self._filescount), + speed=humanfriendly.parse_size(match.group(2)), + eta=match.group(3), + ) + return self.last_progress + + def terminate(self): + self._counting_quit_event.set() + if self._proc_started: + for proc in (self.rm_proc, self.pv_proc): + proc.terminate() + + def poll(self): + if not self._proc_started: + return None + return self.pv_proc.poll() + + def close(self): + self._counting_thread.join() + if self._proc_started: + for proc in (self.rm_proc, self.pv_proc): + proc.wait() + self.read_fd.close() + + +if __name__ == '__main__': + import sys + import contextlib + import time + import selectors + + with contextlib.ExitStack() as stack: + stack.enter_context(contextlib.suppress(KeyboardInterrupt)) + runner = stack.enter_context(Remove(sys.argv[1])) + selector = stack.enter_context(selectors.DefaultSelector()) + selector.register(runner.get_read_fd(), selectors.EVENT_READ) + while (rc := runner.poll()) is None: + if selector.select(): + print(runner.progress_read()) + print('ended with code:', rc) + print('test main ended') diff --git a/gamechestcli/gamechest/runners/runnerbase.py b/gamechestcli/gamechest/runners/runnerbase.py new file mode 100644 index 0000000..ac64ebb --- /dev/null +++ b/gamechestcli/gamechest/runners/runnerbase.py @@ -0,0 +1,44 @@ +from abc import ABCMeta, abstractmethod + + +class RunnerBase(metaclass=ABCMeta): + + @abstractmethod + def get_read_fd(self): + 'fd to select for read evts to know when to progress_read nonblockingly' + raise NotImplementedError + + @abstractmethod + def progress_read(self): + 'parse the fd given by get_read_fd and returns current progress' + raise NotImplementedError + + @abstractmethod + def terminate(self): + '''signal runner(s) to cancel immediately the operation, must + be idempotent if already terminated (not running)''' + raise NotImplementedError + + @abstractmethod + def poll(self): + 'returns None if still running, otherwise return returncode' + raise NotImplementedError + + def close(self): + 'frees resources, clean/unzombify/gc/close-fds, can block if running' + + def __enter__(self): + 'returns self' + return self + + def __exit__(self, exc_type, value, traceback): + 'terminate and wait garbage-collect' + self.terminate() + self.close() + + +neutral_locale_variables = { + 'LC_ALL':'C.UTF-8', + 'LANG':'C.UTF-8', + 'LANGUAGE':'C.UTF-8', +} diff --git a/gamechestcli/gamechest/runners/runnermulti.py b/gamechestcli/gamechest/runners/runnermulti.py new file mode 100644 index 0000000..2a5e17c --- /dev/null +++ b/gamechestcli/gamechest/runners/runnermulti.py @@ -0,0 +1,91 @@ +import contextlib +import os +import selectors +import threading +from functools import partial + +from ..structures import Progress +from .runnerbase import RunnerBase + + +def _create_pipe(): + pipe_rd, pipe_wd = os.pipe() + return open(pipe_rd, 'rb', buffering=0), open(pipe_wd, 'wb', buffering=0) + + +class MultiSequencialRunnerBase(RunnerBase): + + def __init__(self, runners): + super().__init__() + self.runners = runners + self._pipe_rd, self._pipe_wd = _create_pipe() + self._pipesig_rd, self._pipesig_wd = _create_pipe() + self._thread = threading.Thread(target=self._thread_target) + self._thread.start() + self._last_progress = Progress() + self._last_rc = -1 + + def _runner_run(self, + runner_callable, + step_index, + runners_count): + with contextlib.ExitStack() as stack: + runner = stack.enter_context(runner_callable()) + selector = stack.enter_context(selectors.DefaultSelector()) + selector.register(self._pipesig_rd, selectors.EVENT_READ) + selector.register(runner.get_read_fd(), selectors.EVENT_READ) + while (rc := runner.poll()) is None: + for key, events in selector.select(): + if key.fileobj is self._pipesig_rd: + self._pipesig_rd.read(1) + return -1 + progress = runner.progress_read() + self._last_progress = progress._replace( + step=step_index, + steps_count=runners_count, + ) + self._pipe_wd.write(b'p') # (p)rogress, could be anything + return rc + + def _thread_target(self): + runners_count = len(self.runners) + with contextlib.ExitStack() as stack: + for step_index, runner_callable in enumerate(self.runners): + self._last_rc = self._runner_run(runner_callable, step_index, + runners_count) + if self._last_rc != 0: + break + # closing writing end of a pipe, allows select on reading-end to + # immediately return and reading on reading-end will returns EOF (or + # empty string on python). + self._pipe_wd.close() + + def get_read_fd(self): + return self._pipe_rd + + def progress_read(self): + # read: discard byte used to signal progress, and ignore if EOF. + self._pipe_rd.read(1) + return self._last_progress + + def terminate(self): + # ignore ValueError, can arrive on closed _pipesig_wd if terminate is + # called multiple times. + with contextlib.suppress(ValueError): + self._pipesig_wd.write(b't') # t for terminate, could be anything + # close to avoid filling the pipe buffer uselessly if called multiple + # times, since nothing will read next sent bytes. + self._pipesig_wd.close() + + def poll(self): + return None if self._thread.is_alive() else self._last_rc + + def close(self): + self._thread.join() + for fd in ( + self._pipesig_rd, + self._pipesig_wd, + self._pipe_rd, + self._pipe_wd, + ): + fd.close() diff --git a/gamechestcli/gamechest/statusdb.py b/gamechestcli/gamechest/statusdb.py new file mode 100644 index 0000000..a010206 --- /dev/null +++ b/gamechestcli/gamechest/statusdb.py @@ -0,0 +1,93 @@ +import sqlite3 + +from xdg import xdg_state_home + +from . import consts + + +class StatusDB: + + def __init__(self): + db_path_dir = xdg_state_home() / consts.XDG_RESOURCE_NAME + db_path_dir.mkdir(parents=True, exist_ok=True) + db_path = db_path_dir / consts.STATUS_DB_NAME + self.conn = sqlite3.connect(db_path) + with self.conn: + self.conn.execute(''' + CREATE TABLE IF NOT EXISTS status ( + game_id text, + installed bool, + version_installed text + ) + ''') + + def close(self): + self.conn.close() + + def is_installed(self, game_info): + row = ( + self.conn + .execute('SELECT installed FROM status WHERE game_id = ?', + (game_info['id'], )) + .fetchone() + ) + if row is None: + return False + return bool(row[0]) + + def set_installed(self, game_info, installed=True): + cursor = self.conn.cursor() + row = ( + cursor + .execute('SELECT installed FROM status WHERE game_id = ?', + (game_info['id'], )) + .fetchone() + ) + with cursor: + if row is None: + cursor.execute(''' + INSERT INTO status + (game_id, installed, version_installed) + VALUES (?, ?, ?) + ''', ( + game_info['id'], + installed, + game_info['version'], + )) + else: + cursor.execute(''' + UPDATE status SET + installed = ?, + version_installed = ? + WHERE game_id = ? + ''', ( + installed, + game_info['version'], + game_info['id'], + )) + + def get_installed_game_name(self, game_id): + row = ( + self.conn + .execute('SELECT version_installed FROM status WHERE game_id = ?', + (game_id, )) + .fetchone() + ) + if row is None: + #return False + raise ValueError('Game not found') + version_installed = row[0] + return f'{game_id}_v{version}' + + def unset_installed(self, game_info): + return self.set_installed(game_info, installed=False) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + +if __name__ == "__main__": + status_db = StatusDB() diff --git a/gamechestcli/gamechest/structures.py b/gamechestcli/gamechest/structures.py new file mode 100644 index 0000000..35a8861 --- /dev/null +++ b/gamechestcli/gamechest/structures.py @@ -0,0 +1,38 @@ +from typing import NamedTuple + + +class Progress(NamedTuple): + ''' + linemode(False) + Change number of bytes to number of lines in nbbytes field. Same is + true for speed field which in this case will be expressed as lines per + second. + + percent(0) + Current percentage for the current step. + + step(0) + Current step index. + + steps_count(1) + Number of steps. + + nbbytes(0) + Number of bytes currently processed in this step. + + speed(0) + Current number of bytes per second processed. + + eta("inifinite") + Current estimation for the end of this step. + ''' + + linemode: bool = False + + percent: int = 0 + step: int = 0 + steps_count: int = 1 + + nbbytes: int = 0 + speed: int = 0 + eta: str = "infinite" diff --git a/gamechestcli/multitest.py b/gamechestcli/multitest.py deleted file mode 100644 index 8023a4e..0000000 --- a/gamechestcli/multitest.py +++ /dev/null @@ -1,10 +0,0 @@ - -class Multi: - - def __init__(self): - pass - - - def read_progress(self): - while True: - yield diff --git a/gamechestcli/remove.py b/gamechestcli/remove.py deleted file mode 100644 index 00247f7..0000000 --- a/gamechestcli/remove.py +++ /dev/null @@ -1,170 +0,0 @@ -#!/usr/bin/python3 - -import io -import os -import re -import subprocess -import threading - -import humanfriendly - -from structures import Progress - - -class Remove: - - _progress_re = re.compile(r'^\s*(\S+)\s+\[([^]]+)/s\]\s+ETA\s+(\S+)\s*$') - - def __init__(self, path): - self.last_progress = Progress() - self.path = path - self.rm_proc = None - self.pv_proc = None - self.pipe = os.pipe() - self.read_fd = io.open(self.pipe[0], 'r', encoding='utf8') - self._proc_started = False - self._filescount = 0 - self._counting_quit_event = threading.Event() - self._counting_thread = threading.Thread( - target=self._counting_worker, - args=(self._counting_quit_event, path), - ) - self._counting_thread.start() - - def _start_proc(self): - common_parameters = dict( - encoding='utf8', - env={**os.environ, - **{'LC_ALL':'C.UTF-8', - 'LANG':'C.UTF-8', - 'LANGUAGE':'C.UTF-8', - }}, - ) - self.rm_proc = subprocess.Popen( - [ - 'rm', - '--verbose', - '--recursive', - '--one-file-system', - '--preserve-root=all', - '--interactive=never', - '--', - self.path, - ], - stdout=subprocess.PIPE, - **common_parameters, - ) - #self.rm_proc = subprocess.Popen( # only for testing purposes - # [ - # 'testtools/fake-rm', - # self.path, - # ], - # stdout=subprocess.PIPE, - # **common_parameters, - #) - self.pv_proc = subprocess.Popen( - [ - 'pv', - '--force', - '--size', str(self._filescount), - '--format', '%b %a %e', - '--line-mode', - ], - stdin=self.rm_proc.stdout, - stdout=subprocess.DEVNULL, - stderr=self.pipe[1], - **common_parameters, - ) - # close child's pipe fd on parent's side or selectors will hang if - # process closes. - os.close(self.pipe[1]) - self._proc_started = True - - def _counting_worker(self, event, path): - # as counting files for a deep directory can take some time, make it - # interruptible with a thread and event for cancelling. - # using functools.reduce(x+1+len(y[2]), os.walk) would have been much - # cleaner, but the code gets harder to read with the addition of the - # event checking. Fallback to accumalator and iterations. - total = 0 - for _, _, files in os.walk(path): - total += 1 + len(files) - if event.is_set(): - break - # set filescount with calculated total (even then interrupted, as it - # would be a better estimation than nothing). - self._filescount = total - # start next processes (if event was not set) - if not event.is_set(): - self._start_proc() - - def select_fd(self): - 'useful to use selectors with the process most meaningful fd' - return self.read_fd - - def progress_read(self): - if not self._proc_started: - return self.last_progress - line = self.read_fd.readline() - if match := self._progress_re.search(line): - written_bytes = humanfriendly.parse_size(match.group(1)) - self.last_progress = Progress( - written_bytes, - int(100 * written_bytes / self._filescount), - humanfriendly.parse_size(match.group(2)), - match.group(3), - ) - return self.last_progress - - def terminate(self): - if self._proc_started: - for proc in (self.rm_proc, self.pv_proc): - proc.terminate() - else: - self._counting_quit_event.set() - - def poll(self): - 'returns None if not terminated, otherwise return returncode' - if not self._proc_started: - return None - return self.pv_proc.poll() - - def wait(self, timeout=None): - if self._proc_started: - self.rm_proc.wait(timeout) - return self.pv_proc.wait(timeout) - else: - self._counting_thread.join(timeout) - return -1 - - def close(self): - self.read_fd.close() - - def __enter__(self): - return self - - def __exit__(self, exc_type, value, traceback): - self.terminate() - self.wait() - self.close() - - -if __name__ == '__main__': - import sys - import contextlib - import time - import selectors - - with contextlib.ExitStack() as stack: - stack.enter_context(contextlib.suppress(KeyboardInterrupt)) - remove = stack.enter_context(Remove(sys.argv[1])) - selector = stack.enter_context(selectors.DefaultSelector()) - selector.register(remove.select_fd(), selectors.EVENT_READ) - while remove.poll() is None: - selector.select() - progress = remove.progress_read() - print(f'{progress.bytes}b {progress.percent}% ' - f'{progress.speed}b/s {progress.eta}') - rc = remove.poll() - print(f'ended with code: {rc}') - print('test main ended') diff --git a/gamechestcli/requirements-dev.txt b/gamechestcli/requirements-dev.txt new file mode 100644 index 0000000..fe96c42 --- /dev/null +++ b/gamechestcli/requirements-dev.txt @@ -0,0 +1,2 @@ +ipython +ipdb diff --git a/gamechestcli/requirements.txt b/gamechestcli/requirements.txt index f5368c4..576e0b3 100644 --- a/gamechestcli/requirements.txt +++ b/gamechestcli/requirements.txt @@ -1 +1,4 @@ humanfriendly +pyyaml +xdg +rich diff --git a/gamechestcli/rsync.py b/gamechestcli/rsync.py deleted file mode 100644 index 37330f9..0000000 --- a/gamechestcli/rsync.py +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/python3 - -import os -import re -import subprocess - -import humanfriendly - -from .structures import Progress - -class Rsync: - - _rsync_progress_re = re.compile(r'^\s*(\S+)\s+(\d+)%\s+(\S+)\s+(\S+)\s+$') - - def __init__(self, src, dst): - self.proc = subprocess.Popen( - [ - 'rsync', - '--partial', - # not human readable, easier to parse (but speed still appears - # in human form). - '--no-h', - '--info=progress2', - src, - dst, - ], - stdout=subprocess.PIPE, - #stderr=subprocess.DEVNULL, - encoding='utf8', - env={**os.environ, - **{'LC_ALL':'C.UTF-8', - 'LANG':'C.UTF-8', - 'LANGUAGE':'C.UTF-8', - }}, - ) - self.last_progress = Progress() - - def select_fd(self): - 'useful to use selectors with the process stdout file descriptor' - return self.proc.stdout - - def progress_read(self): - line = self.proc.stdout.readline() - if match := self._rsync_progress_re.search(line): - self.last_progress = Progress( - int(match.group(1)), - int(match.group(2)), - humanfriendly.parse_size(match.group(3), binary=True), - match.group(4), - ) - return self.last_progress - - def terminate(self): - self.proc.terminate() - - def poll(self): - 'returns None if not terminated, otherwise return returncode' - return self.proc.poll() - - def wait(self, timeout=None): - return self.proc.wait(timeout) - - def __enter__(self): - return self - - def __exit__(self, exc_type, value, traceback): - self.terminate() - self.wait() - - -if __name__ == '__main__': - import sys - import contextlib - with contextlib.suppress(KeyboardInterrupt): - with Rsync(sys.argv[1], sys.argv[2]) as rsync: - while rsync.poll() is None: - progress = rsync.progress_read() - print(f'{progress.bytes}b {progress.percent}% ' - f'{progress.speed}b/s {progress.eta}') - rc = rsync.poll() - print(f'rsync ended with code: {rc}') - print('Rsync test main ended') diff --git a/gamechestcli/structures.py b/gamechestcli/structures.py deleted file mode 100644 index 22b0e7e..0000000 --- a/gamechestcli/structures.py +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/python3 - -import collections - -Progress = collections.namedtuple( - 'CurrentProgress', - 'bytes percent speed eta', - defaults=[0, 0, 0, 'infinite']) - -- cgit v1.2.3