🎨 Various code style / behaviour fixes

- Merged platform sources when possible
- Added URLExecutableSource class
- Moved replaced_by_schema_key to utils/decorators
- Better retryable exception handling in some managers
-  Split SteamHelper into SteamFileHelper and SteamAPIHelper
- Delegated SteamRateLimiter creation to SteamAPIManager init
- Using additional_data for appid in SteamAPIManager
- Added Windows support for Legendary
- Stylistic changed suggested by pylint
This commit is contained in:
GeoffreyCoulaud
2023-06-10 02:59:41 +02:00
parent 070d875ff8
commit 842f9fe522
17 changed files with 182 additions and 224 deletions

View File

@@ -6,12 +6,15 @@ import yaml
from src import shared
from src.game import Game
from src.importer.sources.source import (
LinuxSource,
Source,
SourceIterationResult,
SourceIterator,
URLExecutableSource,
)
from src.utils.decorators import (
replaced_by_env_path,
replaced_by_path,
replaced_by_schema_key,
)
from src.utils.decorators import replaced_by_env_path, replaced_by_path
class BottlesSourceIterator(SourceIterator):
@@ -49,22 +52,16 @@ class BottlesSourceIterator(SourceIterator):
yield (game, additional_data)
class BottlesSource(Source):
class BottlesSource(URLExecutableSource):
"""Generic Bottles source"""
name = "Bottles"
location_key = "bottles-location"
def __iter__(self) -> SourceIterator:
return BottlesSourceIterator(self)
class BottlesLinuxSource(BottlesSource, LinuxSource):
variant = "linux"
executable_format = 'xdg-open bottles:run/"{bottle_name}"/"{game_name}"'
iterator_class = BottlesSourceIterator
url_format = 'bottles:run/"{bottle_name}"/"{game_name}"'
available_on = set(("linux",))
@property
@BottlesSource.replaced_by_schema_key()
@replaced_by_schema_key
@replaced_by_path("~/.var/app/com.usebottles.bottles/data/bottles/")
@replaced_by_env_path("XDG_DATA_HOME", "bottles/")
@replaced_by_path("~/.local/share/bottles/")

View File

@@ -9,13 +9,15 @@ from typing import Optional, TypedDict
from src import shared
from src.game import Game
from src.importer.sources.source import (
LinuxSource,
Source,
URLExecutableSource,
SourceIterationResult,
SourceIterator,
WindowsSource,
)
from src.utils.decorators import replaced_by_env_path, replaced_by_path
from src.utils.decorators import (
replaced_by_env_path,
replaced_by_path,
replaced_by_schema_key,
)
class HeroicLibraryEntry(TypedDict):
@@ -112,40 +114,24 @@ class HeroicSourceIterator(SourceIterator):
yield result
class HeroicSource(Source):
class HeroicSource(URLExecutableSource):
"""Generic heroic games launcher source"""
name = "Heroic"
location_key = "heroic-location"
iterator_class = HeroicSourceIterator
url_format = "heroic://launch/{app_name}"
available_on = set(("linux", "win32"))
@property
def game_id_format(self) -> str:
"""The string format used to construct game IDs"""
return self.name.lower() + "_{service}_{game_id}"
def __iter__(self):
return HeroicSourceIterator(source=self)
class HeroicLinuxSource(HeroicSource, LinuxSource):
variant = "linux"
executable_format = "xdg-open heroic://launch/{app_name}"
@property
@HeroicSource.replaced_by_schema_key()
@replaced_by_schema_key
@replaced_by_path("~/.var/app/com.heroicgameslauncher.hgl/config/heroic/")
@replaced_by_env_path("XDG_CONFIG_HOME", "heroic/")
@replaced_by_path("~/.config/heroic/")
def location(self) -> Path:
raise FileNotFoundError()
class HeroicWindowsSource(HeroicSource, WindowsSource):
variant = "windows"
executable_format = "start heroic://launch/{app_name}"
@property
@HeroicSource.replaced_by_schema_key()
@replaced_by_env_path("appdata", "heroic/")
def location(self) -> Path:
raise FileNotFoundError()

View File

@@ -5,13 +5,15 @@ from time import time
from src import shared
from src.game import Game
from src.importer.sources.source import (
LinuxSource,
Source,
SourceIterationResult,
SourceIterator,
WindowsSource,
URLExecutableSource,
)
from src.utils.decorators import (
replaced_by_env_path,
replaced_by_path,
replaced_by_schema_key,
)
from src.utils.decorators import replaced_by_env_path, replaced_by_path
class ItchSourceIterator(SourceIterator):
@@ -54,33 +56,17 @@ class ItchSourceIterator(SourceIterator):
yield (game, additional_data)
class ItchSource(Source):
class ItchSource(URLExecutableSource):
name = "Itch"
location_key = "itch-location"
def __iter__(self) -> SourceIterator:
return ItchSourceIterator(self)
class ItchLinuxSource(ItchSource, LinuxSource):
variant = "linux"
executable_format = "xdg-open itch://caves/{cave_id}/launch"
iterator_class = ItchSourceIterator
url_format = "itch://caves/{cave_id}/launch"
available_on = set(("linux", "win32"))
@property
@ItchSource.replaced_by_schema_key()
@replaced_by_schema_key
@replaced_by_path("~/.var/app/io.itch.itch/config/itch/")
@replaced_by_env_path("XDG_DATA_HOME", "itch/")
@replaced_by_path("~/.config/itch")
def location(self) -> Path:
raise FileNotFoundError()
class ItchWindowsSource(ItchSource, WindowsSource):
variant = "windows"
executable_format = "start itch://caves/{cave_id}/launch"
@property
@ItchSource.replaced_by_schema_key()
@replaced_by_env_path("appdata", "itch/")
def location(self) -> Path:
raise FileNotFoundError()

