Files
modorganizer-basic_games/basic_features/basic_mod_data_checker.py
T

217 lines
7.0 KiB
Python
Raw Normal View History

from __future__ import annotations
import fnmatch
import re
from dataclasses import dataclass, field
from typing import Iterable, Literal
import mobase
from .utils import is_directory
class OptionalRegexPattern:
_pattern: re.Pattern[str] | None
def __init__(self, globs: Iterable[str] | None) -> None:
if globs is None:
self._pattern = None
else:
self._pattern = OptionalRegexPattern.regex_from_glob_list(globs)
@staticmethod
def regex_from_glob_list(glob_list: Iterable[str]) -> re.Pattern[str]:
"""
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 match(self, value: str) -> bool:
if self._pattern is None:
return False
return bool(self._pattern.match(value))
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), re.I) for key in globs.move
}
def move_match(self, value: str) -> str | None:
"""
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
def _merge_list(l1: list[str] | None, l2: list[str] | None) -> list[str] | None:
if l1 is None and l2 is None:
return None
return (l1 or []) + (l2 or [])
@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
2025-04-25 08:23:10 +02:00
move: dict[str, str] = field(default_factory=dict[str, str])
def merge(
self, other: GlobPatterns, mode: Literal["merge", "replace"] = "replace"
) -> GlobPatterns:
"""
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.
Args:
other: Other patterns to "merge" with this one.
mode: Merge mode.
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):
"""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 and fixed in definition order of the `file_patterns` dict.
Args:
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`.
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`.
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:
BasicModDataChecker(
GlobPatterns(
valid=["valid_folder", "*.ext1"]
move={"*.ext2": "path/to/target_folder/"}
)
)
See Also:
`mobase.IFileTree.move` for the `"move"` target path specs.
"""
_file_patterns: GlobPatterns
"""Private `file_patterns`, updated together with `._regex` and `._move_targets`."""
_regex_patterns: RegexPatterns
"""The regex patterns derived from the file (glob) patterns."""
def __init__(self, file_patterns: GlobPatterns | None = None):
super().__init__()
self._file_patterns = file_patterns or GlobPatterns()
self._regex_patterns = RegexPatterns(self._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()
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:
status = mobase.ModDataChecker.INVALID
break
return status
def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree:
rp = self._regex_patterns
for entry in list(filetree):
2022-04-05 23:31:33 +02:00
name = entry.name()
# 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