🎨 SourceIterator can yield addtitional data

SourceIterator-s can yield a game and a tuple of additional data.
This data will be passed to the Store, Pipeline and Managers.
This commit is contained in:
GeoffreyCoulaud
2023-06-07 14:01:06 +02:00
parent 7eef050a64
commit 98f02da36c
12 changed files with 68 additions and 37 deletions

View File

@@ -3,9 +3,9 @@ import logging
from gi.repository import Adw, Gio, Gtk from gi.repository import Adw, Gio, Gtk
from src import shared from src import shared
from src.game import Game
from src.utils.task import Task from src.utils.task import Task
from src.store.pipeline import Pipeline from src.store.pipeline import Pipeline
from src.store.managers.manager import Manager
from src.importer.sources.source import Source from src.importer.sources.source import Source
@@ -92,6 +92,7 @@ class Importer:
def source_task_thread_func(self, _task, _obj, data, _cancellable): def source_task_thread_func(self, _task, _obj, data, _cancellable):
"""Source import task code""" """Source import task code"""
source: Source
source, *_rest = data source, *_rest = data
# Early exit if not installed # Early exit if not installed
@@ -107,20 +108,35 @@ class Importer:
while True: while True:
# Handle exceptions raised when iterating # Handle exceptions raised when iterating
try: try:
game = next(iterator) iteration_result = next(iterator)
except StopIteration: except StopIteration:
break break
except Exception as exception: # pylint: disable=broad-exception-caught except Exception as exception: # pylint: disable=broad-exception-caught
logging.exception( logging.exception(
msg=f"Exception in source {source.id}", "Exception in source %s", source.id, exc_info=exception
exc_info=exception,
) )
continue continue
if game is None:
# Handle the result depending on its type
if isinstance(iteration_result, Game):
game = iteration_result
additional_data = tuple()
elif isinstance(iteration_result, tuple):
game, additional_data = iteration_result
elif iteration_result is None:
continue
else:
# Warn source implementers that an invalid type was produced
# Should not happen on production code
logging.warn(
"%s produced an invalid iteration return type %s",
source.id,
type(iteration_result),
)
continue continue
# Register game # Register game
pipeline: Pipeline = shared.store.add_game(game) pipeline: Pipeline = shared.store.add_game(game, additional_data)
if pipeline is not None: if pipeline is not None:
logging.info("Imported %s (%s)", game.name, game.game_id) logging.info("Imported %s (%s)", game.name, game.game_id)
pipeline.connect("advanced", self.pipeline_advanced_callback) pipeline.connect("advanced", self.pipeline_advanced_callback)

View File

@@ -48,12 +48,9 @@ class ItchSourceIterator(SourceIterator):
"game_id": self.source.game_id_format.format(game_id=row[0]), "game_id": self.source.game_id_format.format(game_id=row[0]),
"executable": self.source.executable_format.format(cave_id=row[4]), "executable": self.source.executable_format.format(cave_id=row[4]),
} }
yield Game(values, allow_side_effects=False) additional_data = (row[3], row[2])
game = Game(values, allow_side_effects=False)
# TODO pass image URIs to the pipeline somehow yield (game, additional_data)
# - Add a reserved field to the Game object
# - Reconstruct those from the pipeline (we already have them)
# - Pass game and additional data to the pipeline separately (requires deep changes)
class ItchSource(Source): class ItchSource(Source):

View File

@@ -3,12 +3,15 @@ from abc import abstractmethod
from collections.abc import Iterable, Iterator from collections.abc import Iterable, Iterator
from functools import wraps from functools import wraps
from pathlib import Path from pathlib import Path
from typing import Generator, Optional from typing import Generator, Any
from src import shared from src import shared
from src.game import Game from src.game import Game
from src.utils.decorators import replaced_by_path from src.utils.decorators import replaced_by_path
# Type of the data returned by iterating on a Source
SourceIterationResult = None | Game | tuple[Game, tuple[Any]]
class SourceIterator(Iterator): class SourceIterator(Iterator):
"""Data producer for a source of games""" """Data producer for a source of games"""
@@ -24,11 +27,11 @@ class SourceIterator(Iterator):
def __iter__(self) -> "SourceIterator": def __iter__(self) -> "SourceIterator":
return self return self
def __next__(self) -> Optional[Game]: def __next__(self) -> SourceIterationResult:
return next(self.generator) return next(self.generator)
@abstractmethod @abstractmethod
def generator_builder(self) -> Generator[Optional[Game], None, None]: def generator_builder(self) -> Generator[SourceIterationResult, None, None]:
""" """
Method that returns a generator that produces games Method that returns a generator that produces games
* Should be implemented as a generator method * Should be implemented as a generator method
@@ -50,7 +53,6 @@ class Source(Iterable):
super().__init__() super().__init__()
self.available_on = set() self.available_on = set()
@property @property
def full_name(self) -> str: def full_name(self) -> str:
"""The source's full name""" """The source's full name"""

