mirror of
https://github.com/ModOrganizer2/modorganizer-basic_games.git
synced 2026-07-27 14:07:29 -07:00
+ basic_feature: BasicModDataChecker - ModDataChecker with simple file patterns (glob).
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from .basic_save_game_info import BasicGameSaveGameInfo # noqa
|
||||
from .basic_mod_data_checker import BasicModDataChecker # noqa
|
||||
|
||||
@@ -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
|
||||
+53
-181
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user