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