Coverage for enderchest/craft.py: 81%
359 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 setting up the folder structure of both chests and shulker boxes"""
3import logging
4import re
5from collections import Counter
6from collections.abc import Callable, Iterable, Sequence
7from pathlib import Path
8from urllib.parse import ParseResult
10from pathvalidate import is_valid_filename
12from . import filesystem as fs
13from . import sync
14from .enderchest import EnderChest, create_ender_chest
15from .gather import gather_minecraft_instances
16from .instance import InstanceSpec, normalize_modloader
17from .inventory import (
18 load_ender_chest,
19 load_ender_chest_instances,
20 load_ender_chest_remotes,
21 load_shulker_boxes,
22 render_instance,
23 report_shulker_boxes,
24)
25from .loggers import CRAFT_LOGGER
26from .prompt import NO, YES, confirm, prompt
27from .remote import fetch_remotes_from_a_remote_ender_chest
28from .shulker_box import ShulkerBox, create_shulker_box
31def craft_ender_chest(
32 minecraft_root: Path,
33 copy_from: str | ParseResult | None = None,
34 instance_search_paths: Iterable[str | Path] | None = None,
35 remotes: (
36 Iterable[str | ParseResult | tuple[str, str] | tuple[ParseResult, str]] | None
37 ) = None,
38 overwrite: bool = False,
39) -> None:
40 """Craft an EnderChest, either from the specified keyword arguments, or
41 interactively via prompts
43 Parameters
44 ----------
45 minecraft_root : Path
46 The root directory that your minecraft stuff is in (or, at least, the
47 one inside which you want to create your EnderChest)
48 copy_from : URI, optional
49 Optionally bootstrap your configuration by pulling the list of remotes
50 from an existing remote EnderChest
51 instance_search_paths : list of Paths, optional
52 Any paths to search for Minecraft instances
53 remotes : list of URIs or (URI, str) tuples, optional
54 Any remotes you wish you manually specify. If used with `copy_from`, these
55 will overwrite any remotes pulled from the remote EnderChest. When a
56 (URI, str) tuple is provided, the second value will be used as the
57 name/alias of the remote.
58 overwrite : bool, optional
59 This method will not overwrite an EnderChest instance installed within
60 the `minecraft_root` unless the user provides `overwrite=True`
62 Notes
63 -----
64 - The guided / interactive specifier will only be used if no other keyword
65 arguments are provided (not even `overwrite=True`)
66 - The instance searcher will first attempt to parse any instances it finds
67 as official-launcher Minecrafts and then, if that doesn't work, will try
68 parsing them as MultiMC-style instances.
69 - The instance searcher is fully recursive, so keep that in mind before
70 passing in, say "/"
71 """
72 if not minecraft_root.exists():
73 CRAFT_LOGGER.error("The directory %s does not exist", minecraft_root)
74 CRAFT_LOGGER.error("Aborting")
75 return
76 if (
77 copy_from is None
78 and instance_search_paths is None
79 and remotes is None
80 and not overwrite
81 ):
82 # then we go interactive
83 try:
84 ender_chest = specify_ender_chest_from_prompt(minecraft_root)
85 except (FileExistsError, RuntimeError):
86 CRAFT_LOGGER.error("Aborting")
87 return
88 else:
89 try:
90 fs.ender_chest_config(minecraft_root, check_exists=True)
91 exist_message = (
92 "There is already an EnderChest installed to %s",
93 minecraft_root,
94 )
95 if overwrite:
96 CRAFT_LOGGER.warning(*exist_message)
97 else:
98 CRAFT_LOGGER.error(*exist_message)
99 CRAFT_LOGGER.error("Aborting")
100 return
101 except FileNotFoundError:
102 pass # no existing chest? no problem!
104 ender_chest = EnderChest(minecraft_root)
106 for search_path in instance_search_paths or ():
107 for instance in gather_minecraft_instances(
108 minecraft_root, Path(search_path), None
109 ):
110 ender_chest.register_instance(instance)
112 if copy_from:
113 try:
114 for remote, alias in fetch_remotes_from_a_remote_ender_chest(copy_from):
115 if alias == ender_chest.name:
116 continue # don't register yourself!
117 ender_chest.register_remote(remote, alias)
118 except (RuntimeError, ValueError) as fetch_fail:
119 CRAFT_LOGGER.error(
120 "Could not fetch remotes from %s:\n %s", copy_from, fetch_fail
121 )
122 CRAFT_LOGGER.error("Aborting.")
123 return
125 for extra_remote in remotes or ():
126 if isinstance(extra_remote, (str, ParseResult)):
127 ender_chest.register_remote(extra_remote)
128 else:
129 ender_chest.register_remote(*extra_remote)
131 create_ender_chest(minecraft_root, ender_chest)
132 CRAFT_LOGGER.info(
133 "\nNow craft some shulker boxes via\n$ enderchest craft shulker_box\n"
134 )
137def craft_shulker_box(
138 minecraft_root: Path,
139 name: str,
140 priority: int | None = None,
141 link_folders: Sequence[str] | None = None,
142 instances: Sequence[str] | None = None,
143 tags: Sequence[str] | None = None,
144 hosts: Sequence[str] | None = None,
145 overwrite: bool = False,
146):
147 """Craft a shulker box, either from the specified keyword arguments, or
148 interactively via prompts
150 Parameters
151 ----------
152 minecraft_root : Path
153 The root directory that your minecraft stuff (or, at least, the one
154 that's the parent of your EnderChest folder)
155 name : str
156 A name to give to this shulker box
157 priority : int, optional
158 The priority for linking assets in the shulker box (higher priority
159 shulkers are linked last)
160 link_folders : list of str, optional
161 The folders that should be linked in their entirety
162 instances : list of str, optional
163 The names of the instances you'd like to link to this shulker box
164 tags : list of str, optional
165 You can instead (see notes) provide a list of tags where any instances
166 with those tags will be linked to this shulker box
167 hosts : list of str, optional
168 The EnderChest installations that this shulker box should be applied to
169 overwrite : bool, optional
170 This method will not overwrite an existing shulker box unless the user
171 provides `overwrite=True`
173 Notes
174 -----
175 - The guided / interactive specifier will only be used if no other keyword
176 arguments are provided (not even `overwrite=True`)
177 - The conditions specified by instances, tags and hosts are ANDed
178 together--that is, if an instance is listed explicitly, but it doesn't
179 match a provided tag, it will not link to this shulker box
180 - Wildcards are supported for instances, tags and hosts (but not link-folders)
181 - Not specifying instances, tags or hosts is equivalent to providing `["*"]`
182 - When values are provided to the keyword arguments, no validation is performed
183 to ensure that they are valid or actively in use
184 """
185 if not is_valid_filename(name):
186 CRAFT_LOGGER.error("%s is not a valid name: must be usable as a filename", name)
187 return
189 try:
190 folders = load_ender_chest(minecraft_root).shulker_box_folders
191 if (
192 priority is None
193 and link_folders is None
194 and instances is None
195 and tags is None
196 and hosts is None
197 and not overwrite
198 ):
199 try:
200 shulker_box = specify_shulker_box_from_prompt(minecraft_root, name)
201 except FileExistsError as seat_taken:
202 CRAFT_LOGGER.error(seat_taken)
203 CRAFT_LOGGER.error("Aborting")
204 return
205 else:
206 config_path = fs.shulker_box_config(minecraft_root, name)
207 if config_path.exists():
208 exist_message = (
209 "There is already a shulker box named %s in %s",
210 name,
211 fs.ender_chest_folder(minecraft_root),
212 )
213 if overwrite:
214 CRAFT_LOGGER.warning(*exist_message)
215 else:
216 CRAFT_LOGGER.error(*exist_message)
217 CRAFT_LOGGER.error("Aborting")
218 return
219 match_criteria: list[tuple[str, tuple[str, ...]]] = []
220 if instances is not None:
221 match_criteria.append(("instances", tuple(instances)))
222 if tags is not None:
223 match_criteria.append(("tags", tuple(tags)))
224 if hosts is not None:
225 match_criteria.append(("hosts", tuple(hosts)))
226 shulker_box = ShulkerBox(
227 priority=priority or 0,
228 name=name,
229 root=minecraft_root,
230 match_criteria=tuple(match_criteria),
231 link_folders=tuple(link_folders or ()),
232 )
233 except FileNotFoundError as no_ender_chest:
234 CRAFT_LOGGER.error(no_ender_chest)
235 return
237 create_shulker_box(minecraft_root, shulker_box, folders)
240def specify_ender_chest_from_prompt(minecraft_root: Path) -> EnderChest:
241 """Parse an EnderChest based on interactive user input
243 Parameters
244 ----------
245 minecraft_root : Path
246 The root directory that your minecraft stuff is in (or, at least, the
247 one inside which you want to create your EnderChest)
249 Returns
250 -------
251 EnderChest
252 The resulting EnderChest
253 """
254 try:
255 root = fs.ender_chest_folder(minecraft_root)
256 CRAFT_LOGGER.info(
257 "This will overwrite the EnderChest configuration at %s.", root
258 )
259 if not confirm(default=False):
260 message = f"Aborting: {fs.ender_chest_config(minecraft_root)} exists."
261 raise FileExistsError(message)
262 except FileNotFoundError:
263 # good! Then we don't already have an EnderChest here
264 CRAFT_LOGGER.debug("%s does not already contain an EnderChest", minecraft_root)
266 instances: list[InstanceSpec] = []
268 while True:
269 search_home = prompt(
270 "Would you like to search your home directory for the official launcher?",
271 suggestion="Y/n",
272 ).lower()
273 if search_home == "" or search_home in YES:
274 instances.extend(
275 gather_minecraft_instances(minecraft_root, Path.home(), official=True)
276 )
277 elif search_home not in NO:
278 continue
279 break
281 while True:
282 search_here = prompt(
283 "Would you like to search the current directory for MultiMC-type instances?",
284 suggestion="Y/n",
285 ).lower()
286 if search_here == "" or search_here in YES:
287 instances.extend(
288 gather_minecraft_instances(minecraft_root, Path(), official=False)
289 )
290 elif search_here not in NO:
291 continue
292 break
294 if minecraft_root.absolute() != Path().absolute():
295 while True:
296 search_mc_folder = prompt(
297 f"Would you like to search {minecraft_root} for MultiMC-type instances?",
298 suggestion="Y/n",
299 ).lower()
300 if search_mc_folder == "" or search_here in YES:
301 instances.extend(
302 gather_minecraft_instances(
303 minecraft_root, minecraft_root, official=False
304 )
305 )
306 elif search_mc_folder not in NO:
307 continue
308 break
310 CRAFT_LOGGER.info(
311 "\nYou can always add more instances later using"
312 "\n$ enderchest gather minecraft\n"
313 )
315 while True:
316 remotes: list[tuple[ParseResult, str]] = []
317 remote_uri = prompt(
318 "Would you like to grab the list of remotes from another EnderChest?"
319 "\nIf so, enter the URI of that EnderChest now (leave empty to skip)."
320 )
321 if remote_uri == "":
322 break
323 try:
324 remotes.extend(fetch_remotes_from_a_remote_ender_chest(remote_uri))
325 except Exception as fetch_fail:
326 CRAFT_LOGGER.error(
327 "Could not fetch remotes from %s\n %s", remote_uri, fetch_fail
328 )
329 if not confirm(default=True):
330 continue
331 break
333 CRAFT_LOGGER.info(
334 "\nYou can always add more remotes later using"
335 "\n$ enderchest gather enderchest\n"
336 )
338 while True:
339 protocol = (
340 prompt(
341 (
342 "Specify the method for syncing with this EnderChest."
343 "\nSupported protocols are: " + ", ".join(sync.SUPPORTED_PROTOCOLS)
344 ),
345 suggestion=sync.DEFAULT_PROTOCOL,
346 ).lower()
347 or sync.DEFAULT_PROTOCOL
348 )
350 if protocol not in sync.SUPPORTED_PROTOCOLS:
351 CRAFT_LOGGER.error("Unsupported protocol\n")
352 continue
353 break
355 while True:
356 default_netloc = sync.get_default_netloc()
357 netloc = (
358 prompt(
359 (
360 "What's the address for accessing this machine?"
361 "\n(hostname or IP address, plus often a username)"
362 ),
363 suggestion=default_netloc,
364 )
365 or default_netloc
366 )
368 uri = ParseResult(
369 scheme=protocol,
370 netloc=netloc,
371 path=minecraft_root.as_posix(),
372 params="",
373 query="",
374 fragment="",
375 )
376 if not uri.hostname:
377 CRAFT_LOGGER.error("Invalid hostname")
378 continue
379 break
381 while True:
382 name = (
383 prompt("Provide a name for this EnderChest", suggestion=uri.hostname)
384 or uri.hostname
385 )
386 if name in (alias for _, alias in remotes):
387 CRAFT_LOGGER.error(
388 "The name %s is already in use. Choose a different name.", name
389 )
390 continue
391 break
393 ender_chest = EnderChest(uri, name, remotes, instances)
395 CRAFT_LOGGER.info(
396 "\n%s\nPreparing to generate an EnderChest with the above configuration.",
397 ender_chest.write_to_cfg(),
398 )
400 if not confirm(default=True):
401 raise RuntimeError("EnderChest creation aborted.")
403 return ender_chest
406def specify_shulker_box_from_prompt(minecraft_root: Path, name: str) -> ShulkerBox:
407 """Parse a shulker box based on interactive user input
409 Parameters
410 ----------
411 minecraft_root : Path
412 The root directory that your minecraft stuff (or, at least, the one
413 that's the parent of your EnderChest folder)
414 name : str
415 The name to give to the shulker box
417 Returns
418 -------
419 ShulkerBox
420 The resulting ShulkerBox
421 """
422 ender_chest = load_ender_chest(minecraft_root)
423 shulker_root = fs.shulker_box_root(minecraft_root, name)
424 if shulker_root in shulker_root.parent.iterdir():
425 if not shulker_root.is_dir():
426 raise FileExistsError(
427 f"A file named {name} already exists in your EnderChest folder."
428 )
429 CRAFT_LOGGER.warning(
430 "There is already a folder named %s in your EnderChest folder.", name
431 )
432 if not confirm(default=False):
433 raise FileExistsError(
434 f"There is already a folder named {name} in your EnderChest folder."
435 )
437 shulker_box = ShulkerBox(0, name, shulker_root, (), ())
439 def refresh_ender_chest_instance_list() -> Sequence[InstanceSpec]:
440 """The primary reason to lambda-fy this is to re-print the instance list."""
441 return load_ender_chest_instances(minecraft_root)
443 instances = refresh_ender_chest_instance_list()
445 explicit_type = "name"
446 if len(instances) > 0:
447 explicit_type = "number"
448 while True:
449 selection_type = prompt(
450 f"Would you like to specify instances by [F]ilter or by [N]{explicit_type[1:]}?"
451 ).lower()
452 match selection_type:
453 case "f" | "filter":
454 shulker_box = _prompt_for_filters(shulker_box, instances)
455 case "n":
456 if explicit_type == "name":
457 shulker_box = _prompt_for_instance_names(shulker_box)
458 else: # if explicit_type == "number"
459 shulker_box = _prompt_for_instance_numbers(
460 shulker_box, instances, refresh_ender_chest_instance_list
461 )
462 case "name":
463 # yeah, this is always available
464 shulker_box = _prompt_for_instance_names(shulker_box)
465 case "number":
466 if explicit_type == "name":
467 continue
468 shulker_box = _prompt_for_instance_numbers(
469 shulker_box, instances, refresh_ender_chest_instance_list
470 )
471 case _:
472 continue
473 break
475 while True:
476 selection_type = prompt(
477 "Folders to Link?"
478 "\nThe [G]lobal set is:"
479 f' {", ".join(ender_chest.global_link_folders) or "(none)"}'
480 "\nThe [S]tandard set is:"
481 f' {", ".join(ender_chest.standard_link_folders) or "(none)"}'
482 "\nYou can also choose [N]one or to [M]anually specify the folders to link",
483 suggestion="S",
484 ).lower()
485 match selection_type:
486 case "n" | "none":
487 link_folders: tuple[str, ...] = ()
488 case "g" | "global" | "global set":
489 link_folders = tuple(ender_chest.global_link_folders)
490 case "s" | "standard" | "standard set" | "":
491 link_folders = tuple(ender_chest.standard_link_folders)
492 case "m" | "manual" | "manually specify":
493 folder_choices = prompt(
494 "Specify the folders to link using a comma-separated list"
495 " (wildcards are not allowed)"
496 )
497 link_folders = tuple(
498 folder.strip() for folder in folder_choices.split(",")
499 )
500 case _:
501 continue
502 break
504 while True:
505 # this is such a kludge
506 existing_shulker_boxes = load_shulker_boxes(
507 minecraft_root, log_level=logging.DEBUG
508 )
509 if existing_shulker_boxes:
510 report_shulker_boxes(
511 existing_shulker_boxes, logging.INFO, "the current EnderChest"
512 )
514 value = (
515 prompt(
516 (
517 "What priority value should be assigned to this shulker box?"
518 "\nhigher number = applied later"
519 ),
520 suggestion="0",
521 )
522 or "0"
523 )
524 try:
525 priority = int(value)
526 except ValueError:
527 continue
528 break
530 while True:
531 _ = load_ender_chest_remotes(minecraft_root) # to display some log messages
532 values = (
533 prompt(
534 (
535 "What hosts (EnderChest installations) should use this shulker box?"
536 "\nProvide a comma-separated list (wildcards are allowed)"
537 "\nand remember to include the name of this EnderChest"
538 f' ("{ender_chest.name}")'
539 ),
540 suggestion="*",
541 )
542 or "*"
543 )
544 hosts = tuple(host.strip() for host in values.split(","))
546 host = ender_chest.name
548 if not shulker_box._replace(match_criteria=(("hosts", hosts),)).matches_host(
549 host
550 ):
551 CRAFT_LOGGER.warning(
552 "This shulker box will not link to any instances on this machine"
553 )
554 if not confirm(default=False):
555 continue
556 break
558 shulker_box = shulker_box._replace(
559 priority=priority,
560 match_criteria=shulker_box.match_criteria + (("hosts", hosts),),
561 link_folders=link_folders,
562 )
564 CRAFT_LOGGER.info(
565 "\n%sPreparing to generate a shulker box with the above configuration.",
566 shulker_box.write_to_cfg(),
567 )
569 if not confirm(default=True):
570 raise RuntimeError("Shulker box creation aborted.")
572 return shulker_box
575def _prompt_for_filters(
576 shulker_box: ShulkerBox, instances: Sequence[InstanceSpec]
577) -> ShulkerBox:
578 """Prompt the user for a ShulkerBox spec by filters
580 Parameters
581 ----------
582 shulker_box : ShulkerBox
583 The starting ShulkerBox (with no match critera)
584 instances : list of InstanceSpec
585 The list of instances registered to the EnderChest
587 Returns
588 -------
589 ShulkerBox
590 The updated shulker box (with filter-based match criteria)
592 Notes
593 -----
594 When instances is non-empty, the prompt will check in with the user at
595 each step to make sure that the specified filters are filtering as intended.
596 If that list is empty (or at the point that the user has filtered down to
597 an empty list) then the user's gonna be flying blind.
598 """
600 def selected_instances(tester: ShulkerBox) -> list[InstanceSpec]:
601 matching = [instance for instance in instances if tester.matches(instance)]
602 CRAFT_LOGGER.info(
603 "Specified filters match the instances:\n%s",
604 "\n".join(
605 [
606 f" {i + 1}. {render_instance(instance)}"
607 for i, instance in enumerate(matching)
608 ]
609 ),
610 )
611 return matching
613 def check_progress(
614 new_condition: str, values: Iterable[str]
615 ) -> tuple[ShulkerBox | None, list[InstanceSpec]]:
616 """As we add new conditions, report to the user the list of instances
617 that this shulker will match and confirm with them that they want
618 to continue.
620 Parameters
621 ----------
622 new_condition : str
623 The type of condition being added
624 values : list-like of str
625 The values for the new condition
627 Returns
628 -------
629 ShulkerBox or None
630 If the user confirms that things look good, this will return the
631 shulker box, updated with the new condition. Otherwise, the first
632 returned value will be None
633 list of str
634 The names of the instances matching the updated set of filters
636 Notes
637 -----
638 If instances is empty, then this method won't check in with the user
639 and will just return the updated shulker box.
640 """
641 tester = shulker_box._replace(
642 match_criteria=shulker_box.match_criteria
643 + ((new_condition, tuple(values)),)
644 )
646 if len(instances) == 0:
647 return tester, []
649 matches = selected_instances(tester)
651 default = True
652 if len(matches) == 0:
653 CRAFT_LOGGER.warning("Filters do not match any registered instance.")
654 default = False
655 return tester if confirm(default=default) else None, matches
657 while True:
658 version_spec = (
659 prompt(
660 (
661 "Minecraft versions:"
662 ' (e.g: "*", "1.19.1, 1.19.2, 1.19.3", "1.19.*", ">=1.19.0,<1.20")'
663 ),
664 suggestion="*",
665 )
666 or "*"
667 )
669 updated, matches = check_progress(
670 "minecraft", (version.strip() for version in version_spec.split(", "))
671 )
672 if updated:
673 shulker_box = updated
674 instances = matches
675 break
677 while True:
678 modloader = (
679 prompt(
680 (
681 "Modloader?"
682 "\n[N]one, For[G]e, Fa[B]ric, [Q]uilt, [L]iteLoader"
683 ' (or multiple, e.g: "B,Q", or any using "*")'
684 ),
685 suggestion="*",
686 )
687 or "*"
688 )
690 modloaders: set[str] = set()
691 for entry in modloader.split(","):
692 match entry.strip().lower():
693 case "" | "n" | "none" | "vanilla":
694 modloaders.update(normalize_modloader(None))
695 case "g" | "forge":
696 modloaders.update(normalize_modloader("Forge"))
697 case "b" | "fabric" | "fabric loader":
698 modloaders.update(normalize_modloader("Fabric Loader"))
699 case "q" | "quilt":
700 modloaders.update(normalize_modloader("Quilt Loader"))
701 case "l" | "liteloader":
702 modloaders.update(normalize_modloader("LiteLoader"))
703 case _:
704 modloaders.update(normalize_modloader(entry))
706 updated, matches = check_progress("modloader", sorted(modloaders))
707 if updated:
708 shulker_box = updated
709 instances = matches
710 break
712 while True:
713 tag_count = Counter(sum((instance.tags for instance in instances), ()))
714 # TODO: should this be most common among matches?
715 example_tags: list[str] = [tag for tag, _ in tag_count.most_common(5)]
716 CRAFT_LOGGER.debug(
717 "Tag counts:\n%s",
718 "\n".join(f" - {tag}: {count}" for tag, count in tag_count.items()),
719 )
721 if len(example_tags) == 0:
722 # provide examples if the user isn't using tags
723 example_tags = [
724 "vanilla-plus",
725 "multiplayer",
726 "modded",
727 "dev",
728 "april-fools",
729 ]
731 tags = (
732 prompt(
733 "Tags?"
734 f'\ne.g.{", ".join(example_tags)}'
735 "\n(or multiple using comma-separated lists or wildcards)"
736 "\nNote: tag-matching is not case-sensitive."
737 '\nNote: you can also specify a tag to exclude by prefacing it with "!"',
738 suggestion="*",
739 )
740 or "*"
741 )
743 updated, matches = check_progress(
744 "tags", (tag.strip() for tag in tags.split(","))
745 )
746 if updated:
747 shulker_box = updated
748 instances = matches
749 break
751 if len(instances) > 0:
752 shulker_box = _prompt_for_instance_numbers(
753 shulker_box,
754 selected_instances(shulker_box), # display the list again
755 lambda: selected_instances(shulker_box),
756 exclude=True,
757 )
759 return shulker_box
762def _prompt_for_instance_names(shulker_box: ShulkerBox) -> ShulkerBox:
763 """Prompt a user for the names of specific instances, then add that list
764 to the shulker box spec.
766 Parameters
767 ----------
768 shulker_box : ShulkerBox
769 The starting ShulkerBox, presumably with limited or no match criteria
771 Returns
772 -------
773 ShulkerBox
774 The updated shulker box (with explicit criteria based on instance names)
776 Notes
777 -----
778 This method does not validate against lists of known instances
779 """
780 instances = tuple(
781 entry.strip()
782 for entry in prompt(
783 "Specify instances by name, separated by commas."
784 "\nNote: this is case-sensitive."
785 "\nNote: You can also use wildcards (? and *)"
786 '\nNote: you can also specify an instance to exclude by prefacing it with "!"',
787 suggestion="*",
788 ).split(",")
789 )
790 if instances == ("",):
791 instances = ("*",)
793 if instances == ("*",):
794 CRAFT_LOGGER.warning(
795 "This shulker box will be applied to all instances,"
796 " including ones you create in the future."
797 )
798 default = False
799 else:
800 CRAFT_LOGGER.info(
801 "You specified the following instances:\n%s",
802 "\n".join([f" - {name}" for name in instances]),
803 )
804 default = True
806 if not confirm(default=default):
807 CRAFT_LOGGER.debug("Trying again to prompt for instance names")
808 return _prompt_for_instance_names(shulker_box)
810 return shulker_box._replace(
811 match_criteria=shulker_box.match_criteria + (("instances", instances),)
812 )
815def _prompt_for_instance_numbers(
816 shulker_box: ShulkerBox,
817 instances: Sequence[InstanceSpec],
818 instance_loader: Callable[[], Sequence[InstanceSpec]],
819 exclude: bool = False,
820) -> ShulkerBox:
821 """Prompt the user to specify the instances they'd like by number
823 Parameters
824 ----------
825 shulker_box : ShulkerBox
826 The starting ShulkerBox, presumably with limited or no match criteria
827 instances : list of InstanceSpec
828 The list of instances registered to the EnderChest
829 instance_loader : method that returns a list of InstanceSpec
830 A method that when called, prints and returns a refreshed list of instances
831 registered to the EnderChest
832 exclude: bool, optional
833 To modify this method to prompt for and return a list of shulker boxes to _exclude_
834 instead of _include_, pass in `exclude=True`
836 Returns
837 -------
838 ShulkerBox
839 The updated shulker box (with explicit criteria based on instance names)
840 """
841 selections = prompt(
842 (
843 "Which instances would you like to {}?".format(
844 "explicitly exclude" if exclude else "include"
845 )
846 + '\ne.g. "1,2,3", "1-3", "1-6"'
847 + (' or "*" to specify all' if not exclude else "")
848 ),
849 suggestion="" if "exclude" else "*",
850 )
851 selections = re.sub("/s", " ", selections) # normalize whitespace
852 if selections == "":
853 if exclude:
854 return shulker_box
855 selections = "*"
857 if re.search("[^0-9-,* ]", selections): # check for invalid characters
858 CRAFT_LOGGER.error("Invalid selection.\n")
859 return _prompt_for_instance_numbers(
860 shulker_box, instance_loader(), instance_loader, exclude=exclude
861 )
863 selected_instances: set[str] = set()
864 for entry in selections.split(","):
865 match entry.replace(" ", ""):
866 case "*":
867 selected_instances.update(instance.name for instance in instances)
868 break # because it's not like there's any that can be added
869 case value if value.isdigit():
870 # luckily we don't need to worry about negative numbers
871 index = int(value) - 1
872 if index < 0 or index >= len(instances):
873 CRAFT_LOGGER.error("Invalid selection: %s is out of range\n", entry)
874 return _prompt_for_instance_numbers(
875 shulker_box, instance_loader(), instance_loader, exclude=exclude
876 )
877 selected_instances.add(instances[index].name)
878 case value if match := re.match("([0-9]+)-([0-9]+)$", value):
879 bounds = tuple(int(bound) for bound in match.groups())
880 if bounds[0] > bounds[1]:
881 CRAFT_LOGGER.error(
882 "Invalid selection: %s is not a valid range\n", entry
883 )
884 return _prompt_for_instance_numbers(
885 shulker_box, instance_loader(), instance_loader, exclude=exclude
886 )
887 if max(bounds) > len(instances) or min(bounds) < 1:
888 CRAFT_LOGGER.error("Invalid selection: %s is out of range\n", entry)
889 return _prompt_for_instance_numbers(
890 shulker_box, instance_loader(), instance_loader, exclude=exclude
891 )
892 selected_instances.update(
893 instance.name for instance in instances[bounds[0] - 1 : bounds[1]]
894 )
895 case _:
896 CRAFT_LOGGER.error("Invalid selection.\n")
897 return _prompt_for_instance_numbers(
898 shulker_box, instance_loader(), instance_loader, exclude=exclude
899 )
901 choices = tuple(
902 instance.name for instance in instances if instance.name in selected_instances
903 )
904 if len(choices) == 0: # this might not be possible to trigger
905 return shulker_box
907 CRAFT_LOGGER.info(
908 "You selected to %s the instances:\n%s",
909 "explicitly exclude" if exclude else "include",
910 "\n".join([f" - {name}" for name in choices]),
911 )
912 if not confirm(default=True):
913 CRAFT_LOGGER.debug("Trying again to prompt for instance numbers")
914 CRAFT_LOGGER.info("") # just making a newline
915 return _prompt_for_instance_numbers(
916 shulker_box, instance_loader(), instance_loader, exclude=exclude
917 )
918 if exclude:
919 choices = tuple(f"!{choice}" for choice in choices)
921 prior_criteria: list[tuple[str, tuple[str, ...]]] = []
922 prior_instance_selections: list[str] = []
923 for condition, values in shulker_box.match_criteria:
924 if condition == "instances":
925 prior_instance_selections.extend(values)
926 else:
927 prior_criteria.append((condition, values))
929 return shulker_box._replace(
930 match_criteria=(
931 *prior_criteria,
932 ("instances", (*prior_instance_selections, *choices)),
933 )
934 )