Coverage for enderchest/craft.py: 78%
336 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-03 20:14 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-03 20:14 +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 STANDARD_LINK_FOLDERS, 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 if (
187 priority is None
188 and link_folders is None
189 and instances is None
190 and tags is None
191 and hosts is None
192 and not overwrite
193 ):
194 try:
195 shulker_box = specify_shulker_box_from_prompt(minecraft_root, name)
196 except FileExistsError as seat_taken:
197 CRAFT_LOGGER.error(seat_taken)
198 CRAFT_LOGGER.error("Aborting")
199 return
200 else:
201 config_path = fs.shulker_box_config(minecraft_root, name)
202 if config_path.exists():
203 exist_message = (
204 f"There is already a shulker box named {name}"
205 f" in {fs.ender_chest_folder(minecraft_root)}"
206 )
207 if overwrite:
208 CRAFT_LOGGER.warning(exist_message)
209 else:
210 CRAFT_LOGGER.error(exist_message)
211 CRAFT_LOGGER.error("Aborting")
212 return
213 match_criteria: list[tuple[str, tuple[str, ...]]] = []
214 if instances is not None:
215 match_criteria.append(("instances", tuple(instances)))
216 if tags is not None:
217 match_criteria.append(("tags", tuple(tags)))
218 if hosts is not None:
219 match_criteria.append(("hosts", tuple(hosts)))
220 shulker_box = ShulkerBox(
221 priority=priority or 0,
222 name=name,
223 root=minecraft_root,
224 match_criteria=tuple(match_criteria),
225 link_folders=tuple(link_folders or ()),
226 )
227 except FileNotFoundError as no_ender_chest:
228 CRAFT_LOGGER.error(no_ender_chest)
229 return
231 create_shulker_box(minecraft_root, shulker_box)
234def specify_ender_chest_from_prompt(minecraft_root: Path) -> EnderChest:
235 """Parse an EnderChest based on interactive user input
237 Parameters
238 ----------
239 minecraft_root : Path
240 The root directory that your minecraft stuff is in (or, at least, the
241 one inside which you want to create your EnderChest)
243 Returns
244 -------
245 EnderChest
246 The resulting EnderChest
247 """
248 try:
249 root = fs.ender_chest_folder(minecraft_root)
250 CRAFT_LOGGER.info(
251 f"This will overwrite the EnderChest configuration at {root}."
252 )
253 if not confirm(default=False):
254 message = f"Aborting: {fs.ender_chest_config(minecraft_root)} exists."
255 raise FileExistsError(message)
256 except FileNotFoundError:
257 # good! Then we don't already have an EnderChest here
258 CRAFT_LOGGER.debug(f"{minecraft_root} does not already contain an EnderChest")
260 instances: list[InstanceSpec] = []
262 while True:
263 search_home = prompt(
264 "Would you like to search your home directory for the official launcher?",
265 suggestion="Y/n",
266 ).lower()
267 if search_home == "" or search_home in YES:
268 instances.extend(
269 gather_minecraft_instances(minecraft_root, Path.home(), official=True)
270 )
271 elif search_home not in NO:
272 continue
273 break
275 while True:
276 search_here = prompt(
277 "Would you like to search the current directory for MultiMC-type instances?",
278 suggestion="Y/n",
279 ).lower()
280 if search_here == "" or search_here in YES:
281 instances.extend(
282 gather_minecraft_instances(minecraft_root, Path(), official=False)
283 )
284 elif search_here not in NO:
285 continue
286 break
288 if minecraft_root.absolute() != Path().absolute():
289 while True:
290 search_mc_folder = prompt(
291 f"Would you like to search {minecraft_root} for MultiMC-type instances?",
292 suggestion="Y/n",
293 ).lower()
294 if search_mc_folder == "" or search_here in YES:
295 instances.extend(
296 gather_minecraft_instances(
297 minecraft_root, minecraft_root, official=False
298 )
299 )
300 elif search_mc_folder not in NO:
301 continue
302 break
304 CRAFT_LOGGER.info(
305 "\nYou can always add more instances later using"
306 "\n$ enderchest gather minecraft\n"
307 )
309 while True:
310 remotes: list[tuple[ParseResult, str]] = []
311 remote_uri = prompt(
312 "Would you like to grab the list of remotes from another EnderChest?"
313 "\nIf so, enter the URI of that EnderChest now (leave empty to skip)."
314 )
315 if remote_uri == "":
316 break
317 try:
318 remotes.extend(fetch_remotes_from_a_remote_ender_chest(remote_uri))
319 except Exception as fetch_fail:
320 CRAFT_LOGGER.error(
321 f"Could not fetch remotes from {remote_uri}\n {fetch_fail}"
322 )
323 if not confirm(default=True):
324 continue
325 break
327 CRAFT_LOGGER.info(
328 "\nYou can always add more remotes later using"
329 "\n$ enderchest gather enderchest\n"
330 )
332 while True:
333 protocol = (
334 prompt(
335 (
336 "Specify the method for syncing with this EnderChest."
337 "\nSupported protocols are: " + ", ".join(sync.SUPPORTED_PROTOCOLS)
338 ),
339 suggestion=sync.DEFAULT_PROTOCOL,
340 ).lower()
341 or sync.DEFAULT_PROTOCOL
342 )
344 if protocol not in sync.SUPPORTED_PROTOCOLS:
345 CRAFT_LOGGER.error("Unsupported protocol\n")
346 continue
347 break
349 while True:
350 default_netloc = sync.get_default_netloc()
351 netloc = (
352 prompt(
353 (
354 "What's the address for accessing this machine?"
355 "\n(hostname or IP address, plus often a username)"
356 ),
357 suggestion=default_netloc,
358 )
359 or default_netloc
360 )
362 uri = ParseResult(
363 scheme=protocol,
364 netloc=netloc,
365 path=minecraft_root.as_posix(),
366 params="",
367 query="",
368 fragment="",
369 )
370 if not uri.hostname:
371 CRAFT_LOGGER.error("Invalid hostname")
372 continue
373 break
375 while True:
376 name = (
377 prompt("Provide a name for this EnderChest", suggestion=uri.hostname)
378 or uri.hostname
379 )
380 if name in (alias for _, alias in remotes):
381 CRAFT_LOGGER.error(
382 f"The name {name} is already in use. Choose a different name."
383 )
384 continue
385 break
387 ender_chest = EnderChest(uri, name, remotes, instances)
389 CRAFT_LOGGER.info(
390 "\n%s\nPreparing to generate an EnderChest with the above configuration.",
391 ender_chest.write_to_cfg(),
392 )
394 if not confirm(default=True):
395 raise RuntimeError("EnderChest creation aborted.")
397 return ender_chest
400def specify_shulker_box_from_prompt(minecraft_root: Path, name: str) -> ShulkerBox:
401 """Parse a shulker box based on interactive user input
403 Parameters
404 ----------
405 minecraft_root : Path
406 The root directory that your minecraft stuff (or, at least, the one
407 that's the parent of your EnderChest folder)
408 name : str
409 The name to give to the shulker box
411 Returns
412 -------
413 ShulkerBox
414 The resulting ShulkerBox
415 """
416 shulker_root = fs.shulker_box_root(minecraft_root, name)
417 if shulker_root in shulker_root.parent.iterdir():
418 if not shulker_root.is_dir():
419 raise FileExistsError(
420 f"A file named {name} already exists in your EnderChest folder."
421 )
422 CRAFT_LOGGER.warning(
423 f"There is already a folder named {name} in your EnderChest folder."
424 )
425 if not confirm(default=False):
426 raise FileExistsError(
427 f"There is already a folder named {name} in your EnderChest folder."
428 )
430 shulker_box = ShulkerBox(0, name, shulker_root, (), ())
432 def refresh_ender_chest_instance_list() -> Sequence[InstanceSpec]:
433 """The primary reason to lambda-fy this is to re-print the instance list."""
434 return load_ender_chest_instances(minecraft_root)
436 instances = refresh_ender_chest_instance_list()
438 explicit_type = "name"
439 if len(instances) > 0:
440 explicit_type = "number"
441 while True:
442 selection_type = prompt(
443 f"Would you like to specify instances by [F]ilter or by [N]{explicit_type[1:]}?"
444 ).lower()
445 match selection_type:
446 case "f" | "filter":
447 shulker_box = _prompt_for_filters(shulker_box, instances)
448 case "n":
449 if explicit_type == "name":
450 shulker_box = _prompt_for_instance_names(shulker_box)
451 else: # if explicit_type == "number"
452 shulker_box = _prompt_for_instance_numbers(
453 shulker_box, instances, refresh_ender_chest_instance_list
454 )
455 case "name":
456 # yeah, this is always available
457 shulker_box = _prompt_for_instance_names(shulker_box)
458 case "number":
459 if explicit_type == "name":
460 continue
461 shulker_box = _prompt_for_instance_numbers(
462 shulker_box, instances, refresh_ender_chest_instance_list
463 )
464 case _:
465 continue
466 break
468 while True:
469 selection_type = prompt(
470 "Folders to Link?"
471 "\nUse the [S]tandard set, [M]anually specify or do [N]one?"
472 "\nThe standard set is: " + ", ".join(STANDARD_LINK_FOLDERS)
473 ).lower()
474 match selection_type:
475 case "n" | "none":
476 link_folders: tuple[str, ...] = ()
477 case "s" | "standard" | "standard set":
478 link_folders = STANDARD_LINK_FOLDERS
479 case "m" | "manual" | "manually specify":
480 folder_choices = prompt(
481 "Specify the folders to link using a comma-separated list"
482 " (wildcards are not allowed)"
483 )
484 link_folders = tuple(
485 folder.strip() for folder in folder_choices.split(",")
486 )
487 case _:
488 continue
489 break
491 while True:
492 # this is such a kludge
493 existing_shulker_boxes = load_shulker_boxes(
494 minecraft_root, log_level=logging.DEBUG
495 )
496 if existing_shulker_boxes:
497 _report_shulker_boxes(
498 existing_shulker_boxes, logging.INFO, "the current EnderChest"
499 )
501 value = (
502 prompt(
503 (
504 "What priority value should be assigned to this shulker box?"
505 "\nhigher number = applied later"
506 ),
507 suggestion="0",
508 )
509 or "0"
510 )
511 try:
512 priority = int(value)
513 except ValueError:
514 continue
515 break
517 while True:
518 _ = load_ender_chest_remotes(minecraft_root) # to display some log messages
519 values = (
520 prompt(
521 (
522 "What hosts (EnderChest installations) should use this shulker box?"
523 "\nProvide a comma-separated list (wildcards are allowed)"
524 "\nand remember to include the name of this EnderChest"
525 f' ("{load_ender_chest(minecraft_root).name}")'
526 ),
527 suggestion="*",
528 )
529 or "*"
530 )
531 hosts = tuple(host.strip() for host in values.split(","))
533 # TODO: stop wastefully reloading the cfg
534 host = load_ender_chest(minecraft_root).name
536 if not shulker_box._replace(match_criteria=(("hosts", hosts),)).matches_host(
537 host
538 ):
539 CRAFT_LOGGER.warning(
540 "This shulker box will not link to any instances on this machine"
541 )
542 if not confirm(default=False):
543 continue
544 break
546 shulker_box = shulker_box._replace(
547 priority=priority,
548 match_criteria=shulker_box.match_criteria + (("hosts", hosts),),
549 link_folders=link_folders,
550 )
552 CRAFT_LOGGER.info(
553 "\n%sPreparing to generate a shulker box with the above configuration.",
554 shulker_box.write_to_cfg(),
555 )
557 if not confirm(default=True):
558 raise RuntimeError("Shulker box creation aborted.")
560 return shulker_box
563def _prompt_for_filters(
564 shulker_box: ShulkerBox, instances: Sequence[InstanceSpec]
565) -> ShulkerBox:
566 """Prompt the user for a ShulkerBox spec by filters
568 Parameters
569 ----------
570 shulker_box : ShulkerBox
571 The starting ShulkerBox (with no match critera)
572 instances : list of InstanceSpec
573 The list of instances registered to the EnderChest
575 Returns
576 -------
577 ShulkerBox
578 The updated shulker box (with filter-based match criteria)
580 Notes
581 -----
582 When instances is non-empty, the prompt will check in with the user at
583 each step to make sure that the specified filters are filtering as intended.
584 If that list is empty (or at the point that the user has filtered down to
585 an empty list) then the user's gonna be flying blind.
586 """
588 def check_progress(
589 new_condition: str, values: Iterable[str]
590 ) -> tuple[ShulkerBox | None, list[str]]:
591 """As we add new conditions, report to the user the list of instances
592 that this shulker will match and confirm with them that they want
593 to continue.
595 Parameters
596 ----------
597 new_condition : str
598 The type of condition being added
599 values : list-like of str
600 The values for the new condition
602 Returns
603 -------
604 ShulkerBox or None
605 If the user confirms that things look good, this will return the
606 shulker box, updated with the new condition. Otherwise, the first
607 returned value will be None
608 list of str
609 The names of the instances matching the updated set of filters
611 Notes
612 -----
613 If instances is empty, then this method won't check in with the user
614 and will just return the updated shulker box.
615 """
616 tester = shulker_box._replace(
617 match_criteria=shulker_box.match_criteria
618 + ((new_condition, tuple(values)),)
619 )
621 if len(instances) == 0:
622 return tester, []
624 matches = [instance.name for instance in instances if tester.matches(instance)]
626 default = True
627 if len(matches) == 0:
628 CRAFT_LOGGER.warning("Filters do not match any known instance.")
629 default = False
630 elif len(matches) == 1:
631 CRAFT_LOGGER.info(f"Filters match the instance: {matches[0]}")
632 else:
633 CRAFT_LOGGER.info(
634 "Filters match the instances:\n%s",
635 "\n".join([f" - {name}" for name in matches]),
636 )
637 return tester if confirm(default=default) else None, matches
639 while True:
640 version_spec = (
641 prompt(
642 (
643 "Minecraft versions:"
644 ' (e.g: "*", "1.19.1, 1.19.2, 1.19.3", "1.19.*", ">=1.19.0,<1.20")'
645 ),
646 suggestion="*",
647 )
648 or "*"
649 )
651 updated, matches = check_progress(
652 "minecraft", (version.strip() for version in version_spec.split(", "))
653 )
654 if updated:
655 shulker_box = updated
656 instances = [instance for instance in instances if instance.name in matches]
657 break
659 while True:
660 modloader = (
661 prompt(
662 (
663 "Modloader?"
664 "\n[N]one, For[G]e, Fa[B]ric, [Q]uilt, [L]iteLoader"
665 ' (or multiple, e.g: "B,Q", or any using "*")'
666 ),
667 suggestion="*",
668 )
669 or "*"
670 )
672 modloaders: set[str] = set()
673 for entry in modloader.split(","):
674 match entry.strip().lower():
675 case "" | "n" | "none" | "vanilla":
676 modloaders.update(normalize_modloader(None))
677 case "g" | "forge":
678 modloaders.update(normalize_modloader("Forge"))
679 case "b" | "fabric" | "fabric loader":
680 modloaders.update(normalize_modloader("Fabric Loader"))
681 case "q" | "quilt":
682 modloaders.update(normalize_modloader("Quilt Loader"))
683 case "l" | "liteloader":
684 modloaders.update(normalize_modloader("LiteLoader"))
685 case _:
686 modloaders.update(normalize_modloader(entry))
688 updated, matches = check_progress("modloader", sorted(modloaders))
689 if updated:
690 shulker_box = updated
691 instances = [instance for instance in instances if instance.name in matches]
692 break
694 while True:
695 tag_count = Counter(sum((instance.tags for instance in instances), ()))
696 # TODO: should this be most common among matches?
697 example_tags: list[str] = [tag for tag, _ in tag_count.most_common(5)]
698 CRAFT_LOGGER.debug(
699 "Tag counts:\n%s",
700 "\n".join(f" - {tag}: {count}" for tag, count in tag_count.items()),
701 )
703 if len(example_tags) == 0:
704 # provide examples if the user isn't using tags
705 example_tags = [
706 "vanilla-plus",
707 "multiplayer",
708 "modded",
709 "dev",
710 "april-fools",
711 ]
713 tags = (
714 prompt(
715 "Tags?"
716 f'\ne.g.{", ".join(example_tags)}'
717 "\n(or multiple using comma-separated lists or wildcards)"
718 "\nNote: tag-matching is not case-sensitive.",
719 suggestion="*",
720 )
721 or "*"
722 )
724 updated, matches = check_progress(
725 "tags", (tag.strip() for tag in tags.split(","))
726 )
727 if updated:
728 shulker_box = updated
729 instances = [instance for instance in instances if instance.name in matches]
730 break
732 return shulker_box
735def _prompt_for_instance_names(shulker_box: ShulkerBox) -> ShulkerBox:
736 """Prompt a user for the names of specific instances, then add that list
737 to the shulker box spec.
739 Parameters
740 ----------
741 shulker_box : ShulkerBox
742 The starting ShulkerBox, presumably with limited or no match criteria
744 Returns
745 -------
746 ShulkerBox
747 The updated shulker box (with explicit criteria based on instance names)
749 Notes
750 -----
751 This method does not validate against lists of known instances
752 """
753 instances = tuple(
754 entry.strip()
755 for entry in prompt(
756 "Specify instances by name, separated by commas."
757 "\nYou can also use wildcards (? and *)",
758 suggestion="*",
759 ).split(",")
760 )
761 if instances == ("",):
762 instances = ("*",)
764 if instances == ("*",):
765 CRAFT_LOGGER.warning(
766 "This shulker box will be applied to all instances,"
767 " including ones you create in the future."
768 )
769 default = False
770 else:
771 CRAFT_LOGGER.info(
772 "You specified the following instances:\n%s",
773 "\n".join([f" - {name}" for name in instances]),
774 )
775 default = True
777 if not confirm(default=default):
778 CRAFT_LOGGER.debug("Trying again to prompt for instance names")
779 return _prompt_for_instance_names(shulker_box)
781 return shulker_box._replace(
782 match_criteria=shulker_box.match_criteria + (("instances", instances),)
783 )
786def _prompt_for_instance_numbers(
787 shulker_box: ShulkerBox,
788 instances: Sequence[InstanceSpec],
789 instance_loader: Callable[[], Sequence[InstanceSpec]],
790) -> ShulkerBox:
791 """Prompt the user to specify the instances they'd like by number
793 Parameters
794 ----------
795 shulker_box : ShulkerBox
796 The starting ShulkerBox, presumably with limited or no match criteria
797 instances : list of InstanceSpec
798 The list of instances registered to the EnderChest
799 instance_loader : method that returns a list of InstanceSpec
800 A method that when called, prints and returns a refreshed list of instances
801 registered to the EnderChest
803 Returns
804 -------
805 ShulkerBox
806 The updated shulker box (with explicit criteria based on instance names)
807 """
808 selections = prompt(
809 (
810 "Which instances would you like to include?"
811 '\ne.g. "1,2,3", "1-3", "1-6" or "*" to specify all'
812 ),
813 suggestion="*",
814 )
815 selections = re.sub("/s", " ", selections) # normalize whitespace
816 if selections == "":
817 selections = "*"
819 if re.search("[^0-9-,* ]", selections): # check for invalid characters
820 CRAFT_LOGGER.error("Invalid selection\n")
821 return _prompt_for_instance_numbers(
822 shulker_box, instance_loader(), instance_loader
823 )
825 selected_instances: set[str] = set()
826 for entry in selections.split(","):
827 match entry.replace(" ", ""):
828 case "*":
829 selected_instances.update(instance.name for instance in instances)
830 break # because it's not like there's any that can be added
831 case value if value.isdigit():
832 # luckily we don't need to worry about negative numbers
833 index = int(value) - 1
834 if index < 0 or index >= len(instances):
835 CRAFT_LOGGER.error(f"Invalid selection: {entry} is out of range\n")
836 return _prompt_for_instance_numbers(
837 shulker_box, instance_loader(), instance_loader
838 )
839 selected_instances.add(instances[index].name)
840 case value if match := re.match("([0-9]+)-([0-9]+)$", value):
841 bounds = tuple(int(bound) for bound in match.groups())
842 if bounds[0] > bounds[1]:
843 CRAFT_LOGGER.error(
844 f"Invalid selection: {entry} is not a valid range\n"
845 )
846 return _prompt_for_instance_numbers(
847 shulker_box, instance_loader(), instance_loader
848 )
849 if max(bounds) > len(instances) or min(bounds) < 1:
850 CRAFT_LOGGER.error(f"Invalid selection: {entry} is out of range\n")
851 return _prompt_for_instance_numbers(
852 shulker_box, instance_loader(), instance_loader
853 )
854 selected_instances.update(
855 instance.name for instance in instances[bounds[0] - 1 : bounds[1]]
856 )
858 choices = tuple(
859 instance.name for instance in instances if instance.name in selected_instances
860 )
862 CRAFT_LOGGER.info(
863 "You selected the instances:\n%s",
864 "\n".join([f" - {name}" for name in choices]),
865 )
866 if not confirm(default=True):
867 CRAFT_LOGGER.debug("Trying again to prompt for instance numbers")
868 CRAFT_LOGGER.info("") # just making a newline
869 return _prompt_for_instance_numbers(
870 shulker_box, instance_loader(), instance_loader
871 )
873 return shulker_box._replace(
874 match_criteria=shulker_box.match_criteria + (("instances", choices),)
875 )