diff options
| author | vg <vgm+dev@devys.org> | 2026-02-04 17:02:50 +0100 |
|---|---|---|
| committer | vg <vgm+dev@devys.org> | 2026-02-04 17:02:50 +0100 |
| commit | dfd985bcc2b15390e29dc7f779cf2edb69071ca0 (patch) | |
| tree | 27a76dd6f5a1dbd0996bc1f91bebabd58f5e6620 /gamechestcli/src | |
| parent | 26e22b96e6e1f1cd4669887e2be2f23b82ae4323 (diff) | |
| download | gamechest-dfd985bcc2b15390e29dc7f779cf2edb69071ca0.tar.gz gamechest-dfd985bcc2b15390e29dc7f779cf2edb69071ca0.tar.bz2 gamechest-dfd985bcc2b15390e29dc7f779cf2edb69071ca0.zip | |
git-sync on dita
Diffstat (limited to 'gamechestcli/src')
| -rwxr-xr-x | gamechestcli/src/__main__.py | 67 | ||||
| -rw-r--r-- | gamechestcli/src/gamechest/cliactions/install.py | 41 | ||||
| -rw-r--r-- | gamechestcli/src/gamechest/cliactions/remove.py | 38 | ||||
| -rw-r--r-- | gamechestcli/src/gamechest/cliactions/run.py | 59 | ||||
| -rw-r--r-- | gamechestcli/src/gamechest/config.py | 51 | ||||
| -rw-r--r-- | gamechestcli/src/gamechest/consts.py | 2 | ||||
| -rw-r--r-- | gamechestcli/src/gamechest/gameconfig.py | 151 | ||||
| -rw-r--r-- | gamechestcli/src/gamechest/gamedb.py | 61 | ||||
| -rw-r--r-- | gamechestcli/src/gamechest/processor.py | 41 | ||||
| -rw-r--r-- | gamechestcli/src/gamechest/runners/download.py | 68 | ||||
| -rw-r--r-- | gamechestcli/src/gamechest/runners/extract.py | 224 | ||||
| -rw-r--r-- | gamechestcli/src/gamechest/runners/install.py | 40 | ||||
| -rw-r--r-- | gamechestcli/src/gamechest/runners/remove.py | 150 | ||||
| -rw-r--r-- | gamechestcli/src/gamechest/runners/runnerbase.py | 44 | ||||
| -rw-r--r-- | gamechestcli/src/gamechest/runners/runnermulti.py | 91 | ||||
| -rw-r--r-- | gamechestcli/src/gamechest/statusdb.py | 106 | ||||
| -rw-r--r-- | gamechestcli/src/gamechest/structures.py | 38 |
17 files changed, 1272 insertions, 0 deletions
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 <GAME_ID> + gamechest remove <GAME_ID> + gamechest run [--profile_id=<PROFILE_ID>] <GAME_ID> + gamechest set [--profile_id=<PROFILE_ID>] [--remote_basedir=<PATH>] [--gamesaves_path=<PATH>] + gamechest list [--installed|--not-installed] + gamechest showconfig + +Options: + --profile_id=<PROFILE_ID>, -p <PROFILE_ID> + use profile <PROFILE_ID> 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['<GAME_ID>']) + elif args['remove']: + remove.remove(args['<GAME_ID>']) + 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['<GAME_ID>'], 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" |
