diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index 7451134..381473d 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -5,22 +5,20 @@ on: [push, pull_request] jobs: checks: runs-on: ubuntu-latest - strategy: - max-parallel: 4 - matrix: - python-version: [3.8] - steps: - - uses: actions/checkout@v1 - with: - path: basic_games - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install tox - - name: Test with tox - run: tox -e py38-lint + - uses: actions/checkout@v4 + with: + path: "basic_games" + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.11 + - uses: abatilo/actions-poetry@v2 + - name: Install + run: | + cd basic_games + poetry install + - name: Lint + run: | + cd basic_games + poetry run poe lint-all diff --git a/.gitignore b/.gitignore index c5ddba3..73d396f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .mypy_cache __pycache__ +.venv .tox .vscode .idea diff --git a/README.md b/README.md index 4759ce2..063bfe5 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Download the archive for your MO2 version and extract it directly into your MO2 **Important:** Extract the *folder* in your `plugins` folder, not the individual files. Your `plugins` folder should look like this: -``` +```text dlls/ plugins/ data/ @@ -152,6 +152,7 @@ class Witcher3Game(BasicGame): | GameLauncher | Name of the game launcher, relative to the game path (Optional) | `getLauncherName` | `str` | | GameDataPath | Name of the folder containing mods, relative to game folder| `dataDirectory` | | | GameDocumentsDirectory | Documents directory (Optional) | `documentsDirectory` | `str` or `QDir` | +| GameIniFiles | Config files in documents, for profile specific config (Optional) | `iniFiles` | `str` or `List[str]` | | GameSavesDirectory | Directory containing saves (Optional, default to `GameDocumentsDirectory`) | `savesDirectory` | `str` or `QDir` | | GameSaveExtension | Save file extension (Optional) `savegameExtension` | `str` | | GameSteamId | Steam ID of the game (Optional) | `steamAPPId` | `List[str]` or `str` or `int` | @@ -176,10 +177,13 @@ The meta-plugin provides some useful extra feature: `GameOriginManifestIds`, `GameEpicId` or `GameEaDesktopId`), the game will be listed in the list of available games when creating a new MO2 instance (if the game is installed via Steam, GOG, Origin, Epic Games / Legendary or EA Desktop). -2. **Basic save game preview:** If you use the Python version, and if you can easily obtain a picture (file) - for any saves, you can provide basic save-game preview by using the `BasicGameSaveGameInfo`. - See [games/game_witcher3.py](games/game_witcher3.py) for more details. -3. **Basic mod data checker** (Python): +2. **Basic save game preview / metadata** (Python): If you can easily obtain a picture + (file) and/or metadata (like from json) for any saves, you can provide basic save-game + preview by using the `BasicGameSaveGameInfo`. See + [games/game_witcher3.py](games/game_witcher3.py) and + [games/game_bladeandsorcery.py](games/game_bladeandsorcery.py) for more details. +3. **Basic local save games** (Python): profile specific save games, as in [games/game_valheim.py](games/game_valheim.py). +4. **Basic mod data checker** (Python): Check and fix different mod archive layouts for an automatic installation with the proper file structure, using simple (glob) patterns via `BasicModDataChecker`. See [games/game_valheim.py](games/game_valheim.py) and [game_subnautica.py](games/game_subnautica.py) for an example. @@ -195,3 +199,23 @@ Game IDs can be found here: - For Legendary (alt. Epic launcher) via command `legendary list-games` or from: `%USERPROFILE%\.config\legendary\installed.json` - For EA Desktop from `\\__Installer\installerdata.xml` + +## Contribute + +We recommend using a dedicated Python environment to write a new basic game plugins. + +1. Install the required version of Python --- Currently Python 3.11 (MO2 2.5). +2. Remove the repository at `${MO2_INSTALL}/plugins/basic_games`. +3. Clone this repository at the location of the old plugin ( + `${MO2_INSTALL}/plugins/basic_games`). +4. Place yourself inside the cloned folder and: + + ```bash + # create a virtual environment (recommended) + py -3.11 -m venv .\venv + .\venv\scripts\Activate.ps1 + + # "install" poetry and the development package + pip install poetry + poetry install + ``` diff --git a/__init__.py b/__init__.py index bc2c3c8..5725b5f 100644 --- a/__init__.py +++ b/__init__.py @@ -1,4 +1,4 @@ -# -*- encoding: utf-8 -*- +# pyright: reportUnboundVariable=false import glob import importlib @@ -17,7 +17,6 @@ def createPlugins(): - # List of game class from python: game_plugins: typing.List[BasicGame] = [] diff --git a/basic_features/__init__.py b/basic_features/__init__.py index 326613e..9326dd9 100644 --- a/basic_features/__init__.py +++ b/basic_features/__init__.py @@ -1,4 +1,10 @@ -# -*- encoding: utf-8 -*- +from .basic_local_savegames import BasicLocalSavegames +from .basic_mod_data_checker import BasicModDataChecker, GlobPatterns +from .basic_save_game_info import BasicGameSaveGameInfo -from .basic_mod_data_checker import BasicModDataChecker # noqa -from .basic_save_game_info import BasicGameSaveGameInfo # noqa +__all__ = [ + "BasicModDataChecker", + "BasicGameSaveGameInfo", + "GlobPatterns", + "BasicLocalSavegames", +] diff --git a/basic_features/basic_local_savegames.py b/basic_features/basic_local_savegames.py new file mode 100644 index 0000000..6d794ae --- /dev/null +++ b/basic_features/basic_local_savegames.py @@ -0,0 +1,21 @@ +import mobase +from PyQt6.QtCore import QDir + + +class BasicLocalSavegames(mobase.LocalSavegames): + def __init__(self, game_save_dir: QDir): + super().__init__() + self._game_saves_dir = game_save_dir.absolutePath() + + def mappings(self, profile_save_dir: QDir): + return [ + mobase.Mapping( + source=profile_save_dir.absolutePath(), + destination=self._game_saves_dir, + is_directory=True, + create_target=True, + ) + ] + + def prepareProfile(self, profile: mobase.IProfile) -> bool: + return profile.localSavesEnabled() diff --git a/basic_features/basic_mod_data_checker.py b/basic_features/basic_mod_data_checker.py index d849484..0653d6a 100644 --- a/basic_features/basic_mod_data_checker.py +++ b/basic_features/basic_mod_data_checker.py @@ -1,89 +1,117 @@ -# -*- encoding: utf-8 -*- from __future__ import annotations import fnmatch import re -import sys -from collections.abc import Iterable, Sequence -from typing import ClassVar, MutableMapping, Optional, TypedDict +from dataclasses import dataclass, field +from typing import Iterable, Literal import mobase +from .utils import is_directory -def convert_entry_to_tree(entry: mobase.FileTreeEntry) -> Optional[mobase.IFileTree]: - if not entry.isDir(): - return None - if isinstance(entry, mobase.IFileTree): - return entry - if (parent := entry.parent()) is None: - return None - converted_entry = parent.find( - entry.name(), mobase.FileTreeEntry.FileTypes.DIRECTORY - ) - if isinstance(converted_entry, mobase.IFileTree): - return converted_entry - return None +class OptionalRegexPattern: + _pattern: re.Pattern[str] | None -class RegexPatternDict(dict, MutableMapping[str, re.Pattern]): - """Regex patterns for validation in `BasicModDataChecker`.""" + def __init__(self, globs: Iterable[str] | None) -> None: + if globs is None: + self._pattern = None + else: + self._pattern = OptionalRegexPattern.regex_from_glob_list(globs) - @classmethod - def from_glob_patterns(cls, glob_patterns: GlobPatternDict) -> RegexPatternDict: - """Returns an instance of `RegexPatternDict`, with the `glob_patterns` - translated to regex. + @staticmethod + def regex_from_glob_list(glob_list: Iterable[str]) -> re.Pattern[str]: """ - return cls( - ( - key, - cls.regex_from_glob_list(value) - if isinstance(value, Iterable) - else None, - ) - for key, value in glob_patterns.items() + Returns a regex pattern form a list of glob patterns. + + Every pattern has a capturing group, so that `match.lastindex - 1` will + give the `glob_list` index. + """ + return re.compile( + "|".join(f"({fnmatch.translate(f)})" for f in glob_list), re.I ) - def get_match_index(self, key: str, search_str: str) -> Optional[int]: - """Get the index of the matched group if the `self[key]` matches `search_str`. + def match(self, value: str) -> bool: + if self._pattern is None: + return False + return bool(self._pattern.match(value)) - Returns: - The 0-based index of the matched group or None for no match. + +class RegexPatterns: + """ + Regex patterns for validation in `BasicModDataChecker`. + """ + + def __init__(self, globs: GlobPatterns) -> None: + self.unfold = OptionalRegexPattern(globs.unfold) + self.delete = OptionalRegexPattern(globs.delete) + self.valid = OptionalRegexPattern(globs.valid) + + self.move = {key: re.compile(fnmatch.translate(key)) for key in globs.move} + + def move_match(self, value: str) -> str | None: """ - if pattern := self.get(key): - return self.match_index_of_pattern(search_str, pattern) + Retrieve the first move patterns that matches the given value, or None if no + move matches. + """ + for key, pattern in self.move.items(): + if pattern.match(value): + return key return None - @staticmethod - def match_index_of_pattern(search_str: str, pattern: re.Pattern) -> Optional[int]: - """Get the index of the matched group if `search_str` matches `pattern` - (from this dict). - Returns: - The 0-based index of the matched group or None for no match. - """ - if (match := pattern.match(search_str)) and match.lastindex: - return match.lastindex - 1 +def _merge_list(l1: list[str] | None, l2: list[str] | None) -> list[str] | None: + if l1 is None and l2 is None: return None - @staticmethod - def regex_from_glob_list(glob_list: Iterable[str]) -> re.Pattern: - """Returns a regex pattern form a list of glob patterns. + return (l1 or []) + (l2 or []) - Every pattern has a capturing group, so that `match.lastindex - 1` will - give the `glob_list` index. + +@dataclass(frozen=True, unsafe_hash=True) +class GlobPatterns: + """ + See: `BasicModDataChecker` + """ + + unfold: list[str] | None = None + valid: list[str] | None = None + delete: list[str] | None = None + move: dict[str, str] = field(default_factory=dict) + + def merge( + self, other: GlobPatterns, mode: Literal["merge", "replace"] = "replace" + ) -> GlobPatterns: """ - return re.compile( - f'(?:{"|".join(f"({fnmatch.translate(f)})" for f in glob_list)})', re.I - ) + Construct a new GlobPatterns by merging the current one with the given one. + There are two different modes: + - 'merge': In this mode, unfold/valid/delete are concatenated and move + will contain the union of key from self and other, with values from other + overriding common keys. + - 'replace': The merged object will contains attributes from other, except + for None attributes taken from self. -class GlobPatternDict(TypedDict, total=False): - """See: `BasicModDataChecker`""" + Args: + other: Other patterns to "merge" with this one. + mode: Merge mode. - unfold: Iterable[str] | None - valid: Iterable[str] | None - delete: Iterable[str] | None - move: MutableMapping[str, str] | None + Returns: + A new glob pattern representing the merge of this one with other. + """ + if mode == "merge": + return GlobPatterns( + unfold=_merge_list(self.unfold, other.unfold), + valid=_merge_list(self.valid, other.valid), + delete=_merge_list(self.delete, other.delete), + move=self.move | other.move, + ) + else: + return GlobPatterns( + unfold=other.unfold or self.unfold, + valid=other.valid or self.valid, + delete=other.delete or self.delete, + move=other.move or self.move, + ) class BasicModDataChecker(mobase.ModDataChecker): @@ -94,121 +122,93 @@ class BasicModDataChecker(mobase.ModDataChecker): checked and fixed in definition order of the `file_patterns` dict. Args: - file_patterns (optional): A dict (GlobPatternDict) with the following keys:: + file_patterns (optional): A GlobPatterns object, with the following attributes: + unfold: [ "list of folders to unfold" ], + # (remove and move contents to parent), after being checked and + # fixed recursively. + # Check result: `mobase.ModDataChecker.VALID`. - { - "unfold": [ "list of folders to unfold" ], - # (remove and move contents to parent), after being checked and - # fixed recursively. - # Check result: `mobase.ModDataChecker.VALID`. + valid: [ "list of files and folders in the right path." ], + # Check result: `mobase.ModDataChecker.VALID`. - "valid": [ "list of files and folders in the right path." ], - # Check result: `mobase.ModDataChecker.VALID`. + delete: [ "list of files/folders to delete." ], + # Check result: `mobase.ModDataChecker.FIXABLE`. - "delete": [ "list of files/folders to delete." ], - # Check result: `mobase.ModDataChecker.FIXABLE`. + move: {"Files/folders to move": "target path"} + # If the path ends with `/` or `\\`, the entry will be inserted + # in the corresponding directory instead of replacing it. + # Check result: `mobase.ModDataChecker.FIXABLE`. - "move": {"Files/folders to move": "target path"} - # If the path ends with `/` or `\\`, the entry will be inserted - # in the corresponding directory instead of replacing it. - # Check result: `mobase.ModDataChecker.FIXABLE`. - } + Example: - Example:: - - BasicModDataChecker( - { - "valid": ["valid_folder", "*.ext1"] - "move": {"*.ext2": "path/to/target_folder/"} - } + BasicModDataChecker( + GlobPatterns( + valid=["valid_folder", "*.ext1"] + move={"*.ext2": "path/to/target_folder/"} ) + ) See Also: `mobase.IFileTree.move` for the `"move"` target path specs. """ - default_file_patterns: ClassVar[GlobPatternDict] = {} - """Default for `file_patterns` - for subclasses.""" - - _file_patterns: GlobPatternDict + _file_patterns: GlobPatterns """Private `file_patterns`, updated together with `._regex` and `._move_targets`.""" - _regex: RegexPatternDict + _regex_patterns: RegexPatterns """The regex patterns derived from the file (glob) patterns.""" - _move_targets: Sequence[str] - """Target paths from `file_patterns["move"]`.""" - - def __init__(self, file_patterns: Optional[GlobPatternDict] = None): + def __init__(self, file_patterns: GlobPatterns = GlobPatterns()): super().__init__() - # Init with copy from class var by default (for unique instance var). - self.set_patterns(file_patterns or (self.default_file_patterns.copy())) - def set_patterns(self, file_patterns: GlobPatternDict): - """Sets the file patterns, replacing previous/default values and order.""" self._file_patterns = file_patterns - self.update_patterns() - - def update_patterns(self, file_patterns: Optional[GlobPatternDict] = None): - """Update file patterns. Preserves previous/default definition - (check/fix) order. - """ - if file_patterns: - self._file_patterns.update(file_patterns) - self._regex = RegexPatternDict.from_glob_patterns(self._file_patterns) - if move_map := self._file_patterns.get("move"): - self._move_targets = list(move_map.values()) + self._regex_patterns = RegexPatterns(file_patterns) def dataLooksValid( self, filetree: mobase.IFileTree ) -> mobase.ModDataChecker.CheckReturn: status = mobase.ModDataChecker.INVALID + + rp = self._regex_patterns for entry in filetree: name = entry.name().casefold() - for key, regex in self._regex.items(): - if not regex or not regex.match(name): - continue - if key == "unfold": - return mobase.ModDataChecker.FIXABLE - elif key == "valid": - if status is not mobase.ModDataChecker.FIXABLE: - status = mobase.ModDataChecker.VALID - elif key in ("move", "delete"): - status = mobase.ModDataChecker.FIXABLE - break + + if rp.unfold.match(name): + if is_directory(entry): + status = self.dataLooksValid(entry) + else: + status = mobase.ModDataChecker.INVALID + break + elif rp.valid.match(name): + if status is mobase.ModDataChecker.INVALID: + status = mobase.ModDataChecker.VALID + elif rp.delete.match(name) or rp.move_match(name) is not None: + status = mobase.ModDataChecker.FIXABLE else: - return mobase.ModDataChecker.INVALID + status = mobase.ModDataChecker.INVALID + break return status - def fix(self, filetree: mobase.IFileTree) -> Optional[mobase.IFileTree]: + def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + rp = self._regex_patterns for entry in list(filetree): name = entry.name() - # Fix entries in pattern definition order. - for key, regex in self._regex.items(): - if not regex or not regex.match(name): - continue - if key == "valid": - break - elif key == "unfold": - if (folder_tree := convert_entry_to_tree(entry)) is not None: - if folder_tree: # Not empty - # Recursively fix subtree and unfold. - if not (fixed_folder_tree := self.fix(folder_tree)): - return None - filetree.merge(fixed_folder_tree) - folder_tree.detach() - else: - print(f"Cannot unfold {name}!", file=sys.stderr) - return None - elif key == "delete": - entry.detach() - elif key == "move": - if (move_target := self._get_move_target(name)) is not None: - filetree.move(entry, move_target) - break - return filetree - def _get_move_target(self, filename: str) -> Optional[str]: - if (i := self._regex.get_match_index("move", filename)) is None: - return None - return self._move_targets[i] + # unfold first - if this match, entry is a directory (checked in + # dataLooksValid) + if rp.unfold.match(name): + assert is_directory(entry) + filetree.merge(entry) + entry.detach() + + elif rp.valid.match(name): + continue + + elif rp.delete.match(name): + entry.detach() + + elif (move_key := rp.move_match(name)) is not None: + target = self._file_patterns.move[move_key] + filetree.move(entry, target) + + return filetree diff --git a/basic_features/basic_save_game_info.py b/basic_features/basic_save_game_info.py index 13a9b50..9adf2f9 100644 --- a/basic_features/basic_save_game_info.py +++ b/basic_features/basic_save_game_info.py @@ -1,14 +1,31 @@ # -*- encoding: utf-8 -*- import sys +from collections.abc import Mapping +from datetime import datetime from pathlib import Path -from typing import Callable +from typing import Any, Callable, Self, Sequence -from PyQt6.QtCore import QDateTime, Qt +import mobase +from PyQt6.QtCore import QDateTime, QLocale, Qt from PyQt6.QtGui import QImage, QPixmap -from PyQt6.QtWidgets import QLabel, QVBoxLayout, QWidget +from PyQt6.QtWidgets import QFormLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget -import mobase + +def format_date(date_time: QDateTime | datetime | str, format_str: str | None = None): + """Default format for date and time in the `BasicGameSaveGameInfoWidget`. + + Args: + date_time: either a `QDateTime`/`datetime` or a string together with + a `format_str`. + format_str (optional): date/time format string (see `QDateTime.fromString`). + + Returns: + Date and time in short locale format. + """ + if isinstance(date_time, str): + date_time = QDateTime.fromString(date_time, format_str) + return QLocale.system().toString(date_time, QLocale.FormatType.ShortFormat) class BasicGameSaveGame(mobase.ISaveGame): @@ -32,71 +49,170 @@ def allFiles(self) -> list[str]: return [self.getFilepath()] +def get_filedate_metadata(p: Path, save: mobase.ISaveGame) -> Mapping[str, str]: + """Returns saves file date as the metadata for `BasicGameSaveGameInfoWidget`.""" + return {"File Date:": format_date(save.getCreationTime())} + + class BasicGameSaveGameInfoWidget(mobase.ISaveGameInfoWidget): - def __init__(self, parent: QWidget, get_preview: Callable[[Path], Path | None]): + """Save game info widget to display metadata and a preview.""" + + def __init__( + self, + parent: QWidget | None, + get_preview: Callable[[Path], QPixmap | QImage | Path | str | None] + | None = lambda p: None, + get_metadata: Callable[[Path, mobase.ISaveGame], Mapping[str, Any] | None] + | None = get_filedate_metadata, + max_width: int = 320, + ): + """ + Args: + parent: parent widget + get_preview (optional): `callback(savegame_path)` returning the + saves preview image or the path to it. + get_metadata (optional): `callback(savegame_path, ISaveGame)` returning + the saves metadata. By default the saves file date is shown. + max_width (optional): The maximum widget and (scaled) preview width. + Defaults to 320. + """ super().__init__(parent) - self._get_preview = get_preview + self._get_preview = get_preview or (lambda p: None) + self._get_metadata = get_metadata or get_filedate_metadata + self._max_width = max_width or 320 layout = QVBoxLayout() + + # Metadata form + self._metadata_widget = QWidget() + self._metadata_widget.setMaximumWidth(self._max_width) + self._metadata_layout = form_layout = QFormLayout(self._metadata_widget) + form_layout.setContentsMargins(0, 0, 0, 0) + form_layout.setVerticalSpacing(2) + layout.addWidget(self._metadata_widget) + self._metadata_widget.hide() # Backwards compatibility (no metadata) + + # Preview (pixmap) self._label = QLabel() - palette = self._label.palette() - palette.setColor(self._label.foregroundRole(), Qt.GlobalColor.white) - self._label.setPalette(palette) layout.addWidget(self._label) self.setLayout(layout) - palette = self.palette() - palette.setColor(self.backgroundRole(), Qt.GlobalColor.black) - self.setAutoFillBackground(True) - self.setPalette(palette) - self.setWindowFlags( Qt.WindowType.ToolTip | Qt.WindowType.BypassGraphicsProxyWidget ) def setSave(self, save: mobase.ISaveGame): - # Resize the label to (0, 0) to hide it: - self.resize(0, 0) - - # Retrieve the pixmap: - value = self._get_preview(Path(save.getFilepath())) - - if value is None: - return - - if isinstance(value, Path): - pixmap = QPixmap(str(value)) - elif isinstance(value, str): - pixmap = QPixmap(value) - elif isinstance(value, QPixmap): - pixmap = value - elif isinstance(value, QImage): - pixmap = QPixmap.fromImage(value) + save_path = Path(save.getFilepath()) + + # Clear previous + self.hide() + self._label.clear() + while self._metadata_layout.count(): + layoutItem = self._metadata_layout.takeAt(0) + if layoutItem is not None and (w := layoutItem.widget()): + w.deleteLater() + + # Retrieve the pixmap and metadata: + preview = self._get_preview(save_path) + pixmap = None + + # Set the preview pixmap if the preview file exits + if preview is not None: + if isinstance(preview, str): + preview = Path(preview) + if isinstance(preview, Path): + if preview.exists(): + pixmap = QPixmap(str(preview)) + else: + print( + f"Failed to retrieve the preview, file not found: {preview}", + file=sys.stderr, + ) + elif isinstance(preview, QImage): + pixmap = QPixmap.fromImage(preview) + else: + pixmap = preview + if pixmap and not pixmap.isNull(): + # Scale the pixmap and show it: + pixmap = pixmap.scaledToWidth(self._max_width) + self._label.setPixmap(pixmap) + self._label.show() else: - print( - "Failed to retrieve the preview, bad return type: {}.".format( - type(value) - ), - file=sys.stderr, - ) - return + self._label.hide() + pixmap = None + + # Add metadata, file date by default. + metadata = self._get_metadata(save_path, save) + if metadata: + for key, value in metadata.items(): + self._metadata_layout.addRow(*self._new_form_row(key, str(value))) + self._metadata_widget.show() + self._metadata_widget.setLayout(self._metadata_layout) + self._metadata_widget.adjustSize() + else: + self._metadata_widget.hide() + + if metadata or pixmap: + self.adjustSize() + self.show() - # Scale the pixmap and show it: - pixmap = pixmap.scaledToWidth(320) - self._label.setPixmap(pixmap) - self.resize(pixmap.width(), pixmap.height()) + def _new_form_row(self, label: str = "", field: str = ""): + qLabel = QLabel(text=label) + qLabel.setAlignment(Qt.AlignmentFlag.AlignTop) + qLabel.setStyleSheet("font: italic") + qLabel.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) + qField = QLabel(text=field) + qField.setWordWrap(True) + qField.setAlignment(Qt.AlignmentFlag.AlignTop) + qField.setStyleSheet("font: bold") + qField.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + return qLabel, qField + + def set_maximum_width(self, width: int): + self._max_width = width + self._metadata_widget.setMaximumWidth(width) class BasicGameSaveGameInfo(mobase.SaveGameInfo): - def __init__(self, get_preview: Callable[[Path], Path | None] | None = None): + _get_widget: Callable[[QWidget | None], mobase.ISaveGameInfoWidget | None] | None + + def __init__( + self, + get_preview: Callable[[Path], QPixmap | QImage | Path | str | None] + | None = None, + get_metadata: Callable[[Path, mobase.ISaveGame], Mapping[str, Any] | None] + | None = None, + max_width: int = 0, + ): + """Args from: `BasicGameSaveGameInfoWidget`.""" super().__init__() - self._get_preview = get_preview + self._get_widget = lambda parent: BasicGameSaveGameInfoWidget( + parent, get_preview, get_metadata, max_width + ) - def getMissingAssets(self, save: mobase.ISaveGame): + @classmethod + def with_widget( + cls, + widget: type[mobase.ISaveGameInfoWidget] | None, + ) -> Self: + """ + + Args: + widget: a custom `ISaveGameInfoWidget` instead of the default + `BasicGameSaveGameInfoWidget`. + """ + self = cls() + self._get_widget = lambda parent: widget(parent) if widget else None + return self + + def getMissingAssets(self, save: mobase.ISaveGame) -> dict[str, Sequence[str]]: return {} - def getSaveGameWidget(self, parent=None): - if self._get_preview is not None: - return BasicGameSaveGameInfoWidget(parent, self._get_preview) - return None + def getSaveGameWidget( + self, parent: QWidget | None = None + ) -> mobase.ISaveGameInfoWidget | None: + if self._get_widget: + return self._get_widget(parent) + else: + return None diff --git a/basic_features/utils.py b/basic_features/utils.py new file mode 100644 index 0000000..b703901 --- /dev/null +++ b/basic_features/utils.py @@ -0,0 +1,7 @@ +from typing import TypeGuard + +import mobase + + +def is_directory(entry: mobase.FileTreeEntry) -> TypeGuard[mobase.IFileTree]: + return entry.isDir() diff --git a/basic_game.py b/basic_game.py index 1a6f691..3d952f5 100644 --- a/basic_game.py +++ b/basic_game.py @@ -1,19 +1,21 @@ -# -*- encoding: utf-8 -*- +from __future__ import annotations import shutil import sys from pathlib import Path -from typing import Callable, Dict, Generic, List, Optional, TypeVar, Union +from typing import Callable, Generic, TypeVar +import mobase from PyQt6.QtCore import QDir, QFileInfo, QStandardPaths from PyQt6.QtGui import QIcon -import mobase - -from .basic_features.basic_save_game_info import BasicGameSaveGame +from .basic_features.basic_save_game_info import ( + BasicGameSaveGame, + BasicGameSaveGameInfo, +) -def replace_variables(value: str, game: "BasicGame") -> str: +def replace_variables(value: str, game: BasicGame) -> str: """Replace special paths in the given value.""" if value.find("%DOCUMENTS%") != -1: @@ -40,11 +42,10 @@ def replace_variables(value: str, game: "BasicGame") -> str: return value -T = TypeVar("T") - +_T = TypeVar("_T") -class BasicGameMapping(Generic[T]): +class BasicGameMapping(Generic[_T]): # The game: _game: "BasicGame" @@ -58,20 +59,19 @@ class BasicGameMapping(Generic[T]): _required: bool # Callable returning a default value (if not required): - _default: Callable[["BasicGame"], T] + _default: Callable[["BasicGame"], _T] # Function to apply to the value: - _apply_fn: Optional[Callable[[Union[T, str]], T]] + _apply_fn: Callable[[_T | str], _T] | None def __init__( self, - game, - exposed_name, - internal_method, - default: Optional[Callable[["BasicGame"], T]] = None, - apply_fn: Optional[Callable[[Union[T, str]], T]] = None, + game: BasicGame, + exposed_name: str, + internal_method: str, + default: Callable[[BasicGame], _T] | None = None, + apply_fn: Callable[[_T | str], _T] | None = None, ): - self._game = game self._exposed_name = exposed_name self._internal_method_name = internal_method @@ -86,7 +86,8 @@ def __init__( except: # noqa raise ValueError( "Basic game plugin from {} has an invalid {} property.".format( - game._fromName, self._exposed_name + game._fromName, # pyright: ignore[reportPrivateUsage] + self._exposed_name, ) ) self._default = lambda game: value # type: ignore @@ -97,11 +98,12 @@ def __init__( ): raise ValueError( "Basic game plugin from {} is missing {} property.".format( - game._fromName, self._exposed_name + game._fromName, # pyright: ignore[reportPrivateUsage] + self._exposed_name, ) ) - def get(self) -> T: + def get(self) -> _T: """Return the value of this mapping.""" value = self._default(self._game) # type: ignore @@ -117,7 +119,7 @@ def get(self) -> T: return value -class BasicGameOptionsMapping(BasicGameMapping[List[T]]): +class BasicGameOptionsMapping(BasicGameMapping[list[_T]]): """ Represents a game mappings for which multiple options are possible. The game @@ -128,11 +130,11 @@ class BasicGameOptionsMapping(BasicGameMapping[List[T]]): def __init__( self, - game, - exposed_name, - internal_method, - default: Optional[Callable[["BasicGame"], T]] = None, - apply_fn: Optional[Callable[[Union[List[T], str]], List[T]]] = None, + game: BasicGame, + exposed_name: str, + internal_method: str, + default: Callable[[BasicGame], _T] | None = None, + apply_fn: Callable[[list[_T] | str], list[_T]] | None = None, ): super().__init__(game, exposed_name, internal_method, lambda g: [], apply_fn) self._index = -1 @@ -147,7 +149,7 @@ def set_index(self, index: int): """ self._index = index - def set_value(self, value: T): + def set_value(self, value: _T): """ Set the index corresponding of the given value. If the value is not present, the index is set to -1. @@ -169,7 +171,7 @@ def has_value(self) -> bool: """ return self._index != -1 - def current(self) -> T: + def current(self) -> _T: values = self._default(self._game) # type: ignore if not values: @@ -189,7 +191,6 @@ def current(self) -> T: class BasicGameMappings: - name: BasicGameMapping[str] author: BasicGameMapping[str] version: BasicGameMapping[mobase.VersionInfo] @@ -197,25 +198,25 @@ class BasicGameMappings: gameName: BasicGameMapping[str] gameShortName: BasicGameMapping[str] gameNexusName: BasicGameMapping[str] - validShortNames: BasicGameMapping[List[str]] + validShortNames: BasicGameMapping[list[str]] nexusGameId: BasicGameMapping[int] binaryName: BasicGameMapping[str] launcherName: BasicGameMapping[str] dataDirectory: BasicGameMapping[str] documentsDirectory: BasicGameMapping[QDir] + iniFiles: BasicGameMapping[list[str]] savesDirectory: BasicGameMapping[QDir] savegameExtension: BasicGameMapping[str] steamAPPId: BasicGameOptionsMapping[str] gogAPPId: BasicGameOptionsMapping[str] originManifestIds: BasicGameOptionsMapping[str] - originWatcherExecutables: BasicGameMapping[List[str]] + originWatcherExecutables: BasicGameMapping[list[str]] epicAPPId: BasicGameOptionsMapping[str] eaDesktopContentId: BasicGameOptionsMapping[str] supportURL: BasicGameMapping[str] @staticmethod - def _default_documents_directory(game): - + def _default_documents_directory(game: mobase.IPluginGame): folders = [ "{}/My Games/{}".format( QStandardPaths.writableLocation( @@ -238,7 +239,7 @@ def _default_documents_directory(game): return QDir() # Game mappings: - def __init__(self, game: "BasicGame"): + def __init__(self, game: BasicGame): self._game = game self.name = BasicGameMapping(game, "Name", "name") @@ -290,6 +291,15 @@ def __init__(self, game: "BasicGame"): apply_fn=lambda s: QDir(s) if isinstance(s, str) else s, default=BasicGameMappings._default_documents_directory, ) + self.iniFiles = BasicGameMapping( + game, + "GameIniFiles", + "iniFiles", + lambda g: [], + apply_fn=lambda value: [c.strip() for c in value.split(",")] + if isinstance(value, str) + else value, + ) self.savesDirectory = BasicGameMapping( game, "GameSavesDirectory", @@ -302,9 +312,14 @@ def __init__(self, game: "BasicGame"): ) # Convert Union[int, str, List[Union[int, str]]] to List[str]. - def ids_apply(v) -> List[str]: + def ids_apply(v: list[int] | list[str] | int | str) -> list[str]: + """ + Convert various types to a list of string. If the given value is already a + list, returns a new list with all values converted to string, otherwise + returns a list with the value convert to a string as its only element. + """ if isinstance(v, (int, str)): - v = [v] + v = [str(v)] return [str(x) for x in v] self.steamAPPId = BasicGameOptionsMapping( @@ -342,6 +357,19 @@ def ids_apply(v) -> List[str]: ) +_GameFeature = ( + mobase.BSAInvalidation + | mobase.DataArchives + | mobase.GamePlugins + | mobase.LocalSavegames + | mobase.ModDataChecker + | mobase.ModDataContent + | mobase.SaveGameInfo + | mobase.ScriptExtender + | mobase.UnmanagedMods +) + + class BasicGame(mobase.IPluginGame): """This class implements some methods from mobase.IPluginGame @@ -349,11 +377,11 @@ class BasicGame(mobase.IPluginGame): all the methods of mobase.IPluginGame.""" # List of steam, GOG, origin and Epic games: - steam_games: Dict[str, Path] - gog_games: Dict[str, Path] - origin_games: Dict[str, Path] - epic_games: Dict[str, Path] - eadesktop_games: Dict[str, Path] + steam_games: dict[str, Path] + gog_games: dict[str, Path] + origin_games: dict[str, Path] + epic_games: dict[str, Path] + eadesktop_games: dict[str, Path] @staticmethod def setup(): @@ -379,7 +407,7 @@ def setup(): _gamePath: str # The feature map: - _featureMap: Dict + _featureMap: dict[type[_GameFeature], _GameFeature] def __init__(self): super(BasicGame, self).__init__() @@ -412,6 +440,7 @@ def is_eadesktop(self) -> bool: def init(self, organizer: mobase.IOrganizer) -> bool: self._organizer = organizer + self._featureMap[mobase.SaveGameInfo] = BasicGameSaveGameInfo() if self._mappings.originWatcherExecutables.get(): from .origin_utils import OriginWatcher @@ -449,7 +478,7 @@ def isActive(self) -> bool: # Note: self is self._organizer.managedGame() does not work: return self.name() == self._organizer.managedGame().name() - def settings(self) -> List[mobase.PluginSetting]: + def settings(self) -> list[mobase.PluginSetting]: return [] # IPluginGame interface: @@ -491,7 +520,7 @@ def gameIcon(self) -> QIcon: self.gameDirectory().absoluteFilePath(self.binaryName()) ) - def validShortNames(self) -> List[str]: + def validShortNames(self) -> list[str]: return self._mappings.validShortNames.get() def gameNexusName(self) -> str: @@ -524,8 +553,11 @@ def getLauncherName(self) -> str: def getSupportURL(self) -> str: return self._mappings.supportURL.get() - def executables(self) -> List[mobase.ExecutableInfo]: - execs = [] + def iniFiles(self) -> list[str]: + return self._mappings.iniFiles.get() + + def executables(self) -> list[mobase.ExecutableInfo]: + execs: list[mobase.ExecutableInfo] = [] if self.getLauncherName(): execs.append( mobase.ExecutableInfo( @@ -543,37 +575,32 @@ def executables(self) -> List[mobase.ExecutableInfo]: ) return execs - def executableForcedLoads(self) -> List[mobase.ExecutableForcedLoadSetting]: + def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: return [] - def listSaves(self, folder: QDir) -> List[mobase.ISaveGame]: + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: ext = self._mappings.savegameExtension.get() return [ BasicGameSaveGame(path) for path in Path(folder.absolutePath()).glob(f"**/*.{ext}") ] - def initializeProfile(self, path: QDir, settings: mobase.ProfileSetting): + def initializeProfile( + self, directory: QDir, settings: mobase.ProfileSetting + ) -> None: if settings & mobase.ProfileSetting.CONFIGURATION: for iniFile in self.iniFiles(): try: shutil.copyfile( self.documentsDirectory().absoluteFilePath(iniFile), - path.absoluteFilePath(QFileInfo(iniFile).fileName()), + directory.absoluteFilePath(QFileInfo(iniFile).fileName()), ) except FileNotFoundError: - Path(path.absoluteFilePath(QFileInfo(iniFile).fileName())).touch() + Path( + directory.absoluteFilePath(QFileInfo(iniFile).fileName()) + ).touch() - def primarySources(self): - return [] - - def primaryPlugins(self): - return [] - - def gameVariants(self): - return [] - - def setGameVariant(self, variantStr): + def setGameVariant(self, variant: str) -> None: pass def gameVersion(self) -> str: @@ -581,23 +608,8 @@ def gameVersion(self) -> str: self.gameDirectory().absoluteFilePath(self.binaryName()) ) - def iniFiles(self): - return [] - - def DLCPlugins(self): - return [] - - def CCPlugins(self): - return [] - - def loadOrderMechanism(self): - return mobase.LoadOrderMechanism.PluginsTxt - - def sortMechanism(self): - return mobase.SortMechanism.NONE - - def looksValid(self, aQDir: QDir): - return aQDir.exists(self.binaryName()) + def looksValid(self, directory: QDir): + return directory.exists(self.binaryName()) def isInstalled(self) -> bool: return bool(self._gamePath) @@ -613,7 +625,7 @@ def dataDirectory(self) -> QDir: self.gameDirectory().absoluteFilePath(self._mappings.dataDirectory.get()) ) - def setGamePath(self, path: Union[Path, str]): + def setGamePath(self, path: Path | str) -> None: self._gamePath = str(path) path = Path(path) diff --git a/eadesktop_utils.py b/eadesktop_utils.py index 76805f6..52757f0 100644 --- a/eadesktop_utils.py +++ b/eadesktop_utils.py @@ -26,7 +26,10 @@ def find_games() -> Dict[str, Path]: if not ea_desktop_settings_path.exists(): return games - user_ini, *_ = list(ea_desktop_settings_path.glob("user_*.ini")) + try: + user_ini, *_ = list(ea_desktop_settings_path.glob("user_*.ini")) + except ValueError: + return games # The INI file in its current form has no section headers. # So we wrangle the input to add it all under a fake section. @@ -42,6 +45,9 @@ def find_games() -> Dict[str, Path]: install_path = Path(os.environ["ProgramW6432"]) / "EA Games" config.set("mod_organizer", "user.downloadinplacedir", install_path.__str__()) + if not install_path.exists(): + return games + for game_dir in install_path.iterdir(): try: installer_file = game_dir.joinpath("__Installer", "installerdata.xml") diff --git a/games/game_assettocorsa.py b/games/game_assettocorsa.py index 80a476d..da70669 100644 --- a/games/game_assettocorsa.py +++ b/games/game_assettocorsa.py @@ -2,7 +2,6 @@ class AssettoCorsaGame(BasicGame): - Name = "Assetto Corsa Support Plugin" Author = "Deorder" Version = "0.0.1" diff --git a/games/game_blackandwhite2.py b/games/game_blackandwhite2.py index 4ea88bf..c81df30 100644 --- a/games/game_blackandwhite2.py +++ b/games/game_blackandwhite2.py @@ -1,21 +1,18 @@ -# -*- encoding: utf-8 -*- import datetime import os import struct -import sys import time from pathlib import Path -from typing import List +from typing import BinaryIO +import mobase from PyQt6.QtCore import QDateTime, QDir, QFile, QFileInfo, Qt from PyQt6.QtGui import QPainter, QPixmap -import mobase - +from ..basic_features import BasicLocalSavegames from ..basic_features.basic_save_game_info import ( BasicGameSaveGame, BasicGameSaveGameInfo, - BasicGameSaveGameInfoWidget, ) from ..basic_game import BasicGame @@ -99,9 +96,9 @@ class BlackAndWhite2ModDataChecker(mobase.ModDataChecker): _mapFile = ["chl", "bmp", "bwe", "ter", "pat", "xml", "wal", "txt"] _fileIgnore = ["readme", "read me", "meta.ini", "thumbs.db", "backup", ".png"] - def fix(self, tree: mobase.IFileTree): - toMove = [] - for entry in tree: + def fix(self, filetree: mobase.IFileTree): + toMove: list[tuple[mobase.FileTreeEntry, str]] = [] + for entry in filetree: if any([sub in entry.name().casefold() for sub in self._fileIgnore]): continue elif entry.suffix() == "chl": @@ -113,25 +110,24 @@ def fix(self, tree: mobase.IFileTree): else: toMove.append((entry, "/Data/landscape/BW2/")) - for (entry, path) in toMove: - tree.move(entry, path, policy=mobase.IFileTree.MERGE) + for entry, path in toMove: + filetree.move(entry, path, policy=mobase.IFileTree.MERGE) - return tree + return filetree def dataLooksValid( - self, tree: mobase.IFileTree + self, filetree: mobase.IFileTree ) -> mobase.ModDataChecker.CheckReturn: # qInfo("Data validation start") - root = tree + root = filetree unpackagedMap = False - for entry in tree: + for entry in filetree: entryName = entry.name().casefold() canIgnore = any([sub in entryName for sub in self._fileIgnore]) if not canIgnore: parent = entry.parent() if parent is not None: - if parent != root: parentName = parent.name().casefold() else: @@ -176,7 +172,7 @@ class BlackAndWhite2SaveGame(BasicGameSaveGame): "empty2": [0x00000108, 0x0000011C], } - def __init__(self, filepath): + def __init__(self, filepath: Path): super().__init__(filepath) self._filepath = Path(filepath) self.name: str = "" @@ -200,11 +196,11 @@ def __init__(self, filepath): ) - (time.localtime().tm_gmtoff * 1000) info.close() - def readInf(self, inf, key): + def readInf(self, inf: BinaryIO, key: str): inf.seek(self._saveInfLayout[key][0]) return inf.read(self._saveInfLayout[key][1] - self._saveInfLayout[key][0]) - def allFiles(self) -> List[str]: + def allFiles(self) -> list[str]: files = [str(file) for file in self._filepath.glob("./*")] files.append(str(self._filepath)) return files @@ -226,35 +222,24 @@ def getSaveGroupIdentifier(self): return self._filepath.parent.parent.name -class BlackAndWhite2LocalSavegames(mobase.LocalSavegames): - def __init__(self, myGameSaveDir): - super().__init__() - self._savesDir = myGameSaveDir.absolutePath() - - def mappings(self, profile_save_dir): - m = mobase.Mapping() - - m.createTarget = True - m.isDirectory = True - m.source = profile_save_dir.absolutePath() - m.destination = self._savesDir - - return [m] - - def prepareProfile(self, profile): - return profile.localSavesEnabled() - - -def getPreview(save): - save = BlackAndWhite2SaveGame(save) +def _getPreview(savepath: Path): + save = BlackAndWhite2SaveGame(savepath) lines = [ [ - ("Name : " + save.getName(), Qt.AlignLeft), - ("| Profile : " + save.getSaveGroupIdentifier()[1:], Qt.AlignLeft), + ("Name : " + save.getName(), Qt.AlignmentFlag.AlignLeft), + ( + "| Profile : " + save.getSaveGroupIdentifier()[1:], + Qt.AlignmentFlag.AlignLeft, + ), + ], + [("Land number : " + save.getLand(), Qt.AlignmentFlag.AlignLeft)], + [ + ( + "Saved at : " + save.getCreationTime().toString(), + Qt.AlignmentFlag.AlignLeft, + ) ], - [("Land number : " + save.getLand(), Qt.AlignLeft)], - [("Saved at : " + save.getCreationTime().toString(), Qt.AlignLeft)], - [("Elapsed time : " + save.getElapsed(), Qt.AlignLeft)], + [("Elapsed time : " + save.getElapsed(), Qt.AlignmentFlag.AlignLeft)], ] pixmap = QPixmap(320, 320) @@ -269,15 +254,14 @@ def getPreview(save): width = 0 ln = 0 for line in lines: - cHeight = 0 cWidth = 0 - for (toPrint, align) in line: + for toPrint, align in line: bRect = fm.boundingRect(toPrint) cHeight = bRect.height() * (ln + 1) bRect.moveTop(cHeight - bRect.height()) - if align != Qt.AlignLeft: + if align != Qt.AlignmentFlag.AlignLeft: continue else: bRect.moveLeft(cWidth + margin) @@ -294,48 +278,12 @@ def getPreview(save): return pixmap.copy(0, 0, width, height) -class BlackAndWhite2SaveGameInfoWidget(BasicGameSaveGameInfoWidget): - def setSave(self, save: mobase.ISaveGame): - # Resize the label to (0, 0) to hide it: - self.resize(0, 0) - - # Retrieve the pixmap: - value = self._get_preview(Path(save.getFilepath())) - - if value is None: - return - - elif isinstance(value, QPixmap): - pixmap = value - else: - print( - "Failed to retrieve the preview, bad return type: {}.".format( - type(value) - ), - file=sys.stderr, - ) - return - - # Scale the pixmap and show it: - # pixmap = pixmap.scaledToWidth(pixmap.width()) - self._label.setPixmap(pixmap) - self.resize(pixmap.width(), pixmap.height()) - - -class BlackAndWhite2SaveGameInfo(BasicGameSaveGameInfo): - def getSaveGameWidget(self, parent=None): - if self._get_preview is not None: - return BasicGameSaveGameInfoWidget(parent, self._get_preview) - return None - - PSTART_MENU = ( str(os.getenv("ProgramData")) + "\\Microsoft\\Windows\\Start Menu\\Programs" ) class BlackAndWhite2Game(BasicGame, mobase.IPluginFileMapper): - Name = "Black & White 2 Support Plugin" Author = "Ilyu" Version = "1.0.1" @@ -361,10 +309,10 @@ def __init__(self): def init(self, organizer: mobase.IOrganizer) -> bool: BasicGame.init(self, organizer) self._featureMap[mobase.ModDataChecker] = BlackAndWhite2ModDataChecker() - self._featureMap[mobase.LocalSavegames] = BlackAndWhite2LocalSavegames( + self._featureMap[mobase.LocalSavegames] = BasicLocalSavegames( self.savesDirectory() ) - self._featureMap[mobase.SaveGameInfo] = BlackAndWhite2SaveGameInfo(getPreview) + self._featureMap[mobase.SaveGameInfo] = BasicGameSaveGameInfo(_getPreview) return True def detectGame(self): @@ -378,7 +326,7 @@ def detectGame(self): return - def executables(self) -> List[mobase.ExecutableInfo]: + def executables(self) -> list[mobase.ExecutableInfo]: execs = super().executables() """ @@ -401,8 +349,8 @@ def executables(self) -> List[mobase.ExecutableInfo]: return execs - def listSaves(self, folder: QDir) -> List[mobase.ISaveGame]: - profiles = list() + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + profiles: list[Path] = [] for path in Path(folder.absolutePath()).glob("*/Saved Games/*"): if ( path.name == "Autosave" @@ -425,7 +373,6 @@ def listSaves(self, folder: QDir) -> List[mobase.ISaveGame]: class BOTGGame(BlackAndWhite2Game): - Name = "Black & White 2 Battle of the Gods Support Plugin" GameName = "Black & White 2 Battle of the Gods" diff --git a/games/game_bladeandsorcery.py b/games/game_bladeandsorcery.py index cccefd0..6e0569c 100644 --- a/games/game_bladeandsorcery.py +++ b/games/game_bladeandsorcery.py @@ -1,18 +1,95 @@ +import json +from collections.abc import Mapping +from pathlib import Path + +import mobase +from PyQt6.QtCore import QDateTime, QDir + +from ..basic_features.basic_save_game_info import ( + BasicGameSaveGame, + BasicGameSaveGameInfo, + format_date, +) from ..basic_game import BasicGame -class BaSGame(BasicGame): +class BaSSaveGame(BasicGameSaveGame): + def __init__(self, filepath: Path): + super().__init__(filepath) + with open(self._filepath, "rb") as save: + save_data = json.load(save) + self._gameMode: str = save_data["gameModeId"] + self._gender = ( + "Male" if save_data["creatureId"] == "PlayerDefaultMale" else "Female" + ) + self._ethnicity: str = save_data["ethnicGroupId"] + h, m, s = save_data["playTime"].split(":") + self._elapsed = (int(h), int(m), float(s)) + f_stat = self._filepath.stat() + self._created = f_stat.st_ctime + self._modified = f_stat.st_mtime + + def getName(self) -> str: + return f"{self.getPlayerSlug()} - {self._gameMode}" + + def getCreationTime(self) -> QDateTime: + return QDateTime.fromSecsSinceEpoch(int(self._created)) + + def getModifiedTime(self) -> QDateTime: + return QDateTime.fromSecsSinceEpoch(int(self._modified)) + + def getPlayerSlug(self) -> str: + return f"{self._gender} {self._ethnicity}" + + def getElapsed(self) -> str: + return ( + f"{self._elapsed[0]} hours, " + f"{self._elapsed[1]} minutes, " + f"{int(self._elapsed[2])} seconds" + ) + def getGameMode(self) -> str: + return self._gameMode + + +def bas_parse_metadata(p: Path, save: mobase.ISaveGame) -> Mapping[str, str]: + assert isinstance(save, BaSSaveGame) + return { + "Character": save.getPlayerSlug(), + "Game Mode": save.getGameMode(), + "Created At": format_date(save.getCreationTime()), + "Last Saved": format_date(save.getModifiedTime()), + "Session Duration": save.getElapsed(), + } + + +class BaSGame(BasicGame): Name = "Blade & Sorcery Plugin" - Author = "R3z Shark" - Version = "0.1.0" + Author = "R3z Shark & Silarn" + Version = "0.5.0" GameName = "Blade & Sorcery" GameShortName = "bladeandsorcery" GameBinary = "BladeAndSorcery.exe" - GameDataPath = r"BladeAndSorcery_Data\StreamingAssets\Mods" + GameDataPath = r"BladeAndSorcery_Data\\StreamingAssets\\Mods" + GameDocumentsDirectory = "%DOCUMENTS%/My Games/BladeAndSorcery" + GameSavesDirectory = "%GAME_DOCUMENTS%/Saves/Default" + GameSaveExtension = "chr" GameSteamId = 629730 GameSupportURL = ( r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" "Game:-Blade-&-Sorcery" ) + + def init(self, organizer: mobase.IOrganizer) -> bool: + BasicGame.init(self, organizer) + self._featureMap[mobase.SaveGameInfo] = BasicGameSaveGameInfo( + get_metadata=bas_parse_metadata, max_width=400 + ) + return True + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + ext = self._mappings.savegameExtension.get() + return [ + BaSSaveGame(path) for path in Path(folder.absolutePath()).glob(f"*.{ext}") + ] diff --git a/games/game_control.py b/games/game_control.py index 67f1e11..23c0581 100644 --- a/games/game_control.py +++ b/games/game_control.py @@ -1,16 +1,12 @@ -# -*- encoding: utf-8 -*- - from __future__ import annotations -from PyQt6.QtCore import QFileInfo - import mobase +from PyQt6.QtCore import QFileInfo from ..basic_game import BasicGame class ControlGame(BasicGame): - Name = "Control Support Plugin" Author = "Zash" Version = "1.0.0" diff --git a/games/game_cyberpunk2077.py b/games/game_cyberpunk2077.py index 9f690cd..c3a499f 100644 --- a/games/game_cyberpunk2077.py +++ b/games/game_cyberpunk2077.py @@ -1,18 +1,295 @@ +import filecmp +import json +import re +import shutil +from collections import Counter +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal, TypeVar + +import mobase +from PyQt6.QtCore import QDateTime, QDir, qCritical, qInfo, qWarning + +from ..basic_features import BasicLocalSavegames, BasicModDataChecker, GlobPatterns +from ..basic_features.basic_save_game_info import ( + BasicGameSaveGame, + BasicGameSaveGameInfo, + format_date, +) +from ..basic_features.utils import is_directory from ..basic_game import BasicGame -class Cyberpunk2077Game(BasicGame): +class CyberpunkModDataChecker(BasicModDataChecker): + def __init__(self): + super().__init__( + GlobPatterns( + move={ + # archive and ArchiveXL + "*.archive": "archive/pc/mod/", + "*.xl": "archive/pc/mod/", + }, + valid=[ + "archive", + # redscript + "engine", + "r6", + "mods", # RedMod + "red4ext", # red4ext/RED4ext.dll is moved to root in .fix() + "bin", # CET etc. gets handled below + "root", # RootBuilder: hardlink / copy to game root + ], + ) + ) + + _extra_files_to_move = { + # Red4ext: only .dll files + "red4ext/RED4ext.dll": "root/red4ext/", + "bin/x64/winmm.dll": "root/bin/x64/", + # CET: all files, folder gets handled in .fix() + "bin/x64/version.dll": "root/bin/x64/", + "bin/x64/global.ini": "root/bin/x64/", + "bin/x64/plugins/cyber_engine_tweaks.asi": "root/bin/x64/plugins/", + } + _cet_path = "bin/x64/plugins/cyber_engine_tweaks/" + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + # fix: single root folders get traversed by Simple Installer + parent = filetree.parent() + if parent is not None and self.dataLooksValid(parent) is self.FIXABLE: + return self.FIXABLE + status = mobase.ModDataChecker.INVALID + # Check extra fixes + if any(filetree.exists(p) for p in self._extra_files_to_move): + return mobase.ModDataChecker.FIXABLE + rp = self._regex_patterns + for entry in filetree: + name = entry.name().casefold() + if rp.move_match(name) is not None: + status = mobase.ModDataChecker.FIXABLE + elif rp.valid.match(name): + if status is mobase.ModDataChecker.INVALID: + status = mobase.ModDataChecker.VALID + elif self._valid_redmod(entry): + # Archive with REDmod folders, not in mods/ + status = mobase.ModDataChecker.FIXABLE + # Accept any other entry + return status + + def _valid_redmod(self, filetree: mobase.IFileTree | mobase.FileTreeEntry) -> bool: + return isinstance(filetree, mobase.IFileTree) and bool( + filetree and filetree.find("info.json") + ) + + def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + for source, target in self._extra_files_to_move.items(): + if file := filetree.find(source): + parent = file.parent() + filetree.move(file, target) + clear_empty_folder(parent) + if filetree := super().fix(filetree): + filetree = self._fix_cet_framework(filetree) + # REDmod + for entry in list(filetree): + if not self._regex_patterns.valid.match( + entry.name().casefold() + ) and self._valid_redmod(entry): + filetree.move(entry, "mods/") + return filetree + + def _fix_cet_framework(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + """Move CET framework to `root/`, except for `mods`. + Only CET >= v1.27.0 (Patch 2.01) works with USVFS. + + See: https://github.com/maximegmd/CyberEngineTweaks/pull/877 + """ + if cet_folder := filetree.find( + self._cet_path, mobase.FileTreeEntry.FileTypes.DIRECTORY + ): + assert is_directory(cet_folder) + root_cet_path = f"root/{self._cet_path}" + if not cet_folder.exists("mods"): + parent = cet_folder.parent() + filetree.move(cet_folder, root_cet_path.rstrip("/\\")) + else: + parent = cet_folder + for entry in list(cet_folder): + if entry.name() != "mods": + filetree.move(entry, root_cet_path) + clear_empty_folder(parent) + return filetree + + +def clear_empty_folder(filetree: mobase.IFileTree | None): + if filetree is None: + return + while not filetree: + parent = filetree.parent() + filetree.detach() + if parent is None: + break + filetree = parent + + +def time_from_seconds(s: int | float) -> str: + m, s = divmod(int(s), 60) + h, m = divmod(int(m), 60) + return f"{h:02}:{m:02}:{s:02}" + + +def parse_cyberpunk_save_metadata(save_path: Path, save: mobase.ISaveGame): + metadata_file = save_path / "metadata.9.json" + try: + with open(metadata_file) as file: + meta_data = json.load(file)["Data"]["metadata"] + name = meta_data["name"] + if name != (save_name := save.getName()): + name = f"{save_name} ({name})" + return { + "Name": name, + "Date": format_date(meta_data["timestampString"], "hh:mm:ss, d.M.yyyy"), + "Play Time": time_from_seconds(meta_data["playthroughTime"]), + "Quest": meta_data["trackedQuestEntry"], + "Level": int(meta_data["level"]), + "Street Cred": int(meta_data["streetCred"]), + "Life Path": meta_data["lifePath"], + "Difficulty": meta_data["difficulty"], + "Gender": f'{meta_data["bodyGender"]} / {meta_data["brainGender"]}', + "Game version": meta_data["buildPatch"], + } + except (FileNotFoundError, json.JSONDecodeError): + return None + + +class CyberpunkSaveGame(BasicGameSaveGame): + _name_file = "NamedSave.txt" # from mod: Named Saves + + def __init__(self, filepath: Path): + super().__init__(filepath) + try: # Custom name from Named Saves + with open(filepath / self._name_file) as file: + self._name = file.readline() + except FileNotFoundError: + self._name = "" + + def getName(self) -> str: + return self._name or super().getName() + + def getCreationTime(self) -> QDateTime: + return QDateTime.fromSecsSinceEpoch( + int((self._filepath / "sav.dat").stat().st_mtime) + ) + + +@dataclass +class ModListFile: + list_path: Path + mod_search_pattern: str + + +_MOD_TYPE = TypeVar("_MOD_TYPE") + + +class ModListFileManager(dict[_MOD_TYPE, ModListFile]): + """Manages modlist files for specific mod types.""" + + def __init__(self, organizer: mobase.IOrganizer, **kwargs: ModListFile) -> None: + self._organizer = organizer + super().__init__(**kwargs) + + def update_modlist( + self, mod_type: _MOD_TYPE, mod_files: list[str] | None = None + ) -> tuple[Path, list[str], list[str]]: + """ + Updates the mod list file for `mod_type` with the current load order. + Removes the file if it is not needed. + + Args: + mod_type: Which modlist to update. + mod_files (optional): By default mod files in order of mod priority. + + Returns: + `(modlist_path, new_mod_list, old_mod_list)` + """ + if mod_files is None: + mod_files = list(self.modfile_names(mod_type)) + modlist_path = self.absolute_modlist_path(mod_type) + old_modlist = ( + modlist_path.read_text().splitlines() if modlist_path.exists() else [] + ) + if not mod_files or len(mod_files) == 1: + # No load order required + if old_modlist: + qInfo(f"Removing {mod_type} load order {modlist_path}") + modlist_path.unlink() + return modlist_path, [], old_modlist + else: + qInfo(f'Updating {mod_type} load order "{modlist_path}" with: {mod_files}') + modlist_path.parent.mkdir(parents=True, exist_ok=True) + modlist_path.write_text("\n".join(mod_files)) + return modlist_path, mod_files, old_modlist + + def absolute_modlist_path(self, mod_type: _MOD_TYPE) -> Path: + modlist_path = self[mod_type].list_path + if not modlist_path.is_absolute(): + existing = self._organizer.findFiles(modlist_path.parent, modlist_path.name) + overwrite = self._organizer.overwritePath() + modlist_path = ( + Path(existing[0]) if (existing) else Path(overwrite, modlist_path) + ) + return modlist_path + def modfile_names(self, mod_type: _MOD_TYPE) -> Iterable[str]: + """Get all files from the `mod_type` in load order.""" + yield from (file.name for file in self.modfiles(mod_type)) + + def modfiles(self, mod_type: _MOD_TYPE) -> Iterable[Path]: + """Get all files from the `mod_type` in load order.""" + mod_search_pattern = self[mod_type].mod_search_pattern + for mod_path in self.active_mod_paths(): + yield from mod_path.glob(mod_search_pattern) + + def active_mod_paths(self) -> Iterable[Path]: + """Yield the path to active mods in load order.""" + mods_path = Path(self._organizer.modsPath()) + modlist = self._organizer.modList() + for mod in modlist.allModsByProfilePriority(): + if modlist.state(mod) & mobase.ModState.ACTIVE: + yield mods_path / mod + + +@dataclass +class PluginDefaultSettings: + organizer: mobase.IOrganizer + plugin_name: str + settings: Mapping[str, mobase.MoVariant] + + def is_plugin_enabled(self) -> bool: + return self.organizer.isPluginEnabled(self.plugin_name) + + def apply(self) -> bool: + if not self.is_plugin_enabled(): + return False + for setting, value in self.settings.items(): + self.organizer.setPluginSetting(self.plugin_name, setting, value) + return True + + +class Cyberpunk2077Game(BasicGame): Name = "Cyberpunk 2077 Support Plugin" - Author = "6788" - Version = "1.0.0" + Author = "6788, Zash" + Version = "2.2.3" GameName = "Cyberpunk 2077" GameShortName = "cyberpunk2077" GameBinary = "bin/x64/Cyberpunk2077.exe" GameLauncher = "REDprelauncher.exe" GameDataPath = "%GAME_PATH%" - GameDocumentsDirectory = "%USERPROFILE%/Saved Games/CD Projekt Red/Cyberpunk 2077" + GameDocumentsDirectory = "%USERPROFILE%/AppData/Local/CD Projekt Red/Cyberpunk 2077" + GameSavesDirectory = "%USERPROFILE%/Saved Games/CD Projekt Red/Cyberpunk 2077" GameSaveExtension = "dat" GameSteamId = 1091500 GameGogId = 1423049311 @@ -20,3 +297,300 @@ class Cyberpunk2077Game(BasicGame): r"https://github.com/ModOrganizer2/modorganizer-basic_games/wiki/" "Game:-Cyberpunk-2077" ) + + _redmod_binary = Path("tools/redmod/bin/redMod.exe") + _redmod_log = Path("tools/redmod/bin/REDmodLog.txt") + _redmod_deploy_path = Path("r6/cache/modded/") + _redmod_deploy_args = "deploy -reportProgress" + """Deploy arguments for `redmod.exe`, -modlist=... is added.""" + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + self._featureMap[mobase.LocalSavegames] = BasicLocalSavegames( + self.savesDirectory() + ) + self._featureMap[mobase.SaveGameInfo] = BasicGameSaveGameInfo( + lambda p: Path(p or "", "screenshot.png"), + parse_cyberpunk_save_metadata, + ) + self._featureMap[mobase.ModDataChecker] = CyberpunkModDataChecker() + + self._modlist_files = ModListFileManager[Literal["archive", "redmod"]]( + organizer, + archive=ModListFile( + Path("archive/pc/mod/modlist.txt"), + "archive/pc/mod/*", + ), + redmod=ModListFile( + Path(self._redmod_deploy_path, "MO_REDmod_load_order.txt"), + "mods/*/", + ), + ) + self._rootbuilder_settings = PluginDefaultSettings( + organizer, + "RootBuilder", + { + "usvfsmode": False, + "linkmode": False, + "linkonlymode": True, # RootBuilder v4.5 + "backup": True, + "cache": True, + "autobuild": True, + "redirect": False, + "installer": False, + "exclusions": "archive,setup_redlauncher.exe,tools", + "linkextensions": "dll,exe", + }, + ) + + def apply_rootbuilder_settings_once(*args: Any): + if not self.isActive() or not self._get_setting("configure_RootBuilder"): + return + if self._rootbuilder_settings.apply(): + qInfo(f"RootBuilder configured for {self.gameName()}") + self._set_setting("configure_RootBuilder", False) + + organizer.onUserInterfaceInitialized(apply_rootbuilder_settings_once) + organizer.onPluginEnabled("RootBuilder", apply_rootbuilder_settings_once) + organizer.onAboutToRun(self._onAboutToRun) + return True + + def iniFiles(self): + return ["UserSettings.json"] + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + ext = self._mappings.savegameExtension.get() + return [ + CyberpunkSaveGame(path.parent) + for path in Path(folder.absolutePath()).glob(f"**/*.{ext}") + ] + + def settings(self) -> list[mobase.PluginSetting]: + return [ + mobase.PluginSetting( + "skipStartScreen", + 'Skips the "Breaching..." start screen on game launch', + True, + ), + mobase.PluginSetting( + "enforce_archive_load_order", + ( + "Enforce the current load order via" + " archive/pc/mod/modlist.txt" + ), + False, + ), + mobase.PluginSetting( + "enforce_redmod_load_order", + "Enforce the current load order on redmod deployment", + True, + ), + mobase.PluginSetting( + "auto_deploy_redmod", + "Deploy redmod before game launch if necessary", + True, + ), + mobase.PluginSetting( + "clear_cache_after_game_update", + ( + 'Clears "overwrite/r6/cache/*" if the original game files changed' + " (after update)" + ), + True, + ), + mobase.PluginSetting( + "configure_RootBuilder", + "Configures RootBuilder for Cyberpunk if installed and enabled", + True, + ), + ] + + def _get_setting(self, key: str) -> mobase.MoVariant: + return self._organizer.pluginSetting(self.name(), key) + + def _set_setting(self, key: str, value: mobase.MoVariant): + self._organizer.setPluginSetting(self.name(), key, value) + + def executables(self) -> list[mobase.ExecutableInfo]: + game_name = self.gameName() + game_dir = self.gameDirectory() + bin_path = game_dir.absoluteFilePath(self.binaryName()) + skip_start_screen = ( + " -skipStartScreen" if self._get_setting("skipStartScreen") else "" + ) + return [ + # Default, runs REDmod deploy if necessary + mobase.ExecutableInfo( + f"{game_name}", + bin_path, + ).withArgument(f"--launcher-skip -modded{skip_start_screen}"), + # Start game without REDmod + mobase.ExecutableInfo( + f"{game_name} - skip REDmod deploy", + bin_path, + ).withArgument(f"--launcher-skip {skip_start_screen}"), + # Deploy REDmods only + mobase.ExecutableInfo( + "Manually deploy REDmod", + self._get_redmod_binary(), + ).withArgument("deploy -reportProgress -force %modlist%"), + # Launcher + mobase.ExecutableInfo( + "REDprelauncher", + game_dir.absoluteFilePath(self.getLauncherName()), + ).withArgument(f"{skip_start_screen}"), + ] + + def _get_redmod_binary(self) -> Path: + """Absolute path to redmod binary""" + return Path(self.gameDirectory().absolutePath(), self._redmod_binary) + + def _onAboutToRun(self, app_path_str: str, wd: QDir, args: str) -> bool: + if not self.isActive(): + return True + app_path = Path(app_path_str) + if app_path == self._get_redmod_binary(): + if m := re.search(r"%modlist%", args, re.I): + # Manual deployment: replace %modlist% variable + ( + modlist_path, + modlist, + _, + ) = self._modlist_files.update_modlist("redmod") + modlist_param = f'-modlist="{modlist_path}"' if modlist else "" + args = f"{args[:m.start()]}{modlist_param}{args[m.end():]}" + qInfo(f"Manual modlist deployment: replacing {m[0]}, new args = {args}") + self._check_redmod_result( + self._organizer.waitForApplication( + self._organizer.startApplication(app_path_str, [args], wd), + False, + ) + ) + return False # redmod with new args started + return True # No recursive redmod call + if ( + self._get_setting("auto_deploy_redmod") + and app_path == Path(self.gameDirectory().absolutePath(), self.binaryName()) + and "-modded" in args + and not self._check_redmod_result(self._deploy_redmod()) + ): + qWarning("Aborting game launch.") + return False # Auto deploy failed + self._map_cache_files() + if self._get_setting("enforce_archive_load_order"): + self._modlist_files.update_modlist("archive") + return True + + def _check_redmod_result(self, result: tuple[bool, int]) -> bool: + if result == (True, 0): + return True + if result[1] < 0: + qWarning(f"REDmod deployment aborted (exit code {result[1]}).") + else: + qCritical( + f"REDmod deployment failed with exit code {result[1]} !" + f" Check {Path('GAME_FOLDER/', self._redmod_log)}" + ) + return False + + def _deploy_redmod(self) -> tuple[bool, int]: + """Deploys redmod. Clears deployed files if no redmods are active. + Recreates deployed files to force load order when necessary. + + Returns: + (success?, exit code) + """ + # Add REDmod load order if none is specified + redmod_list = list(self._modlist_files.modfile_names("redmod")) + if not redmod_list: + qInfo("Cleaning up redmod deployed files") + self._clean_deployed_redmod() + return True, 0 + args = self._redmod_deploy_args + if self._get_setting("enforce_redmod_load_order"): + modlist_path, _, old_redmods = self._modlist_files.update_modlist( + "redmod", redmod_list + ) + if ( + Counter(redmod_list) == Counter(old_redmods) + and not redmod_list == old_redmods + ): + # Only load order changed: recreate redmod deploys + # Fix for redmod not detecting change of load order. + # Faster than -force https://github.com/E1337Kat/cyberpunk2077_ext_redux/issues/297 # noqa: E501 + qInfo("Redmod order changed, recreate deployed files") + self._clean_deployed_redmod(modlist_path) + qInfo(f"Deploying redmod with modlist: {modlist_path}") + args += f' -modlist="{modlist_path}"' + else: + qInfo("Deploying redmod") + redmod_binary = self._get_redmod_binary() + return self._organizer.waitForApplication( + self._organizer.startApplication( + redmod_binary, [args], redmod_binary.parent + ), + False, + ) + + def _clean_deployed_redmod(self, modlist_path: Path | None = None): + """Delete all files from `_redmod_deploy_path` except for `modlist_path`.""" + for file in self._organizer.findFiles(self._redmod_deploy_path, "*"): + file_path = Path(file) + if modlist_path is None or file_path != modlist_path: + file_path.unlink() + + def _map_cache_files(self): + """ + Copy cache files (`final.redscript` etc.) to overwrite to catch + overwritten game files. + """ + data_path = Path(self.dataDirectory().absolutePath()) + overwrite_path = Path(self._organizer.overwritePath()) + cache_files = list(data_path.glob("r6/cache/*")) + if self._get_setting("clear_cache_after_game_update") and any( + self._is_cache_file_updated(file.relative_to(data_path), data_path) + for file in cache_files + ): + qInfo('Updated game files detected, clearing "overwrite/r6/cache/*"') + shutil.rmtree(overwrite_path / "r6/cache") + new_cache_files = cache_files + else: + new_cache_files = list(self._unmapped_cache_files(data_path)) + for file in new_cache_files: + qInfo(f'Copying "{file}" to overwrite (to catch file overwrites)') + dst = overwrite_path / file + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(data_path / file, dst) + + def _is_cache_file_updated(self, file: Path, data_path: Path) -> bool: + """Checks if a cache file is updated (in game dir). + + Args: + file: Relative to data dir. + """ + game_file = data_path.absolute() / file + mapped_files = self._organizer.findFiles(file.parent, file.name) + return bool( + mapped_files + and (mapped_file := mapped_files[0]) + and not ( + game_file.samefile(mapped_file) + or filecmp.cmp(game_file, mapped_file) + or ( # different backup file + ( + backup_files := self._organizer.findFiles( + file.parent, f"{file.name}.bk" + ) + ) + and filecmp.cmp(game_file, backup_files[0]) + ) + ) + ) + + def _unmapped_cache_files(self, data_path: Path) -> Iterable[Path]: + """Yields unmapped cache files relative to `data_path`.""" + for file in self._organizer.findFiles("r6/cache", "*"): + try: + yield Path(file).absolute().relative_to(data_path) + except ValueError: + continue diff --git a/games/game_da2.py b/games/game_da2.py index a8fdc30..045055d 100644 --- a/games/game_da2.py +++ b/games/game_da2.py @@ -5,7 +5,6 @@ class DA2Game(BasicGame): - Name = "Dragon Age 2 Support Plugin" Author = "Patchier" @@ -26,7 +25,7 @@ class DA2Game(BasicGame): def version(self): # Don't forget to import mobase! - return mobase.VersionInfo(1, 0, 1, mobase.ReleaseType.final) + return mobase.VersionInfo(1, 0, 1, mobase.ReleaseType.FINAL) def init(self, organizer: mobase.IOrganizer): super().init(organizer) diff --git a/games/game_daggerfallunity.py b/games/game_daggerfallunity.py index f76c444..a6983dd 100644 --- a/games/game_daggerfallunity.py +++ b/games/game_daggerfallunity.py @@ -25,9 +25,9 @@ def __init__(self): ] def dataLooksValid( - self, tree: mobase.IFileTree + self, filetree: mobase.IFileTree ) -> mobase.ModDataChecker.CheckReturn: - for entry in tree: + for entry in filetree: if not entry.isDir(): continue if entry.name().casefold() in self.validDirNames: diff --git a/games/game_dao.py b/games/game_dao.py index 0f0c493..458b96a 100644 --- a/games/game_dao.py +++ b/games/game_dao.py @@ -5,7 +5,6 @@ class DAOriginsGame(BasicGame): - Name = "Dragon Age Origins Support Plugin" Author = "Patchier" Version = "1.1.1" diff --git a/games/game_darkestdungeon.py b/games/game_darkestdungeon.py index 7102886..2ddc227 100644 --- a/games/game_darkestdungeon.py +++ b/games/game_darkestdungeon.py @@ -1,11 +1,8 @@ -# -*- encoding: utf-8 -*- import json from pathlib import Path -from typing import List - -from PyQt6.QtCore import QDir, QFileInfo, QStandardPaths import mobase +from PyQt6.QtCore import QDir, QFileInfo, QStandardPaths from ..basic_game import BasicGame, BasicGameSaveGame from ..steam_utils import find_steam_path @@ -51,9 +48,9 @@ def __init__(self): ] def dataLooksValid( - self, tree: mobase.IFileTree + self, filetree: mobase.IFileTree ) -> mobase.ModDataChecker.CheckReturn: - for entry in tree: + for entry in filetree: if not entry.isDir(): continue if entry.name().casefold() in self.validDirNames: @@ -62,7 +59,7 @@ def dataLooksValid( class DarkestDungeonSaveGame(BasicGameSaveGame): - def __init__(self, filepath): + def __init__(self, filepath: Path): super().__init__(filepath) dataPath = filepath.joinpath("persist.game.json") self.name: str = "" @@ -127,12 +124,12 @@ def loadBinarySaveFile(self, dataPath: Path): raise ValueError( "Meta2 has wrong number of bytes: " + str(meta2DataLength) ) - meta2List = list() + meta2List: list[tuple[int, int, int]] = [] for x in range(numMeta2Entries): entryHash = int.from_bytes(fp.read(4), "little") offset = int.from_bytes(fp.read(4), "little") fieldInfo = int.from_bytes(fp.read(4), "little") - meta2List.append([entryHash, offset, fieldInfo]) + meta2List.append((entryHash, offset, fieldInfo)) # read Data fp.seek(dataOffset, 0) @@ -193,8 +190,11 @@ def executables(self): ] @staticmethod - def getCloudSaveDirectory(): - steamPath = Path(find_steam_path()) + def getCloudSaveDirectory() -> str | None: + steamPath = find_steam_path() + if steamPath is None: + return None + userData = steamPath.joinpath("userdata") for child in userData.iterdir(): name = child.name @@ -224,8 +224,8 @@ def savesDirectory(self) -> QDir: return QDir(cloudSaves) return documentsSaves - def listSaves(self, folder: QDir) -> List[mobase.ISaveGame]: - profiles = list() + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + profiles: list[Path] = [] for path in Path(folder.absolutePath()).glob("profile_*"): # profile_9 is only for the Multiplayer DLC "The Butcher's Circus" # and contains different files than other profiles diff --git a/games/game_darkmessiahofmightandmagic.py b/games/game_darkmessiahofmightandmagic.py index 3c8f62f..d1e1a51 100644 --- a/games/game_darkmessiahofmightandmagic.py +++ b/games/game_darkmessiahofmightandmagic.py @@ -1,10 +1,8 @@ -# -*- encoding: utf-8 -*- - import struct - -from PyQt6.QtGui import QImage +from pathlib import Path import mobase +from PyQt6.QtGui import QImage from ..basic_features import BasicGameSaveGameInfo from ..basic_game import BasicGame @@ -31,15 +29,17 @@ class DarkMessiahOfMightAndMagicGame(BasicGame): GameSavesDirectory = "%GAME_PATH%/mm/SAVE" GameSaveExtension = "sav" - def _read_save_tga(self, filename): + def _read_save_tga(self, filepath: Path) -> QImage | None: # Qt TGA reader does not work for TGA, I hope that all files # have the same format: - with open(filename.replace(".sav", ".tga"), "rb") as fp: + with open( + filepath.parent.joinpath(filepath.name.replace(".sav", ".tga")), "rb" + ) as fp: data = fp.read() _, _, w, h, bpp, _ = struct.unpack(" mobase.ModDataChecker.CheckReturn: + folders: list[mobase.IFileTree] = [] + files: list[mobase.FileTreeEntry] = [] - folders: List[mobase.IFileTree] = [] - files: List[mobase.FileTreeEntry] = [] - - for entry in tree: + for entry in filetree: if isinstance(entry, mobase.IFileTree): folders.append(entry) else: @@ -53,9 +52,6 @@ def dataLooksValid( return mobase.ModDataChecker.INVALID - def fix(self, tree: mobase.IFileTree) -> Optional[mobase.IFileTree]: - return None - class DivinityOriginalSinEnhancedEditionGame(BasicGame, mobase.IPluginFileMapper): Name = "Divinity: Original Sin (Enhanced Edition) Support Plugin" @@ -101,10 +97,9 @@ def init(self, organizer: mobase.IOrganizer): ] = DivinityOriginalSinEnhancedEditionModDataChecker() return True - def mappings(self) -> List[mobase.Mapping]: - map = [] - modDirs = [self.DOCS_MOD_SPECIAL_NAME] - self._listDirsRecursive(modDirs, prefix=self.DOCS_MOD_SPECIAL_NAME) + def mappings(self) -> list[mobase.Mapping]: + map: list[mobase.Mapping] = [] + modDirs = self._listDirsRecursive(Path(self.DOCS_MOD_SPECIAL_NAME)) for dir_ in modDirs: for file_ in self._organizer.findFiles(path=dir_, filter=lambda x: True): m = mobase.Mapping() @@ -121,9 +116,9 @@ def mappings(self) -> List[mobase.Mapping]: def primarySources(self): return self.GameValidShortNames - def _listDirsRecursive(self, dirs_list, prefix=""): - dirs = self._organizer.listDirectories(prefix) + def _listDirsRecursive(self, prefix: Path) -> list[str]: + res = [str(prefix)] + dirs = self._organizer.listDirectories(str(prefix)) for dir_ in dirs: - dir_ = os.path.join(prefix, dir_) - dirs_list.append(dir_) - self._listDirsRecursive(dirs_list, dir_) + res.extend(self._listDirsRecursive(prefix.joinpath(dir_))) + return res diff --git a/games/game_dragonsdogmadarkarisen.py b/games/game_dragonsdogmadarkarisen.py index 77af70e..e26b00e 100644 --- a/games/game_dragonsdogmadarkarisen.py +++ b/games/game_dragonsdogmadarkarisen.py @@ -1,10 +1,7 @@ -# -*- encoding: utf-8 -*- - from ..basic_game import BasicGame class NoMansSkyGame(BasicGame): - Name = "Dragon's Dogma: Dark Arisen Support Plugin" Author = "Luca/EzioTheDeadPoet" Version = "1.0.0" diff --git a/games/game_dungeonsiege2.py b/games/game_dungeonsiege2.py index 3c32ca8..76c365c 100644 --- a/games/game_dungeonsiege2.py +++ b/games/game_dungeonsiege2.py @@ -1,10 +1,5 @@ -# -*- encoding: utf-8 -*- - -from typing import List, Tuple - -from PyQt6.QtCore import QFileInfo - import mobase +from PyQt6.QtCore import QFileInfo from ..basic_game import BasicGame @@ -15,10 +10,9 @@ def __init__(self): def get_resources_and_maps( self, tree: mobase.IFileTree - ) -> Tuple[List[mobase.FileTreeEntry], List[mobase.FileTreeEntry]]: - - ress: List[mobase.FileTreeEntry] = [] - maps: List[mobase.FileTreeEntry] = [] + ) -> tuple[list[mobase.FileTreeEntry], list[mobase.FileTreeEntry]]: + ress: list[mobase.FileTreeEntry] = [] + maps: list[mobase.FileTreeEntry] = [] for e in tree: if e.isFile(): @@ -30,31 +24,31 @@ def get_resources_and_maps( return ress, maps def dataLooksValid( - self, tree: mobase.IFileTree + self, filetree: mobase.IFileTree ) -> mobase.ModDataChecker.CheckReturn: # Check if we have a Resources / Maps folder or .ds2res/.ds2map - ress, maps = self.get_resources_and_maps(tree) + ress, maps = self.get_resources_and_maps(filetree) if not ress and not maps: - if tree.exists("Resources") or tree.exists("Maps"): + if filetree.exists("Resources") or filetree.exists("Maps"): return mobase.ModDataChecker.VALID else: return mobase.ModDataChecker.INVALID return mobase.ModDataChecker.FIXABLE - def fix(self, tree: mobase.IFileTree) -> mobase.IFileTree: - ress, maps = self.get_resources_and_maps(tree) + def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + ress, maps = self.get_resources_and_maps(filetree) if ress: - rfolder = tree.addDirectory("Resources") + rfolder = filetree.addDirectory("Resources") for r in ress: rfolder.insert(r, mobase.IFileTree.REPLACE) if maps: - rfolder = tree.addDirectory("Maps") + rfolder = filetree.addDirectory("Maps") for r in maps: rfolder.insert(r, mobase.IFileTree.REPLACE) - return tree + return filetree class DungeonSiegeIIGame(BasicGame): diff --git a/games/game_gta-3-de.py b/games/game_gta-3-de.py index 3b8eef4..09bc6ff 100644 --- a/games/game_gta-3-de.py +++ b/games/game_gta-3-de.py @@ -1,9 +1,8 @@ import os from pathlib import Path -from PyQt6.QtCore import QDir, QFileInfo - import mobase +from PyQt6.QtCore import QDir, QFileInfo from ..basic_game import BasicGame @@ -13,9 +12,9 @@ def __init__(self): super().__init__() def dataLooksValid( - self, tree: mobase.IFileTree + self, filetree: mobase.IFileTree ) -> mobase.ModDataChecker.CheckReturn: - for entry in tree: + for entry in filetree: if Path(entry.name().casefold()).suffix == ".pak": return mobase.ModDataChecker.VALID @@ -23,7 +22,6 @@ def dataLooksValid( class GTA3DefinitiveEditionGame(BasicGame): - Name = "Grand Theft Auto III - Definitive Edition Support Plugin" Author = "dekart811" Version = "1.0" @@ -64,10 +62,10 @@ def executables(self): def iniFiles(self): return ["GameUserSettings.ini", "CustomSettings.ini"] - def initializeProfile(self, path: QDir, settings: mobase.ProfileSetting): + def initializeProfile(self, directory: QDir, settings: mobase.ProfileSetting): # Create the mods directory if it doesn't exist modsPath = self.dataDirectory().absolutePath() if not os.path.exists(modsPath): os.mkdir(modsPath) - super().initializeProfile(path, settings) + super().initializeProfile(directory, settings) diff --git a/games/game_gta-san-andreas-de.py b/games/game_gta-san-andreas-de.py index e06c144..2d58fec 100644 --- a/games/game_gta-san-andreas-de.py +++ b/games/game_gta-san-andreas-de.py @@ -1,9 +1,8 @@ import os from pathlib import Path -from PyQt6.QtCore import QDir, QFileInfo - import mobase +from PyQt6.QtCore import QDir, QFileInfo from ..basic_game import BasicGame @@ -13,9 +12,9 @@ def __init__(self): super().__init__() def dataLooksValid( - self, tree: mobase.IFileTree + self, filetree: mobase.IFileTree ) -> mobase.ModDataChecker.CheckReturn: - for entry in tree: + for entry in filetree: if Path(entry.name().casefold()).suffix == ".pak": return mobase.ModDataChecker.VALID @@ -23,7 +22,6 @@ def dataLooksValid( class GTASanAndreasDefinitiveEditionGame(BasicGame): - Name = "Grand Theft Auto: San Andreas - Definitive Edition Support Plugin" Author = "dekart811" Version = "1.0" @@ -66,10 +64,10 @@ def executables(self): def iniFiles(self): return ["GameUserSettings.ini", "CustomSettings.ini"] - def initializeProfile(self, path: QDir, settings: mobase.ProfileSetting): + def initializeProfile(self, directory: QDir, settings: mobase.ProfileSetting): # Create the mods directory if it doesn't exist modsPath = self.dataDirectory().absolutePath() if not os.path.exists(modsPath): os.mkdir(modsPath) - super().initializeProfile(path, settings) + super().initializeProfile(directory, settings) diff --git a/games/game_gta-vice-city-de.py b/games/game_gta-vice-city-de.py index 44129f3..2a65449 100644 --- a/games/game_gta-vice-city-de.py +++ b/games/game_gta-vice-city-de.py @@ -1,9 +1,8 @@ import os from pathlib import Path -from PyQt6.QtCore import QDir, QFileInfo - import mobase +from PyQt6.QtCore import QDir, QFileInfo from ..basic_game import BasicGame @@ -13,9 +12,9 @@ def __init__(self): super().__init__() def dataLooksValid( - self, tree: mobase.IFileTree + self, filetree: mobase.IFileTree ) -> mobase.ModDataChecker.CheckReturn: - for entry in tree: + for entry in filetree: if Path(entry.name().casefold()).suffix == ".pak": return mobase.ModDataChecker.VALID @@ -23,7 +22,6 @@ def dataLooksValid( class GTAViceCityDefinitiveEditionGame(BasicGame): - Name = "Grand Theft Auto: Vice City - Definitive Edition Support Plugin" Author = "dekart811" Version = "1.0" @@ -66,10 +64,10 @@ def executables(self): def iniFiles(self): return ["GameUserSettings.ini", "CustomSettings.ini"] - def initializeProfile(self, path: QDir, settings: mobase.ProfileSetting): + def initializeProfile(self, directory: QDir, settings: mobase.ProfileSetting): # Create the mods directory if it doesn't exist modsPath = self.dataDirectory().absolutePath() if not os.path.exists(modsPath): os.mkdir(modsPath) - super().initializeProfile(path, settings) + super().initializeProfile(directory, settings) diff --git a/games/game_kerbalspaceprogram.py b/games/game_kerbalspaceprogram.py index c2a6a3b..f9f5848 100644 --- a/games/game_kerbalspaceprogram.py +++ b/games/game_kerbalspaceprogram.py @@ -1,7 +1,7 @@ -from os import path from pathlib import Path import mobase +from PyQt6.QtCore import QDir from ..basic_features import BasicGameSaveGameInfo from ..basic_features.basic_save_game_info import BasicGameSaveGame @@ -10,22 +10,20 @@ class KerbalSpaceProgramSaveGame(BasicGameSaveGame): def allFiles(self): - group = path.parent - banner = group.joinpath("banners").joinpath(f"${self.getName()}.png") - files = [self.filename] + files = [super().getFilepath()] + banner = self._filepath.joinpath("banners").joinpath(f"${self.getName()}.png") if banner.exists(): - files.append(banner) + files.append(banner.as_posix()) return files def getName(self): return self._filepath.stem def getSaveGroupIdentifier(self): - return path.parent.name + return self._filepath.parent.name class KerbalSpaceProgramGame(BasicGame): - Name = "Kerbal Space Program Support Plugin" Author = "LaughingHyena" Version = "1.0.0" @@ -43,7 +41,7 @@ class KerbalSpaceProgramGame(BasicGame): "Game:-Kerbal-Space-Program" ) - def init(self, organizer): + def init(self, organizer: mobase.IOrganizer): super().init(organizer) self._featureMap[mobase.SaveGameInfo] = BasicGameSaveGameInfo( lambda s: str( @@ -52,7 +50,7 @@ def init(self, organizer): ) return True - def listSaves(self, folder): + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: ext = self._mappings.savegameExtension.get() return [ KerbalSpaceProgramSaveGame(path) diff --git a/games/game_kingdomcomedeliverance.py b/games/game_kingdomcomedeliverance.py index 0cfcfd0..503ff5d 100644 --- a/games/game_kingdomcomedeliverance.py +++ b/games/game_kingdomcomedeliverance.py @@ -1,10 +1,7 @@ -# -*- encoding: utf-8 -*- - import os -from PyQt6.QtCore import QDir - import mobase +from PyQt6.QtCore import QDir from ..basic_game import BasicGame @@ -34,7 +31,7 @@ class KingdomComeDeliveranceGame(BasicGame): def iniFiles(self): return ["custom.cfg", "system.cfg", "user.cfg"] - def initializeProfile(self, path: QDir, settings: mobase.ProfileSetting): + def initializeProfile(self, directory: QDir, settings: mobase.ProfileSetting): # Create .cfg files if they don't exist for iniFile in self.iniFiles(): iniPath = self.documentsDirectory().absoluteFilePath(iniFile) @@ -47,4 +44,4 @@ def initializeProfile(self, path: QDir, settings: mobase.ProfileSetting): if not os.path.exists(modsPath): os.mkdir(modsPath) - super().initializeProfile(path, settings) + super().initializeProfile(directory, settings) diff --git a/games/game_masterduel.py b/games/game_masterduel.py index e832bc4..ff9147b 100644 --- a/games/game_masterduel.py +++ b/games/game_masterduel.py @@ -1,17 +1,13 @@ -# -*- encoding: utf-8 -*- - -from os.path import exists, join +from pathlib import Path from typing import List, Optional -from PyQt6.QtCore import QDir, QFileInfo - import mobase +from PyQt6.QtCore import QDir, QFileInfo from ..basic_game import BasicGame class MasterDuelGame(BasicGame, mobase.IPluginFileMapper): - Name = "Yu-Gi-Oh! Master Duel Support Plugin" Author = "The Conceptionist & uwx" Version = "1.0.2" @@ -62,7 +58,7 @@ def userDataDir(self) -> str: return self._userDataDirCached def mappings(self) -> List[mobase.Mapping]: - modsPath = self._organizer.modsPath() + modsPath = Path(self._organizer.modsPath()) unityMods = self.getUnityDataMods() mappings: List[mobase.Mapping] = [] @@ -71,22 +67,22 @@ def mappings(self) -> List[mobase.Mapping]: m = mobase.Mapping() m.createTarget = False m.isDirectory = True - m.source = join(modsPath, modName, "AssetBundle") + m.source = modsPath.joinpath(modName, "AssetBundle").as_posix() m.destination = self.gameDirectory().filePath( - join("masterduel_Data", "StreamingAssets", "AssetBundle") + Path("masterduel_Data", "StreamingAssets", "AssetBundle").as_posix() ) mappings.append(m) return mappings - def getUnityDataMods(self): - modsPath = self._organizer.modsPath() + def getUnityDataMods(self) -> list[str]: + modsPath = Path(self._organizer.modsPath()) allMods = self._organizer.modList().allModsByProfilePriority() - unityMods = [] + unityMods: list[str] = [] for modName in allMods: if self._organizer.modList().state(modName) & mobase.ModState.ACTIVE != 0: - if exists(join(modsPath, modName, "AssetBundle")): + if modsPath.joinpath(modName, "AssetBundle").exists(): unityMods.append(modName) return unityMods diff --git a/games/game_mirrorsedge.py b/games/game_mirrorsedge.py index 70b8241..db81908 100644 --- a/games/game_mirrorsedge.py +++ b/games/game_mirrorsedge.py @@ -1,10 +1,7 @@ -# -*- encoding: utf-8 -*- - from ..basic_game import BasicGame class MirrorsEdgeGame(BasicGame): - Name = "Mirror's Edge Support Plugin" Author = "Luca/EzioTheDeadPoet" Version = "1.0.0" diff --git a/games/game_monsterhunterrise.py b/games/game_monsterhunterrise.py new file mode 100644 index 0000000..e63ed10 --- /dev/null +++ b/games/game_monsterhunterrise.py @@ -0,0 +1,15 @@ +from ..basic_game import BasicGame + + +class MonsterHunterRiseGame(BasicGame): + Name = "Monster Hunter: Rise Support Plugin" + Author = "RodolfoFigueroa" + Version = "1.0.0" + + GameName = "Monster Hunter: Rise" + GameShortName = "monsterhunterrise" + GameBinary = "MonsterHunterRise.exe" + GameDataPath = "%GAME_PATH%" + GameSaveExtension = "bin" + GameNexusId = 4095 + GameSteamId = 1446780 diff --git a/games/game_monsterhunterworld.py b/games/game_monsterhunterworld.py index 23a9869..290048c 100644 --- a/games/game_monsterhunterworld.py +++ b/games/game_monsterhunterworld.py @@ -2,7 +2,6 @@ class MonsterHunterWorldGame(BasicGame): - Name = "Monster Hunter: World Support Plugin" Author = "prz" Version = "1.0.0" diff --git a/games/game_mountandblade2.py b/games/game_mountandblade2.py index 485aa58..e710840 100644 --- a/games/game_mountandblade2.py +++ b/games/game_mountandblade2.py @@ -1,17 +1,11 @@ -# -*- encoding: utf-8 -*- - -from typing import List - -from PyQt6.QtCore import QFileInfo - import mobase +from PyQt6.QtCore import QFileInfo from ..basic_game import BasicGame class MountAndBladeIIModDataChecker(mobase.ModDataChecker): - - _valid_folders: List[str] = [ + _valid_folders: list[str] = [ "native", "sandbox", "sandboxcore", @@ -23,10 +17,9 @@ def __init__(self): super().__init__() def dataLooksValid( - self, tree: mobase.IFileTree + self, filetree: mobase.IFileTree ) -> mobase.ModDataChecker.CheckReturn: - - for e in tree: + for e in filetree: if e.isDir(): if e.name().lower() in self._valid_folders: return mobase.ModDataChecker.VALID diff --git a/games/game_msfs2020.py b/games/game_msfs2020.py index b9ecdd1..d0c91ba 100644 --- a/games/game_msfs2020.py +++ b/games/game_msfs2020.py @@ -7,7 +7,6 @@ class MSFS2020Game(BasicGame): - Name = "Microsoft Flight Simulator 2020 Support Plugin" Author = "Deorder" Version = "0.0.1" diff --git a/games/game_nierautomata.py b/games/game_nierautomata.py index 3bed00f..f106bc4 100644 --- a/games/game_nierautomata.py +++ b/games/game_nierautomata.py @@ -1,10 +1,7 @@ -# -*- encoding: utf-8 -*- - from ..basic_game import BasicGame class NierAutomataGame(BasicGame): - Name = "NieR:Automata Support Plugin" Author = "Luca/EzioTheDeadPoet" Version = "1.0.0" diff --git a/games/game_nomanssky.py b/games/game_nomanssky.py index fdc109d..6653644 100644 --- a/games/game_nomanssky.py +++ b/games/game_nomanssky.py @@ -1,14 +1,10 @@ -# -*- encoding: utf-8 -*- - -from PyQt6.QtCore import QFileInfo - import mobase +from PyQt6.QtCore import QFileInfo from ..basic_game import BasicGame class NoMansSkyGame(BasicGame): - Name = "No Man's Sky Support Plugin" Author = "Luca/EzioTheDeadPoet" Version = "1.0.0" diff --git a/games/game_sekiroshadowsdietwice.py b/games/game_sekiroshadowsdietwice.py new file mode 100644 index 0000000..5e7aaf0 --- /dev/null +++ b/games/game_sekiroshadowsdietwice.py @@ -0,0 +1,14 @@ +from ..basic_game import BasicGame + + +class SekiroShadowsDieTwiceGame(BasicGame): + Name = "Sekiro: Shadows Die Twice Support Plugin" + Author = "Kane Dou" + Version = "1.0.0" + + GameName = "Sekiro: Shadows Die Twice" + GameShortName = "sekiro" + GameBinary = "sekiro.exe" + GameDataPath = "mods" + GameSaveExtension = "sl2" + GameSteamId = 814380 diff --git a/games/game_sims4.py b/games/game_sims4.py index 1c3e5e7..a3c905b 100644 --- a/games/game_sims4.py +++ b/games/game_sims4.py @@ -4,7 +4,6 @@ class TS4Game(BasicGame): - Name = "The Sims 4 Support Plugin" Author = "R3z Shark" @@ -18,4 +17,4 @@ class TS4Game(BasicGame): def version(self): # Don't forget to import mobase! - return mobase.VersionInfo(1, 0, 0, mobase.ReleaseType.final) + return mobase.VersionInfo(1, 0, 0, mobase.ReleaseType.FINAL) diff --git a/games/game_stalkeranomaly.py b/games/game_stalkeranomaly.py index df05f9e..0b83477 100644 --- a/games/game_stalkeranomaly.py +++ b/games/game_stalkeranomaly.py @@ -1,14 +1,10 @@ -# -*- encoding: utf-8 -*- - from enum import IntEnum from pathlib import Path -from typing import List +import mobase from PyQt6.QtCore import QDir, QFileInfo, Qt from PyQt6.QtWidgets import QLabel, QVBoxLayout, QWidget -import mobase - from ..basic_features.basic_save_game_info import ( BasicGameSaveGame, BasicGameSaveGameInfo, @@ -18,7 +14,7 @@ class StalkerAnomalyModDataChecker(mobase.ModDataChecker): - _valid_folders: List[str] = [ + _valid_folders: list[str] = [ "appdata", "bin", "db", @@ -33,8 +29,8 @@ def hasValidFolders(self, tree: mobase.IFileTree) -> bool: return False - def findLostData(self, tree: mobase.IFileTree) -> List[mobase.FileTreeEntry]: - lost_db: List[mobase.FileTreeEntry] = [] + def findLostData(self, tree: mobase.IFileTree) -> list[mobase.FileTreeEntry]: + lost_db: list[mobase.FileTreeEntry] = [] for e in tree: if e.isFile(): @@ -44,24 +40,24 @@ def findLostData(self, tree: mobase.IFileTree) -> List[mobase.FileTreeEntry]: return lost_db def dataLooksValid( - self, tree: mobase.IFileTree + self, filetree: mobase.IFileTree ) -> mobase.ModDataChecker.CheckReturn: - if self.hasValidFolders(tree): + if self.hasValidFolders(filetree): return mobase.ModDataChecker.VALID - if self.findLostData(tree): + if self.findLostData(filetree): return mobase.ModDataChecker.FIXABLE return mobase.ModDataChecker.INVALID - def fix(self, tree: mobase.IFileTree) -> mobase.IFileTree: - lost_db = self.findLostData(tree) + def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + lost_db = self.findLostData(filetree) if lost_db: - rfolder = tree.addDirectory("db").addDirectory("mods") + rfolder = filetree.addDirectory("db").addDirectory("mods") for r in lost_db: rfolder.insert(r, mobase.IFileTree.REPLACE) - return tree + return filetree class Content(IntEnum): @@ -75,9 +71,9 @@ class Content(IntEnum): class StalkerAnomalyModDataContent(mobase.ModDataContent): - content: List[int] = [] + content: list[int] = [] - def getAllContents(self) -> List[mobase.ModDataContent.Content]: + def getAllContents(self) -> list[mobase.ModDataContent.Content]: return [ mobase.ModDataContent.Content( Content.INTERFACE, "Interface", ":/MO/gui/content/interface" @@ -125,9 +121,9 @@ def walkContent( return mobase.IFileTree.WalkReturn.CONTINUE - def getContentsFor(self, tree: mobase.IFileTree) -> List[int]: + def getContentsFor(self, filetree: mobase.IFileTree) -> list[int]: self.content = [] - tree.walk(self.walkContent, "/") + filetree.walk(self.walkContent, "/") return self.content @@ -150,7 +146,7 @@ def getName(self) -> str: return f"{name}, {xr_save.save_fmt} [{time}]" return "" - def allFiles(self) -> List[str]: + def allFiles(self) -> list[str]: filepath = str(self._filepath) paths = [filepath] scoc = filepath.replace(".scop", ".scoc") @@ -163,7 +159,7 @@ def allFiles(self) -> List[str]: class StalkerAnomalySaveGameInfoWidget(mobase.ISaveGameInfoWidget): - def __init__(self, parent: QWidget): + def __init__(self, parent: QWidget | None): super().__init__(parent) layout = QVBoxLayout() self._labelSave = self.newLabel(layout) @@ -211,7 +207,7 @@ def setSave(self, save: mobase.ISaveGame): class StalkerAnomalySaveGameInfo(BasicGameSaveGameInfo): - def getSaveGameWidget(self, parent=None): + def getSaveGameWidget(self, parent: QWidget | None = None): return StalkerAnomalySaveGameInfoWidget(parent) @@ -258,11 +254,11 @@ def aboutToRun(self, _str: str) -> bool: dbg_path = Path(self._gamePath, "gamedata/configs/cache_dbg.ltx") if not dbg_path.exists(): dbg_path.parent.mkdir(parents=True, exist_ok=True) - with open(dbg_path, "w", encoding="utf-8") as file: # noqa + with open(dbg_path, "w", encoding="utf-8"): pass return True - def executables(self) -> List[mobase.ExecutableInfo]: + def executables(self) -> list[mobase.ExecutableInfo]: info = [ ["Anomaly Launcher", "AnomalyLauncher.exe"], ["Anomaly (DX11-AVX)", "bin/AnomalyDX11AVX.exe"], @@ -279,14 +275,14 @@ def executables(self) -> List[mobase.ExecutableInfo]: mobase.ExecutableInfo(inf[0], QFileInfo(gamedir, inf[1])) for inf in info ] - def listSaves(self, folder: QDir) -> List[mobase.ISaveGame]: + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: ext = self._mappings.savegameExtension.get() return [ StalkerAnomalySaveGame(path) for path in Path(folder.absolutePath()).glob(f"*.{ext}") ] - def mappings(self) -> List[mobase.Mapping]: + def mappings(self) -> list[mobase.Mapping]: appdata = self.gameDirectory().filePath("appdata") m = mobase.Mapping() m.createTarget = True diff --git a/games/game_stalkershadowofchernobyl.py b/games/game_stalkershadowofchernobyl.py new file mode 100644 index 0000000..e25e821 --- /dev/null +++ b/games/game_stalkershadowofchernobyl.py @@ -0,0 +1,41 @@ +from PyQt5.QtCore import QDir, QStandardPaths + +import mobase + +from ..basic_game import BasicGame + + +class StalkerShocGame(BasicGame): + Name = "S.T.A.L.K.E.R.: Shadow of Chernobyl Support Plugin" + Author = "lowtied" + Version = "0.1.0" + + GameName = "S.T.A.L.K.E.R.: Shadow of Chernobyl" + GameShortName = "stalkershadowofchernobyl" + GameNexusName = "stalkershadowofchernobyl" + + GameNexusId = 1428 + GameSteamId = 4500 + GameGogId = 1207660573 + + GameBinary = "bin/XR_3DA.exe" + GameDataPath = "" + + GameSaveExtension = "sav" + GameSavesDirectory = "%GAME_DOCUMENTS%/savedgames" + + def documentsDirectory(self) -> QDir: + fsgame = self.gameDirectory().absoluteFilePath("fsgame.ltx") + if self.is_steam(): + return QDir(self.gameDirectory().absoluteFilePath("_appdata_")) + + documents = QDir( + QStandardPaths.writableLocation(QStandardPaths.DocumentsLocation) + ) + return QDir(documents.absoluteFilePath("stalker-shoc")) + + def savesDirectory(self) -> QDir: + return QDir(self.documentsDirectory().absoluteFilePath("savedgames")) + + +# vim: ft=python sw=4 ts=4 sts=-1 sta et diff --git a/games/game_stardewvalley.py b/games/game_stardewvalley.py index dd3d507..70db9cd 100644 --- a/games/game_stardewvalley.py +++ b/games/game_stardewvalley.py @@ -1,8 +1,5 @@ -# -*- encoding: utf-8 -*- - -from PyQt6.QtCore import QFileInfo - import mobase +from PyQt6.QtCore import QFileInfo from ..basic_game import BasicGame @@ -12,11 +9,10 @@ def __init__(self): super().__init__() def dataLooksValid( - self, tree: mobase.IFileTree + self, filetree: mobase.IFileTree ) -> mobase.ModDataChecker.CheckReturn: - - for e in tree: - if e.isDir() and e.exists( # type: ignore + for e in filetree: + if isinstance(e, mobase.IFileTree) and e.exists( "manifest.json", mobase.IFileTree.FILE ): return mobase.ModDataChecker.VALID diff --git a/games/game_starsector.py b/games/game_starsector.py index ac37771..567ce0c 100644 --- a/games/game_starsector.py +++ b/games/game_starsector.py @@ -1,10 +1,7 @@ -# -*- encoding: utf-8 -*- from pathlib import Path -from typing import List - -from PyQt6.QtCore import QDir import mobase +from PyQt6.QtCore import QDir from ..basic_game import BasicGame, BasicGameSaveGame @@ -25,7 +22,7 @@ class Starsector(BasicGame): "Game:-Starsector" ) - def listSaves(self, folder: QDir) -> List[mobase.ISaveGame]: + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: return [ BasicGameSaveGame(path) for path in Path(folder.absolutePath()).glob("save_*") diff --git a/games/game_starwars-empire-at-war-foc.py b/games/game_starwars-empire-at-war-foc.py index 93131b5..2fc1fa9 100644 --- a/games/game_starwars-empire-at-war-foc.py +++ b/games/game_starwars-empire-at-war-foc.py @@ -1,10 +1,5 @@ -# -*- encoding: utf-8 -*- - -from typing import List - -from PyQt6.QtCore import QFileInfo - import mobase +from PyQt6.QtCore import QFileInfo from ..basic_game import BasicGame @@ -28,7 +23,7 @@ class StarWarsEmpireAtWarGame(BasicGame): "Game:-Star-Wars:-Empire-At-War" ) - def executables(self) -> List[mobase.ExecutableInfo]: + def executables(self) -> list[mobase.ExecutableInfo]: return [ mobase.ExecutableInfo( "STAR WARS Empire at War: Forces of Corruption", diff --git a/games/game_starwars-empire-at-war.py b/games/game_starwars-empire-at-war.py index 0bab7f4..4fb916a 100644 --- a/games/game_starwars-empire-at-war.py +++ b/games/game_starwars-empire-at-war.py @@ -1,10 +1,5 @@ -# -*- encoding: utf-8 -*- - -from typing import List - -from PyQt6.QtCore import QFileInfo - import mobase +from PyQt6.QtCore import QFileInfo from ..basic_game import BasicGame @@ -28,7 +23,7 @@ class StarWarsEmpireAtWarGame(BasicGame): "Game:-Star-Wars:-Empire-At-War" ) - def executables(self) -> List[mobase.ExecutableInfo]: + def executables(self) -> list[mobase.ExecutableInfo]: return [ mobase.ExecutableInfo( "STAR WARS Empire at War", diff --git a/games/game_subnautica-below-zero.py b/games/game_subnautica-below-zero.py index 5b9e3a7..49c1497 100644 --- a/games/game_subnautica-below-zero.py +++ b/games/game_subnautica-below-zero.py @@ -2,11 +2,11 @@ import mobase +from ..basic_features.basic_mod_data_checker import GlobPatterns from . import game_subnautica # namespace to not load SubnauticaGame here, too! class SubnauticaBelowZeroGame(game_subnautica.SubnauticaGame): - Name = "Subnautica Below Zero Support Plugin" Author = "dekart811, Zash" Version = "2.1" @@ -31,7 +31,9 @@ class SubnauticaBelowZeroGame(game_subnautica.SubnauticaGame): def init(self, organizer: mobase.IOrganizer) -> bool: super().init(organizer) - checker = game_subnautica.SubnauticaModDataChecker() - checker.update_patterns({"unfold": ["BepInExPack_BelowZero"]}) - self._featureMap[mobase.ModDataChecker] = checker + self._featureMap[ + mobase.ModDataChecker + ] = game_subnautica.SubnauticaModDataChecker( + GlobPatterns(unfold=["BepInExPack_BelowZero"]) + ) return True diff --git a/games/game_subnautica.py b/games/game_subnautica.py index 49097af..ac915b7 100644 --- a/games/game_subnautica.py +++ b/games/game_subnautica.py @@ -1,15 +1,14 @@ from __future__ import annotations -from enum import Enum import os from collections.abc import Iterable +from enum import Enum from pathlib import Path -from PyQt6.QtCore import QDir, qWarning - import mobase +from PyQt6.QtCore import QDir, qWarning -from ..basic_features import BasicModDataChecker +from ..basic_features import BasicModDataChecker, GlobPatterns from ..basic_features.basic_save_game_info import ( BasicGameSaveGame, BasicGameSaveGameInfo, @@ -18,22 +17,24 @@ class SubnauticaModDataChecker(BasicModDataChecker): - default_file_patterns = { - "unfold": ["BepInExPack_Subnautica"], - "valid": ["winhttp.dll", "doorstop_config.ini", "BepInEx", "QMods"], - "delete": [ - "*.txt", - "*.md", - "icon.png", - "license", - "manifest.json", - ], - "move": {"plugins": "BepInEx/", "patchers": "BepInEx/", "*": "QMods/"}, - } + def __init__(self, patterns: GlobPatterns = GlobPatterns()): + super().__init__( + GlobPatterns( + unfold=["BepInExPack_Subnautica"], + valid=["winhttp.dll", "doorstop_config.ini", "BepInEx", "QMods"], + delete=[ + "*.txt", + "*.md", + "icon.png", + "license", + "manifest.json", + ], + move={"plugins": "BepInEx/", "patchers": "BepInEx/", "*": "QMods/"}, + ).merge(patterns), + ) class SubnauticaGame(BasicGame, mobase.IPluginFileMapper): - Name = "Subnautica Support Plugin" Author = "dekart811, Zash" Version = "2.1.1" @@ -82,7 +83,7 @@ def init(self, organizer: mobase.IOrganizer) -> bool: super().init(organizer) self._featureMap[mobase.ModDataChecker] = SubnauticaModDataChecker() self._featureMap[mobase.SaveGameInfo] = BasicGameSaveGameInfo( - lambda s: os.path.join(s, "screenshot.jpg") + lambda s: Path(s or "", "screenshot.jpg") ) return True diff --git a/games/game_thebindingofisaacrebirth.py b/games/game_thebindingofisaacrebirth.py index 4d184dc..a21f11d 100644 --- a/games/game_thebindingofisaacrebirth.py +++ b/games/game_thebindingofisaacrebirth.py @@ -1,5 +1,3 @@ -# -*- encoding: utf-8 -*- - from ..basic_game import BasicGame diff --git a/games/game_trainsimulator.py b/games/game_trainsimulator.py index 44c369d..6c6e8df 100644 --- a/games/game_trainsimulator.py +++ b/games/game_trainsimulator.py @@ -1,12 +1,10 @@ -from PyQt6.QtCore import QFileInfo - import mobase +from PyQt6.QtCore import QFileInfo from ..basic_game import BasicGame class RailworksGame(BasicGame): - Name = "Train Simulator 20xx Support Plugin" Author = "Ryan Young" @@ -19,7 +17,7 @@ class RailworksGame(BasicGame): def version(self): # Don't forget to import mobase! - return mobase.VersionInfo(1, 0, 0, mobase.ReleaseType.final) + return mobase.VersionInfo(1, 0, 0, mobase.ReleaseType.FINAL) def executables(self): return [ diff --git a/games/game_valheim.py b/games/game_valheim.py index 9c8839c..7ce64f9 100644 --- a/games/game_valheim.py +++ b/games/game_valheim.py @@ -1,20 +1,17 @@ -# -*- encoding: utf-8 -*- - from __future__ import annotations import itertools import re import shutil -from collections.abc import Collection, Container, Iterable, Mapping, Sequence +from collections.abc import Collection, Iterable, Mapping, Sequence from dataclasses import dataclass, field from pathlib import Path -from typing import Optional, TextIO, Union - -from PyQt6.QtCore import QDir +from typing import Any, Optional, TextIO import mobase +from PyQt6.QtCore import QDir -from ..basic_features import BasicModDataChecker +from ..basic_features import BasicLocalSavegames, BasicModDataChecker, GlobPatterns from ..basic_features.basic_save_game_info import BasicGameSaveGame from ..basic_game import BasicGame @@ -29,10 +26,10 @@ def move_file(source: Path, target: Path): @dataclass class PartialMatch: - partial_match_regex: re.Pattern = re.compile(r"[A-Z]?[a-z]+") + partial_match_regex: re.Pattern[str] = re.compile(r"[A-Z]?[a-z]+") """Matches words, for e.g. 'Camel' and 'Case' in 'CamelCase'.""" - exclude: Container = field(default_factory=set) + exclude: set[str] = field(default_factory=set) min_length: int = 3 def partial_match(self, str_with_parts: str, search_string: str) -> Collection[str]: @@ -84,10 +81,10 @@ def __init__(self, column_keys: Collection[str]) -> None: """ self.new_table(column_keys) - def __call__(self, **kwargs) -> None: + def __call__(self, **kwargs: Any) -> None: self.add(**kwargs) - def new_table(self, column_keys: Optional[Collection[str]] = None) -> None: + def new_table(self, column_keys: Collection[str] | None = None) -> None: if column_keys: self._column_keys = column_keys self._table: list[dict[str, str]] = [ @@ -97,7 +94,7 @@ def new_table(self, column_keys: Optional[Collection[str]] = None) -> None: def add( self, - **kwargs, + **kwargs: Any, ) -> None: """Add data to the table. Adds a new line if the last row has already data in for the column key. @@ -128,9 +125,9 @@ class OverwriteSync: overwrite_file_pattern: Iterable[str] = ["BepInEx/config/*"] """File pattern (glob) in overwrite folder.""" partial_match: PartialMatch = PartialMatch(exclude={"valheim", "mod"}) - content_match: Optional[ContentMatch] = ContentMatch( + content_match: ContentMatch = ContentMatch( file_glob_patterns=["*.cfg"], - content_regex=re.compile(r"\A.*plugin (?P.+) v[\d\.]+?$", re.I | re.M), + content_regex=re.compile(r"\A.*plugin (?P.+) v[\d.]+?$", re.I | re.M), match_group="mod", ) @@ -160,8 +157,8 @@ def sync(self) -> None: self._debug.print() def _get_active_mods( - self, modlist: Optional[mobase.IModList] = None - ) -> Mapping[str, mobase.IModInterface]: + self, modlist: mobase.IModList | None = None + ) -> dict[str, mobase.IModInterface]: """Get all active mods. Args: modlist (optional): the `mobase.IModList`. Defaults to None (get it from @@ -181,10 +178,10 @@ def _get_active_mods( and (modlist.state(name) & mobase.ModState.ACTIVE) } - def _get_mod_dll_map(self, mod_map): + def _get_mod_dll_map(self, mod_map: Mapping[str, str | mobase.IModInterface]): return {name: self._get_mod_dlls(mod) for name, mod in mod_map.items()} - def _get_mod_dlls(self, mod: Union[str, mobase.IModInterface]) -> Sequence[str]: + def _get_mod_dlls(self, mod: str | mobase.IModInterface) -> Sequence[str]: """Get all BepInEx/plugins/*.dll files of a mod.""" if isinstance(mod, str): mod = self.organizer.modList().getMod(mod) @@ -202,7 +199,7 @@ def _find_mod_for_overwrite_file( """Find the mod (name) matching a file in Overwrite (using the mods dll name). Args: - file_name: The name of the file. + file_path: The name of the file. mod_dll_map: Mods names and their dll files `{mod_name: ["ModName.dll"]}`. Returns: @@ -236,8 +233,8 @@ def _get_matching_mods( """Find matching mods for the given `search_str`. Args: - search_name: A string to find a mod match for. mod_dll_map: Mods names and - their dll files `{mod_name: ["ModName.dll"]}`. + search_str: A string to find a mod match for. + mod_dll_map: Mods names and their dll files `{mod_name: ["ModName.dll"]}`. Returns: Mods with partial matches, sorted descending by their metric @@ -289,27 +286,7 @@ def allFiles(self) -> list[str]: return files -class ValheimLocalSavegames(mobase.LocalSavegames): - def __init__(self, myGameSaveDir): - super().__init__() - self._savesDir = myGameSaveDir.absolutePath() - - def mappings(self, profile_save_dir): - return [ - mobase.Mapping( - source=profile_save_dir.absolutePath(), - destination=self._savesDir, - is_directory=True, - create_target=True, - ) - ] - - def prepareProfile(self, profile): - return profile.localSavesEnabled() - - class ValheimGame(BasicGame): - Name = "Valheim Support Plugin" Author = "Zash" Version = "1.2.1" @@ -330,11 +307,11 @@ class ValheimGame(BasicGame): def init(self, organizer: mobase.IOrganizer) -> bool: super().init(organizer) self._featureMap[mobase.ModDataChecker] = BasicModDataChecker( - { - "unfold": [ + GlobPatterns( + unfold=[ "BepInExPack_Valheim", ], - "valid": [ + valid=[ "meta.ini", # Included in installed mod folder. "BepInEx", "doorstop_libs", @@ -352,7 +329,7 @@ def init(self, organizer: mobase.IOrganizer) -> bool: # "AdvancedBuilder", ], - "delete": [ + delete=[ "*.txt", "*.md", "README", @@ -362,7 +339,7 @@ def init(self, organizer: mobase.IOrganizer) -> bool: "*.dll.mdb", "*.pdb", ], - "move": { + move={ "*_VML.dll": "InSlimVML/Mods/", # "plugins": "BepInEx/", @@ -379,9 +356,9 @@ def init(self, organizer: mobase.IOrganizer) -> bool: # "*.assets": "valheim_Data/", }, - } + ) ) - self._featureMap[mobase.LocalSavegames] = ValheimLocalSavegames( + self._featureMap[mobase.LocalSavegames] = BasicLocalSavegames( self.savesDirectory() ) self._overwrite_sync = OverwriteSync(organizer=self._organizer, game=self) @@ -421,7 +398,7 @@ def _register_event_handler(self): self._organizer.onUserInterfaceInitialized(lambda win: self._sync_overwrite()) self._organizer.onFinishedRun(self._game_finished_event_handler) - def _game_finished_event_handler(self, app_path: str, *args) -> None: + def _game_finished_event_handler(self, app_path: str, exit_code: int) -> None: """Sync overwrite folder with mods after game was closed.""" if Path(app_path) == Path( self.gameDirectory().absolutePath(), self.binaryName() diff --git a/games/game_vampirebloodlines.py b/games/game_vampirebloodlines.py index 66fdf0c..b5e84ff 100644 --- a/games/game_vampirebloodlines.py +++ b/games/game_vampirebloodlines.py @@ -1,12 +1,10 @@ -# -*- encoding: utf-8 -*- -import os from pathlib import Path from typing import List -from PyQt6.QtCore import QDir - import mobase +from PyQt6.QtCore import QDir +from ..basic_features import BasicLocalSavegames from ..basic_game import BasicGame, BasicGameSaveGame @@ -31,9 +29,9 @@ def __init__(self): ] def dataLooksValid( - self, tree: mobase.IFileTree + self, filetree: mobase.IFileTree ) -> mobase.ModDataChecker.CheckReturn: - for entry in tree: + for entry in filetree: if not entry.isDir(): continue if entry.name().casefold() in self.validDirNames: @@ -51,24 +49,6 @@ def __init__(self, filepath: Path): self.elapsedTime = None -class VampireLocalSavegames(mobase.LocalSavegames): - def __init__(self, myGameSaveDir): - super().__init__() - self._savesDir = myGameSaveDir.absolutePath() - - def mappings(self, profile_save_dir): - m = mobase.Mapping() - m.createTarget = True - m.isDirectory = True - m.source = profile_save_dir.absolutePath() - m.destination = self._savesDir - - return [m] - - def prepareProfile(self, profile): - return profile.localSavesEnabled() - - class VampireTheMasqueradeBloodlinesGame(BasicGame): Name = "Vampire - The Masquerade: Bloodlines Support Plugin" Author = "John" @@ -94,27 +74,24 @@ class VampireTheMasqueradeBloodlinesGame(BasicGame): def init(self, organizer: mobase.IOrganizer) -> bool: super().init(organizer) self._featureMap[mobase.ModDataChecker] = VampireModDataChecker() - self._featureMap[mobase.SaveGameInfo] = VampireSaveGame( - Path(self.savesDirectory().absolutePath()) - ) - self._featureMap[mobase.LocalSavegames] = VampireLocalSavegames( + self._featureMap[mobase.LocalSavegames] = BasicLocalSavegames( self.savesDirectory() ) return True - def initializeProfile(self, path: QDir, settings: mobase.ProfileSetting): + def initializeProfile(self, directory: QDir, settings: mobase.ProfileSetting): # Create .cfg files if they don't exist for iniFile in self.iniFiles(): - iniPath = self.documentsDirectory().absoluteFilePath(iniFile) - if not os.path.exists(iniPath): + iniPath = Path(self.documentsDirectory().absoluteFilePath(iniFile)) + if not iniPath.exists(): with open(iniPath, "w") as _: pass - super().initializeProfile(path, settings) + super().initializeProfile(directory, settings) def version(self): # Don't forget to import mobase! - return mobase.VersionInfo(1, 0, 0, mobase.ReleaseType.final) + return mobase.VersionInfo(1, 0, 0, mobase.ReleaseType.FINAL) def iniFiles(self): return ["autoexec.cfg", "user.cfg"] diff --git a/games/game_witcher1.py b/games/game_witcher1.py index 2d0e050..4fe1a8c 100644 --- a/games/game_witcher1.py +++ b/games/game_witcher1.py @@ -1,26 +1,24 @@ -# -*- encoding: utf-8 -*- from pathlib import Path -from typing import List - -from PyQt6.QtCore import QDir, QFileInfo +from typing import BinaryIO, List import mobase +from PyQt6.QtCore import QDir, QFileInfo from ..basic_game import BasicGame, BasicGameSaveGame class Witcher1SaveGame(BasicGameSaveGame): - def __init__(self, filepath): + def __init__(self, filepath: Path): super().__init__(filepath) self.areaName: str = "" self.parseSaveFile(filepath) @staticmethod - def readInt(fp, length=4) -> int: + def readInt(fp: BinaryIO, length: int = 4) -> int: return int.from_bytes(fp.read(length), "little") @staticmethod - def readFixedString(fp, length) -> str: + def readFixedString(fp: BinaryIO, length: int) -> str: b: bytes = fp.read(length) res = b.decode("utf-16") return res.rstrip("\0") diff --git a/games/game_witcher3.py b/games/game_witcher3.py index 12248a4..e5991ef 100644 --- a/games/game_witcher3.py +++ b/games/game_witcher3.py @@ -1,11 +1,8 @@ -# -*- encoding: utf-8 -*- - from pathlib import Path from typing import List -from PyQt6.QtCore import QDir - import mobase +from PyQt6.QtCore import QDir from ..basic_features import BasicGameSaveGameInfo from ..basic_features.basic_save_game_info import BasicGameSaveGame @@ -14,11 +11,10 @@ class Witcher3SaveGame(BasicGameSaveGame): def allFiles(self): - return [self._filename, self._filename.replace(".sav", ".png")] + return [self._filepath.name, self._filepath.name.replace(".sav", ".png")] class Witcher3Game(BasicGame): - Name = "Witcher 3 Support Plugin" Author = "Holt59" Version = "1.0.0a" diff --git a/games/game_xplane11.py b/games/game_xplane11.py index 23d2ad4..ecbe89a 100644 --- a/games/game_xplane11.py +++ b/games/game_xplane11.py @@ -2,7 +2,6 @@ class XP11Game(BasicGame): - Name = "X-Plane 11 Support Plugin" Author = "Deorder" Version = "0.0.1" diff --git a/games/game_zeusandposeidon.py b/games/game_zeusandposeidon.py index a0528d6..9a1b090 100644 --- a/games/game_zeusandposeidon.py +++ b/games/game_zeusandposeidon.py @@ -1,5 +1,3 @@ -# -*- encoding: utf-8 -*- - from typing import List, Optional import mobase @@ -12,13 +10,12 @@ def __init__(self): super().__init__() def dataLooksValid( - self, tree: mobase.IFileTree + self, filetree: mobase.IFileTree ) -> mobase.ModDataChecker.CheckReturn: - folders: List[mobase.IFileTree] = [] files: List[mobase.FileTreeEntry] = [] - for entry in tree: + for entry in filetree: if isinstance(entry, mobase.IFileTree): folders.append(entry) else: @@ -30,25 +27,25 @@ def dataLooksValid( folder = folders[0] pakfile = folder.name() + ".pak" if folder.exists(pakfile): - if tree.exists(pakfile): + if filetree.exists(pakfile): return mobase.ModDataChecker.VALID else: return mobase.ModDataChecker.FIXABLE return mobase.ModDataChecker.INVALID - def fix(self, tree: mobase.IFileTree) -> Optional[mobase.IFileTree]: - if not isinstance(tree[0], mobase.IFileTree): + def fix(self, filetree: mobase.IFileTree) -> Optional[mobase.IFileTree]: + first_entry = filetree[0] + if not isinstance(first_entry, mobase.IFileTree): return None - entry = tree[0].find(tree[0].name() + ".pak") + entry = first_entry.find(filetree[0].name() + ".pak") if entry is None: return None - tree.copy(entry, "", mobase.IFileTree.InsertPolicy.FAIL_IF_EXISTS) - return tree + filetree.copy(entry, "", mobase.IFileTree.InsertPolicy.FAIL_IF_EXISTS) + return filetree class ZeusAndPoseidonGame(BasicGame): - Name = "Zeus and Poseidon Support Plugin" Author = "Holt59" Version = "1.0.0a" diff --git a/games/quarantine/game_masseffectlegendary.py b/games/quarantine/game_masseffectlegendary.py index 4b9ef71..fadc250 100644 --- a/games/quarantine/game_masseffectlegendary.py +++ b/games/quarantine/game_masseffectlegendary.py @@ -1,8 +1,7 @@ -from ..basic_game import BasicGame +from ...basic_game import BasicGame class MassEffectLegendaryGame(BasicGame): - Name = "Mass Effect Legendary Edition Support Plugin" Author = "LostDragonist" Version = "1.0.0" diff --git a/games/stalkeranomaly/XRIO.py b/games/stalkeranomaly/XRIO.py index 1ff7253..870f28d 100644 --- a/games/stalkeranomaly/XRIO.py +++ b/games/stalkeranomaly/XRIO.py @@ -35,7 +35,7 @@ def peek(self, size: int = -1) -> bytes: size = len(self._buffer) if len(self._buffer) <= self._pos: return b"" - (buffer, pos) = self._read(size) + (buffer, _pos) = self._read(size) return buffer def seek(self, pos: int, whence: int = io.SEEK_SET) -> int: diff --git a/games/stalkeranomaly/XRMath.py b/games/stalkeranomaly/XRMath.py index a62f090..355fcaf 100644 --- a/games/stalkeranomaly/XRMath.py +++ b/games/stalkeranomaly/XRMath.py @@ -1,8 +1,5 @@ -# -*- encoding: utf-8 -*- - - class IVec3: - def __init__(self, x, y, z): + def __init__(self, x: float, y: float, z: float): self.x = x self.y = y self.z = z @@ -10,24 +7,15 @@ def __init__(self, x, y, z): def __str__(self) -> str: return f"{self.x}, {self.y}, {self.z}" - def set(self, x, y, z): - self.x = x - self.y = y - self.z = z - class IVec4(IVec3): - def __init__(self, x, y, z, w): + def __init__(self, x: float, y: float, z: float, w: float): super().__init__(x, y, z) self.w = w def __str__(self) -> str: return f"{self.x}, {self.y}, {self.z}, f{self.w}" - def set(self, x, y, z, w): - super().set(x, y, z) - self.w = w - class IFlag: def __init__(self, flag: int): @@ -36,14 +24,14 @@ def __init__(self, flag: int): def __str__(self) -> str: return str(self._flag) - def assign(self, mask): + def assign(self, mask: int): self._flag = mask - def has(self, mask) -> bool: + def has(self, mask: int) -> bool: return bool((self._flag & mask) == mask) - def set(self, mask): + def set(self, mask: int) -> None: self._flag |= mask - def remove(self, mask): + def remove(self, mask: int) -> None: self._flag &= ~mask diff --git a/games/stalkeranomaly/XRNET.py b/games/stalkeranomaly/XRNET.py index b006fb2..2df87a9 100644 --- a/games/stalkeranomaly/XRNET.py +++ b/games/stalkeranomaly/XRNET.py @@ -1,5 +1,3 @@ -# -*- encoding: utf-8 -*- - from .XRIO import XRReader from .XRMath import IVec3, IVec4 diff --git a/games/stalkeranomaly/XRObject.py b/games/stalkeranomaly/XRObject.py index 02eb689..d1eccdb 100644 --- a/games/stalkeranomaly/XRObject.py +++ b/games/stalkeranomaly/XRObject.py @@ -63,7 +63,7 @@ def read_spawn(self, reader: XRReader): if self.flags.has(XRFlag.SPAWN_VERSION): self.version = reader.u16() if self.version == 0: - reader._pos -= 2 + reader._pos -= 2 # pyright: ignore[reportPrivateUsage] return if self.version > 120: self.game_type.set(reader.u16()) @@ -76,7 +76,7 @@ def read_spawn(self, reader: XRReader): else: cl_size = reader.u8() if cl_size > 0: - for x in range(cl_size): + for _ in range(cl_size): data = reader.u8() self.client_data.append(data) if self.version > 79: @@ -95,7 +95,7 @@ def __init__(self): def read_visual(self, reader: XRReader, version: int): self.visual_name = reader.str() - self.flags = reader.u8() + self.flags = IFlag(reader.u8()) class XRBoneData: @@ -104,7 +104,7 @@ def __init__(self): self.root_bone = 0 self.min = IVec3(0.0, 0.0, 0.0) self.max = IVec3(0.0, 0.0, 0.0) - self.bones = [] + self.bones: list[XRNETState] = [] def load(self, reader: XRReader): self.bones_mask = reader.u64() @@ -112,7 +112,7 @@ def load(self, reader: XRReader): self.min = reader.fvec3() self.max = reader.fvec3() bones_count = reader.u16() - for x in range(bones_count): + for _ in range(bones_count): bone = XRNETState() bone.read(reader, self.min, self.max) self.bones.append(bone) @@ -187,8 +187,8 @@ def __init__(self): self.squad = 0 self.group = 0 self.health = 1.0 - self.dynamic_out = [] - self.dynamic_in = [] + self.dynamic_out: list[int] = [] + self.dynamic_in: list[int] = [] self.killer_id = -1 self.death_time = 0 @@ -199,11 +199,11 @@ def read_state(self, reader: XRReader): self.group = reader.u8() self.health = reader.float() * 100 - for x in range(reader.u32()): + for _ in range(reader.u32()): _id = reader.u16() self.dynamic_out.append(_id) - for x in range(reader.u32()): + for _ in range(reader.u32()): _id = reader.u16() self.dynamic_in.append(_id) diff --git a/games/stalkeranomaly/XRSave.py b/games/stalkeranomaly/XRSave.py index 6040ad9..b73a988 100644 --- a/games/stalkeranomaly/XRSave.py +++ b/games/stalkeranomaly/XRSave.py @@ -1,12 +1,10 @@ -# -*- encoding: utf-8 -*- - import io import struct from datetime import datetime from pathlib import Path -from typing import Optional +from typing import BinaryIO, Optional, cast -import lzokay +import lzokay # pyright: ignore[reportMissingTypeStubs] from .XRIO import XRReader, XRStream from .XRObject import XRCreatureActor, XRFlag @@ -101,7 +99,7 @@ def splitInfo(self): else: self.save_fmt = "Unknown" - def readFile(self, file) -> Optional[XRStream]: + def readFile(self, file: BinaryIO) -> Optional[XRStream]: size = self.filepath.stat().st_size if size < 8: return None @@ -110,7 +108,14 @@ def readFile(self, file) -> Optional[XRStream]: if (start == -1) and (version >= 6): file.seek(12) data = file.read(size - 12) - return XRStream(lzokay.decompress(data, source)) + return XRStream( + cast( + bytes, + lzokay.decompress( # pyright: ignore[reportUnknownMemberType] + data, source + ), + ) + ) return None diff --git a/games/stalkeranomaly/__init__.py b/games/stalkeranomaly/__init__.py index ce1313b..a8730a4 100644 --- a/games/stalkeranomaly/__init__.py +++ b/games/stalkeranomaly/__init__.py @@ -1,6 +1,3 @@ -# -*- encoding: utf-8 -*- -# flake8: noqa - from .XRIO import XRReader, XRStream from .XRMath import IFlag, IVec3, IVec4 from .XRNET import XRNETState @@ -18,3 +15,24 @@ XRVisual, ) from .XRSave import XRSave + +__all__ = [ + "IFlag", + "IVec3", + "IVec4", + "XRAbstract", + "XRBoneData", + "XRCreatureAbstract", + "XRCreatureActor", + "XRDynamicObject", + "XRDynamicObjectVisual", + "XRFlag", + "XRNETState", + "XRObject", + "XRReader", + "XRSave", + "XRSkeleton", + "XRStream", + "XRTraderAbstract", + "XRVisual", +] diff --git a/gog_utils.py b/gog_utils.py index 713015c..e5e6d24 100644 --- a/gog_utils.py +++ b/gog_utils.py @@ -1,17 +1,13 @@ -# -*- encoding: utf-8 -*- - # Code adapted from EzioTheDeadPoet / erri120: # https://github.com/ModOrganizer2/modorganizer-basic_games/pull/5 -import winreg # type: ignore +import winreg from pathlib import Path -from typing import Dict - -def find_games() -> Dict[str, Path]: +def find_games() -> dict[str, Path]: # List the game IDs from the registry: - game_ids = [] + game_ids: list[str] = [] try: with winreg.OpenKey( winreg.HKEY_LOCAL_MACHINE, r"Software\Wow6432Node\GOG.com\Games" @@ -25,7 +21,7 @@ def find_games() -> Dict[str, Path]: return {} # For each game, query the path: - games: Dict[str, Path] = {} + games: dict[str, Path] = {} for game_id in game_ids: try: with winreg.OpenKey( diff --git a/plugin-requirements.txt b/plugin-requirements.txt index d3b1822..1f1295e 100644 --- a/plugin-requirements.txt +++ b/plugin-requirements.txt @@ -1,3 +1,3 @@ psutil==5.8.0 vdf==3.4 -lzokay==1.0.1 +lzokay==1.1.5 diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..b9b8ee6 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,535 @@ +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. + +[[package]] +name = "black" +version = "23.9.1" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.8" +files = [ + {file = "black-23.9.1-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:d6bc09188020c9ac2555a498949401ab35bb6bf76d4e0f8ee251694664df6301"}, + {file = "black-23.9.1-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:13ef033794029b85dfea8032c9d3b92b42b526f1ff4bf13b2182ce4e917f5100"}, + {file = "black-23.9.1-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:75a2dc41b183d4872d3a500d2b9c9016e67ed95738a3624f4751a0cb4818fe71"}, + {file = "black-23.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13a2e4a93bb8ca74a749b6974925c27219bb3df4d42fc45e948a5d9feb5122b7"}, + {file = "black-23.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:adc3e4442eef57f99b5590b245a328aad19c99552e0bdc7f0b04db6656debd80"}, + {file = "black-23.9.1-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:8431445bf62d2a914b541da7ab3e2b4f3bc052d2ccbf157ebad18ea126efb91f"}, + {file = "black-23.9.1-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:8fc1ddcf83f996247505db6b715294eba56ea9372e107fd54963c7553f2b6dfe"}, + {file = "black-23.9.1-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:7d30ec46de88091e4316b17ae58bbbfc12b2de05e069030f6b747dfc649ad186"}, + {file = "black-23.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031e8c69f3d3b09e1aa471a926a1eeb0b9071f80b17689a655f7885ac9325a6f"}, + {file = "black-23.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:538efb451cd50f43aba394e9ec7ad55a37598faae3348d723b59ea8e91616300"}, + {file = "black-23.9.1-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:638619a559280de0c2aa4d76f504891c9860bb8fa214267358f0a20f27c12948"}, + {file = "black-23.9.1-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:a732b82747235e0542c03bf352c126052c0fbc458d8a239a94701175b17d4855"}, + {file = "black-23.9.1-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:cf3a4d00e4cdb6734b64bf23cd4341421e8953615cba6b3670453737a72ec204"}, + {file = "black-23.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf99f3de8b3273a8317681d8194ea222f10e0133a24a7548c73ce44ea1679377"}, + {file = "black-23.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:14f04c990259576acd093871e7e9b14918eb28f1866f91968ff5524293f9c573"}, + {file = "black-23.9.1-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:c619f063c2d68f19b2d7270f4cf3192cb81c9ec5bc5ba02df91471d0b88c4c5c"}, + {file = "black-23.9.1-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:6a3b50e4b93f43b34a9d3ef00d9b6728b4a722c997c99ab09102fd5efdb88325"}, + {file = "black-23.9.1-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:c46767e8df1b7beefb0899c4a95fb43058fa8500b6db144f4ff3ca38eb2f6393"}, + {file = "black-23.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50254ebfa56aa46a9fdd5d651f9637485068a1adf42270148cd101cdf56e0ad9"}, + {file = "black-23.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:403397c033adbc45c2bd41747da1f7fc7eaa44efbee256b53842470d4ac5a70f"}, + {file = "black-23.9.1-py3-none-any.whl", hash = "sha256:6ccd59584cc834b6d127628713e4b6b968e5f79572da66284532525a042549f9"}, + {file = "black-23.9.1.tar.gz", hash = "sha256:24b6b3ff5c6d9ea08a8888f6977eae858e1f340d7260cf56d70a49823236b62d"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "flake8" +version = "6.1.0" +description = "the modular source code checker: pep8 pyflakes and co" +optional = false +python-versions = ">=3.8.1" +files = [ + {file = "flake8-6.1.0-py2.py3-none-any.whl", hash = "sha256:ffdfce58ea94c6580c77888a86506937f9a1a227dfcd15f245d694ae20a6b6e5"}, + {file = "flake8-6.1.0.tar.gz", hash = "sha256:d5b3857f07c030bdb5bf41c7f53799571d75c4491748a3adcd47de929e34cd23"}, +] + +[package.dependencies] +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.11.0,<2.12.0" +pyflakes = ">=3.1.0,<3.2.0" + +[[package]] +name = "flake8-black" +version = "0.3.6" +description = "flake8 plugin to call black as a code style validator" +optional = false +python-versions = ">=3.7" +files = [ + {file = "flake8-black-0.3.6.tar.gz", hash = "sha256:0dfbca3274777792a5bcb2af887a4cad72c72d0e86c94e08e3a3de151bb41c34"}, + {file = "flake8_black-0.3.6-py3-none-any.whl", hash = "sha256:fe8ea2eca98d8a504f22040d9117347f6b367458366952862ac3586e7d4eeaca"}, +] + +[package.dependencies] +black = ">=22.1.0" +flake8 = ">=3" + +[package.extras] +develop = ["build", "twine"] + +[[package]] +name = "flake8-pyproject" +version = "1.2.3" +description = "Flake8 plug-in loading the configuration from pyproject.toml" +optional = false +python-versions = ">= 3.6" +files = [ + {file = "flake8_pyproject-1.2.3-py3-none-any.whl", hash = "sha256:6249fe53545205af5e76837644dc80b4c10037e73a0e5db87ff562d75fb5bd4a"}, +] + +[package.dependencies] +Flake8 = ">=5" + +[package.extras] +dev = ["pyTest", "pyTest-cov"] + +[[package]] +name = "isort" +version = "5.12.0" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"}, + {file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"}, +] + +[package.extras] +colors = ["colorama (>=0.4.3)"] +pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"] +plugins = ["setuptools"] +requirements-deprecated-finder = ["pip-api", "pipreqs"] + +[[package]] +name = "lzokay" +version = "1.1.5" +description = "Python bindings for LZ👌, a LZO compression/decompression algorithm." +optional = false +python-versions = ">=3.8" +files = [ + {file = "lzokay-1.1.5-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:4faeefdef8132c4db995de8e96a649c890c63a81cee06b3bbcae9d224302fa09"}, + {file = "lzokay-1.1.5-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ee2c7144dd3916cf0392d3851cebe8f28136e7998b7a50a91f63d7276327d70"}, + {file = "lzokay-1.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:829bfd081c8f03e85994d920ce7cc8d45033d7c467fc2fadc17b5c47667ec045"}, + {file = "lzokay-1.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d6b1d80121cdbc3cb106b3fafc03b139f3de3c134ce58b38fcc2d751fc5b013"}, + {file = "lzokay-1.1.5-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8f07bdd8c3bd9443d3f5ca69a03b9dc35522c5c92fce61db087ded1f348274f"}, + {file = "lzokay-1.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:266db5697a0d01428e4c31633f8dc1e87666ad17bb46b4c48ec7b7b8b70b94bc"}, + {file = "lzokay-1.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:eec7679194643bc1417f8883ef3c9d27e95e00632d4d2a7dc9ef1a1ca92cdd95"}, + {file = "lzokay-1.1.5-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5426a80664c471c18a47766111629cae5c9435ca8421cd208b00b10de5df5df9"}, + {file = "lzokay-1.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:a4df719d5fef922e0f43ec2408cd70280171ded18ab096c1e88fd1c3ca4dff46"}, + {file = "lzokay-1.1.5-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:88fa389193cde7feb13fd4b57b6b8d626a6159bfe84cd795e664e0a738debb42"}, + {file = "lzokay-1.1.5-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c83a0f1cc7628237466c298e676a64a1ab4914ccf628b3775f8680477b77481"}, + {file = "lzokay-1.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:f1997c6994239ea24a2c7c7812f9e06bceb6b216c5537b85d8b585dbfd711cb0"}, + {file = "lzokay-1.1.5-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:4389b8b6f3c95aaa33308e2129b026140c850d94440fbea4fdeabcd42477f3b2"}, + {file = "lzokay-1.1.5-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:867a9fd4a76c830b17366fd2e8bd03fe9b29cf5c13c42bc19562e411cf648dad"}, + {file = "lzokay-1.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:f828864453ddfca036de7470da74245d8dcf369c521291037b1b01ac7c5dbd83"}, + {file = "lzokay-1.1.5.tar.gz", hash = "sha256:3c2e81d178161de58bf233eec87c6ef3dba96fecdd9ba72f8f2c8790adc18f74"}, +] + +[package.extras] +test = ["pytest"] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "mobase-stubs" +version = "2.5.0.dev15" +description = "PEP561 stub files for the mobase Python API." +optional = false +python-versions = ">=3.11,<4.0" +files = [ + {file = "mobase_stubs-2.5.0.dev15-py3-none-any.whl", hash = "sha256:990b04541e5e8aa8161cc84d6d2740489d6b1aad79ee21be7b684ea7c19a7c9d"}, + {file = "mobase_stubs-2.5.0.dev15.tar.gz", hash = "sha256:eb7af4f4bbcb7af79aa4f04c3a04abd8bef6252fcd3d751bb89713c2ef65886e"}, +] + +[[package]] +name = "mypy" +version = "1.5.1" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f33592ddf9655a4894aef22d134de7393e95fcbdc2d15c1ab65828eee5c66c70"}, + {file = "mypy-1.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:258b22210a4a258ccd077426c7a181d789d1121aca6db73a83f79372f5569ae0"}, + {file = "mypy-1.5.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9ec1f695f0c25986e6f7f8778e5ce61659063268836a38c951200c57479cc12"}, + {file = "mypy-1.5.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:abed92d9c8f08643c7d831300b739562b0a6c9fcb028d211134fc9ab20ccad5d"}, + {file = "mypy-1.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:a156e6390944c265eb56afa67c74c0636f10283429171018446b732f1a05af25"}, + {file = "mypy-1.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6ac9c21bfe7bc9f7f1b6fae441746e6a106e48fc9de530dea29e8cd37a2c0cc4"}, + {file = "mypy-1.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:51cb1323064b1099e177098cb939eab2da42fea5d818d40113957ec954fc85f4"}, + {file = "mypy-1.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:596fae69f2bfcb7305808c75c00f81fe2829b6236eadda536f00610ac5ec2243"}, + {file = "mypy-1.5.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:32cb59609b0534f0bd67faebb6e022fe534bdb0e2ecab4290d683d248be1b275"}, + {file = "mypy-1.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:159aa9acb16086b79bbb0016145034a1a05360626046a929f84579ce1666b315"}, + {file = "mypy-1.5.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f6b0e77db9ff4fda74de7df13f30016a0a663928d669c9f2c057048ba44f09bb"}, + {file = "mypy-1.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:26f71b535dfc158a71264e6dc805a9f8d2e60b67215ca0bfa26e2e1aa4d4d373"}, + {file = "mypy-1.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fc3a600f749b1008cc75e02b6fb3d4db8dbcca2d733030fe7a3b3502902f161"}, + {file = "mypy-1.5.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:26fb32e4d4afa205b24bf645eddfbb36a1e17e995c5c99d6d00edb24b693406a"}, + {file = "mypy-1.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:82cb6193de9bbb3844bab4c7cf80e6227d5225cc7625b068a06d005d861ad5f1"}, + {file = "mypy-1.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4a465ea2ca12804d5b34bb056be3a29dc47aea5973b892d0417c6a10a40b2d65"}, + {file = "mypy-1.5.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9fece120dbb041771a63eb95e4896791386fe287fefb2837258925b8326d6160"}, + {file = "mypy-1.5.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d28ddc3e3dfeab553e743e532fb95b4e6afad51d4706dd22f28e1e5e664828d2"}, + {file = "mypy-1.5.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:57b10c56016adce71fba6bc6e9fd45d8083f74361f629390c556738565af8eeb"}, + {file = "mypy-1.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:ff0cedc84184115202475bbb46dd99f8dcb87fe24d5d0ddfc0fe6b8575c88d2f"}, + {file = "mypy-1.5.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8f772942d372c8cbac575be99f9cc9d9fb3bd95c8bc2de6c01411e2c84ebca8a"}, + {file = "mypy-1.5.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5d627124700b92b6bbaa99f27cbe615c8ea7b3402960f6372ea7d65faf376c14"}, + {file = "mypy-1.5.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:361da43c4f5a96173220eb53340ace68cda81845cd88218f8862dfb0adc8cddb"}, + {file = "mypy-1.5.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:330857f9507c24de5c5724235e66858f8364a0693894342485e543f5b07c8693"}, + {file = "mypy-1.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:c543214ffdd422623e9fedd0869166c2f16affe4ba37463975043ef7d2ea8770"}, + {file = "mypy-1.5.1-py3-none-any.whl", hash = "sha256:f757063a83970d67c444f6e01d9550a7402322af3557ce7630d3c957386fa8f5"}, + {file = "mypy-1.5.1.tar.gz", hash = "sha256:b031b9601f1060bf1281feab89697324726ba0c0bae9d7cd7ab4b690940f0b92"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +typing-extensions = ">=4.1.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "nodeenv" +version = "1.8.0" +description = "Node.js virtual environment builder" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +files = [ + {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, + {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, +] + +[package.dependencies] +setuptools = "*" + +[[package]] +name = "packaging" +version = "23.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, + {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, +] + +[[package]] +name = "pastel" +version = "0.2.1" +description = "Bring colors to your terminal." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364"}, + {file = "pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d"}, +] + +[[package]] +name = "pathspec" +version = "0.11.2" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, + {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, +] + +[[package]] +name = "platformdirs" +version = "3.11.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = ">=3.7" +files = [ + {file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"}, + {file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"}, +] + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] + +[[package]] +name = "poethepoet" +version = "0.23.0" +description = "A task runner that works well with poetry." +optional = false +python-versions = ">=3.8" +files = [ + {file = "poethepoet-0.23.0-py3-none-any.whl", hash = "sha256:d573ff31d7678e62b6f9bc9a1291ae2009ac14e0eead0a450598f9f05abb27a3"}, + {file = "poethepoet-0.23.0.tar.gz", hash = "sha256:62a0a6a518df5985c191aee0c1fcd2bb6a0a04eb102997786fcdf118e4147d22"}, +] + +[package.dependencies] +pastel = ">=0.2.1,<0.3.0" +tomli = ">=1.2.2" + +[package.extras] +poetry-plugin = ["poetry (>=1.0,<2.0)"] + +[[package]] +name = "psutil" +version = "5.9.5" +description = "Cross-platform lib for process and system monitoring in Python." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "psutil-5.9.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:be8929ce4313f9f8146caad4272f6abb8bf99fc6cf59344a3167ecd74f4f203f"}, + {file = "psutil-5.9.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ab8ed1a1d77c95453db1ae00a3f9c50227ebd955437bcf2a574ba8adbf6a74d5"}, + {file = "psutil-5.9.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:4aef137f3345082a3d3232187aeb4ac4ef959ba3d7c10c33dd73763fbc063da4"}, + {file = "psutil-5.9.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:ea8518d152174e1249c4f2a1c89e3e6065941df2fa13a1ab45327716a23c2b48"}, + {file = "psutil-5.9.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:acf2aef9391710afded549ff602b5887d7a2349831ae4c26be7c807c0a39fac4"}, + {file = "psutil-5.9.5-cp27-none-win32.whl", hash = "sha256:5b9b8cb93f507e8dbaf22af6a2fd0ccbe8244bf30b1baad6b3954e935157ae3f"}, + {file = "psutil-5.9.5-cp27-none-win_amd64.whl", hash = "sha256:8c5f7c5a052d1d567db4ddd231a9d27a74e8e4a9c3f44b1032762bd7b9fdcd42"}, + {file = "psutil-5.9.5-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3c6f686f4225553615612f6d9bc21f1c0e305f75d7d8454f9b46e901778e7217"}, + {file = "psutil-5.9.5-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a7dd9997128a0d928ed4fb2c2d57e5102bb6089027939f3b722f3a210f9a8da"}, + {file = "psutil-5.9.5-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89518112647f1276b03ca97b65cc7f64ca587b1eb0278383017c2a0dcc26cbe4"}, + {file = "psutil-5.9.5-cp36-abi3-win32.whl", hash = "sha256:104a5cc0e31baa2bcf67900be36acde157756b9c44017b86b2c049f11957887d"}, + {file = "psutil-5.9.5-cp36-abi3-win_amd64.whl", hash = "sha256:b258c0c1c9d145a1d5ceffab1134441c4c5113b2417fafff7315a917a026c3c9"}, + {file = "psutil-5.9.5-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:c607bb3b57dc779d55e1554846352b4e358c10fff3abf3514a7a6601beebdb30"}, + {file = "psutil-5.9.5.tar.gz", hash = "sha256:5410638e4df39c54d957fc51ce03048acd8e6d60abc0f5107af51e5fb566eb3c"}, +] + +[package.extras] +test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"] + +[[package]] +name = "pycodestyle" +version = "2.11.0" +description = "Python style guide checker" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycodestyle-2.11.0-py2.py3-none-any.whl", hash = "sha256:5d1013ba8dc7895b548be5afb05740ca82454fd899971563d2ef625d090326f8"}, + {file = "pycodestyle-2.11.0.tar.gz", hash = "sha256:259bcc17857d8a8b3b4a2327324b79e5f020a13c16074670f9c8c8f872ea76d0"}, +] + +[[package]] +name = "pyflakes" +version = "3.1.0" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyflakes-3.1.0-py2.py3-none-any.whl", hash = "sha256:4132f6d49cb4dae6819e5379898f2b8cce3c5f23994194c24b77d5da2e36f774"}, + {file = "pyflakes-3.1.0.tar.gz", hash = "sha256:a0aae034c444db0071aa077972ba4768d40c830d9539fd45bf4cd3f8f6992efc"}, +] + +[[package]] +name = "pyqt6" +version = "6.5.2" +description = "Python bindings for the Qt cross platform application toolkit" +optional = false +python-versions = ">=3.6.1" +files = [ + {file = "PyQt6-6.5.2-cp37-abi3-macosx_10_14_universal2.whl", hash = "sha256:5bad1437eb0c1ae801103b32ef04ef62ef1cce505b448525f60089ce36329b89"}, + {file = "PyQt6-6.5.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:70468cca4537756c714a57fa1baa5beabb9b38775f52f9611f49870705672c55"}, + {file = "PyQt6-6.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:ff1d12767b578f0f0e87cdb12198e7dcad9a176c40d1d1d799984181b0af93cb"}, + {file = "PyQt6-6.5.2.tar.gz", hash = "sha256:1487ee7350f9ffb66d60ab4176519252c2b371762cbe8f8340fd951f63801280"}, +] + +[package.dependencies] +PyQt6-Qt6 = ">=6.5.0" +PyQt6-sip = ">=13.4,<14" + +[[package]] +name = "pyqt6-qt6" +version = "6.5.2" +description = "The subset of a Qt installation needed by PyQt6." +optional = false +python-versions = "*" +files = [ + {file = "PyQt6_Qt6-6.5.2-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:4b37f6f93c0980469ccc570998d3e3de243028bae7389fb6330443ab215ce2f6"}, + {file = "PyQt6_Qt6-6.5.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8dad61b4666d91882d7e1c4d619c71e7429c13e19182f8b3bebf3ecf95107d4c"}, + {file = "PyQt6_Qt6-6.5.2-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:953f3c0e99e486081a6d438b32fbc240da97457226562eb68cf1b11c516386fd"}, + {file = "PyQt6_Qt6-6.5.2-py3-none-win_amd64.whl", hash = "sha256:5a3c7bb899678bf801136b31cd589ed4d0d54ab32be5fb76c2bdeb161a9662ad"}, +] + +[[package]] +name = "pyqt6-sip" +version = "13.5.2" +description = "The sip module support for PyQt6" +optional = false +python-versions = ">=3.7" +files = [ + {file = "PyQt6_sip-13.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c0b4e55c4d0dc44728da90eb1451dfff0d05260b4a3496ff0014494e6c1865a6"}, + {file = "PyQt6_sip-13.5.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:9ad5b8c4c92d62e00ebf254a4c9656668b130f2a1d2792034e0d82b2d6571509"}, + {file = "PyQt6_sip-13.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:5d0fd42da765198b51d7fe12c29721cbe3e14b77ca4f68aa43618149ee7dbeff"}, + {file = "PyQt6_sip-13.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4b5b0c4b557e0293eba008021342459a0f91c69a7a2eb5231276d495d1d2960a"}, + {file = "PyQt6_sip-13.5.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:831f5d606fc5296a80303ab30892c3308954c5766039bf7a96267488bb2524a5"}, + {file = "PyQt6_sip-13.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:91812f0094443b816a74a89954d60bb50060807f54d7c016a4de7bd29454091e"}, + {file = "PyQt6_sip-13.5.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:94afb031db89159aa330891eba2c937b0378b4b264570998848c7a78eddf7c94"}, + {file = "PyQt6_sip-13.5.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ace942b78378bff8ae2d6bafccc84465f1ff0cf30720b8321e0bd6c95c36ede6"}, + {file = "PyQt6_sip-13.5.2-cp37-cp37m-win_amd64.whl", hash = "sha256:318d4d1c7ef60f69c68227cef78fc439accc9028ec6149200eea3d34c433d373"}, + {file = "PyQt6_sip-13.5.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:87d758ba999baa16459f0a3c7f7ed47a5b45e8991ad691f17345bf3c493a4281"}, + {file = "PyQt6_sip-13.5.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:5b499eff7150d9f31fe835a73cc41f076bba2fcde0f5b0325b1284797f17c0ac"}, + {file = "PyQt6_sip-13.5.2-cp38-cp38-win_amd64.whl", hash = "sha256:1bdb1f7b5b2f6ac31d517a6f3a13c38055640ac0014f67a2e5422d2083ce69ec"}, + {file = "PyQt6_sip-13.5.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9c9dac067710015895f523f5a2a4d59cbef8624a152b6f9a426e5b848d8c6d29"}, + {file = "PyQt6_sip-13.5.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:dcf602c233ee7600e810927adcb9e518d61bc796a6b2013c17feedd24c4e5413"}, + {file = "PyQt6_sip-13.5.2-cp39-cp39-win_amd64.whl", hash = "sha256:b54b0d8d21c5835af8f6d6d8a323a23106e67b7cd4c31e23c35bb4c321000de8"}, + {file = "PyQt6_sip-13.5.2.tar.gz", hash = "sha256:ebf6264b6feda01ba37d3b60a4bb87493bdb87be70f7b2a5384a7acd4902d88d"}, +] + +[[package]] +name = "pyright" +version = "1.1.329" +description = "Command line wrapper for pyright" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyright-1.1.329-py3-none-any.whl", hash = "sha256:c16f88a7ac14ddd0513e62fec56d69c37e3c6b412161ad16aa23a9c7e3dabaf4"}, + {file = "pyright-1.1.329.tar.gz", hash = "sha256:5baf82ff5ecb8c8b3ac400e8536348efbde0b94a09d83d5b440c0d143fd151a8"}, +] + +[package.dependencies] +nodeenv = ">=1.6.0" + +[package.extras] +all = ["twine (>=3.4.1)"] +dev = ["twine (>=3.4.1)"] + +[[package]] +name = "ruff" +version = "0.0.291" +description = "An extremely fast Python linter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.0.291-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:b97d0d7c136a85badbc7fd8397fdbb336e9409b01c07027622f28dcd7db366f2"}, + {file = "ruff-0.0.291-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:6ab44ea607967171e18aa5c80335237be12f3a1523375fa0cede83c5cf77feb4"}, + {file = "ruff-0.0.291-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a04b384f2d36f00d5fb55313d52a7d66236531195ef08157a09c4728090f2ef0"}, + {file = "ruff-0.0.291-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b727c219b43f903875b7503a76c86237a00d1a39579bb3e21ce027eec9534051"}, + {file = "ruff-0.0.291-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87671e33175ae949702774071b35ed4937da06f11851af75cd087e1b5a488ac4"}, + {file = "ruff-0.0.291-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b75f5801547f79b7541d72a211949754c21dc0705c70eddf7f21c88a64de8b97"}, + {file = "ruff-0.0.291-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b09b94efdcd162fe32b472b2dd5bf1c969fcc15b8ff52f478b048f41d4590e09"}, + {file = "ruff-0.0.291-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d5b56bc3a2f83a7a1d7f4447c54d8d3db52021f726fdd55d549ca87bca5d747"}, + {file = "ruff-0.0.291-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13f0d88e5f367b2dc8c7d90a8afdcfff9dd7d174e324fd3ed8e0b5cb5dc9b7f6"}, + {file = "ruff-0.0.291-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b3eeee1b1a45a247758ecdc3ab26c307336d157aafc61edb98b825cadb153df3"}, + {file = "ruff-0.0.291-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6c06006350c3bb689765d71f810128c9cdf4a1121fd01afc655c87bab4fb4f83"}, + {file = "ruff-0.0.291-py3-none-musllinux_1_2_i686.whl", hash = "sha256:fd17220611047de247b635596e3174f3d7f2becf63bd56301fc758778df9b629"}, + {file = "ruff-0.0.291-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5383ba67ad360caf6060d09012f1fb2ab8bd605ab766d10ca4427a28ab106e0b"}, + {file = "ruff-0.0.291-py3-none-win32.whl", hash = "sha256:1d5f0616ae4cdc7a938b493b6a1a71c8a47d0300c0d65f6e41c281c2f7490ad3"}, + {file = "ruff-0.0.291-py3-none-win_amd64.whl", hash = "sha256:8a69bfbde72db8ca1c43ee3570f59daad155196c3fbe357047cd9b77de65f15b"}, + {file = "ruff-0.0.291-py3-none-win_arm64.whl", hash = "sha256:d867384a4615b7f30b223a849b52104214442b5ba79b473d7edd18da3cde22d6"}, + {file = "ruff-0.0.291.tar.gz", hash = "sha256:c61109661dde9db73469d14a82b42a88c7164f731e6a3b0042e71394c1c7ceed"}, +] + +[[package]] +name = "setuptools" +version = "68.2.2" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-68.2.2-py3-none-any.whl", hash = "sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a"}, + {file = "setuptools-68.2.2.tar.gz", hash = "sha256:4ac1475276d2f1c48684874089fefcd83bd7162ddaafb81fac866ba0db282a87"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "types-psutil" +version = "5.9.5.16" +description = "Typing stubs for psutil" +optional = false +python-versions = "*" +files = [ + {file = "types-psutil-5.9.5.16.tar.gz", hash = "sha256:4e9b219efb625d3d04f6bf106934f87cab49aa41a94b0a3b3089403f47a79228"}, + {file = "types_psutil-5.9.5.16-py3-none-any.whl", hash = "sha256:fec713104d5d143afea7b976cfa691ca1840f5d19e8714a5d02a96ebd061363e"}, +] + +[[package]] +name = "typing-extensions" +version = "4.8.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"}, + {file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"}, +] + +[[package]] +name = "vdf" +version = "3.4" +description = "Library for working with Valve's VDF text format" +optional = false +python-versions = "*" +files = [ + {file = "vdf-3.4-py2.py3-none-any.whl", hash = "sha256:68c1a125cc49e343d535af2dd25074e9cb0908c6607f073947c4a04bbe234534"}, + {file = "vdf-3.4.tar.gz", hash = "sha256:fd5419f41e07a1009e5ffd027c7dcbe43d1f7e8ef453aeaa90d9d04b807de2af"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.11" +content-hash = "8dca66d012064dd3da57c1e0e6c6c204d6d79399bf746296e52af9e810fed177" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..313c6b5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,84 @@ +# note: this file is for local development, currently the file is not used to build +# the plugin + +[tool.poetry] +name = "basic-games" +version = "0.1.0" +description = "" +authors = [] +license = "MIT" +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.11" +psutil = "^5.9" +vdf = "3.4" +lzokay = "1.1.5" + +[tool.poetry.group.dev.dependencies] +mobase-stubs = { version = "^2.5.0.dev15", allow-prereleases = true } +pyqt6 = "^6.5.2" +pyright = "^1.1.327" +flake8 = "^6.1.0" +flake8-pyproject = "^1.2.3" +ruff = "^0.0.291" +mypy = "^1.5.1" +isort = "^5.12.0" +black = "^23.9.1" +flake8-black = "^0.3.6" +types-psutil = "^5.9.5.16" +poethepoet = "^0.23.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.poe.tasks] +lint-black = "black --check --diff ." +lint-isort = "isort -c ." +lint-mypy = "mypy ." +lint-ruff = "ruff ." +lint-pyright = "pyright ." + +lint-all.sequence = [ + "lint-black", + "lint-isort", + "lint-mypy", + "lint-ruff", + "lint-pyright", +] +lint-all.ignore_fail = "return_non_zero" + +[tool.flake8] +max-line-length = 88 +extend-ignore = ["E203"] + +[tool.isort] +profile = "black" +multi_line_output = 3 +skip_gitignore = true + +[tool.mypy] +warn_return_any = true +warn_unused_configs = true +check_untyped_defs = true +exclude = ["lib", "games/quarantine"] +platform = "win32" + +[[tool.mypy.overrides]] +module = "vdf.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "lzokay.*" +ignore_missing_imports = true + +[tool.ruff] +line-length = 88 +target-version = "py311" + +[tool.pyright] +exclude = ["lib", "**/.*"] +typeCheckingMode = "strict" +pythonPlatform = "Windows" +reportMissingModuleSource = false diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 9396477..0000000 --- a/setup.cfg +++ /dev/null @@ -1,52 +0,0 @@ -[flake8] -# Use black line length: -max-line-length = 88 -extend-ignore = - # See https://github.com/PyCQA/pycodestyle/issues/373 - E203, E266 - -[isort] -profile = black -multi_line_output = 3 -known_mobase = mobase -sections=FUTURE,STDLIB,THIRDPARTY,MOBASE,FIRSTPARTY,LOCALFOLDER - -[mypy] -warn_return_any = True -warn_unused_configs = True - -[mypy-psutil.*] -ignore_missing_imports = True - -[mypy-vdf.*] -ignore_missing_imports = True - -[mypy-_lzokay.*] -ignore_missing_imports = True - -[mypy-lzokay.*] -ignore_missing_imports = True - -[tox:tox] -skipsdist = true -envlist = py310-lint - -[testenv:py310-lint] -pip_version = pip>=20 -skip_install = true -deps = - git+https://github.com/TilmanK/PyQt6-stubs.git - mobase-stubs>=2.5.0.dev9 - psutil==5.8.0 - vdf==3.4 - lzokay==1.0.1 - black - flake8 - flake8-black - mypy - isort -commands = - black --check --diff . - isort -c . --skip lib --skip .tox - flake8 . --exclude "lib,.tox,games/quarantine" - mypy . --platform win32 --exclude "lib" --exclude "games/quarantine" diff --git a/steam_utils.py b/steam_utils.py index e854f22..d1d4a06 100644 --- a/steam_utils.py +++ b/steam_utils.py @@ -1,17 +1,15 @@ -# -*- encoding: utf-8 -*- - # Code greatly inspired by https://github.com/LostDragonist/steam-library-setup-tool import sys -import winreg # type: ignore +import winreg from pathlib import Path -from typing import Dict, List, Optional +from typing import TypedDict, cast -import vdf +import vdf # pyright: ignore[reportMissingTypeStubs] class SteamGame: - def __init__(self, appid, installdir): + def __init__(self, appid: str, installdir: str): self.appid = appid self.installdir = installdir @@ -22,15 +20,36 @@ def __str__(self): return "{} ({})".format(self.appid, self.installdir) +class _AppState(TypedDict): + appid: str + installdir: str + + +class _AppManifest(TypedDict): + AppState: _AppState + + +class _LibraryFolder(TypedDict): + path: str + + +class _LibraryFolders(TypedDict, total=False): + libraryfolders: dict[str, _LibraryFolder] + LibraryFolders: dict[str, str] + + class LibraryFolder: def __init__(self, path: Path): self.path = path - self.games = [] + self.games: list[SteamGame] = [] for filepath in path.joinpath("steamapps").glob("appmanifest_*.acf"): try: with open(filepath, "r", encoding="utf-8") as fp: - info = vdf.load(fp) + info = cast( + _AppManifest, + vdf.load(fp), # pyright: ignore[reportUnknownMemberType] + ) app_state = info["AppState"] except KeyError: print( @@ -61,7 +80,7 @@ def __str__(self): return "LibraryFolder at {}: {}".format(self.path, self.games) -def parse_library_info(library_vdf_path: Path) -> List[LibraryFolder]: +def parse_library_info(library_vdf_path: Path) -> list[LibraryFolder]: """ Read library folders from the main library file. @@ -74,27 +93,26 @@ def parse_library_info(library_vdf_path: Path) -> List[LibraryFolder]: """ with open(library_vdf_path, "r", encoding="utf-8") as f: - info = vdf.load(f) + info = cast( + _LibraryFolders, + vdf.load(f), # pyright: ignore[reportUnknownMemberType] + ) - library_folders = [] + info_folders: dict[str, str] | dict[str, _LibraryFolder] if "libraryfolders" in info: # new format info_folders = info["libraryfolders"] - def get_path(value): - return value["path"] - elif "LibraryFolders" in info: # old format info_folders = info["LibraryFolders"] - def get_path(value): - return value - else: raise ValueError(f'Unknown file format from "{library_vdf_path}"') + library_folders: list[LibraryFolder] = [] + for key, value in info_folders.items(): # only keys that are integer values contains library folder try: @@ -102,7 +120,11 @@ def get_path(value): except ValueError: continue - path = get_path(value) + if isinstance(value, str): + path = value + else: + path = value["path"] + try: library_folders.append(LibraryFolder(Path(path))) except Exception as e: @@ -114,7 +136,7 @@ def get_path(value): return library_folders -def find_steam_path() -> Optional[Path]: +def find_steam_path() -> Path | None: """ Retrieve the Steam path, if available. @@ -129,7 +151,7 @@ def find_steam_path() -> Optional[Path]: return None -def find_games() -> Dict[str, Path]: +def find_games() -> dict[str, Path]: """ Find the list of Steam games installed. @@ -149,7 +171,7 @@ def find_games() -> Dict[str, Path]: except FileNotFoundError: return {} - games: Dict[str, Path] = {} + games: dict[str, Path] = {} for library in library_folders: for game in library.games: games[game.appid] = Path(library.path).joinpath(