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