Coverage for enderchest/gather.py: 82%
255 statements
« prev ^ index » next coverage.py v7.10.1, created at 2025-07-30 12:06 +0000
« prev ^ index » next coverage.py v7.10.1, created at 2025-07-30 12:06 +0000
1"""Functionality for onboarding and updating new installations and instances"""
3import itertools
4import json
5import logging
6import os
7import re
8from collections.abc import Iterable
9from configparser import ConfigParser, ParsingError
10from pathlib import Path
11from typing import Any, TypedDict
12from urllib.parse import ParseResult
14from . import filesystem as fs
15from .enderchest import EnderChest, create_ender_chest
16from .instance import InstanceSpec, normalize_modloader, parse_version
17from .inventory import load_ender_chest
18from .loggers import GATHER_LOGGER
19from .prompt import prompt
20from .shulker_box import _matches_version
23def gather_minecraft_instances(
24 minecraft_root: Path, search_path: Path, official: bool | None
25) -> list[InstanceSpec]:
26 """Search the specified directory for Minecraft installations and return
27 any that are can be found and parsed
29 Parameters
30 ----------
31 minecraft_root : Path
32 The root directory that your minecraft stuff (or, at least, the one
33 that's the parent of your EnderChest folder). This will be used to
34 construct relative paths.
35 search_path : Path
36 The path to search
37 official : bool or None
38 Whether we expect that the instances found in this location will be:
39 - from the official launcher (official=True)
40 - from a MultiMC-style launcher (official=False)
41 - a mix / unsure (official=None)
43 Returns
44 -------
45 list of InstanceSpec
46 A list of parsed instances
48 Notes
49 -----
50 - If a minecraft installation is found but cannot be parsed
51 (or parsed as specified) this method will report that failure but then
52 continue on.
53 - As a corollary, if _no_ valid Minecraft installations can be found, this
54 method will return an empty list.
55 """
56 try:
57 ender_chest = load_ender_chest(minecraft_root)
58 except FileNotFoundError:
59 # because this method can be called during crafting
60 ender_chest = EnderChest(minecraft_root)
61 GATHER_LOGGER.debug("Searching for Minecraft folders inside %s", search_path)
62 instances: list[InstanceSpec] = []
63 for folder in fs.minecraft_folders(search_path):
64 folder_path = folder.absolute()
65 GATHER_LOGGER.debug("Found minecraft installation at %s", folder)
66 if official is not False:
67 try:
68 instances.append(gather_metadata_for_official_instance(folder_path))
69 GATHER_LOGGER.info(
70 "Gathered official Minecraft installation from %s", folder
71 )
72 _check_for_allowed_symlinks(ender_chest, instances[-1])
73 continue
74 except ValueError as not_official:
75 GATHER_LOGGER.log(
76 logging.DEBUG if official is None else logging.WARNING,
77 ("%s is not an official instance:\n%s", folder, not_official),
78 )
79 if official is not True:
80 try:
81 instances.append(gather_metadata_for_mmc_instance(folder_path))
82 GATHER_LOGGER.info(
83 "Gathered MMC-like Minecraft installation from %s", folder
84 )
85 _check_for_allowed_symlinks(ender_chest, instances[-1])
86 continue
87 except ValueError as not_mmc:
88 GATHER_LOGGER.log(
89 logging.DEBUG if official is None else logging.WARNING,
90 "%s is not an MMC-like instance:\n%s",
91 folder,
92 not_mmc,
93 )
94 GATHER_LOGGER.warning(
95 "%s does not appear to be a valid Minecraft instance", folder_path
96 )
97 for i, mc_instance in enumerate(instances):
98 try:
99 instances[i] = mc_instance._replace(
100 root=mc_instance.root.relative_to(minecraft_root.resolve())
101 )
102 except ValueError:
103 # TODO: if not Windows, try making relative to "~"
104 pass # instance isn't inside the minecraft root
105 if not instances:
106 GATHER_LOGGER.warning(
107 "Could not find any Minecraft instances inside %s", search_path
108 )
109 return instances
112def gather_metadata_for_official_instance(
113 minecraft_folder: Path, name: str = "official"
114) -> InstanceSpec:
115 """Parse files to generate metadata for an official Minecraft installation
117 Parameters
118 ----------
119 minecraft_folder : Path
120 The path to the installation's .minecraft folder
121 name : str, optional
122 A name or alias to give to the instance. If None is provided, the
123 default name is "official"
125 Returns
126 -------
127 InstanceSpec
128 The metadata for this instance
130 Raises
131 ------
132 ValueError
133 If this is not a valid official Minecraft installation
135 Notes
136 -----
137 This method will always consider this instance to be vanilla, with no
138 modloader. If a Forge or Fabric executable is installed inside this
139 instance, the precise name of that version of that modded minecraft
140 will be included in the version list.
141 """
142 launcher_profile_file = minecraft_folder / "launcher_profiles.json"
143 try:
144 with launcher_profile_file.open() as lp_json:
145 launcher_profiles = json.load(lp_json)
146 raw_versions: list[str] = [
147 profile["lastVersionId"]
148 for profile in launcher_profiles["profiles"].values()
149 ]
150 except FileNotFoundError as no_json:
151 raise ValueError(f"Could not find {launcher_profile_file}") from no_json
152 except json.JSONDecodeError as bad_json:
153 raise ValueError(
154 f"{launcher_profile_file} is corrupt and could not be parsed"
155 ) from bad_json
156 except KeyError as weird_json:
157 raise ValueError(
158 f"Could not parse metadata from {launcher_profile_file}"
159 ) from weird_json
161 version_manifest_file = minecraft_folder / "versions" / "version_manifest_v2.json"
162 try:
163 with version_manifest_file.open() as vm_json:
164 version_lookup: dict[str, str] = json.load(vm_json)["latest"]
165 except FileNotFoundError as no_json:
166 raise ValueError(f"Could not find {version_manifest_file}") from no_json
167 except json.JSONDecodeError as bad_json:
168 raise ValueError(
169 f"{version_manifest_file} is corrupt and could not be parsed"
170 ) from bad_json
171 except KeyError:
172 GATHER_LOGGER.warning(
173 "%s has no latest-version lookup."
174 "\nPlease check the parsed metadata to ensure that it's accurate.",
175 version_manifest_file,
176 )
177 version_lookup = {}
179 versions: list[str] = []
180 groups: list[str] = ["vanilla"]
181 for version in raw_versions:
182 if version.startswith("latest-"):
183 mapped_version = version_lookup.get(version[len("latest-") :])
184 if mapped_version is not None:
185 versions.append(parse_version(mapped_version))
186 groups.append(version)
187 continue
188 versions.append(parse_version(version))
190 return InstanceSpec(name, minecraft_folder, tuple(versions), "", tuple(groups), ())
193def gather_metadata_for_mmc_instance(
194 minecraft_folder: Path, instgroups_file: Path | None = None
195) -> InstanceSpec:
196 """Parse files to generate metadata for a MultiMC-like instance
198 Parameters
199 ----------
200 minecraft_folder : Path
201 The path to the installation's .minecraft folder
202 instgroups_file : Path
203 The path to instgroups.json. If None is provided, this method will
204 look for it two directories up from the minecraft folder
206 Returns
207 -------
208 InstanceSpec
209 The metadata for this instance
211 Raises
212 ------
213 ValueError
214 If this is not a valid MMC-like Minecraft instance
216 Notes
217 -----
218 If this method is failing to find the appropriate files, you may want
219 to try ensuring that minecraft_folder is an absolute path.
220 """
221 mmc_pack_file = minecraft_folder.parent / "mmc-pack.json"
222 try:
223 with mmc_pack_file.open() as mmc_json:
224 components: list[dict] = json.load(mmc_json)["components"]
226 version: str | None = None
227 modloader: str | None = None
229 for component in components:
230 match component.get("uid"), component.get("cachedName", ""):
231 case "net.minecraft", _:
232 version = parse_version(component["version"])
233 case "net.fabricmc.fabric-loader", _:
234 modloader = "Fabric Loader"
235 case "org.quiltmc.quilt-loader", _:
236 modloader = "Quilt Loader"
237 case ("net.minecraftforge", _) | (_, "Forge"):
238 modloader = "Forge"
239 case _, name if name.endswith("oader"):
240 modloader = name
241 case _:
242 continue
243 modloader = normalize_modloader(modloader)[0]
244 if version is None:
245 raise KeyError("Could not find a net.minecraft component")
246 except FileNotFoundError as no_json:
247 raise ValueError(f"Could not find {mmc_pack_file}") from no_json
248 except json.JSONDecodeError as bad_json:
249 raise ValueError(
250 f"{mmc_pack_file} is corrupt and could not be parsed"
251 ) from bad_json
252 except KeyError as weird_json:
253 raise ValueError(
254 f"Could not parse metadata from {mmc_pack_file}"
255 ) from weird_json
257 name = minecraft_folder.parent.name
259 instance_groups: list[str] = []
261 if name == "":
262 GATHER_LOGGER.warning(
263 "Could not resolve the name of the parent folder"
264 " and thus could not load tags."
265 )
266 else:
267 instgroups_file = (
268 instgroups_file or minecraft_folder.parent.parent / "instgroups.json"
269 )
271 try:
272 with instgroups_file.open() as groups_json:
273 groups: dict[str, dict] = json.load(groups_json)["groups"]
274 for group, metadata in groups.items():
275 # interestingly this comes from the folder name, not the actual name
276 if name in metadata.get("instances", ()):
277 instance_groups.append(group)
279 except FileNotFoundError:
280 GATHER_LOGGER.warning(
281 "Could not find %s and thus could not load tags", instgroups_file
282 )
283 except json.JSONDecodeError:
284 GATHER_LOGGER.warning(
285 "%s is corrupt and could not be parsed for tags", instgroups_file
286 )
287 except KeyError:
288 GATHER_LOGGER.warning("Could not parse tags from %s", instgroups_file)
290 instance_cfg = minecraft_folder.parent / "instance.cfg"
292 try:
293 parser = ConfigParser(allow_no_value=True, interpolation=None)
294 parser.read_string("[instance]\n" + instance_cfg.read_text())
295 name = parser["instance"]["name"]
296 except FileNotFoundError:
297 GATHER_LOGGER.warning(
298 "Could not find %s and thus could not load the instance name", instance_cfg
299 )
300 except ParsingError:
301 GATHER_LOGGER.warning(
302 "is corrupt and could not be parsed the instance name", instance_cfg
303 )
304 except KeyError:
305 GATHER_LOGGER.warning(f"Could not parse instance name from %s", instance_cfg)
307 if name == "":
308 raise ValueError("Could not determine the name of the instance.")
310 return InstanceSpec(
311 name,
312 minecraft_folder,
313 (version,),
314 modloader or "",
315 tuple(instance_groups),
316 (),
317 )
320SERVER_JAR_PATTERNS: tuple[str, ...] = (
321 r"^(minecraft_server).([^-]*).jar$", # vanilla naming as per official docs
322 # (not much we can do with server.jar)
323 r"^(forge)-([0-9\.]*)-([0-9\.]*).*\.jar$",
324 r"^(fabric)-server-mc.([^-]*)-loader.([0-9\.]*)-launcher.([0-9\.]*).jar$",
325 r"^(paper)-([^-]*)-([0-9]*).jar$",
326 r"^(purpur)-([^-]*)-([0-9]*).jar$",
327 r"^(spigot)-([^-]*).jar$",
328)
331class _JarFileMeta(TypedDict):
332 modloader: str
333 minecraft_versions: tuple[str]
336def _gather_metadata_from_jar_filename(jar_name: str) -> _JarFileMeta:
337 """
339 Parameters
340 ----------
341 jar_name : str
342 The filename of the server jar
344 Returns
345 -------
346 dict with two entries :
347 modloader : str
348 The (display) name of the modloader (vanilla corresponds to "")
349 minecraft_versions : tuple of single str
350 The minecraft version of the instance (tupled for `InstanceSpec`
351 compatibility).
353 Notes
354 -----
355 The filename may contain additional metadata (such as the modloader version).
356 That metadata is ignored.
358 Raises
359 ------
360 ValueError
361 If the filename doesn't conform to any known patterns and thus
362 metadata cannot be extracted).
363 """
364 for pattern in SERVER_JAR_PATTERNS:
365 if pattern_match := re.match(pattern, jar_name):
366 modloader, version, *_ = pattern_match.groups()
367 break
368 else:
369 raise ValueError(f"Could not parse metadata from jar filename {jar_name}")
370 return {
371 "modloader": normalize_modloader(modloader)[0],
372 "minecraft_versions": (version,),
373 }
376def gather_metadata_for_minecraft_server(
377 server_home: Path,
378 name: str | None = None,
379 tags: Iterable[str] | None = None,
380 server_jar: Path | None = None,
381) -> InstanceSpec:
382 """Parse files (or user input) to generate metadata for a minecraft server
383 installation
385 Parameters
386 ----------
387 server_home : Path
388 The working directory of the Minecraft server
389 name : str, optional
390 A name or alias to give to the server. If None is provided, the user
391 will be prompted to enter it.
392 tags : list of str, optional
393 The tags to assign to the server. If None are specified, the user will
394 be prompted to enter them.
395 server_jar : Path, optional
396 The path to the server JAR file. If None is provided, this method will
397 attempt to locate it within the `server_home`.
399 Returns
400 -------
401 InstanceSpec
402 The metadata for this instance
404 Raises
405 ------
406 ValueError
407 If this is not a valid Minecraft server installation or the requisite
408 metadata could not be parsed
410 Notes
411 -----
412 This method extracts metadata entirely from the filename of the server jar
413 file. Custom-named jars or executables in non-standard locations will
414 require their metadata be added manually.
415 """
416 instance_spec: dict[str, Any] = {"root": server_home, "groups_": ("server",)}
417 if server_jar is not None:
418 jars: Iterable[Path] = (server_jar,)
419 else:
420 jars = sorted(
421 filter(
422 lambda jar: not jar.is_relative_to(server_home / "mods"),
423 itertools.chain(server_home.rglob("*.jar"), server_home.rglob("*.JAR")),
424 ),
425 key=lambda jar: (len(jar.parts), -len(str(jar))),
426 )
428 failed_parses: list[Path] = []
429 for jar in jars:
430 GATHER_LOGGER.debug("Attempting to extract server metadata from %s", jar)
431 try:
432 instance_spec.update(_gather_metadata_from_jar_filename(jar.name.lower()))
433 break
434 except ValueError as parse_fail:
435 GATHER_LOGGER.debug(parse_fail)
436 failed_parses.append(jar)
437 else:
438 GATHER_LOGGER.warning(
439 "Could not parse server metadata from:\n%s",
440 "\n".join((f" - {jar}" for jar in failed_parses)),
441 )
442 if "modloader" not in instance_spec:
443 instance_spec["modloader"] = normalize_modloader(
444 prompt(
445 "What modloader / type of server is this?"
446 "\ne.g. Vanilla, Fabric, Forge, Paper, Spigot, PurPur..."
447 )
448 .lower()
449 .strip()
450 )[0]
451 instance_spec["minecraft_versions"] = (
452 prompt(
453 "What version of Minecraft is this server?\ne.g.1.20.4, 23w13a_or_b..."
454 )
455 .lower()
456 .strip(),
457 )
458 if name is None:
459 name = prompt(
460 "Enter a name / alias for this server", suggestion=server_home.name
461 )
462 if name == "":
463 name = server_home.name
464 instance_spec["name"] = name
466 if tags is None:
467 tags = prompt(
468 "Enter any tags you'd like to use to label the server, separated by commas"
469 '(it will be tagged as "server" automatically).'
470 )
471 if tags == "":
472 tags = ()
473 else:
474 tags = (tag.lower().strip() for tag in tags.split(","))
475 instance_spec["tags_"] = tuple(tags)
477 return InstanceSpec(**instance_spec)
480def update_ender_chest(
481 minecraft_root: Path,
482 search_paths: Iterable[str | Path] | None = None,
483 instance_type: str | None = None,
484 remotes: (
485 Iterable[str | ParseResult | tuple[str, str] | tuple[ParseResult, str]] | None
486 ) = None,
487 **server_meta,
488) -> None:
489 """Orchestration method that coordinates the onboarding of new instances or
490 EnderChest installations
492 Parameters
493 ----------
494 minecraft_root : Path
495 The root directory that your minecraft stuff (or, at least, the one
496 that's the parent of your EnderChest folder).
497 search_paths : list of Paths, optional
498 The local search paths to look for Minecraft installations within.
499 Be warned that this search is performed recursively.
500 instance_type : str, optional
501 Optionally specify the type of the Minecraft instances you expect to find.
502 Options are:
504 - from the official launcher (`instance_type="official"`)
505 - from a MultiMC derivative (`instance_type="mmc"`)
506 - server (in which case, each search path will be accepted verbatim
507 as the server's home directory) (`instance_type="server"`)
509 If `None` is specified, this method will search for both official and
510 MMC-style instances (but not servers).
511 remotes : list of URIs or (URI, str) tuples, optional
512 Any remotes you wish to register to this instance. When a (URI, str) tuple
513 is provided, the second value will be used as the name/alias of the remote.
514 If there is already a remote specified with the given alias, this method will
515 replace it.
516 **server_meta
517 Pass-through for metadata to pass through to any gathered servers (such
518 as name or jar location)
519 """
520 try:
521 ender_chest = load_ender_chest(minecraft_root)
522 except (FileNotFoundError, ValueError) as bad_chest:
523 GATHER_LOGGER.error(
524 f"Could not load EnderChest from {minecraft_root}:\n {bad_chest}"
525 )
526 return
527 for search_path in search_paths or ():
528 match instance_type:
529 case "server":
530 instance = gather_metadata_for_minecraft_server(
531 Path(search_path), **server_meta
532 )
533 _ = ender_chest.register_instance(instance)
534 continue
535 case "official":
536 official: bool | None = True
537 case "mmc":
538 official = False
539 case None:
540 official = None
541 case _:
542 raise NotImplementedError(
543 f"{instance_type} instances are not currently supported."
544 )
545 for instance in gather_minecraft_instances(
546 minecraft_root, Path(search_path), official=official
547 ):
548 _ = ender_chest.register_instance(instance)
550 for remote in remotes or ():
551 try:
552 if isinstance(remote, (str, ParseResult)):
553 ender_chest.register_remote(remote)
554 else:
555 ender_chest.register_remote(*remote)
556 except ValueError as bad_remote:
557 GATHER_LOGGER.warning(bad_remote)
559 create_ender_chest(minecraft_root, ender_chest)
562def _check_for_allowed_symlinks(
563 ender_chest: EnderChest, instance: InstanceSpec
564) -> None:
565 """Check if the instance:
566 - is 1.20+
567 - has not already blanket-allowed symlinks into the EnderChest
569 and if it hasn't, offer to update the allow-list now *but only if* the user
570 hasn't already told EnderChest "shut up I know what I'm doing."
572 Parameters
573 ----------
574 ender_chest : EnderChest
575 This EnderChest
576 instance : InstanceSpec
577 The instance spec to check
578 """
579 if ender_chest.offer_to_update_symlink_allowlist is False:
580 return
582 if not any(
583 _needs_symlink_allowlist(version) for version in instance.minecraft_versions
584 ):
585 return
586 ender_chest_abspath = os.path.realpath(ender_chest.root)
588 symlink_allowlist = instance.root / "allowed_symlinks.txt"
590 try:
591 allowlist_contents = symlink_allowlist.read_text()
592 already_allowed = ender_chest_abspath in allowlist_contents.splitlines()
593 allowlist_needs_newline = not allowlist_contents.endswith("\n")
594 except FileNotFoundError:
595 already_allowed = False
596 allowlist_needs_newline = False
598 if already_allowed:
599 return
601 GATHER_LOGGER.warning(
602 """
603Starting with Minecraft 1.20, Mojang by default no longer allows worlds
604to load if they are or if they contain symbolic links.
605Read more: https://help.minecraft.net/hc/en-us/articles/16165590199181"""
606 )
608 response = prompt(
609 f"Would you like EnderChest to add {ender_chest_abspath} to {symlink_allowlist}?",
610 "Y/n",
611 )
613 if response.lower() not in ("y", "yes", ""):
614 return
616 with symlink_allowlist.open("a") as allow_file:
617 if allowlist_needs_newline:
618 allow_file.write("\n")
619 allow_file.write(ender_chest_abspath + "\n")
621 GATHER_LOGGER.info(f"{symlink_allowlist} updated.")
624def _needs_symlink_allowlist(version: str) -> bool:
625 """Determine if a version needs `allowed_symlinks.txt` in order to link
626 to EnderChest. Note that this is going a little broader than is strictly
627 necessary.
629 Parameters
630 ----------
631 version: str
632 The version string to check against
634 Returns
635 -------
636 bool
637 Returns False if the Minecraft version predates the symlink ban. Returns
638 True if it doesn't (or is marginal).
640 Notes
641 -----
642 Have I mentioned that parsing Minecraft version strings is a pain in the
643 toucans?
644 """
645 # first see if it follows basic semver
646 if _matches_version(">1.19", parse_version(version.split("-")[0])):
647 return True
648 if _matches_version("1.20.0*", parse_version(version.split("-")[0])):
649 return True
650 # is it a snapshot?
651 if match := re.match("^([1-2][0-9])w([0-9]{1,2})", version.lower()):
652 year, week = match.groups()
653 if int(year) > 23:
654 return True
655 if int(year) == 23 and int(week) > 18:
656 return True
658 return False