Coverage for enderchest/gather.py: 80%
307 statements
« prev ^ index » next coverage.py v7.4.0, created at 2024-01-11 17:09 +0000
« prev ^ index » next coverage.py v7.4.0, created at 2024-01-11 17:09 +0000
1"""Functionality for finding, resolving and parsing local installations and instances"""
2import json
3import logging
4import os
5import re
6from configparser import ConfigParser, ParsingError
7from pathlib import Path
8from typing import Iterable, Sequence
9from urllib.parse import ParseResult
11from enderchest.sync import render_remote
13from . import filesystem as fs
14from .enderchest import EnderChest, create_ender_chest
15from .instance import InstanceSpec, normalize_modloader, parse_version
16from .loggers import GATHER_LOGGER
17from .prompt import prompt
18from .shulker_box import ShulkerBox, _matches_version
21def load_ender_chest(minecraft_root: Path) -> EnderChest:
22 """Load the configuration from the enderchest.cfg file in the EnderChest
23 folder.
25 Parameters
26 ----------
27 minecraft_root : Path
28 The root directory that your minecraft stuff (or, at least, the one
29 that's the parent of your EnderChest folder)
31 Returns
32 -------
33 EnderChest
34 The EnderChest configuration
36 Raises
37 ------
38 FileNotFoundError
39 If no EnderChest folder exists in the given minecraft root or if no
40 enderchest.cfg file exists within that EnderChest folder
41 ValueError
42 If the EnderChest configuration is invalid and could not be parsed
43 """
44 config_path = fs.ender_chest_config(minecraft_root)
45 GATHER_LOGGER.debug(f"Loading {config_path}")
46 ender_chest = EnderChest.from_cfg(config_path)
47 GATHER_LOGGER.debug(f"Parsed EnderChest installation from {minecraft_root}")
48 return ender_chest
51def load_ender_chest_instances(
52 minecraft_root: Path, log_level: int = logging.INFO
53) -> Sequence[InstanceSpec]:
54 """Get the list of instances registered with the EnderChest located in the
55 minecraft root
57 Parameters
58 ----------
59 minecraft_root : Path
60 The root directory that your minecraft stuff (or, at least, the one
61 that's the parent of your EnderChest folder)
62 log_level : int, optional
63 By default, this method will report out the minecraft instances it
64 finds at the INFO level. You can optionally pass in a lower (or higher)
65 level if this method is being called from another method where that
66 information is redundant or overly verbose.
68 Returns
69 -------
70 list of InstanceSpec
71 The instances registered with the EnderChest
73 Notes
74 -----
75 If no EnderChest is installed in the given location, then this will return
76 an empty list rather than failing outright.
77 """
78 try:
79 ender_chest = load_ender_chest(minecraft_root)
80 instances: Sequence[InstanceSpec] = ender_chest.instances
81 except (FileNotFoundError, ValueError) as bad_chest:
82 GATHER_LOGGER.error(
83 f"Could not load EnderChest from {minecraft_root}:\n {bad_chest}"
84 )
85 instances = []
86 if len(instances) == 0:
87 GATHER_LOGGER.warning(
88 f"There are no instances registered to the {minecraft_root} EnderChest",
89 )
90 else:
91 GATHER_LOGGER.log(
92 log_level,
93 "These are the instances that are currently registered"
94 f" to the {minecraft_root} EnderChest:\n%s",
95 "\n".join(
96 [
97 f" {i + 1}. {_render_instance(instance)}"
98 for i, instance in enumerate(instances)
99 ]
100 ),
101 )
102 return instances
105def _render_instance(instance: InstanceSpec) -> str:
106 """Render an instance spec to a descriptive string
108 Parameters
109 ----------
110 instance : InstanceSpec
111 The instance spec to render
113 Returns
114 -------
115 str
116 {instance.name} ({instance.root})
117 """
118 return f"{instance.name} ({instance.root})"
121def load_shulker_boxes(
122 minecraft_root: Path, log_level: int = logging.INFO
123) -> list[ShulkerBox]:
124 """Load all shulker boxes in the EnderChest folder and return them in the
125 order in which they should be linked.
127 Parameters
128 ----------
129 minecraft_root : Path
130 The root directory that your minecraft stuff (or, at least, the one
131 that's the parent of your EnderChest folder)
132 log_level : int, optional
133 By default, this method will report out the minecraft instances it
134 finds at the INFO level. You can optionally pass in a lower (or higher)
135 level if this method is being called from another method where that
136 information is redundant or overly verbose.
138 Returns
139 -------
140 list of ShulkerBoxes
141 The shulker boxes found in the EnderChest folder, ordered in terms of
142 the sequence in which they should be linked
144 Notes
145 -----
146 If no EnderChest is installed in the given location, then this will return
147 an empty list rather than failing outright.
148 """
149 shulker_boxes: list[ShulkerBox] = []
151 try:
152 for shulker_config in fs.shulker_box_configs(minecraft_root):
153 try:
154 shulker_boxes.append(_load_shulker_box(shulker_config))
155 except (FileNotFoundError, ValueError) as bad_shulker:
156 GATHER_LOGGER.warning(
157 f"{bad_shulker}\n Skipping shulker box {shulker_config.parent.name}"
158 )
160 except FileNotFoundError:
161 GATHER_LOGGER.error(f"There is no EnderChest installed within {minecraft_root}")
162 return []
164 shulker_boxes = sorted(shulker_boxes)
166 if len(shulker_boxes) == 0:
167 if log_level >= logging.INFO:
168 GATHER_LOGGER.warning(
169 f"There are no shulker boxes within the {minecraft_root} EnderChest"
170 )
171 else:
172 _report_shulker_boxes(
173 shulker_boxes, log_level, f"the {minecraft_root} EnderChest"
174 )
175 return shulker_boxes
178def _report_shulker_boxes(
179 shulker_boxes: Iterable[ShulkerBox], log_level: int, ender_chest_name: str
180) -> None:
181 """Log the list of shulker boxes in the order they'll be linked"""
182 GATHER_LOGGER.log(
183 log_level,
184 f"These are the shulker boxes within {ender_chest_name}"
185 "\nlisted in the order in which they are linked:\n%s",
186 "\n".join(
187 f" {shulker_box.priority}. {_render_shulker_box(shulker_box)}"
188 for shulker_box in shulker_boxes
189 ),
190 )
193def _load_shulker_box(config_file: Path) -> ShulkerBox:
194 """Attempt to load a shulker box from a config file, and if you can't,
195 at least log why the loading failed.
197 Parameters
198 ----------
199 config_file : Path
200 Path to the config file
202 Returns
203 -------
204 ShulkerBox | None
205 The parsed shulker box or None, if the shulker box couldn't be parsed
207 Raises
208 ------
209 FileNotFoundError
210 If the given config file could not be found
211 ValueError
212 If there was a problem parsing the config file
213 """
214 GATHER_LOGGER.debug(f"Attempting to parse {config_file}")
215 shulker_box = ShulkerBox.from_cfg(config_file)
216 GATHER_LOGGER.debug(f"Successfully parsed {_render_shulker_box(shulker_box)}")
217 return shulker_box
220def _render_shulker_box(shulker_box: ShulkerBox) -> str:
221 """Render a shulker box to a descriptive string
223 Parameters
224 ----------
225 shulker_box : ShulkerBox
226 The shulker box spec to render
228 Returns
229 -------
230 str
231 {priority}. {folder_name} [({name})]
232 (if different from folder name)
233 """
234 stringified = f"{shulker_box.root.name}"
235 if shulker_box.root.name != shulker_box.name: # pragma: no cover
236 # note: this is not a thing
237 stringified += f" ({shulker_box.name})"
238 return stringified
241def load_ender_chest_remotes(
242 minecraft_root: Path, log_level: int = logging.INFO
243) -> list[tuple[ParseResult, str]]:
244 """Load all remote EnderChest installations registered with this one
246 Parameters
247 ----------
248 minecraft_root : Path
249 The root directory that your minecraft stuff (or, at least, the one
250 that's the parent of your EnderChest folder)
251 log_level : int, optional
252 By default, this method will report out the minecraft instances it
253 finds at the INFO level. You can optionally pass in a lower (or higher)
254 level if this method is being called from another method where that
255 information is redundant or overly verbose.
257 Returns
258 -------
259 list of (URI, str) tuples
260 The URIs of the remote EnderChests, paired with their aliases
262 Notes
263 -----
264 If no EnderChest is installed in the given location, then this will return
265 an empty list rather than failing outright.
266 """
267 try:
268 ender_chest = load_ender_chest(minecraft_root)
269 remotes: Sequence[tuple[ParseResult, str]] = ender_chest.remotes
270 except (FileNotFoundError, ValueError) as bad_chest:
271 GATHER_LOGGER.error(
272 f"Could not load EnderChest from {minecraft_root}:\n {bad_chest}"
273 )
274 remotes = ()
276 if len(remotes) == 0:
277 if log_level >= logging.INFO:
278 GATHER_LOGGER.warning(
279 f"There are no remotes registered to the {minecraft_root} EnderChest"
280 )
281 return []
283 report = (
284 "These are the remote EnderChest installations registered"
285 f" to the one installed at {minecraft_root}"
286 )
287 remote_list: list[tuple[ParseResult, str]] = []
288 log_args: list[str] = []
289 for remote, alias in remotes:
290 report += "\n - %s"
291 log_args.append(render_remote(alias, remote))
292 remote_list.append((remote, alias))
293 GATHER_LOGGER.log(log_level, report, *log_args)
294 return remote_list
297def get_shulker_boxes_matching_instance(
298 minecraft_root: Path, instance_name: str
299) -> list[ShulkerBox]:
300 """Get the list of shulker boxes that the specified instance links to
302 Parameters
303 ----------
304 minecraft_root : Path
305 The root directory that your minecraft stuff (or, at least, the one
306 that's the parent of your EnderChest folder)
307 instance_name : str
308 The name of the instance you're asking about
310 Returns
311 -------
312 list of ShulkerBox
313 The shulker boxes that are linked to by the specified instance
314 """
315 try:
316 chest = load_ender_chest(minecraft_root)
317 except (FileNotFoundError, ValueError) as bad_chest:
318 GATHER_LOGGER.error(
319 f"Could not load EnderChest from {minecraft_root}:\n {bad_chest}"
320 )
321 return []
322 for mc in chest.instances:
323 if mc.name == instance_name:
324 break
325 else:
326 GATHER_LOGGER.error(
327 "No instance named %s is registered to this EnderChest", instance_name
328 )
329 return []
331 matches = [
332 box
333 for box in load_shulker_boxes(minecraft_root, log_level=logging.DEBUG)
334 if box.matches(mc) and box.matches_host(chest.name)
335 ]
337 if len(matches) == 0:
338 report = "does not link to any shulker boxes in this chest"
339 else:
340 report = "links to the following shulker boxes:\n" + "\n".join(
341 f" - {_render_shulker_box(box)}" for box in matches
342 )
344 GATHER_LOGGER.info(f"The instance {_render_instance(mc)} {report}")
346 return matches
349def get_instances_matching_shulker_box(
350 minecraft_root: Path, shulker_box_name: str
351) -> list[InstanceSpec]:
352 """Get the list of registered instances that link to the specified shulker box
354 Parameters
355 ----------
356 minecraft_root : Path
357 The root directory that your minecraft stuff (or, at least, the one
358 that's the parent of your EnderChest folder)
359 shulker_box_name : str
360 The name of the shulker box you're asking about
362 Returns
363 -------
364 list of InstanceSpec
365 The instances that are / should be linked to the specified shulker box
366 """
367 try:
368 config_file = fs.shulker_box_config(minecraft_root, shulker_box_name)
369 except FileNotFoundError:
370 GATHER_LOGGER.error(f"No EnderChest is installed in {minecraft_root}")
371 return []
372 try:
373 shulker_box = _load_shulker_box(config_file)
374 except (FileNotFoundError, ValueError) as bad_box:
375 GATHER_LOGGER.error(
376 f"Could not load shulker box {shulker_box_name}\n {bad_box}"
377 )
378 return []
380 chest = load_ender_chest(minecraft_root)
382 if not shulker_box.matches_host(chest.name):
383 GATHER_LOGGER.warning(
384 "This shulker box will not link to any instances on this machine"
385 )
386 return []
388 if not chest.instances:
389 GATHER_LOGGER.warning(
390 "This EnderChest does not have any instances registered."
391 " To register some, run the command:"
392 "\nenderchest gather minecraft",
393 )
394 return []
396 GATHER_LOGGER.debug(
397 "These are the instances that are currently registered"
398 f" to the {minecraft_root} EnderChest:\n%s",
399 "\n".join(
400 [
401 f" {i + 1}. {_render_instance(instance)}"
402 for i, instance in enumerate(chest.instances)
403 ]
404 ),
405 )
407 matches = [
408 instance for instance in chest.instances if shulker_box.matches(instance)
409 ]
411 if len(matches) == 0:
412 report = "is not linked to by any registered instances"
413 else:
414 report = "is linked to by the following instances:\n" + "\n".join(
415 f" - {_render_instance(instance)}" for instance in matches
416 )
418 GATHER_LOGGER.info(f"The shulker box {_render_shulker_box(shulker_box)} {report}")
420 return matches
423def gather_minecraft_instances(
424 minecraft_root: Path, search_path: Path, official: bool | None
425) -> list[InstanceSpec]:
426 """Search the specified directory for Minecraft installations and return
427 any that are can be found and parsed
429 Parameters
430 ----------
431 minecraft_root : Path
432 The root directory that your minecraft stuff (or, at least, the one
433 that's the parent of your EnderChest folder). This will be used to
434 construct relative paths.
435 search_path : Path
436 The path to search
437 official : bool or None
438 Whether we expect that the instances found in this location will be:
439 - from the official launcher (official=True)
440 - from a MultiMC-style launcher (official=False)
441 - a mix / unsure (official=None)
443 Returns
444 -------
445 list of InstanceSpec
446 A list of parsed instances
448 Notes
449 -----
450 - If a minecraft installation is found but cannot be parsed
451 (or parsed as specified) this method will report that failure but then
452 continue on.
453 - As a corollary, if _no_ valid Minecraft installations can be found, this
454 method will return an empty list.
455 """
456 try:
457 ender_chest = load_ender_chest(minecraft_root)
458 except FileNotFoundError:
459 # because this method can be called during crafting
460 ender_chest = EnderChest(minecraft_root)
461 GATHER_LOGGER.debug(f"Searching for Minecraft folders inside {search_path}")
462 instances: list[InstanceSpec] = []
463 for folder in fs.minecraft_folders(search_path):
464 folder_path = folder.absolute()
465 GATHER_LOGGER.debug(f"Found minecraft installation at {folder}")
466 if official is not False:
467 try:
468 instances.append(gather_metadata_for_official_instance(folder_path))
469 GATHER_LOGGER.info(
470 f"Gathered official Minecraft installation from {folder}"
471 )
472 _check_for_allowed_symlinks(ender_chest, instances[-1])
473 continue
474 except ValueError as not_official:
475 GATHER_LOGGER.log(
476 logging.DEBUG if official is None else logging.WARNING,
477 (f"{folder} is not an official instance:" f"\n{not_official}",),
478 )
479 if official is not True:
480 try:
481 instances.append(gather_metadata_for_mmc_instance(folder_path))
482 GATHER_LOGGER.info(
483 f"Gathered MMC-like Minecraft installation from {folder}"
484 )
485 _check_for_allowed_symlinks(ender_chest, instances[-1])
486 continue
487 except ValueError as not_mmc:
488 GATHER_LOGGER.log(
489 logging.DEBUG if official is None else logging.WARNING,
490 f"{folder} is not an MMC-like instance:\n{not_mmc}",
491 )
492 GATHER_LOGGER.warning(
493 f"{folder_path} does not appear to be a valid Minecraft instance"
494 )
495 for i, mc_instance in enumerate(instances):
496 try:
497 instances[i] = mc_instance._replace(
498 root=mc_instance.root.relative_to(minecraft_root.resolve())
499 )
500 except ValueError:
501 # TODO: if not Windows, try making relative to "~"
502 pass # instance isn't inside the minecraft root
503 if not instances:
504 GATHER_LOGGER.warning(
505 f"Could not find any Minecraft instances inside {search_path}"
506 )
507 return instances
510def gather_metadata_for_official_instance(
511 minecraft_folder: Path, name: str = "official"
512) -> InstanceSpec:
513 """Parse files to generate metadata for an official Minecraft installation
515 Parameters
516 ----------
517 minecraft_folder : Path
518 The path to the installation's .minecraft folder
519 name : str, optional
520 A name or alias to give to the instance. If None is provided, the
521 default name is "official"
523 Returns
524 -------
525 InstanceSpec
526 The metadata for this instance
528 Raises
529 ------
530 ValueError
531 If this is not a valid official Minecraft installation
533 Notes
534 -----
535 This method will always consider this instance to be vanilla, with no
536 modloader. If a Forge or Fabric executable is installed inside this
537 instance, the precise name of that version of that modded minecraft
538 will be included in the version list.
539 """
540 launcher_profile_file = minecraft_folder / "launcher_profiles.json"
541 try:
542 with launcher_profile_file.open() as lp_json:
543 launcher_profiles = json.load(lp_json)
544 raw_versions: list[str] = [
545 profile["lastVersionId"]
546 for profile in launcher_profiles["profiles"].values()
547 ]
548 except FileNotFoundError as no_json:
549 raise ValueError(f"Could not find {launcher_profile_file}") from no_json
550 except json.JSONDecodeError as bad_json:
551 raise ValueError(
552 f"{launcher_profile_file} is corrupt and could not be parsed"
553 ) from bad_json
554 except KeyError as weird_json:
555 raise ValueError(
556 f"Could not parse metadata from {launcher_profile_file}"
557 ) from weird_json
559 version_manifest_file = minecraft_folder / "versions" / "version_manifest_v2.json"
560 try:
561 with version_manifest_file.open() as vm_json:
562 version_lookup: dict[str, str] = json.load(vm_json)["latest"]
563 except FileNotFoundError as no_json:
564 raise ValueError(f"Could not find {version_manifest_file}") from no_json
565 except json.JSONDecodeError as bad_json:
566 raise ValueError(
567 f"{version_manifest_file} is corrupt and could not be parsed"
568 ) from bad_json
569 except KeyError as weird_json:
570 GATHER_LOGGER.warning(
571 f"{version_manifest_file} has no latest-version lookup."
572 "\nPlease check the parsed metadata to ensure that it's accurate.",
573 )
574 version_lookup = {}
576 versions: list[str] = []
577 groups: list[str] = ["vanilla"]
578 for version in raw_versions:
579 if version.startswith("latest-"):
580 mapped_version = version_lookup.get(version[len("latest-") :])
581 if mapped_version is not None:
582 versions.append(parse_version(mapped_version))
583 groups.append(version)
584 continue
585 versions.append(parse_version(version))
587 return InstanceSpec(name, minecraft_folder, tuple(versions), "", tuple(groups), ())
590def gather_metadata_for_mmc_instance(
591 minecraft_folder: Path, instgroups_file: Path | None = None
592) -> InstanceSpec:
593 """Parse files to generate metadata for a MultiMC-like instance
595 Parameters
596 ----------
597 minecraft_folder : Path
598 The path to the installation's .minecraft folder
599 instgroups_file : Path
600 The path to instgroups.json. If None is provided, this method will
601 look for it two directories up from the minecraft folder
603 Returns
604 -------
605 InstanceSpec
606 The metadata for this instance
608 Raises
609 ------
610 ValueError
611 If this is not a valid MMC-like Minecraft instance
613 Notes
614 -----
615 If this method is failing to find the appropriate files, you may want
616 to try ensuring that minecraft_folder is an absolute path.
617 """
618 mmc_pack_file = minecraft_folder.parent / "mmc-pack.json"
619 try:
620 with mmc_pack_file.open() as mmc_json:
621 components: list[dict] = json.load(mmc_json)["components"]
623 version: str | None = None
624 modloader: str | None = None
626 for component in components:
627 match component.get("uid"), component.get("cachedName", ""):
628 case "net.minecraft", _:
629 version = parse_version(component["version"])
630 case "net.fabricmc.fabric-loader", _:
631 modloader = "Fabric Loader"
632 case "org.quiltmc.quilt-loader", _:
633 modloader = "Quilt Loader"
634 case ("net.minecraftforge", _) | (_, "Forge"):
635 modloader = "Forge"
636 case _, name if name.endswith("oader"):
637 modloader = name
638 case _:
639 continue
640 modloader = normalize_modloader(modloader)[0]
641 if version is None:
642 raise KeyError("Could not find a net.minecraft component")
643 except FileNotFoundError as no_json:
644 raise ValueError(f"Could not find {mmc_pack_file}") from no_json
645 except json.JSONDecodeError as bad_json:
646 raise ValueError(
647 f"{mmc_pack_file} is corrupt and could not be parsed"
648 ) from bad_json
649 except KeyError as weird_json:
650 raise ValueError(
651 f"Could not parse metadata from {mmc_pack_file}"
652 ) from weird_json
654 name = minecraft_folder.parent.name
656 instance_groups: list[str] = []
658 if name == "":
659 GATHER_LOGGER.warning(
660 "Could not resolve the name of the parent folder"
661 " and thus could not load tags."
662 )
663 else:
664 instgroups_file = (
665 instgroups_file or minecraft_folder.parent.parent / "instgroups.json"
666 )
668 try:
669 with instgroups_file.open() as groups_json:
670 groups: dict[str, dict] = json.load(groups_json)["groups"]
671 for group, metadata in groups.items():
672 # interestingly this comes from the folder name, not the actual name
673 if name in metadata.get("instances", ()):
674 instance_groups.append(group)
676 except FileNotFoundError as no_json:
677 GATHER_LOGGER.warning(
678 f"Could not find {instgroups_file} and thus could not load tags"
679 )
680 except json.JSONDecodeError as bad_json:
681 GATHER_LOGGER.warning(
682 f"{instgroups_file} is corrupt and could not be parsed for tags"
683 )
684 except KeyError as weird_json:
685 GATHER_LOGGER.warning(f"Could not parse tags from {instgroups_file}")
687 instance_cfg = minecraft_folder.parent / "instance.cfg"
689 try:
690 parser = ConfigParser(allow_no_value=True, interpolation=None)
691 parser.read_string("[instance]\n" + instance_cfg.read_text())
692 name = parser["instance"]["name"]
693 except FileNotFoundError as no_cfg:
694 GATHER_LOGGER.warning(
695 f"Could not find {instance_cfg} and thus could not load the instance name"
696 )
697 except ParsingError as no_cfg:
698 GATHER_LOGGER.warning(
699 f"{instance_cfg} is corrupt and could not be parsed the instance name"
700 )
701 except KeyError as weird_json:
702 GATHER_LOGGER.warning(f"Could not parse instance name from {instance_cfg}")
704 if name == "":
705 raise ValueError("Could not determine the name of the instance.")
707 return InstanceSpec(
708 name,
709 minecraft_folder,
710 (version,),
711 modloader or "",
712 tuple(instance_groups),
713 (),
714 )
717def update_ender_chest(
718 minecraft_root: Path,
719 search_paths: Iterable[str | Path] | None = None,
720 official: bool | None = None,
721 remotes: Iterable[str | ParseResult | tuple[str, str] | tuple[ParseResult, str]]
722 | None = None,
723) -> None:
724 """Orchestration method that coordinates the onboarding of new instances or
725 EnderChest installations
727 Parameters
728 ----------
729 minecraft_root : Path
730 The root directory that your minecraft stuff (or, at least, the one
731 that's the parent of your EnderChest folder).
732 search_paths : list of Paths, optional
733 The local search paths to look for Minecraft installations within.
734 Be warned that this search is performed recursively.
735 official : bool | None, optional
736 Optionally specify whether the Minecraft instances you expect to find
737 are from the official launcher (`official=True`) or a MultiMC-derivative
738 (`official=False`).
739 remotes : list of URIs or (URI, str) tuples, optional
740 Any remotes you wish to register to this instance. When a (URI, str) tuple
741 is provided, the second value will be used as the name/alias of the remote.
742 If there is already a remote specified with the given alias, this method will
743 replace it.
744 """
745 try:
746 ender_chest = load_ender_chest(minecraft_root)
747 except (FileNotFoundError, ValueError) as bad_chest:
748 GATHER_LOGGER.error(
749 f"Could not load EnderChest from {minecraft_root}:\n {bad_chest}"
750 )
751 return
752 for search_path in search_paths or ():
753 for instance in gather_minecraft_instances(
754 minecraft_root, Path(search_path), official=official
755 ):
756 _ = ender_chest.register_instance(instance)
757 for remote in remotes or ():
758 try:
759 if isinstance(remote, (str, ParseResult)):
760 ender_chest.register_remote(remote)
761 else:
762 ender_chest.register_remote(*remote)
763 except ValueError as bad_remote:
764 GATHER_LOGGER.warning(bad_remote)
766 create_ender_chest(minecraft_root, ender_chest)
769def _check_for_allowed_symlinks(
770 ender_chest: EnderChest, instance: InstanceSpec
771) -> None:
772 """Check if the instance:
773 - is 1.20+
774 - has not already blanket-allowed symlinks into the EnderChest
776 and if it hasn't, offer to update the allow-list now *but only if* the user
777 hasn't already told EnderChest "shut up I know what I'm doing."
779 Parameters
780 ----------
781 ender_chest : EnderChest
782 This EnderChest
783 instance : InstanceSpec
784 The instance spec to check
785 """
786 if ender_chest.offer_to_update_symlink_allowlist is False:
787 return
789 if not any(
790 _needs_symlink_allowlist(version) for version in instance.minecraft_versions
791 ):
792 return
793 ender_chest_abspath = os.path.abspath(ender_chest.root)
795 symlink_allowlist = instance.root / "allowed_symlinks.txt"
797 try:
798 allowlist_contents = symlink_allowlist.read_text()
799 already_allowed = ender_chest_abspath in allowlist_contents.splitlines()
800 allowlist_needs_newline = not allowlist_contents.endswith("\n")
801 except FileNotFoundError:
802 already_allowed = False
803 allowlist_needs_newline = False
805 if already_allowed:
806 return
808 GATHER_LOGGER.warning(
809 """
810Starting with Minecraft 1.20, Mojang by default no longer allows worlds
811to load if they are or if they contain symbolic links.
812Read more: https://help.minecraft.net/hc/en-us/articles/16165590199181"""
813 )
815 response = prompt(
816 f"Would you like EnderChest to add {ender_chest_abspath} to {symlink_allowlist}?",
817 "Y/n",
818 )
820 if response.lower() not in ("y", "yes", ""):
821 return
823 with symlink_allowlist.open("a") as allow_file:
824 if allowlist_needs_newline:
825 allow_file.write("\n")
826 allow_file.write(ender_chest_abspath + "\n")
828 GATHER_LOGGER.info(f"{symlink_allowlist} updated.")
831def _needs_symlink_allowlist(version: str) -> bool:
832 """Determine if a version needs `allowed_symlinks.txt` in order to link
833 to EnderChest. Note that this is going a little broader than is strictly
834 necessary.
836 Parameters
837 ----------
838 version: str
839 The version string to check against
841 Returns
842 -------
843 bool
844 Returns False if the Minecraft version predates the symlink ban. Returns
845 True if it doesn't (or is marginal).
847 Notes
848 -----
849 Have I mentioned that parsing Minecraft version strings is a pain in the
850 toucans?
851 """
852 # first see if it follows basic semver
853 if _matches_version(">1.19", parse_version(version.split("-")[0])):
854 return True
855 if _matches_version("1.20.0*", parse_version(version.split("-")[0])):
856 return True
857 # is it a snapshot?
858 if match := re.match("^([1-2][0-9])w([0-9]{1,2})", version.lower()):
859 year, week = match.groups()
860 if int(year) > 23:
861 return True
862 if int(year) == 23 and int(week) > 18:
863 return True
865 return False