Coverage for enderchest/shulker_box.py: 98%
162 statements
« prev ^ index » next coverage.py v7.5.0, created at 2024-05-04 01:41 +0000
« prev ^ index » next coverage.py v7.5.0, created at 2024-05-04 01:41 +0000
1"""Specification and configuration of a shulker box"""
3import fnmatch
4import os
5import re
6from pathlib import Path
7from typing import Any, Iterable, NamedTuple
9import semantic_version as semver
11from . import config as cfg
12from . import filesystem as fs
13from .instance import InstanceSpec, normalize_modloader
14from .loggers import CRAFT_LOGGER
16_DEFAULT_PRIORITY = 0
17_DEFAULT_LINK_DEPTH = 2
18_DEFAULT_DO_NOT_LINK = ("shulkerbox.cfg", ".DS_Store")
21class ShulkerBox(NamedTuple):
22 """Specification of a shulker box
24 Parameters
25 ----------
26 priority : int
27 The priority for linking assets in the shulker box (higher priority
28 boxes are linked last)
29 name : str
30 The name of the shulker box (which is incidentally used to break
31 priority ties)
32 root : Path
33 The path to the root of the shulker box
34 match_criteria : list-like of tuples
35 The parameters for matching instances to this shulker box. Each element
36 consists of:
38 - the name of the condition
39 - the matching values for that condition
41 The logic applied is that an instance must match at least one value
42 for each condition (so it's ANDing a collection of ORs)
43 link_folders : list-like of str
44 The folders that should be linked in their entirety
45 max_link_depth : int, optional
46 By default, non-root-level folders (that is, folders inside of folders)
47 will be treated as files for the purpose of linking. Put another way,
48 only files with a depth of 2 or less from the shulker root will be
49 linked. This behavior can be overridden by explicitly setting
50 the `max_link_depth` value, but **this feature is highly experimental**,
51 so use it at your own risk.
52 do_not_link : list-like of str, optional
53 Glob patterns of files that should not be linked. By default, this list
54 comprises `shulkerbox.cfg` and `.DS_Store` (for all you mac gamers).
56 Notes
57 -----
58 A shulker box specification is immutable, so making changes (such as
59 updating the match criteria) can only be done on copies created via the
60 `_replace` method, inherited from the NamedTuple parent class.
61 """
63 priority: int
64 name: str
65 root: Path
66 match_criteria: tuple[tuple[str, tuple[str, ...]], ...]
67 link_folders: tuple[str, ...]
68 max_link_depth: int = _DEFAULT_LINK_DEPTH
69 do_not_link: tuple[str, ...] = _DEFAULT_DO_NOT_LINK
71 @classmethod
72 def from_cfg(cls, config_file: Path) -> "ShulkerBox":
73 """Parse a shulker box from its config file
75 Parameters
76 ----------
77 config_file : Path
78 The path to the config file
80 Returns
81 -------
82 ShulkerBox
83 The resulting ShulkerBox
85 Raises
86 ------
87 ValueError
88 If the config file at that location cannot be parsed
89 FileNotFoundError
90 If there is no config file at the specified location
91 """
92 priority = 0
93 max_link_depth = 2
94 root = config_file.parent
95 name = root.name
96 config = cfg.read_cfg(config_file)
98 match_criteria: dict[str, tuple[str, ...]] = {}
100 for section in config.sections():
101 normalized = (
102 section.lower().replace(" ", "").replace("-", "").replace("_", "")
103 )
104 if normalized.endswith("s"):
105 normalized = normalized[:-1] # lazy de-pluralization
106 if normalized in ("linkfolder", "folder"):
107 normalized = "link-folders"
108 if normalized in ("donotlink",):
109 normalized = "do-not-link"
110 if normalized in ("minecraft", "version", "minecraftversion"):
111 normalized = "minecraft"
112 if normalized in ("modloader", "loader"):
113 normalized = "modloader"
114 if normalized in ("instance", "tag", "host"):
115 normalized += "s" # lazy re-pluralization
117 if normalized == "propertie": # lulz
118 # TODO check to make sure properties hasn't been read before
119 # most of this section gets ignored
120 priority = config[section].getint("priority", _DEFAULT_PRIORITY)
121 max_link_depth = config[section].getint(
122 "max-link-depth", _DEFAULT_LINK_DEPTH
123 )
124 # TODO: support specifying filters (and link-folders) in the properties section
125 continue
126 if normalized in match_criteria:
127 raise ValueError(f"{config_file} specifies {normalized} more than once")
129 if normalized == "minecraft":
130 minecraft_versions = []
131 for key, value in config[section].items():
132 if value is None:
133 minecraft_versions.append(key)
134 elif key.lower().strip().startswith("version"):
135 minecraft_versions.append(value)
136 else: # what happens if you specify ">=1.19" or "=1.12"
137 minecraft_versions.append("=".join((key, value)))
138 match_criteria[normalized] = tuple(minecraft_versions)
139 elif normalized == "modloader":
140 modloaders: set[str] = set()
141 for loader in config[section].keys():
142 modloaders.update(normalize_modloader(loader))
143 match_criteria[normalized] = tuple(sorted(modloaders))
144 else:
145 # really hoping delimiter shenanigans doesn't show up anywhere else
146 match_criteria[normalized] = tuple(config[section].keys())
148 link_folders = match_criteria.pop("link-folders", ())
149 do_not_link = match_criteria.pop("do-not-link", _DEFAULT_DO_NOT_LINK)
151 return cls(
152 priority,
153 name,
154 root,
155 tuple(match_criteria.items()),
156 link_folders,
157 max_link_depth=max_link_depth,
158 do_not_link=do_not_link,
159 )
161 def write_to_cfg(self, config_file: Path | None = None) -> str:
162 """Write this box's configuration to INI
164 Parameters
165 ----------
166 config_file : Path, optional
167 The path to the config file, assuming you'd like to write the
168 contents to file
170 Returns
171 -------
172 str
173 An INI-syntax rendering of this shulker box's config
175 Notes
176 -----
177 The "root" attribute is ignored for this method
178 """
179 properties: dict[str, Any] = {"priority": self.priority}
180 if self.max_link_depth != _DEFAULT_LINK_DEPTH:
181 properties["max-link-depth"] = self.max_link_depth
183 config = cfg.dumps(
184 os.path.join(self.name, fs.SHULKER_BOX_CONFIG_NAME),
185 properties,
186 **dict(self.match_criteria),
187 link_folders=self.link_folders,
188 do_not_link=self.do_not_link,
189 )
191 if config_file:
192 config_file.write_text(config)
193 return config
195 def matches(self, instance: InstanceSpec) -> bool:
196 """Determine whether the shulker box matches the given instance
198 Parameters
199 ----------
200 instance : InstanceSpec
201 The instance's specification
203 Returns
204 -------
205 bool
206 True if the instance matches the shulker box's conditions, False
207 otherwise.
208 """
209 for condition, values in self.match_criteria:
210 match condition: # these should have been normalized on read-in
211 case "instances":
212 matchers = []
213 exclusions = []
214 for value in values:
215 if value.startswith("!"):
216 exclusions.append(value[1:])
217 else:
218 matchers.append(value)
219 for value in exclusions:
220 if _matches_string(value, instance.name, case_sensitive=True):
221 return False
223 if len(matchers) == 0: # implicit "*"
224 matchers = ["*"]
226 for value in matchers:
227 if _matches_string(value, instance.name, case_sensitive=True):
228 break
229 else:
230 return False
232 case "tags":
233 matchers = []
234 exclusions = []
235 for value in values:
236 if value.startswith("!"):
237 exclusions.append(value[1:])
238 else:
239 matchers.append(value)
241 for value in exclusions:
242 for tag in instance.tags:
243 if _matches_string(value, tag):
244 return False
246 if len(matchers) == 0: # implicit "*"
247 matchers = ["*"]
249 for value in matchers:
250 if value == "*": # in case instance.tags is empty
251 break
252 for tag in instance.tags:
253 if _matches_string(value, tag):
254 break
255 else:
256 continue
257 break
258 else:
259 return False
261 case "modloader":
262 for value in values:
263 if _matches_string(value, instance.modloader):
264 break
265 else:
266 return False
268 case "minecraft":
269 for value in values:
270 if any(
271 (
272 _matches_version(value, version)
273 for version in instance.minecraft_versions
274 )
275 ):
276 break
277 else:
278 return False
280 case "hosts":
281 # this is handled at a higher level
282 pass
284 case _:
285 raise NotImplementedError(
286 f"Don't know how to apply match condition {condition}."
287 )
288 return True
290 def matches_host(self, hostname: str):
291 """Determine whether the shulker box should be linked to from the
292 current host machine
294 Returns
295 -------
296 bool
297 True if the shulker box's hosts spec matches the host, False otherwise.
298 """
299 for condition, values in self.match_criteria:
300 if condition == "hosts":
301 if not any(
302 fnmatch.fnmatchcase(hostname.lower(), host_spec.lower())
303 for host_spec in values
304 ):
305 return False
306 return True
309def _matches_version(version_spec: str, version_string: str) -> bool:
310 """Determine whether a version spec matches a version string, taking into
311 account that neither users nor Mojang rigidly follow semver (or at least
312 PEP440)
314 Parameters
315 ----------
316 version_spec : str
317 A version specification provided by a user
318 version_string : str
319 A version string, likely parsed from an instance's configuration
321 Returns
322 -------
323 bool
324 True if the spec matches the version, False otherwise
326 Notes
327 -----
328 This method *does not* match snapshots to their corresponding version
329 range--for that you're just going to have to be explicit.
330 """
331 try:
332 return semver.SimpleSpec(version_spec).match(semver.Version(version_string))
333 except ValueError:
334 # fall back to simple fnmatching
335 return fnmatch.fnmatchcase(version_string.lower(), version_spec.lower())
338def _matches_string(pattern: str, value: str, case_sensitive: bool = False) -> bool:
339 """Determine whether the given pattern matches the provided value.
341 Parameters
342 ----------
343 pattern : str
344 The pattern to match. This can be a literal value, an fnmatch pattern with wildcards or a regular expression
345 if quoted and prefixed with an "r".
346 value : str
347 The value to check
348 case_sensitive : bool, optional
349 Whether the matching is case-sensitive. Default is False. Note that this is **ignored** if the provided pattern
350 is a regular expression.
352 Returns
353 -------
354 bool
355 True if the pattern matches the value, False otherwise
356 """
357 pattern = pattern.strip()
358 value = value.strip()
359 if re.match(r"^r('|\").*\1$", pattern):
360 return re.match(pattern[2:-1], value) is not None
361 if re.match(r"^('|\").*\1$", pattern):
362 pattern = pattern[1:-1]
363 if not case_sensitive:
364 pattern = pattern.lower()
365 value = value.lower()
366 return fnmatch.fnmatchcase(value, pattern)
369def create_shulker_box(
370 minecraft_root: Path, shulker_box: ShulkerBox, folders: Iterable[str]
371) -> None:
372 """Create a shulker box folder based on the provided configuration
374 Parameters
375 ----------
376 minecraft_root : Path
377 The root directory that your minecraft stuff (or, at least, the one
378 that's the parent of your EnderChest folder)
379 shulker_box : ShulkerBox
380 The spec of the box to create
381 folders : list-like of str
382 The folders to create inside the shulker box (not including link folders)
384 Notes
385 -----
386 - The "root" attribute of the ShulkerBox config will be ignored--instead
387 the shulker box will be created at
388 <minecraft_root>/EnderChest/<shulker box name>
389 - This method will fail if there is no EnderChest set up in the minecraft
390 root
391 - This method does not check to see if there is already a shulker box
392 set up at the specified location--if one exists, its config will
393 be overwritten
394 """
395 root = fs.shulker_box_root(minecraft_root, shulker_box.name)
396 root.mkdir(exist_ok=True)
398 for folder in (*folders, *shulker_box.link_folders):
399 CRAFT_LOGGER.debug(f"Creating {root / folder}")
400 (root / folder).mkdir(exist_ok=True, parents=True)
402 config_path = fs.shulker_box_config(minecraft_root, shulker_box.name)
403 shulker_box.write_to_cfg(config_path)
404 CRAFT_LOGGER.info(f"Shulker box configuration written to {config_path}")