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