1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
|
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
}
self.ignore = OptionalRegexPattern(globs.ignore)
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
move: dict[str, str] = field(default_factory=dict[str, str])
ignore: list[str] | None = None
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,
ignore=_merge_list(self.ignore, other.ignore),
)
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,
ignore=other.ignore or self.ignore,
)
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:
ignore: [ "list of files and folders to ignore." ]
# Check result: unchanged
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.ignore.match(name):
continue
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):
name = entry.name()
if rp.ignore.match(name):
continue
# 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
|