View File

@@ -133,7 +133,7 @@ class CartridgesApplication(Adw.Application):
for game_file in shared.games_dir.iterdir(): for game_file in shared.games_dir.iterdir():
data = json.load(game_file.open()) data = json.load(game_file.open())
game = Game(data, allow_side_effects=False) game = Game(data, allow_side_effects=False)
shared.store.add_game(game) shared.store.add_game(game, tuple())
def on_about_action(self, *_args): def on_about_action(self, *_args):
about = Adw.AboutWindow( about = Adw.AboutWindow(

View File

@@ -26,16 +26,18 @@ class AsyncManager(Manager):
Already scheduled Tasks will no longer be cancellable.""" Already scheduled Tasks will no longer be cancellable."""
self.cancellable = Gio.Cancellable() self.cancellable = Gio.Cancellable()
def process_game(self, game: Game, callback: Callable[["Manager"], Any]) -> None: def process_game(
self, game: Game, additional_data: tuple, callback: Callable[["Manager"], Any]
) -> None:
"""Create a task to process the game in a separate thread""" """Create a task to process the game in a separate thread"""
task = Task.new(None, self.cancellable, self._task_callback, (callback,)) task = Task.new(None, self.cancellable, self._task_callback, (callback,))
task.set_task_data((game,)) task.set_task_data((game, additional_data))
task.run_in_thread(self._task_thread_func) task.run_in_thread(self._task_thread_func)
def _task_thread_func(self, _task, _source_object, data, cancellable): def _task_thread_func(self, _task, _source_object, data, cancellable):
"""Task thread entry point""" """Task thread entry point"""
game, *_rest = data game, additional_data, *_rest = data
self.execute_resilient_manager_logic(game) self.execute_resilient_manager_logic(game, additional_data)
def _task_callback(self, _source_object, _result, data): def _task_callback(self, _source_object, _result, data):
"""Method run after the task is done""" """Method run after the task is done"""

View File

@@ -10,7 +10,7 @@ class DisplayManager(Manager):
run_after = set((SteamAPIManager, SGDBManager)) run_after = set((SteamAPIManager, SGDBManager))
def manager_logic(self, game: Game) -> None: def manager_logic(self, game: Game, _additional_data: tuple) -> None:
# TODO decouple a game from its widget # TODO decouple a game from its widget
shared.win.games[game.game_id] = game shared.win.games[game.game_id] = game
game.update() game.update()

View File

@@ -8,5 +8,5 @@ class FileManager(AsyncManager):
run_after = set((SteamAPIManager,)) run_after = set((SteamAPIManager,))
def manager_logic(self, game: Game) -> None: def manager_logic(self, game: Game, _additional_data: tuple) -> None:
game.save() game.save()

View File

@@ -37,7 +37,7 @@ class Manager:
self.errors_lock = Lock() self.errors_lock = Lock()
def report_error(self, error: Exception): def report_error(self, error: Exception):
"""Report an error that happened in Manager.run""" """Report an error that happened in Manager.process_game"""
with self.errors_lock: with self.errors_lock:
self.errors.append(error) self.errors.append(error)
@@ -49,7 +49,7 @@ class Manager:
return errors return errors
@abstractmethod @abstractmethod
def manager_logic(self, game: Game) -> None: def manager_logic(self, game: Game, additional_data: tuple) -> None:
""" """
Manager specific logic triggered by the run method Manager specific logic triggered by the run method
* Implemented by final child classes * Implemented by final child classes
@@ -58,10 +58,12 @@ class Manager:
* May raise other exceptions that will be reported * May raise other exceptions that will be reported
""" """
def execute_resilient_manager_logic(self, game: Game, try_index: int = 0) -> None: def execute_resilient_manager_logic(
self, game: Game, additional_data: tuple, try_index: int = 0
) -> None:
"""Execute the manager logic and handle its errors by reporting them or retrying""" """Execute the manager logic and handle its errors by reporting them or retrying"""
try: try:
self.manager_logic(game) self.manager_logic(game, additional_data)
except Exception as error: except Exception as error:
if error in self.continue_on: if error in self.continue_on:
# Handle skippable errors (skip silently) # Handle skippable errors (skip silently)
@@ -71,7 +73,9 @@ class Manager:
# Handle retryable errors # Handle retryable errors
logging_format = "Retrying %s in %s for %s" logging_format = "Retrying %s in %s for %s"
sleep(self.retry_delay) sleep(self.retry_delay)
self.execute_resilient_manager_logic(game, try_index + 1) self.execute_resilient_manager_logic(
game, additional_data, try_index + 1
)
else: else:
# Handle being out of retries # Handle being out of retries
logging_format = "Out of retries dues to %s in %s for %s" logging_format = "Out of retries dues to %s in %s for %s"
@@ -88,7 +92,9 @@ class Manager:
f"{game.name} ({game.game_id})", f"{game.name} ({game.game_id})",
) )
def process_game(self, game: Game, callback: Callable[["Manager"], Any]) -> None: def process_game(
self, game: Game, additional_data: tuple, callback: Callable[["Manager"], Any]
) -> None:
"""Pass the game through the manager""" """Pass the game through the manager"""
self.execute_resilient_manager_logic(game) self.execute_resilient_manager_logic(game, additional_data)
callback(self) callback(self)

