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