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