From a127433da5082a258abbd4d504b777a290830cc5 Mon Sep 17 00:00:00 2001 From: Zash Date: Wed, 30 Mar 2022 21:04:23 +0200 Subject: [PATCH] + basic_feature: BasicModDataChecker - ModDataChecker with simple file patterns (glob). --- basic_features/__init__.py | 1 + basic_features/basic_mod_data_checker.py | 192 +++++++++++++++++++ games/game_valheim.py | 234 +++++------------------ 3 files changed, 246 insertions(+), 181 deletions(-) create mode 100644 basic_features/basic_mod_data_checker.py diff --git a/basic_features/__init__.py b/basic_features/__init__.py index cc2d236..918f9dd 100644 --- a/basic_features/__init__.py +++ b/basic_features/__init__.py @@ -1,3 +1,4 @@ # -*- encoding: utf-8 -*- from .basic_save_game_info import BasicGameSaveGameInfo # noqa +from .basic_mod_data_checker import BasicModDataChecker # noqa diff --git a/basic_features/basic_mod_data_checker.py b/basic_features/basic_mod_data_checker.py new file mode 100644 index 0000000..3738d4d --- /dev/null +++ b/basic_features/basic_mod_data_checker.py @@ -0,0 +1,192 @@ +# -*- encoding: utf-8 -*- +from __future__ import annotations + +import fnmatch +import re +from collections.abc import Iterable, Sequence +from dataclasses import dataclass, field, fields +from typing import Optional, Protocol + + +import mobase + + +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 HasGlobPatterns(Protocol): + set_as_root: Optional[Iterable[str]] + valid: Optional[Iterable[str]] + delete: Optional[Iterable[str]] + move: Optional[Iterable[str]] + + +@dataclass +class FileRegexPatterns: + """Regex patterns for validation in `BasicModDataChecker`.""" + + set_as_root: Optional[re.Pattern] = None + valid: Optional[re.Pattern] = None + delete: Optional[re.Pattern] = None + move: Optional[re.Pattern] = None + + @classmethod + def from_glob_patterns(cls, glob_patterns: HasGlobPatterns) -> FileRegexPatterns: + """Returns an instance of `FileRegexPatterns`, + with the glob pattern fields in `glob_patterns` translated to regex. + """ + return cls( + **{ + f.name: ( + cls.regex_from_glob_list(value) + if (value := getattr(glob_patterns, f.name)) + else None + ) + for f in fields(FileRegexPatterns) + } + ) + + @staticmethod + def regex_from_glob_list(glob_list: Iterable[str]) -> re.Pattern: + """Returns a regex pattern form a list of glob patterns. + + Every pattern has a capturing group, + so that `match.lastindex - 1` will give the `file_list` index. + """ + return re.compile( + f'(?:{"|".join(f"({fnmatch.translate(f)})" for f in glob_list)})', re.I + ) + + @staticmethod + def get_match_index(search_str: str, pattern: re.Pattern) -> Optional[int]: + """Get the index of the matched group if `search_str` matches `pattern`. + + Args: + search_str: the string to search + pattern: a pattern from `FileRegexPatterns`. + + Returns: + The 0-based index of the matched group or None for no match. + """ + if not (match := pattern.match(search_str)) or match.lastindex is None: + return None + return match.lastindex - 1 + + +@dataclass +class BasicModDataChecker(mobase.ModDataChecker): + """Game feature that is used to check and fix the content of a data tree + via simple file definitions. + + The file definitions support glob pattern (without subfolders) and are checked in + order. + """ + + set_as_root: Optional[Iterable[str]] = None + """If a folder from this set is found, it will be set as new root dir (unfolded) and + its contents are checked again. + """ + + valid: Optional[Iterable[str]] = None + """Files and folders in the right path. + Check result: `mobase.ModDataChecker.VALID`. + """ + + delete: Optional[Iterable[str]] = None + """Files/folders to delete. Check result: `mobase.ModDataChecker.FIXABLE`.""" + + move: Optional[dict[str, str]] = None + """Files/folders to move and their target. + + 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:: + + {"*.ext": "path/to/target_folder/"} + + See Also: + `mobase.IFileTree.move` + """ + + _regex: FileRegexPatterns = field(init=False, repr=False) + """The regex patterns derived from the file (glob) patterns.""" + + _move_targets: Sequence[str] = field(init=False, repr=False) + + def __post_init__(self): + self.update_patterns() + + def update_patterns(self): + """Update patterns after init field changes.""" + self._regex = FileRegexPatterns.from_glob_patterns(self) + if self.move: + self._move_targets = list(self.move.values()) + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + status = mobase.ModDataChecker.INVALID + for entry in filetree: + name = entry.name().casefold() + regex = self._regex + if regex.set_as_root and regex.set_as_root.match(name): + return mobase.ModDataChecker.FIXABLE + elif regex.valid and regex.valid.match(name): + if status is not mobase.ModDataChecker.FIXABLE: + status = mobase.ModDataChecker.VALID + elif (regex.move and regex.move.match(name)) or ( + regex.delete and regex.delete.match(name) + ): + status = mobase.ModDataChecker.FIXABLE + else: + return mobase.ModDataChecker.INVALID + return status + + def fix(self, filetree: mobase.IFileTree) -> Optional[mobase.IFileTree]: + for entry in list(filetree): + name = entry.name().casefold() + regex = self._regex + if regex.set_as_root and regex.set_as_root.match(name): + new_root = convert_entry_to_tree(entry) + return self.fix(new_root) if new_root else None + elif regex.valid and regex.valid.match(name): + continue + elif regex.delete and regex.delete.match(name): + entry.detach() + elif regex.move: + move_target = self._find_move_target(name) + if move_target is not None: + filetree.move(entry, move_target) + return filetree + + def _find_move_target(self, filename: str) -> Optional[str]: + """Find a matching file pattern (key) from `.move` and return the target (value). + + Args: + filename: the file name to match. + + Returns: + The move target or None for no match or no `.move` pattern. + """ + if ( + self._regex.move + and (i := self._regex.get_match_index(filename, self._regex.move)) + is not None + ): + return self._move_targets[i] + return None diff --git a/games/game_valheim.py b/games/game_valheim.py index 91dca7e..9179a3f 100644 --- a/games/game_valheim.py +++ b/games/game_valheim.py @@ -2,12 +2,11 @@ from __future__ import annotations -import fnmatch import itertools import re import shutil from collections.abc import Collection, Container, Iterable, Mapping, Sequence -from dataclasses import dataclass, field, fields +from dataclasses import dataclass, field from pathlib import Path from typing import Optional, TextIO, Union @@ -15,25 +14,11 @@ from PyQt5.QtCore import QDir import mobase +from ..basic_features import BasicModDataChecker from ..basic_features.basic_save_game_info import BasicGameSaveGame from ..basic_game import BasicGame -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 - - def move_file(source: Path, target: Path): """Move `source` to `target`. Creates missing (parent) directories and overwrites existing `target`.""" @@ -130,7 +115,8 @@ class DebugTable: if self._table: for line in self._table: print("|", " | ".join(line.values()), "|", file=output_file) - output_file and output_file.flush() + if output_file: + output_file.flush() self._table = [] @@ -277,165 +263,6 @@ class OverwriteSync: ) -@dataclass -class RegexFilesDefinition: - """Regex pattern for the file lists in `FilesDefinition` - for globbing support. - Fields should match `RegexFilesDefinition`. - """ - - set_as_root: Optional[re.Pattern] - valid: Optional[re.Pattern] - delete: Optional[re.Pattern] - move: Optional[re.Pattern] - - @classmethod - def from_filesmap(cls, filesdef: FilesDefinition) -> RegexFilesDefinition: - """Returns an instance of `RegexFilesDefinition`, - with the file list fields from `FilesDefinition` as regex patterns. - """ - return cls( - **{ - f.name: ( - cls.file_list_regex(value) - if (value := getattr(filesdef, f.name)) - else None - ) - for f in fields(cls) - } - ) - - @staticmethod - def file_list_regex(file_list: Iterable[str]) -> re.Pattern: - """Returns a regex pattern for a file list with glob patterns. - - Every pattern has a capturing group, - so that match.lastindex - 1 will give the file_list index. - """ - return re.compile( - f'(?:{"|".join(f"({fnmatch.translate(f)})" for f in file_list)})', re.I - ) - - -@dataclass -class FilesDefinition: - """File (pattern) definitions for the `mobase.ModDataChecker`. - Fields should match `RegexFilesDefinition`. - """ - - set_as_root: Optional[set[str]] - """If a folder from this set is found, it will be set as new root dir (unfolded).""" - valid: Optional[set[str]] - """Files and folders in the right path.""" - delete: Optional[set[str]] - """Files/folders to delete.""" - move: Optional[dict[str, str]] - """Files/folders to move, like `{"*.ext": "path/to/folder/"}`. - If the path ends with / or \\, the entry will be inserted - in the corresponding directory instead of replacing it. - """ - - regex: RegexFilesDefinition = field(init=False) - _move_targets: Sequence[str] = field(init=False, repr=False) - - def __post_init__(self): - self.regex = RegexFilesDefinition.from_filesmap(self) - if self.move: - self._move_targets = list(self.move.values()) - - def get_move_target(self, index: int) -> str: - return self._move_targets[index] - - -class ValheimGameModDataChecker(mobase.ModDataChecker): - files_map = FilesDefinition( - set_as_root={ - "BepInExPack_Valheim", - }, - valid={ - "meta.ini", # Included in installed mod folder. - "BepInEx", - "doorstop_libs", - "unstripped_corlib", - "doorstop_config.ini", - "start_game_bepinex.sh", - "start_server_bepinex.sh", - "winhttp.dll", - # - "InSlimVML", - "valheim_Data", - "inslimvml.ini", - # - "unstripped_managed", - # - "AdvancedBuilder", - }, - delete={ - "*.txt", - "*.md", - "icon.png", - "license", - "manifest.json", - }, - move={ - "*_VML.dll": "InSlimVML/Mods/", - # - "plugins": "BepInEx/", - "*.dll": "BepInEx/plugins/", - "config": "BepInEx/", - "*.cfg": "BepInEx/config/", - # - "CustomTextures": "BepInEx/plugins/", - "*.png": "BepInEx/plugins/CustomTextures/", - # - "Builds": "AdvancedBuilder/", - "*.vbuild": "AdvancedBuilder/Builds/", - # - "*.assets": "valheim_Data/", - }, - ) - - def dataLooksValid( - self, filetree: mobase.IFileTree - ) -> mobase.ModDataChecker.CheckReturn: - status = mobase.ModDataChecker.INVALID - for entry in filetree: - name = entry.name().casefold() - regex = self.files_map.regex - if regex.set_as_root and regex.set_as_root.match(name): - return mobase.ModDataChecker.FIXABLE - elif regex.valid and regex.valid.match(name): - if status is not mobase.ModDataChecker.FIXABLE: - status = mobase.ModDataChecker.VALID - elif (regex.move and regex.move.match(name)) or ( - regex.delete and regex.delete.match(name) - ): - status = mobase.ModDataChecker.FIXABLE - else: - return mobase.ModDataChecker.INVALID - return status - - def fix(self, filetree: mobase.IFileTree) -> Optional[mobase.IFileTree]: - for entry in list(filetree): - name = entry.name().casefold() - regex = self.files_map.regex - if regex.set_as_root and regex.set_as_root.match(name): - new_root = convert_entry_to_tree(entry) - return self.fix(new_root) if new_root else None - elif regex.valid and regex.valid.match(name): - continue - elif regex.delete and regex.delete.match(name): - entry.detach() - elif regex.move and (match := regex.move.match(name)): - if match.lastindex is None: - return None - else: - # Get index of matched group - map_index = match.lastindex - 1 - # Get the move target corresponding to the matched group - filetree.move(entry, self.files_map.get_move_target(map_index)) - return filetree - - class ValheimSaveGame(BasicGameSaveGame): def getName(self) -> str: return f"[{self.getSaveGroupIdentifier().rstrip('s')}] {self._filepath.stem}" @@ -485,7 +312,7 @@ class ValheimGame(BasicGame): Name = "Valheim Support Plugin" Author = "Zash" - Version = "1.1.1" + Version = "1.2" GameName = "Valheim" GameShortName = "valheim" @@ -495,11 +322,56 @@ class ValheimGame(BasicGame): GameDataPath = "" GameSavesDirectory = r"%USERPROFILE%/AppData/LocalLow/IronGate/Valheim" - forced_libraries = ["winhttp.dll"] + _forced_libraries = ["winhttp.dll"] def init(self, organizer: mobase.IOrganizer) -> bool: super().init(organizer) - self._featureMap[mobase.ModDataChecker] = ValheimGameModDataChecker() + self._featureMap[mobase.ModDataChecker] = BasicModDataChecker( + set_as_root=[ + "BepInExPack_Valheim", + ], + valid=[ + "meta.ini", # Included in installed mod folder. + "BepInEx", + "doorstop_libs", + "unstripped_corlib", + "doorstop_config.ini", + "start_game_bepinex.sh", + "start_server_bepinex.sh", + "winhttp.dll", + # + "InSlimVML", + "valheim_Data", + "inslimvml.ini", + # + "unstripped_managed", + # + "AdvancedBuilder", + ], + delete=[ + "*.txt", + "*.md", + "icon.png", + "license", + "manifest.json", + ], + move={ + "*_VML.dll": "InSlimVML/Mods/", + # + "plugins": "BepInEx/", + "*.dll": "BepInEx/plugins/", + "config": "BepInEx/", + "*.cfg": "BepInEx/config/", + # + "CustomTextures": "BepInEx/plugins/", + "*.png": "BepInEx/plugins/CustomTextures/", + # + "Builds": "AdvancedBuilder/", + "*.vbuild": "AdvancedBuilder/Builds/", + # + "*.assets": "valheim_Data/", + }, + ) self._featureMap[mobase.LocalSavegames] = ValheimLocalSavegames( self.savesDirectory() ) @@ -510,7 +382,7 @@ class ValheimGame(BasicGame): def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: return [ mobase.ExecutableForcedLoadSetting(self.binaryName(), lib).withEnabled(True) - for lib in self.forced_libraries + for lib in self._forced_libraries ] def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: