From dfd985bcc2b15390e29dc7f779cf2edb69071ca0 Mon Sep 17 00:00:00 2001 From: vg Date: Wed, 4 Feb 2026 17:02:50 +0100 Subject: git-sync on dita --- gamechestcli/README.md | 0 gamechestcli/README.rst | 0 gamechestcli/__main__.py | 67 ------- gamechestcli/gamechest/cliactions/install.py | 41 ---- gamechestcli/gamechest/cliactions/remove.py | 38 ---- gamechestcli/gamechest/cliactions/run.py | 59 ------ gamechestcli/gamechest/config.py | 51 ----- gamechestcli/gamechest/consts.py | 2 - gamechestcli/gamechest/gameconfig.py | 151 --------------- gamechestcli/gamechest/gamedb.py | 61 ------ gamechestcli/gamechest/processor.py | 41 ---- gamechestcli/gamechest/runners/download.py | 68 ------- gamechestcli/gamechest/runners/extract.py | 224 ---------------------- gamechestcli/gamechest/runners/install.py | 40 ---- gamechestcli/gamechest/runners/remove.py | 150 --------------- gamechestcli/gamechest/runners/runnerbase.py | 44 ----- gamechestcli/gamechest/runners/runnermulti.py | 91 --------- gamechestcli/gamechest/statusdb.py | 106 ---------- gamechestcli/gamechest/structures.py | 38 ---- gamechestcli/pyproject.toml | 5 +- gamechestcli/src/__main__.py | 67 +++++++ gamechestcli/src/gamechest/cliactions/install.py | 41 ++++ gamechestcli/src/gamechest/cliactions/remove.py | 38 ++++ gamechestcli/src/gamechest/cliactions/run.py | 59 ++++++ gamechestcli/src/gamechest/config.py | 51 +++++ gamechestcli/src/gamechest/consts.py | 2 + gamechestcli/src/gamechest/gameconfig.py | 151 +++++++++++++++ gamechestcli/src/gamechest/gamedb.py | 61 ++++++ gamechestcli/src/gamechest/processor.py | 41 ++++ gamechestcli/src/gamechest/runners/download.py | 68 +++++++ gamechestcli/src/gamechest/runners/extract.py | 224 ++++++++++++++++++++++ gamechestcli/src/gamechest/runners/install.py | 40 ++++ gamechestcli/src/gamechest/runners/remove.py | 150 +++++++++++++++ gamechestcli/src/gamechest/runners/runnerbase.py | 44 +++++ gamechestcli/src/gamechest/runners/runnermulti.py | 91 +++++++++ gamechestcli/src/gamechest/statusdb.py | 106 ++++++++++ gamechestcli/src/gamechest/structures.py | 38 ++++ 37 files changed, 1276 insertions(+), 1273 deletions(-) delete mode 100644 gamechestcli/README.md create mode 100644 gamechestcli/README.rst delete mode 100755 gamechestcli/__main__.py delete mode 100644 gamechestcli/gamechest/cliactions/install.py delete mode 100644 gamechestcli/gamechest/cliactions/remove.py delete mode 100644 gamechestcli/gamechest/cliactions/run.py delete mode 100644 gamechestcli/gamechest/config.py delete mode 100644 gamechestcli/gamechest/consts.py delete mode 100644 gamechestcli/gamechest/gameconfig.py delete mode 100644 gamechestcli/gamechest/gamedb.py delete mode 100644 gamechestcli/gamechest/processor.py delete mode 100644 gamechestcli/gamechest/runners/download.py delete mode 100644 gamechestcli/gamechest/runners/extract.py delete mode 100644 gamechestcli/gamechest/runners/install.py delete mode 100644 gamechestcli/gamechest/runners/remove.py delete mode 100644 gamechestcli/gamechest/runners/runnerbase.py delete mode 100644 gamechestcli/gamechest/runners/runnermulti.py delete mode 100644 gamechestcli/gamechest/statusdb.py delete mode 100644 gamechestcli/gamechest/structures.py create mode 100755 gamechestcli/src/__main__.py create mode 100644 gamechestcli/src/gamechest/cliactions/install.py create mode 100644 gamechestcli/src/gamechest/cliactions/remove.py create mode 100644 gamechestcli/src/gamechest/cliactions/run.py create mode 100644 gamechestcli/src/gamechest/config.py create mode 100644 gamechestcli/src/gamechest/consts.py create mode 100644 gamechestcli/src/gamechest/gameconfig.py create mode 100644 gamechestcli/src/gamechest/gamedb.py create mode 100644 gamechestcli/src/gamechest/processor.py create mode 100644 gamechestcli/src/gamechest/runners/download.py create mode 100644 gamechestcli/src/gamechest/runners/extract.py create mode 100644 gamechestcli/src/gamechest/runners/install.py create mode 100644 gamechestcli/src/gamechest/runners/remove.py create mode 100644 gamechestcli/src/gamechest/runners/runnerbase.py create mode 100644 gamechestcli/src/gamechest/runners/runnermulti.py create mode 100644 gamechestcli/src/gamechest/statusdb.py create mode 100644 gamechestcli/src/gamechest/structures.py (limited to 'gamechestcli') diff --git a/gamechestcli/README.md b/gamechestcli/README.md deleted file mode 100644 index e69de29..0000000 diff --git a/gamechestcli/README.rst b/gamechestcli/README.rst new file mode 100644 index 0000000..e69de29 diff --git a/gamechestcli/__main__.py b/gamechestcli/__main__.py deleted file mode 100755 index 8452b99..0000000 --- a/gamechestcli/__main__.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/python3 -''' -Manage games. Install, remove, run them. - -Usage: gamechest install - gamechest remove - gamechest run [--profile_id=] - gamechest set [--profile_id=] [--remote_basedir=] [--gamesaves_path=] - gamechest list [--installed|--not-installed] - gamechest showconfig - -Options: - --profile_id=, -p - use profile instead of the default one. -''' - -import sys - -import docopt -from rich import print - -from gamechest.cliactions import install, remove, run -from gamechest.gameconfig import config -from gamechest.statusdb import StatusDB -from gamechest.gamedb import GameDB - - -def main(): - args = docopt.docopt(__doc__) - #print(args); raise SystemExit(0) - - if args['install']: - install.install(args['']) - elif args['remove']: - remove.remove(args['']) - elif args['run']: - profile_id = args['--profile_id'] or config.get_profile_id() - if not profile_id: - print('profile_id must be not null', file=sys.stderr) - run.run(args[''], profile_id) - elif args['set']: - if args['--profile_id']: - config.set_profile_id(args['--profile_id']) - if args['--remote_basedir']: - config.set_remote_basedir(args['--remote_basedir']) - if args['--gamesaves_path']: - config.set_gamesaves_path(args['--gamesaves_path']) - config.save() - elif args['list']: - status_db = StatusDB() - if args['--installed']: - print(list(status_db.get_installed())) - else: - game_db = GameDB() - list_installed = list(status_db.get_installed()) - for game_id in game_db.get_ids(): - if args['--not-installed']: - if game_id not in list_installed: - print(game_id) - else: - print(game_id, 'installed:', game_id in list_installed) - elif args['showconfig']: - config.print_config() - - -if __name__ == "__main__": - main() diff --git a/gamechestcli/gamechest/cliactions/install.py b/gamechestcli/gamechest/cliactions/install.py deleted file mode 100644 index 90e9ac4..0000000 --- a/gamechestcli/gamechest/cliactions/install.py +++ /dev/null @@ -1,41 +0,0 @@ -import functools - -from .. import gamedb, processor -from ..gameconfig import config -from ..runners.install import Install -from ..statusdb import StatusDB - - -def install_game(status_db, game_info): - remote_basedir = config.get_remote_basedir() - package_name = game_info.get("package_name") - dest = config.get_games_install_basedir() / game_info['id'] - dest.mkdir(parents=True, exist_ok=True) - # now as of 2026-01-14: not having a package_name or having it set to none - # means there is no installation archive (all is managed by the runner), - # thus consider it installed with version given without extracting - # anything. - if package_name: - title = 'Installing game...' - source = f'{remote_basedir}/{package_name}' - task = functools.partial(Install, source, dest) - processor.process_task(title, task) - else: - (dest / "dummy").mkdir(parents=True, exist_ok=True) - - status_db.set_installed(game_info) - - -def install(game_id): - game_db = gamedb.GameDB() - game_info = game_db.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 deleted file mode 100644 index 6573aff..0000000 --- a/gamechestcli/gamechest/cliactions/remove.py +++ /dev/null @@ -1,38 +0,0 @@ -import contextlib -import functools -import selectors - -from rich import print -from rich.progress import Progress as RichProgress - -from .. import gamedb, processor -from ..gameconfig import config -from ..runners.remove import Remove -from ..statusdb import StatusDB - - -def remove_game(status_db, game_info): - remote_basedir = config.get_remote_basedir() - path = ( - config.get_games_install_basedir() - / 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_db = gamedb.GameDB() - game_info = game_db.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 deleted file mode 100644 index 1c55cc4..0000000 --- a/gamechestcli/gamechest/cliactions/run.py +++ /dev/null @@ -1,59 +0,0 @@ -import os -import subprocess -import sys -from pathlib import Path - -from ..gameconfig import config -from ..gamedb import GameDB -from ..statusdb import StatusDB - - -def run_game(game_db, profile_id, game_info): - command = game_db.get_game_command(profile_id, game_info['id']) - profile_dir = Path(config.get_profile_dir(profile_id)) - profile_game_dir = profile_dir / game_info['id'] - game_dir = config.get_games_install_basedir() / game_info['id'] - # note: glob('*/') globs regular files too, thus I filter on is_dir. - game_dir = list(item - for item in game_dir.glob('*') if item.is_dir())[-1] - game_dir = Path(game_dir) - #tools_bin_path = config.get_games_saves_tools_bin_path() - #new_env = dict(os.environ) - #new_env['PATH'] = f'{tools_bin_path}:{new_env["PATH"]}' - # path to mod/run scripts are already prepended by get_game_command, no - # need to modify the path environment variable. - profile_lockdir = profile_dir / '.lock' - try: - profile_lockdir.mkdir(parents=True, exist_ok=False) - except FileExistsError: - print(f'ERROR: profile is already locked, {profile_lockdir}', - file=sys.stderr) - sys.exit(1) - try: - subprocess.run( - command, - env={ - **os.environ, - 'PROFILE_DIR': str(profile_game_dir), - 'GAME_DIR': str(game_dir), - 'GAME_ID': game_info['id'], - }, - ) - finally: - profile_lockdir.rmdir() - print(f'INFO: {profile_lockdir} removed') - - -def run(game_id, profile_id): - game_db = GameDB() - status_db = StatusDB() - game_info = game_db.get_game_info(game_id) - if not status_db.is_installed(game_info): - # games is already installed - print('Game', game_id, 'is not installed, aborting.', file=sys.stderr) - return - run_game(game_db, profile_id, game_info) - - -if __name__ == "__main__": - run(*sys.argv[1:]) diff --git a/gamechestcli/gamechest/config.py b/gamechestcli/gamechest/config.py deleted file mode 100644 index cd0c7e8..0000000 --- a/gamechestcli/gamechest/config.py +++ /dev/null @@ -1,51 +0,0 @@ -from dataclasses import asdict, dataclass -from pathlib import Path - -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 deleted file mode 100644 index 091fd70..0000000 --- a/gamechestcli/gamechest/consts.py +++ /dev/null @@ -1,2 +0,0 @@ -XDG_RESOURCE_NAME = 'org.devys.gamechest' -STATUS_DB_NAME = 'status.sqlite' diff --git a/gamechestcli/gamechest/gameconfig.py b/gamechestcli/gamechest/gameconfig.py deleted file mode 100644 index 0b3d855..0000000 --- a/gamechestcli/gamechest/gameconfig.py +++ /dev/null @@ -1,151 +0,0 @@ -import contextlib -from pathlib import Path - -import yaml -from xdg import xdg_data_home, xdg_config_home - -from . import consts - - -DEFAULT_CONFIG_DICT = { - 'remote_basedir': 'jibril:/storage/games', - 'gamesaves_path': Path('~/syncthing/gamesaves').expanduser(), - - # profile_id: no default for this, only set by user. - 'profile_id': None, -} - - -def path_relative_to_user(path): - # first ensure the path is a Path - path = Path(path) - with contextlib.suppress(ValueError): - return '~' / path.relative_to(Path.home()) - # in case the path is not relative to the user home ValueError is raised, - # thus we return a fallback value (the path unmodified). - return path - - -class GameConfig: - - def __init__(self): - self.config = {} - game_config_path = xdg_config_home() / consts.XDG_RESOURCE_NAME - game_config_path.mkdir(parents=True, exist_ok=True) - game_config_filepath = game_config_path / 'config.yaml' - self.game_config_filepath = game_config_filepath - self.config = {} - with contextlib.ExitStack() as stack: - stack.enter_context(contextlib.suppress(FileNotFoundError)) - fdin = stack.enter_context(open(game_config_filepath, 'r', - encoding='utf8')) - # in case yaml file is empty, None will be returned, in this case - # fallback to empty dict. - self.config = yaml.safe_load(fdin) or {} - self.config = { - **DEFAULT_CONFIG_DICT, - **self.config, - } - # convert game_saves_path to a path if not already the case (in case - # loaded from yaml file). - self.config['gamesaves_path'] = ( - Path(self.config['gamesaves_path']) - .expanduser() - ) - - def get_remote_basedir(self): - return self.config['remote_basedir'] - - def get_gamesaves_path(self): - return self.config['gamesaves_path'] - - def get_games_saves_tools_bin_path(self): - return self.get_gamesaves_path() / 'tools' / 'bin' - - def get_profile_dir(self, profile_id): - return self.get_gamesaves_path() / 'profiles' / profile_id - - def get_games_database_path(self): - return self.get_gamesaves_path() / 'gamedata.yaml' - - def get_data_d(self): - return self.get_gamesaves_path() / 'gamedata.yaml.d' - - def get_profile_id(self): - return self.config['profile_id'] - - def save(self): - dict_transformed_for_save = { **self.config } - dict_transformed_for_save['gamesaves_path'] = str( - path_relative_to_user( - self.config['gamesaves_path'] - ) - ) - with open(self.game_config_filepath, 'w', encoding='utf8') as fdout: - yaml.safe_dump(dict_transformed_for_save, fdout) - - def set_profile_id(self, profile_id): - self.config['profile_id'] = profile_id - - def set_remote_basedir(self, remote_basedir): - self.config['remote_basedir'] = remote_basedir - - def set_gamesaves_path(self, path): - self.config['gamesaves_path'] = Path(path).expanduser() - - def get_games_install_basedir(self): - games_install_path = ( - xdg_data_home() / consts.XDG_RESOURCE_NAME / 'games' - ) - games_install_path.mkdir(parents=True, exist_ok=True) - return games_install_path - - def print_config(self): - print(self.config) - - -# default instance of gameconfig, same instance intended to be shared through -# all modules which needs it. -config = GameConfig() - -class GameStatus: - - def load_yaml(self): - with contextlib.ExitStack() as stack: - stack.enter_context(contextlib.suppress(FileNotFoundError)) - fdin = stack.enter_context(open(self.yaml_path, 'r', - encoding='utf8')) - data = yaml.safe_load(fdin) - self.last_loaded_profile = data.get('last_loaded_profile', - self.last_loaded_profile) - self.last_loaded_game = data.get('last_loaded_game', - self.last_loaded_game) - - def write_yaml(self): - with contextlib.ExitStack() as stack: - stack.enter_context(contextlib.suppress(FileNotFoundError)) - fdout = stack.enter_context(open(self.yaml_path, 'w', - encoding='utf8')) - data = { - 'last_loaded_profile': self.last_loaded_profile, - 'last_loaded_game': self.last_loaded_game, - } - yaml.safe_dump(data, fdout) - - def __init__(self): - self.yaml_path = ( - xdg_data_home() / consts.XDG_RESOURCE_NAME / 'status.yaml' - ) - self.last_loaded_profile = config.get_profile_id() - self.last_loaded_game = None - self.load_yaml() - - def set_last_loaded_profile(self, profile): - self.last_loaded_profile = profile - self.write_yaml() - - def set_last_loaded_game(self, game): - self.last_loaded_game = game - self.write_yaml() - -status = GameStatus() diff --git a/gamechestcli/gamechest/gamedb.py b/gamechestcli/gamechest/gamedb.py deleted file mode 100644 index 1f9e8d8..0000000 --- a/gamechestcli/gamechest/gamedb.py +++ /dev/null @@ -1,61 +0,0 @@ -import yaml - -from .gameconfig import config - - -class GameDB: - - def __init__(self): - database_path = config.get_games_database_path() - with open(database_path, 'rb') as fdin: - self.db = yaml.safe_load(fdin) - self.last_game_info = None - - def get_game_info(self, game_id=None): - if self.last_game_info and self.last_game_info['id'] == game_id: - return self.last_game_info - game_info = next(game_info - for game_info in self.db['games'] - if game_info['id'] == game_id) - self.last_game_info = game_info - return game_info - - def get_game_env(self, profile_id, game_id=None): - # TODO - raise Error - - def get_game_command(self, profile_id, game_id=None): - game_info = self.get_game_info(game_id) - game_mods = game_info.get('mods', []) - #game_mods += ['locked-run-profiledir'] - #game_dir = config.get_games_install_basedir() / game_info['id'] - ## note: glob('*/') globs regular files too, thus I filter on is_dir. - #game_dir = list(item - # for item in game_dir.glob('*') if item.is_dir())[-1] - tools_bin_path = config.get_games_saves_tools_bin_path() - #profile_dir = config.get_profile_dir(profile_id) - command_from_game_info = [ - f'{tools_bin_path}/runners/{game_info["command"][0]}', - *game_info['command'][1:], - ] if game_info.get('command') else [ - f'{tools_bin_path}/runners/run-{game_info["id"]}', - ] - command = [ - # mods - *[f'{tools_bin_path}/mod-{item}' for item in game_mods], - # runner - *command_from_game_info, - ] - return command - - def get_ids(self): - for game_info in self.db['games']: - yield game_info['id'] - - def get_idtitles(self): - for game_info in self.db['games']: - yield game_info['id'], game_info['title'] - - def get_info(self, game_id): - return next(game_info for game_info in self.db['games'] - if game_info['id'] == game_id) diff --git a/gamechestcli/gamechest/processor.py b/gamechestcli/gamechest/processor.py deleted file mode 100644 index e31d023..0000000 --- a/gamechestcli/gamechest/processor.py +++ /dev/null @@ -1,41 +0,0 @@ -import contextlib -import selectors - -from rich import print -from rich.progress import Progress as RichProgress - - -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: - print('Process failed, aborting') - raise SystemExit(1) - print('ended with code:', rc) diff --git a/gamechestcli/gamechest/runners/download.py b/gamechestcli/gamechest/runners/download.py deleted file mode 100644 index 8629b16..0000000 --- a/gamechestcli/gamechest/runners/download.py +++ /dev/null @@ -1,68 +0,0 @@ -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 contextlib - import sys - 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 deleted file mode 100644 index 970c956..0000000 --- a/gamechestcli/gamechest/runners/extract.py +++ /dev/null @@ -1,224 +0,0 @@ -import os -import re -import select -import struct -import subprocess -import threading -import zipfile - -import humanfriendly - -from ..structures import Progress -from .runnerbase import RunnerBase, neutral_locale_variables - - -class ExtractZip(RunnerBase): - """ - Simple zip wrapper without multithreading. Should not be an issue as zip - is used for little archives only, bigger archives are tar+zstd with - multithreaded algorithms. So keep this class simple. - """ - - def __init__(self, src, dst): - self.last_progress = Progress() - self.read_fd, self.write_fd = os.pipe() - self.cancel_event = threading.Event() - - # No need to lock this unless GIL is disabled. - self.written_bytes = 0 - self.total_bytes = 0 - self.progress = 0 - - def extract_and_report(): - try: - chunk_size = 1024**2 # 1MiB chunks - with zipfile.ZipFile(src, 'r') as zip_ref: - self.total_bytes = sum(getattr(file_info, 'file_size', 0) for file_info in zip_ref.infolist() if not file_info.is_dir()) - for file_info in zip_ref.infolist(): - if file_info.is_dir(): - continue - with zip_ref.open(file_info) as source_file: - target_path = os.path.join(dst, file_info.filename) - os.makedirs(os.path.dirname(target_path), exist_ok=True) - with open(target_path, 'wb') as target_file: - while not self.cancel_event.is_set(): - chunk = source_file.read(chunk_size) - if not chunk or self.cancel_event.is_set(): - # if we have read everything from the - # source file, OR, we got a cancel event - # in-between the read and the write, we - # break. - break - target_file.write(chunk) - self.written_bytes += len(chunk) - self.progress = (self.written_bytes / self.total_bytes) * 100 - #progress_message = f"Progress: {progress:.2f}% ({bytes_written} of {total_bytes} bytes)\n" - os.write(self.write_fd, b'.') - finally: - self.cancel_event.set() - self.progress = 100 - os.write(self.write_fd, b'.') - #os.close(self.read_fd) - #os.close(self.write_fd) - - # Create a thread for extraction - extraction_thread = threading.Thread(target=extract_and_report) - extraction_thread.start() - self.extraction_thread = extraction_thread - - - def get_read_fd(self): - return self.read_fd - - def progress_read(self): - ready, _, _ = select.select([self.read_fd], [], []) - if ready: - # flush any present data from the pipe - os.read(self.read_fd, 1024) - self.last_progress = Progress( - nbbytes=self.written_bytes, - percent=int(100 * self.written_bytes / self.total_bytes), - #speed=humanfriendly.parse_size(match.group(2)), - speed=0, - eta="UNK", - ) - return self.last_progress - - def terminate(self): - self.close() # same as self.close, can be called multiple time - - def poll(self): - return None if not self.cancel_event.is_set() else 0 - - def close(self): - self.cancel_event.set() - self.extraction_thread.join() - - -class ExtractTar(RunnerBase): - - _progress_re = re.compile(r'^\s*(\S+)\s+\[([^]]+)/s\]\s+ETA\s+(\S+)\s*$') - - def __init__(self, src, dst, compression_type): - 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' - uncompress_command_to_use = [lzip_command, '--decompress'] - if compression_type == 'lzip': - if os.path.exists(plzip_command): - uncompress_command_to_use = [plzip_command, '--decompress'] - elif compression_type == 'zstd': - uncompress_command_to_use = ['zstd', '-T0', '-d', '--stdout'] - self.zip_proc = subprocess.Popen( - uncompress_command_to_use, - 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() - - -class Extract(RunnerBase): - def __init__(self, src, dst): - zip_magics = ( - b'PK\x03\x04', - b'PK\x05\x06', # empty - b'PK\x07\x08', # spanned - ) - - first_word_magic = 0 - first_word_magic_b = b'' - compression_type = None - with open(src, 'rb') as stream: - first_word_magic_b = stream.read(4) - first_word_magic = struct.unpack('>L', first_word_magic_b)[0] - if first_word_magic_b in zip_magics: - compression_type = 'zip' - elif first_word_magic == 0x4c5a4950: # lzip magic - compression_type = 'lzip' - elif first_word_magic == 0x28B52FFD: # zstd magic - compression_type = 'zstd' - if compression_type == 'zip': - self.inner_class = ExtractZip(src, dst) - else: - self.inner_class = ExtractTar(src, dst, compression_type) - - def get_read_fd(self): - return self.inner_class.get_read_fd() - - def progress_read(self): - return self.inner_class.progress_read() - - def terminate(self): - return self.inner_class.terminate() - - def poll(self): - return self.inner_class.poll() - - def close(self): - return self.inner_class.close() - - -if __name__ == '__main__': - import contextlib - import sys - 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 deleted file mode 100644 index 0b37304..0000000 --- a/gamechestcli/gamechest/runners/install.py +++ /dev/null @@ -1,40 +0,0 @@ -import functools -from pathlib import Path - -from .download import Download -from .extract import Extract -from .remove import Remove -from .runnermulti import MultiSequencialRunnerBase - - -class Install(MultiSequencialRunnerBase): - - def __init__(self, source, dest): - tmpdest = Path(dest) / 'archive.rsynctmp' - runners = [ - functools.partial(Download, source, tmpdest), - functools.partial(Extract, tmpdest, dest), - functools.partial(Remove, tmpdest), - ] - super().__init__(runners) - - -if __name__ == '__main__': - import contextlib - import selectors - import sys - 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 deleted file mode 100644 index 87177f3..0000000 --- a/gamechestcli/gamechest/runners/remove.py +++ /dev/null @@ -1,150 +0,0 @@ -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 contextlib - import selectors - import sys - import time - - 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 deleted file mode 100644 index ac64ebb..0000000 --- a/gamechestcli/gamechest/runners/runnerbase.py +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index 2a5e17c..0000000 --- a/gamechestcli/gamechest/runners/runnermulti.py +++ /dev/null @@ -1,91 +0,0 @@ -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 deleted file mode 100644 index 3f2af31..0000000 --- a/gamechestcli/gamechest/statusdb.py +++ /dev/null @@ -1,106 +0,0 @@ -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 - ) - --CREATE TABLE IF NOT EXISTS last_items ( - -- game_id text, - -- profile_id text, - -- type 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 self.conn: - 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 set_last_played_game(self, game_id): - #cursor = self.conn.cursor() - #with self.conn: - # cursor.execute(''' - # INSERT INTO - # game_id text, - # profile_id text, - # type text, - # ''') - pass - - def set_last_played_profile(self, profile_id): - pass - - def get_installed(self): - for row in ( - self.conn - .execute('SELECT game_id FROM status WHERE installed = 1') - ): - yield row[0] - - def set_uninstalled(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 deleted file mode 100644 index 35a8861..0000000 --- a/gamechestcli/gamechest/structures.py +++ /dev/null @@ -1,38 +0,0 @@ -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/pyproject.toml b/gamechestcli/pyproject.toml index ef89d44..7ee2811 100644 --- a/gamechestcli/pyproject.toml +++ b/gamechestcli/pyproject.toml @@ -2,7 +2,7 @@ name = "gamechestcli" version = "0.1.0" description = "Add your description here" -readme = "README.md" +readme = "README.rst" requires-python = ">=3.13" dependencies = [ "docopt-ng>=0.9.0", @@ -19,3 +19,6 @@ dev = [ "ipdb>=0.13.13", "ipython>=9.10.0", ] + +[build-system] +build-backend = "uv_build" diff --git a/gamechestcli/src/__main__.py b/gamechestcli/src/__main__.py new file mode 100755 index 0000000..8452b99 --- /dev/null +++ b/gamechestcli/src/__main__.py @@ -0,0 +1,67 @@ +#!/usr/bin/python3 +''' +Manage games. Install, remove, run them. + +Usage: gamechest install + gamechest remove + gamechest run [--profile_id=] + gamechest set [--profile_id=] [--remote_basedir=] [--gamesaves_path=] + gamechest list [--installed|--not-installed] + gamechest showconfig + +Options: + --profile_id=, -p + use profile instead of the default one. +''' + +import sys + +import docopt +from rich import print + +from gamechest.cliactions import install, remove, run +from gamechest.gameconfig import config +from gamechest.statusdb import StatusDB +from gamechest.gamedb import GameDB + + +def main(): + args = docopt.docopt(__doc__) + #print(args); raise SystemExit(0) + + if args['install']: + install.install(args['']) + elif args['remove']: + remove.remove(args['']) + elif args['run']: + profile_id = args['--profile_id'] or config.get_profile_id() + if not profile_id: + print('profile_id must be not null', file=sys.stderr) + run.run(args[''], profile_id) + elif args['set']: + if args['--profile_id']: + config.set_profile_id(args['--profile_id']) + if args['--remote_basedir']: + config.set_remote_basedir(args['--remote_basedir']) + if args['--gamesaves_path']: + config.set_gamesaves_path(args['--gamesaves_path']) + config.save() + elif args['list']: + status_db = StatusDB() + if args['--installed']: + print(list(status_db.get_installed())) + else: + game_db = GameDB() + list_installed = list(status_db.get_installed()) + for game_id in game_db.get_ids(): + if args['--not-installed']: + if game_id not in list_installed: + print(game_id) + else: + print(game_id, 'installed:', game_id in list_installed) + elif args['showconfig']: + config.print_config() + + +if __name__ == "__main__": + main() diff --git a/gamechestcli/src/gamechest/cliactions/install.py b/gamechestcli/src/gamechest/cliactions/install.py new file mode 100644 index 0000000..90e9ac4 --- /dev/null +++ b/gamechestcli/src/gamechest/cliactions/install.py @@ -0,0 +1,41 @@ +import functools + +from .. import gamedb, processor +from ..gameconfig import config +from ..runners.install import Install +from ..statusdb import StatusDB + + +def install_game(status_db, game_info): + remote_basedir = config.get_remote_basedir() + package_name = game_info.get("package_name") + dest = config.get_games_install_basedir() / game_info['id'] + dest.mkdir(parents=True, exist_ok=True) + # now as of 2026-01-14: not having a package_name or having it set to none + # means there is no installation archive (all is managed by the runner), + # thus consider it installed with version given without extracting + # anything. + if package_name: + title = 'Installing game...' + source = f'{remote_basedir}/{package_name}' + task = functools.partial(Install, source, dest) + processor.process_task(title, task) + else: + (dest / "dummy").mkdir(parents=True, exist_ok=True) + + status_db.set_installed(game_info) + + +def install(game_id): + game_db = gamedb.GameDB() + game_info = game_db.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/src/gamechest/cliactions/remove.py b/gamechestcli/src/gamechest/cliactions/remove.py new file mode 100644 index 0000000..6573aff --- /dev/null +++ b/gamechestcli/src/gamechest/cliactions/remove.py @@ -0,0 +1,38 @@ +import contextlib +import functools +import selectors + +from rich import print +from rich.progress import Progress as RichProgress + +from .. import gamedb, processor +from ..gameconfig import config +from ..runners.remove import Remove +from ..statusdb import StatusDB + + +def remove_game(status_db, game_info): + remote_basedir = config.get_remote_basedir() + path = ( + config.get_games_install_basedir() + / 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_db = gamedb.GameDB() + game_info = game_db.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/src/gamechest/cliactions/run.py b/gamechestcli/src/gamechest/cliactions/run.py new file mode 100644 index 0000000..1c55cc4 --- /dev/null +++ b/gamechestcli/src/gamechest/cliactions/run.py @@ -0,0 +1,59 @@ +import os +import subprocess +import sys +from pathlib import Path + +from ..gameconfig import config +from ..gamedb import GameDB +from ..statusdb import StatusDB + + +def run_game(game_db, profile_id, game_info): + command = game_db.get_game_command(profile_id, game_info['id']) + profile_dir = Path(config.get_profile_dir(profile_id)) + profile_game_dir = profile_dir / game_info['id'] + game_dir = config.get_games_install_basedir() / game_info['id'] + # note: glob('*/') globs regular files too, thus I filter on is_dir. + game_dir = list(item + for item in game_dir.glob('*') if item.is_dir())[-1] + game_dir = Path(game_dir) + #tools_bin_path = config.get_games_saves_tools_bin_path() + #new_env = dict(os.environ) + #new_env['PATH'] = f'{tools_bin_path}:{new_env["PATH"]}' + # path to mod/run scripts are already prepended by get_game_command, no + # need to modify the path environment variable. + profile_lockdir = profile_dir / '.lock' + try: + profile_lockdir.mkdir(parents=True, exist_ok=False) + except FileExistsError: + print(f'ERROR: profile is already locked, {profile_lockdir}', + file=sys.stderr) + sys.exit(1) + try: + subprocess.run( + command, + env={ + **os.environ, + 'PROFILE_DIR': str(profile_game_dir), + 'GAME_DIR': str(game_dir), + 'GAME_ID': game_info['id'], + }, + ) + finally: + profile_lockdir.rmdir() + print(f'INFO: {profile_lockdir} removed') + + +def run(game_id, profile_id): + game_db = GameDB() + status_db = StatusDB() + game_info = game_db.get_game_info(game_id) + if not status_db.is_installed(game_info): + # games is already installed + print('Game', game_id, 'is not installed, aborting.', file=sys.stderr) + return + run_game(game_db, profile_id, game_info) + + +if __name__ == "__main__": + run(*sys.argv[1:]) diff --git a/gamechestcli/src/gamechest/config.py b/gamechestcli/src/gamechest/config.py new file mode 100644 index 0000000..cd0c7e8 --- /dev/null +++ b/gamechestcli/src/gamechest/config.py @@ -0,0 +1,51 @@ +from dataclasses import asdict, dataclass +from pathlib import Path + +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/src/gamechest/consts.py b/gamechestcli/src/gamechest/consts.py new file mode 100644 index 0000000..091fd70 --- /dev/null +++ b/gamechestcli/src/gamechest/consts.py @@ -0,0 +1,2 @@ +XDG_RESOURCE_NAME = 'org.devys.gamechest' +STATUS_DB_NAME = 'status.sqlite' diff --git a/gamechestcli/src/gamechest/gameconfig.py b/gamechestcli/src/gamechest/gameconfig.py new file mode 100644 index 0000000..0b3d855 --- /dev/null +++ b/gamechestcli/src/gamechest/gameconfig.py @@ -0,0 +1,151 @@ +import contextlib +from pathlib import Path + +import yaml +from xdg import xdg_data_home, xdg_config_home + +from . import consts + + +DEFAULT_CONFIG_DICT = { + 'remote_basedir': 'jibril:/storage/games', + 'gamesaves_path': Path('~/syncthing/gamesaves').expanduser(), + + # profile_id: no default for this, only set by user. + 'profile_id': None, +} + + +def path_relative_to_user(path): + # first ensure the path is a Path + path = Path(path) + with contextlib.suppress(ValueError): + return '~' / path.relative_to(Path.home()) + # in case the path is not relative to the user home ValueError is raised, + # thus we return a fallback value (the path unmodified). + return path + + +class GameConfig: + + def __init__(self): + self.config = {} + game_config_path = xdg_config_home() / consts.XDG_RESOURCE_NAME + game_config_path.mkdir(parents=True, exist_ok=True) + game_config_filepath = game_config_path / 'config.yaml' + self.game_config_filepath = game_config_filepath + self.config = {} + with contextlib.ExitStack() as stack: + stack.enter_context(contextlib.suppress(FileNotFoundError)) + fdin = stack.enter_context(open(game_config_filepath, 'r', + encoding='utf8')) + # in case yaml file is empty, None will be returned, in this case + # fallback to empty dict. + self.config = yaml.safe_load(fdin) or {} + self.config = { + **DEFAULT_CONFIG_DICT, + **self.config, + } + # convert game_saves_path to a path if not already the case (in case + # loaded from yaml file). + self.config['gamesaves_path'] = ( + Path(self.config['gamesaves_path']) + .expanduser() + ) + + def get_remote_basedir(self): + return self.config['remote_basedir'] + + def get_gamesaves_path(self): + return self.config['gamesaves_path'] + + def get_games_saves_tools_bin_path(self): + return self.get_gamesaves_path() / 'tools' / 'bin' + + def get_profile_dir(self, profile_id): + return self.get_gamesaves_path() / 'profiles' / profile_id + + def get_games_database_path(self): + return self.get_gamesaves_path() / 'gamedata.yaml' + + def get_data_d(self): + return self.get_gamesaves_path() / 'gamedata.yaml.d' + + def get_profile_id(self): + return self.config['profile_id'] + + def save(self): + dict_transformed_for_save = { **self.config } + dict_transformed_for_save['gamesaves_path'] = str( + path_relative_to_user( + self.config['gamesaves_path'] + ) + ) + with open(self.game_config_filepath, 'w', encoding='utf8') as fdout: + yaml.safe_dump(dict_transformed_for_save, fdout) + + def set_profile_id(self, profile_id): + self.config['profile_id'] = profile_id + + def set_remote_basedir(self, remote_basedir): + self.config['remote_basedir'] = remote_basedir + + def set_gamesaves_path(self, path): + self.config['gamesaves_path'] = Path(path).expanduser() + + def get_games_install_basedir(self): + games_install_path = ( + xdg_data_home() / consts.XDG_RESOURCE_NAME / 'games' + ) + games_install_path.mkdir(parents=True, exist_ok=True) + return games_install_path + + def print_config(self): + print(self.config) + + +# default instance of gameconfig, same instance intended to be shared through +# all modules which needs it. +config = GameConfig() + +class GameStatus: + + def load_yaml(self): + with contextlib.ExitStack() as stack: + stack.enter_context(contextlib.suppress(FileNotFoundError)) + fdin = stack.enter_context(open(self.yaml_path, 'r', + encoding='utf8')) + data = yaml.safe_load(fdin) + self.last_loaded_profile = data.get('last_loaded_profile', + self.last_loaded_profile) + self.last_loaded_game = data.get('last_loaded_game', + self.last_loaded_game) + + def write_yaml(self): + with contextlib.ExitStack() as stack: + stack.enter_context(contextlib.suppress(FileNotFoundError)) + fdout = stack.enter_context(open(self.yaml_path, 'w', + encoding='utf8')) + data = { + 'last_loaded_profile': self.last_loaded_profile, + 'last_loaded_game': self.last_loaded_game, + } + yaml.safe_dump(data, fdout) + + def __init__(self): + self.yaml_path = ( + xdg_data_home() / consts.XDG_RESOURCE_NAME / 'status.yaml' + ) + self.last_loaded_profile = config.get_profile_id() + self.last_loaded_game = None + self.load_yaml() + + def set_last_loaded_profile(self, profile): + self.last_loaded_profile = profile + self.write_yaml() + + def set_last_loaded_game(self, game): + self.last_loaded_game = game + self.write_yaml() + +status = GameStatus() diff --git a/gamechestcli/src/gamechest/gamedb.py b/gamechestcli/src/gamechest/gamedb.py new file mode 100644 index 0000000..1f9e8d8 --- /dev/null +++ b/gamechestcli/src/gamechest/gamedb.py @@ -0,0 +1,61 @@ +import yaml + +from .gameconfig import config + + +class GameDB: + + def __init__(self): + database_path = config.get_games_database_path() + with open(database_path, 'rb') as fdin: + self.db = yaml.safe_load(fdin) + self.last_game_info = None + + def get_game_info(self, game_id=None): + if self.last_game_info and self.last_game_info['id'] == game_id: + return self.last_game_info + game_info = next(game_info + for game_info in self.db['games'] + if game_info['id'] == game_id) + self.last_game_info = game_info + return game_info + + def get_game_env(self, profile_id, game_id=None): + # TODO + raise Error + + def get_game_command(self, profile_id, game_id=None): + game_info = self.get_game_info(game_id) + game_mods = game_info.get('mods', []) + #game_mods += ['locked-run-profiledir'] + #game_dir = config.get_games_install_basedir() / game_info['id'] + ## note: glob('*/') globs regular files too, thus I filter on is_dir. + #game_dir = list(item + # for item in game_dir.glob('*') if item.is_dir())[-1] + tools_bin_path = config.get_games_saves_tools_bin_path() + #profile_dir = config.get_profile_dir(profile_id) + command_from_game_info = [ + f'{tools_bin_path}/runners/{game_info["command"][0]}', + *game_info['command'][1:], + ] if game_info.get('command') else [ + f'{tools_bin_path}/runners/run-{game_info["id"]}', + ] + command = [ + # mods + *[f'{tools_bin_path}/mod-{item}' for item in game_mods], + # runner + *command_from_game_info, + ] + return command + + def get_ids(self): + for game_info in self.db['games']: + yield game_info['id'] + + def get_idtitles(self): + for game_info in self.db['games']: + yield game_info['id'], game_info['title'] + + def get_info(self, game_id): + return next(game_info for game_info in self.db['games'] + if game_info['id'] == game_id) diff --git a/gamechestcli/src/gamechest/processor.py b/gamechestcli/src/gamechest/processor.py new file mode 100644 index 0000000..e31d023 --- /dev/null +++ b/gamechestcli/src/gamechest/processor.py @@ -0,0 +1,41 @@ +import contextlib +import selectors + +from rich import print +from rich.progress import Progress as RichProgress + + +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: + print('Process failed, aborting') + raise SystemExit(1) + print('ended with code:', rc) diff --git a/gamechestcli/src/gamechest/runners/download.py b/gamechestcli/src/gamechest/runners/download.py new file mode 100644 index 0000000..8629b16 --- /dev/null +++ b/gamechestcli/src/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 contextlib + import sys + 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/src/gamechest/runners/extract.py b/gamechestcli/src/gamechest/runners/extract.py new file mode 100644 index 0000000..970c956 --- /dev/null +++ b/gamechestcli/src/gamechest/runners/extract.py @@ -0,0 +1,224 @@ +import os +import re +import select +import struct +import subprocess +import threading +import zipfile + +import humanfriendly + +from ..structures import Progress +from .runnerbase import RunnerBase, neutral_locale_variables + + +class ExtractZip(RunnerBase): + """ + Simple zip wrapper without multithreading. Should not be an issue as zip + is used for little archives only, bigger archives are tar+zstd with + multithreaded algorithms. So keep this class simple. + """ + + def __init__(self, src, dst): + self.last_progress = Progress() + self.read_fd, self.write_fd = os.pipe() + self.cancel_event = threading.Event() + + # No need to lock this unless GIL is disabled. + self.written_bytes = 0 + self.total_bytes = 0 + self.progress = 0 + + def extract_and_report(): + try: + chunk_size = 1024**2 # 1MiB chunks + with zipfile.ZipFile(src, 'r') as zip_ref: + self.total_bytes = sum(getattr(file_info, 'file_size', 0) for file_info in zip_ref.infolist() if not file_info.is_dir()) + for file_info in zip_ref.infolist(): + if file_info.is_dir(): + continue + with zip_ref.open(file_info) as source_file: + target_path = os.path.join(dst, file_info.filename) + os.makedirs(os.path.dirname(target_path), exist_ok=True) + with open(target_path, 'wb') as target_file: + while not self.cancel_event.is_set(): + chunk = source_file.read(chunk_size) + if not chunk or self.cancel_event.is_set(): + # if we have read everything from the + # source file, OR, we got a cancel event + # in-between the read and the write, we + # break. + break + target_file.write(chunk) + self.written_bytes += len(chunk) + self.progress = (self.written_bytes / self.total_bytes) * 100 + #progress_message = f"Progress: {progress:.2f}% ({bytes_written} of {total_bytes} bytes)\n" + os.write(self.write_fd, b'.') + finally: + self.cancel_event.set() + self.progress = 100 + os.write(self.write_fd, b'.') + #os.close(self.read_fd) + #os.close(self.write_fd) + + # Create a thread for extraction + extraction_thread = threading.Thread(target=extract_and_report) + extraction_thread.start() + self.extraction_thread = extraction_thread + + + def get_read_fd(self): + return self.read_fd + + def progress_read(self): + ready, _, _ = select.select([self.read_fd], [], []) + if ready: + # flush any present data from the pipe + os.read(self.read_fd, 1024) + self.last_progress = Progress( + nbbytes=self.written_bytes, + percent=int(100 * self.written_bytes / self.total_bytes), + #speed=humanfriendly.parse_size(match.group(2)), + speed=0, + eta="UNK", + ) + return self.last_progress + + def terminate(self): + self.close() # same as self.close, can be called multiple time + + def poll(self): + return None if not self.cancel_event.is_set() else 0 + + def close(self): + self.cancel_event.set() + self.extraction_thread.join() + + +class ExtractTar(RunnerBase): + + _progress_re = re.compile(r'^\s*(\S+)\s+\[([^]]+)/s\]\s+ETA\s+(\S+)\s*$') + + def __init__(self, src, dst, compression_type): + 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' + uncompress_command_to_use = [lzip_command, '--decompress'] + if compression_type == 'lzip': + if os.path.exists(plzip_command): + uncompress_command_to_use = [plzip_command, '--decompress'] + elif compression_type == 'zstd': + uncompress_command_to_use = ['zstd', '-T0', '-d', '--stdout'] + self.zip_proc = subprocess.Popen( + uncompress_command_to_use, + 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() + + +class Extract(RunnerBase): + def __init__(self, src, dst): + zip_magics = ( + b'PK\x03\x04', + b'PK\x05\x06', # empty + b'PK\x07\x08', # spanned + ) + + first_word_magic = 0 + first_word_magic_b = b'' + compression_type = None + with open(src, 'rb') as stream: + first_word_magic_b = stream.read(4) + first_word_magic = struct.unpack('>L', first_word_magic_b)[0] + if first_word_magic_b in zip_magics: + compression_type = 'zip' + elif first_word_magic == 0x4c5a4950: # lzip magic + compression_type = 'lzip' + elif first_word_magic == 0x28B52FFD: # zstd magic + compression_type = 'zstd' + if compression_type == 'zip': + self.inner_class = ExtractZip(src, dst) + else: + self.inner_class = ExtractTar(src, dst, compression_type) + + def get_read_fd(self): + return self.inner_class.get_read_fd() + + def progress_read(self): + return self.inner_class.progress_read() + + def terminate(self): + return self.inner_class.terminate() + + def poll(self): + return self.inner_class.poll() + + def close(self): + return self.inner_class.close() + + +if __name__ == '__main__': + import contextlib + import sys + 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/src/gamechest/runners/install.py b/gamechestcli/src/gamechest/runners/install.py new file mode 100644 index 0000000..0b37304 --- /dev/null +++ b/gamechestcli/src/gamechest/runners/install.py @@ -0,0 +1,40 @@ +import functools +from pathlib import Path + +from .download import Download +from .extract import Extract +from .remove import Remove +from .runnermulti import MultiSequencialRunnerBase + + +class Install(MultiSequencialRunnerBase): + + def __init__(self, source, dest): + tmpdest = Path(dest) / 'archive.rsynctmp' + runners = [ + functools.partial(Download, source, tmpdest), + functools.partial(Extract, tmpdest, dest), + functools.partial(Remove, tmpdest), + ] + super().__init__(runners) + + +if __name__ == '__main__': + import contextlib + import selectors + import sys + 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/src/gamechest/runners/remove.py b/gamechestcli/src/gamechest/runners/remove.py new file mode 100644 index 0000000..87177f3 --- /dev/null +++ b/gamechestcli/src/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 contextlib + import selectors + import sys + import time + + 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/src/gamechest/runners/runnerbase.py b/gamechestcli/src/gamechest/runners/runnerbase.py new file mode 100644 index 0000000..ac64ebb --- /dev/null +++ b/gamechestcli/src/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/src/gamechest/runners/runnermulti.py b/gamechestcli/src/gamechest/runners/runnermulti.py new file mode 100644 index 0000000..2a5e17c --- /dev/null +++ b/gamechestcli/src/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/src/gamechest/statusdb.py b/gamechestcli/src/gamechest/statusdb.py new file mode 100644 index 0000000..3f2af31 --- /dev/null +++ b/gamechestcli/src/gamechest/statusdb.py @@ -0,0 +1,106 @@ +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 + ) + --CREATE TABLE IF NOT EXISTS last_items ( + -- game_id text, + -- profile_id text, + -- type 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 self.conn: + 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 set_last_played_game(self, game_id): + #cursor = self.conn.cursor() + #with self.conn: + # cursor.execute(''' + # INSERT INTO + # game_id text, + # profile_id text, + # type text, + # ''') + pass + + def set_last_played_profile(self, profile_id): + pass + + def get_installed(self): + for row in ( + self.conn + .execute('SELECT game_id FROM status WHERE installed = 1') + ): + yield row[0] + + def set_uninstalled(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/src/gamechest/structures.py b/gamechestcli/src/gamechest/structures.py new file mode 100644 index 0000000..35a8861 --- /dev/null +++ b/gamechestcli/src/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" -- cgit v1.2.3