View File

@@ -1,3 +1,5 @@
from urllib3.exceptions import SSLError
from src.game import Game from src.game import Game
from src.store.managers.async_manager import AsyncManager from src.store.managers.async_manager import AsyncManager
from src.store.managers.steam_api_manager import SteamAPIManager from src.store.managers.steam_api_manager import SteamAPIManager
@@ -8,9 +10,9 @@ class SGDBManager(AsyncManager):
"""Manager in charge of downloading a game's cover from steamgriddb""" """Manager in charge of downloading a game's cover from steamgriddb"""
run_after = set((SteamAPIManager,)) run_after = set((SteamAPIManager,))
retryable_on = set((HTTPError,)) retryable_on = set((HTTPError, SSLError))
def manager_logic(self, game: Game) -> None: def manager_logic(self, game: Game, _additional_data: tuple) -> None:
try: try:
sgdb = SGDBHelper() sgdb = SGDBHelper()
sgdb.conditionaly_update_cover(game) sgdb.conditionaly_update_cover(game)

View File

@@ -15,7 +15,7 @@ class SteamAPIManager(AsyncManager):
retryable_on = set((HTTPError, SSLError)) retryable_on = set((HTTPError, SSLError))
def manager_logic(self, game: Game) -> None: def manager_logic(self, game: Game, _additional_data: tuple) -> None:
# Skip non-steam games # Skip non-steam games
if not game.source.startswith("steam_"): if not game.source.startswith("steam_"):
return return

View File

@@ -11,14 +11,18 @@ class Pipeline(GObject.Object):
"""Class representing a set of managers for a game""" """Class representing a set of managers for a game"""
game: Game game: Game
additional_data: tuple
waiting: set[Manager] waiting: set[Manager]
running: set[Manager] running: set[Manager]
done: set[Manager] done: set[Manager]
def __init__(self, game: Game, managers: Iterable[Manager]) -> None: def __init__(
self, game: Game, additional_data: tuple, managers: Iterable[Manager]
) -> None:
super().__init__() super().__init__()
self.game = game self.game = game
self.additional_data = additional_data
self.waiting = set(managers) self.waiting = set(managers)
self.running = set() self.running = set()
self.done = set() self.done = set()
@@ -72,7 +76,7 @@ class Pipeline(GObject.Object):
for manager in (*parallel, *blocking): for manager in (*parallel, *blocking):
self.waiting.remove(manager) self.waiting.remove(manager)
self.running.add(manager) self.running.add(manager)
manager.process_game(self.game, self.manager_callback) manager.process_game(self.game, self.additional_data, self.manager_callback)
def manager_callback(self, manager: Manager) -> None: def manager_callback(self, manager: Manager) -> None:
"""Method called by a manager when it's done""" """Method called by a manager when it's done"""

View File

@@ -20,7 +20,9 @@ class Store:
"""Add a manager class that will run when games are added""" """Add a manager class that will run when games are added"""
self.managers.add(manager) self.managers.add(manager)
def add_game(self, game: Game, replace=False) -> Pipeline | None: def add_game(
self, game: Game, additional_data: tuple, replace=False
) -> Pipeline | None:
"""Add a game to the app if not already there """Add a game to the app if not already there
:param replace bool: Replace the game if it already exists :param replace bool: Replace the game if it already exists
@@ -49,7 +51,7 @@ class Store:
return None return None
# Run the pipeline for the game # Run the pipeline for the game
pipeline = Pipeline(game, self.managers) pipeline = Pipeline(game, additional_data, self.managers)
self.games[game.game_id] = game self.games[game.game_id] = game
self.pipelines[game.game_id] = pipeline self.pipelines[game.game_id] = pipeline
pipeline.advance() pipeline.advance()