Files
modorganizer-basic_games/basic_features/basic_mod_data_checker.py
T

215 lines
7.9 KiB
Python

# -*- 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
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 RegexPatternDict(dict, MutableMapping[str, re.Pattern]):
"""Regex patterns for validation in `BasicModDataChecker`."""
@classmethod
def from_glob_patterns(cls, glob_patterns: GlobPatternDict) -> RegexPatternDict:
"""Returns an instance of `RegexPatternDict`, with the `glob_patterns`
translated to regex.
"""
return cls(
(
key,
cls.regex_from_glob_list(value)
if isinstance(value, Iterable)
else None,
)
for key, value in glob_patterns.items()
)
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`.
Returns:
The 0-based index of the matched group or None for no match.
"""
if pattern := self.get(key):
return self.match_index_of_pattern(search_str, pattern)
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
return None
@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 `glob_list` index.
"""
return re.compile(
f'(?:{"|".join(f"({fnmatch.translate(f)})" for f in glob_list)})', re.I
)
class GlobPatternDict(TypedDict, total=False):
"""See: `BasicModDataChecker`"""
unfold: Iterable[str] | None
valid: Iterable[str] | None
delete: Iterable[str] | None
move: MutableMapping[str, str] | None
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 dict (GlobPatternDict) with the following keys::
{
"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(
{
"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
"""Private `file_patterns`, updated together with `._regex` and `._move_targets`."""
_regex: RegexPatternDict
"""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]):
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())
def dataLooksValid(
self, filetree: mobase.IFileTree
) -> mobase.ModDataChecker.CheckReturn:
status = mobase.ModDataChecker.INVALID
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
else:
return mobase.ModDataChecker.INVALID
return status
def fix(self, filetree: mobase.IFileTree) -> Optional[mobase.IFileTree]:
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]