Coverage for enderchest/craft.py: 81%

359 statements  

« 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""" 

2 

3import logging 

4import re 

5from collections import Counter 

6from pathlib import Path 

7from typing import Callable, Iterable, Sequence 

8from urllib.parse import ParseResult 

9 

10from pathvalidate import is_valid_filename 

11 

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 

29 

30 

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 

42 

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` 

61 

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! 

102 

103 ender_chest = EnderChest(minecraft_root) 

104 

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) 

110 

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 

123 

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) 

129 

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 ) 

134 

135 

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 

148 

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` 

171 

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 

187 

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 

234 

235 create_shulker_box(minecraft_root, shulker_box, folders) 

236 

237 

238def specify_ender_chest_from_prompt(minecraft_root: Path) -> EnderChest: 

239 """Parse an EnderChest based on interactive user input 

240 

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) 

246 

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") 

263 

264 instances: list[InstanceSpec] = [] 

265 

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 

278 

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 

291 

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 

307 

308 CRAFT_LOGGER.info( 

309 "\nYou can always add more instances later using" 

310 "\n$ enderchest gather minecraft\n" 

311 ) 

312 

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 

330 

331 CRAFT_LOGGER.info( 

332 "\nYou can always add more remotes later using" 

333 "\n$ enderchest gather enderchest\n" 

334 ) 

335 

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 ) 

347 

348 if protocol not in sync.SUPPORTED_PROTOCOLS: 

349 CRAFT_LOGGER.error("Unsupported protocol\n") 

350 continue 

351 break 

352 

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 ) 

365 

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 

378 

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 

390 

391 ender_chest = EnderChest(uri, name, remotes, instances) 

392 

393 CRAFT_LOGGER.info( 

394 "\n%s\nPreparing to generate an EnderChest with the above configuration.", 

395 ender_chest.write_to_cfg(), 

396 ) 

397 

398 if not confirm(default=True): 

399 raise RuntimeError("EnderChest creation aborted.") 

400 

401 return ender_chest 

402 

403 

404def specify_shulker_box_from_prompt(minecraft_root: Path, name: str) -> ShulkerBox: 

405 """Parse a shulker box based on interactive user input 

406 

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 

414 

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 ) 

434 

435 shulker_box = ShulkerBox(0, name, shulker_root, (), ()) 

436 

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) 

440 

441 instances = refresh_ender_chest_instance_list() 

442 

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 

472 

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 

501 

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 ) 

511 

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 

527 

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(",")) 

543 

544 host = ender_chest.name 

545 

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 

555 

556 shulker_box = shulker_box._replace( 

557 priority=priority, 

558 match_criteria=shulker_box.match_criteria + (("hosts", hosts),), 

559 link_folders=link_folders, 

560 ) 

561 

562 CRAFT_LOGGER.info( 

563 "\n%sPreparing to generate a shulker box with the above configuration.", 

564 shulker_box.write_to_cfg(), 

565 ) 

566 

567 if not confirm(default=True): 

568 raise RuntimeError("Shulker box creation aborted.") 

569 

570 return shulker_box 

571 

572 

573def _prompt_for_filters( 

574 shulker_box: ShulkerBox, instances: Sequence[InstanceSpec] 

575) -> ShulkerBox: 

576 """Prompt the user for a ShulkerBox spec by filters 

577 

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 

584 

585 Returns 

586 ------- 

587 ShulkerBox 

588 The updated shulker box (with filter-based match criteria) 

589 

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 """ 

597 

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 

610 

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. 

617 

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 

624 

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 

633 

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 ) 

643 

644 if len(instances) == 0: 

645 return tester, [] 

646 

647 matches = selected_instances(tester) 

648 

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 

654 

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 ) 

666 

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 

674 

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 ) 

687 

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)) 

703 

704 updated, matches = check_progress("modloader", sorted(modloaders)) 

705 if updated: 

706 shulker_box = updated 

707 instances = matches 

708 break 

709 

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 ) 

718 

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 ] 

728 

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 ) 

740 

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 

748 

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 ) 

756 

757 return shulker_box 

758 

759 

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. 

763 

764 Parameters 

765 ---------- 

766 shulker_box : ShulkerBox 

767 The starting ShulkerBox, presumably with limited or no match criteria 

768 

769 Returns 

770 ------- 

771 ShulkerBox 

772 The updated shulker box (with explicit criteria based on instance names) 

773 

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 = ("*",) 

790 

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 

803 

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) 

807 

808 return shulker_box._replace( 

809 match_criteria=shulker_box.match_criteria + (("instances", instances),) 

810 ) 

811 

812 

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 

820 

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` 

833 

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 = "*" 

855 

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 ) 

861 

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 ) 

899 

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 

905 

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) 

920 

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)) 

928 

929 return shulker_box._replace( 

930 match_criteria=( 

931 *prior_criteria, 

932 ("instances", (*prior_instance_selections, *choices)), 

933 ) 

934 )