View File

@@ -1,19 +1,18 @@
import logging
from pathlib import Path
from typing import Generator
import json
import logging
from json import JSONDecodeError
from pathlib import Path
from time import time
from typing import Generator
from src import shared
from src.game import Game
from src.importer.sources.source import (
LinuxSource,
Source,
SourceIterationResult,
SourceIterator,
from src.importer.sources.source import Source, SourceIterationResult, SourceIterator
from src.utils.decorators import (
replaced_by_env_path,
replaced_by_path,
replaced_by_schema_key,
)
from src.utils.decorators import replaced_by_env_path, replaced_by_path
class LegendarySourceIterator(SourceIterator):
@@ -74,22 +73,14 @@ class LegendarySourceIterator(SourceIterator):
class LegendarySource(Source):
name = "Legendary"
location_key = "legendary-location"
def __iter__(self) -> SourceIterator:
return LegendarySourceIterator(self)
# TODO add Legendary windows variant
class LegendaryLinuxSource(LegendarySource, LinuxSource):
variant = "linux"
executable_format = "legendary launch {app_name}"
iterator_class = LegendarySourceIterator
available_on = set(("linux", "win32"))
@property
@LegendarySource.replaced_by_schema_key()
@replaced_by_schema_key
@replaced_by_env_path("XDG_CONFIG_HOME", "legendary/")
@replaced_by_path("~/.config/legendary/")
@replaced_by_path("~\\.config\\legendary\\")
def location(self) -> Path:
raise FileNotFoundError()

View File

@@ -4,12 +4,11 @@ from time import time
from src import shared
from src.game import Game
from src.importer.sources.source import (
LinuxSource,
Source,
SourceIterationResult,
SourceIterator,
URLExecutableSource,
)
from src.utils.decorators import replaced_by_path
from src.utils.decorators import replaced_by_path, replaced_by_schema_key
class LutrisSourceIterator(SourceIterator):
@@ -47,7 +46,6 @@ class LutrisSourceIterator(SourceIterator):
game_id=row[2], game_internal_id=row[0]
),
"executable": self.source.executable_format.format(game_id=row[2]),
"developer": None, # TODO get developer metadata on Lutris
}
game = Game(values, allow_side_effects=False)
@@ -59,26 +57,20 @@ class LutrisSourceIterator(SourceIterator):
yield (game, additional_data)
class LutrisSource(Source):
class LutrisSource(URLExecutableSource):
"""Generic lutris source"""
name = "Lutris"
location_key = "lutris-location"
iterator_class = LutrisSourceIterator
url_format = "lutris:rungameid/{game_id}"
available_on = set(("linux",))
@property
def game_id_format(self):
return super().game_id_format + "_{game_internal_id}"
def __iter__(self):
return LutrisSourceIterator(source=self)
class LutrisLinuxSource(LutrisSource, LinuxSource):
variant = "linux"
executable_format = "xdg-open lutris:rungameid/{game_id}"
@property
@LutrisSource.replaced_by_schema_key()
@replaced_by_schema_key
@replaced_by_path("~/.var/app/net.lutris.Lutris/data/lutris/")
@replaced_by_path("~/.local/share/lutris/")
def location(self):

View File

