diff options
Diffstat (limited to 'gamechestcli')
-rwxr-xr-x | gamechestcli/__main__.py | 3 | ||||
-rw-r--r-- | gamechestcli/gamechest/cliactions/run.py | 31 | ||||
-rw-r--r-- | gamechestcli/gamechest/gameconfig.py | 5 | ||||
-rw-r--r-- | gamechestcli/gamechest/gamedb.py | 29 | ||||
-rw-r--r-- | gamechestcli/gamechest/runners/extract.py | 140 | ||||
-rw-r--r-- | gamechestcli/gamechest/runners/install.py | 2 | ||||
-rw-r--r-- | gamechestcli/requirements.txt | 2 |
7 files changed, 184 insertions, 28 deletions
diff --git a/gamechestcli/__main__.py b/gamechestcli/__main__.py index 73d5bab..8452b99 100755 --- a/gamechestcli/__main__.py +++ b/gamechestcli/__main__.py @@ -9,6 +9,9 @@ Usage: gamechest install <GAME_ID> 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 diff --git a/gamechestcli/gamechest/cliactions/run.py b/gamechestcli/gamechest/cliactions/run.py index d6791d0..1c55cc4 100644 --- a/gamechestcli/gamechest/cliactions/run.py +++ b/gamechestcli/gamechest/cliactions/run.py @@ -1,18 +1,47 @@ +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. - subprocess.run(command) + 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): diff --git a/gamechestcli/gamechest/gameconfig.py b/gamechestcli/gamechest/gameconfig.py index 5e46efa..0b3d855 100644 --- a/gamechestcli/gamechest/gameconfig.py +++ b/gamechestcli/gamechest/gameconfig.py @@ -34,11 +34,14 @@ class GameConfig: 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')) - self.config = yaml.safe_load(fdin) + # 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, diff --git a/gamechestcli/gamechest/gamedb.py b/gamechestcli/gamechest/gamedb.py index bec90d3..1f9e8d8 100644 --- a/gamechestcli/gamechest/gamedb.py +++ b/gamechestcli/gamechest/gamedb.py @@ -27,24 +27,25 @@ class GameDB: 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] - + #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) - + #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], - profile_dir, - game_dir, - *[item if index > 0 else f'{tools_bin_path}/runners/{item}' - for index, item in enumerate(game_info['command'])], + # runner + *command_from_game_info, ] - return command def get_ids(self): diff --git a/gamechestcli/gamechest/runners/extract.py b/gamechestcli/gamechest/runners/extract.py index 6f13d89..970c956 100644 --- a/gamechestcli/gamechest/runners/extract.py +++ b/gamechestcli/gamechest/runners/extract.py @@ -1,7 +1,10 @@ import os import re +import select import struct import subprocess +import threading +import zipfile import humanfriendly @@ -9,13 +12,94 @@ from ..structures import Progress from .runnerbase import RunnerBase, neutral_locale_variables -class Extract(RunnerBase): +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): - import sys - print('src', src, 'dst', dst, file=sys.stderr) + def __init__(self, src, dst, compression_type): common_parameters = dict( encoding='utf8', env={**os.environ, @@ -37,13 +121,10 @@ class Extract(RunnerBase): lzip_command = '/usr/bin/lzip' plzip_command = '/usr/bin/plzip' uncompress_command_to_use = [lzip_command, '--decompress'] - first_word_magic = 0 - with open(src, 'rb') as stream: - first_word_magic = struct.unpack('>L', stream.read(4))[0] - if first_word_magic == 0x4c5a4950: # lzip magic + if compression_type == 'lzip': if os.path.exists(plzip_command): uncompress_command_to_use = [plzip_command, '--decompress'] - elif first_word_magic == 0x28B52FFD: + elif compression_type == 'zstd': uncompress_command_to_use = ['zstd', '-T0', '-d', '--stdout'] self.zip_proc = subprocess.Popen( uncompress_command_to_use, @@ -90,6 +171,47 @@ class Extract(RunnerBase): 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 diff --git a/gamechestcli/gamechest/runners/install.py b/gamechestcli/gamechest/runners/install.py index 7b2ff7c..0b37304 100644 --- a/gamechestcli/gamechest/runners/install.py +++ b/gamechestcli/gamechest/runners/install.py @@ -1,5 +1,4 @@ import functools -import os from pathlib import Path from .download import Download @@ -24,7 +23,6 @@ if __name__ == '__main__': import contextlib import selectors import sys - import time print('main test') with contextlib.ExitStack() as stack: stack.enter_context(contextlib.suppress(KeyboardInterrupt)) diff --git a/gamechestcli/requirements.txt b/gamechestcli/requirements.txt index 638d583..9531801 100644 --- a/gamechestcli/requirements.txt +++ b/gamechestcli/requirements.txt @@ -1,4 +1,4 @@ -docopt +docopt-ng humanfriendly pyyaml rich |