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