@@ -1,13 +1,11 @@
import sys
from abc import abstractmethod
from collections.abc import Iterable, Iterator
from functools import wraps
from pathlib import Path
from typing import Generator, Any, TypedDict
from typing import Generator, Any
from src import shared
from src.game import Game
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]]
@@ -45,13 +43,9 @@ class Source(Iterable):
"""Source of games. E.g an installed app with a config file that lists game directories"""
name: str
variant: str
location_key: str
available_on: set[str]
def __init__(self) -> None:
super().__init__()
self.available_on = set()
iterator_class: type[SourceIterator]
variant: str = None
available_on: set[str] = set()
@property
def full_name(self) -> str:
@@ -83,6 +77,14 @@ class Source(Iterable):
return False
return sys.platform in self.available_on
@property
def location_key(self) -> str:
"""
The schema key pointing to the user-set location for the source.
May be overriden by inherinting classes.
"""
return f"{self.name.lower()}-location"
def update_location_schema_key(self):
"""Update the schema value for this source's location if possible"""
try:
@@ -91,19 +93,9 @@ class Source(Iterable):
return
shared.schema.set_string(self.location_key, location)
@classmethod
def replaced_by_schema_key(cls): # Decorator builder
"""Replace the returned path with schema's path if valid"""
def decorator(original_function): # Built decorator (closure)
@wraps(original_function)
def wrapper(*args, **kwargs): # func's override
override = shared.schema.get_string(cls.location_key)
return replaced_by_path(override)(original_function)(*args, **kwargs)
return wrapper
return decorator
def __iter__(self) -> SourceIterator:
"""Get an iterator for the source"""
return self.iterator_class(self)
@property
@abstractmethod
@@ -115,22 +107,21 @@ class Source(Iterable):
def executable_format(self) -> str:
"""The executable format used to construct game executables"""
@abstractmethod
def __iter__(self) -> SourceIterator:
"""Get the source's iterator, to use in for loops"""
# pylint: disable=abstract-method
class URLExecutableSource(Source):
"""Source class that use custom URLs to start games"""
class WindowsSource(Source):
"""Mixin for sources available on Windows"""
url_format: str
def __init__(self) -> None:
super().__init__()
self.available_on.add("win32")
class LinuxSource(Source):
"""Mixin for sources available on Linux"""
def __init__(self) -> None:
super().__init__()
self.available_on.add("linux")
@property
def executable_format(self) -> str:
match sys.platform:
case "win32":
return "start " + self.url_format
case "linux":
return "xdg-open " + self.url_format
case other:
raise NotImplementedError(
f"No URL handler command available for {other}"
)

View File

@@ -6,14 +6,16 @@ from typing import Iterable
from src import shared
from src.game import Game
from src.importer.sources.source import (
LinuxSource,
Source,
SourceIterationResult,
SourceIterator,
WindowsSource,
URLExecutableSource,
)
from src.utils.decorators import replaced_by_env_path, replaced_by_path
from src.utils.steam import SteamHelper, SteamInvalidManifestError
from src.utils.decorators import (
replaced_by_env_path,
replaced_by_path,
replaced_by_schema_key,
)
from src.utils.steam import SteamFileHelper, SteamInvalidManifestError
class SteamSourceIterator(SourceIterator):
@@ -22,11 +24,11 @@ class SteamSourceIterator(SourceIterator):
def get_manifest_dirs(self) -> Iterable[Path]:
"""Get dirs that contain steam app manifests"""
libraryfolders_path = self.source.location / "steamapps" / "libraryfolders.vdf"
with open(libraryfolders_path, "r") as file:
with open(libraryfolders_path, "r", encoding="utf-8") as file:
contents = file.read()
return [
Path(path) / "steamapps"
for path in re.findall('"path"\s+"(.*)"\n', contents, re.IGNORECASE)
for path in re.findall('"path"\\s+"(.*)"\n', contents, re.IGNORECASE)
]
def get_manifests(self) -> Iterable[Path]:
@@ -50,15 +52,15 @@ class SteamSourceIterator(SourceIterator):
manifests = self.get_manifests()
for manifest in manifests:
# Get metadata from manifest
steam = SteamHelper()
steam = SteamFileHelper()
try:
local_data = steam.get_manifest_data(manifest)
except (OSError, SteamInvalidManifestError):
continue
# Skip non installed games
INSTALLED_MASK: int = 4
if not int(local_data["stateflags"]) & INSTALLED_MASK:
installed_mask = 4
if not int(local_data["stateflags"]) & installed_mask:
continue
# Skip duplicate appids
@@ -85,40 +87,24 @@ class SteamSourceIterator(SourceIterator):
/ "librarycache"
/ f"{appid}_library_600x900.jpg"
)
additional_data = {"local_image_path": image_path}
additional_data = {"local_image_path": image_path, "steam_appid": appid}
# Produce game
yield (game, additional_data)
class SteamSource(Source):
class SteamSource(URLExecutableSource):
name = "Steam"
location_key = "steam-location"
def __iter__(self):
return SteamSourceIterator(source=self)
class SteamLinuxSource(SteamSource, LinuxSource):
variant = "linux"
executable_format = "xdg-open steam://rungameid/{game_id}"
iterator_class = SteamSourceIterator
url_format = "steam://rungameid/{game_id}"
available_on = set(("linux", "win32"))
@property
@SteamSource.replaced_by_schema_key()
@replaced_by_schema_key
@replaced_by_path("~/.var/app/com.valvesoftware.Steam/data/Steam/")
@replaced_by_env_path("XDG_DATA_HOME", "Steam/")
@replaced_by_path("~/.steam/")
@replaced_by_path("~/.local/share/Steam/")
def location(self):
raise FileNotFoundError()
class SteamWindowsSource(SteamSource, WindowsSource):
variant = "windows"
executable_format = "start steam://rungameid/{game_id}"
@property
@SteamSource.replaced_by_schema_key()
@replaced_by_env_path("programfiles(x86)", "Steam")
def location(self):
raise FileNotFoundError()