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