+ basic_feature: BasicModDataChecker - ModDataChecker with simple file patterns (glob).

This commit is contained in:
Zash
2022-04-04 22:20:45 +02:00
parent ecd882becc
commit a127433da5
3 changed files with 246 additions and 181 deletions
+1
View File
@@ -1,3 +1,4 @@
# -*- encoding: utf-8 -*-
from .basic_save_game_info import BasicGameSaveGameInfo # noqa
from .basic_mod_data_checker import BasicModDataChecker # noqa
+192
View File
@@ -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
View File
@@ -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]: