Coverage for enderchest/craft.py: 81%

359 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2025-07-30 12:06 +0000

1"""Functionality for setting up the folder structure of both chests and shulker boxes""" 

2 

3import logging 

4import re 

5from collections import Counter 

6from collections.abc import Callable, Iterable, Sequence 

7from pathlib import Path 

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("The directory %s does not exist", minecraft_root) 

74 CRAFT_LOGGER.error("Aborting") 

75 return 

76 if ( 

77 copy_from is None 

78 and instance_search_paths is None 

79 and remotes is None 

80 and not overwrite 

81 ): 

82 # then we go interactive 

83 try: 

84 ender_chest = specify_ender_chest_from_prompt(minecraft_root) 

85 except (FileExistsError, RuntimeError): 

86 CRAFT_LOGGER.error("Aborting") 

87 return 

88 else: 

89 try: 

90 fs.ender_chest_config(minecraft_root, check_exists=True) 

91 exist_message = ( 

92 "There is already an EnderChest installed to %s", 

93 minecraft_root, 

94 ) 

95 if overwrite: 

96 CRAFT_LOGGER.warning(*exist_message) 

97 else: 

98 CRAFT_LOGGER.error(*exist_message) 

99 CRAFT_LOGGER.error("Aborting") 

100 return 

101 except FileNotFoundError: 

102 pass # no existing chest? no problem! 

103 

104 ender_chest = EnderChest(minecraft_root) 

105 

106 for search_path in instance_search_paths or (): 

107 for instance in gather_minecraft_instances( 

108 minecraft_root, Path(search_path), None 

109 ): 

110 ender_chest.register_instance(instance) 

111 

112 if copy_from: 

113 try: 

114 for remote, alias in fetch_remotes_from_a_remote_ender_chest(copy_from): 

115 if alias == ender_chest.name: 

116 continue # don't register yourself! 

117 ender_chest.register_remote(remote, alias) 

118 except (RuntimeError, ValueError) as fetch_fail: 

119 CRAFT_LOGGER.error( 

120 "Could not fetch remotes from %s:\n %s", copy_from, fetch_fail 

121 ) 

122 CRAFT_LOGGER.error("Aborting.") 

123 return 

124 

125 for extra_remote in remotes or (): 

126 if isinstance(extra_remote, (str, ParseResult)): 

127 ender_chest.register_remote(extra_remote) 

128 else: 

129 ender_chest.register_remote(*extra_remote) 

130 

131 create_ender_chest(minecraft_root, ender_chest) 

132 CRAFT_LOGGER.info( 

133 "\nNow craft some shulker boxes via\n$ enderchest craft shulker_box\n" 

134 ) 

135 

136 

137def craft_shulker_box( 

138 minecraft_root: Path, 

139 name: str, 

140 priority: int | None = None, 

141 link_folders: Sequence[str] | None = None, 

142 instances: Sequence[str] | None = None, 

143 tags: Sequence[str] | None = None, 

144 hosts: Sequence[str] | None = None, 

145 overwrite: bool = False, 

146): 

147 """Craft a shulker box, either from the specified keyword arguments, or 

148 interactively via prompts 

149 

150 Parameters 

151 ---------- 

152 minecraft_root : Path 

153 The root directory that your minecraft stuff (or, at least, the one 

154 that's the parent of your EnderChest folder) 

155 name : str 

156 A name to give to this shulker box 

157 priority : int, optional 

158 The priority for linking assets in the shulker box (higher priority 

159 shulkers are linked last) 

160 link_folders : list of str, optional 

161 The folders that should be linked in their entirety 

162 instances : list of str, optional 

163 The names of the instances you'd like to link to this shulker box 

164 tags : list of str, optional 

165 You can instead (see notes) provide a list of tags where any instances 

166 with those tags will be linked to this shulker box 

167 hosts : list of str, optional 

168 The EnderChest installations that this shulker box should be applied to 

169 overwrite : bool, optional 

170 This method will not overwrite an existing shulker box unless the user 

171 provides `overwrite=True` 

172 

173 Notes 

174 ----- 

175 - The guided / interactive specifier will only be used if no other keyword 

176 arguments are provided (not even `overwrite=True`) 

177 - The conditions specified by instances, tags and hosts are ANDed 

178 together--that is, if an instance is listed explicitly, but it doesn't 

179 match a provided tag, it will not link to this shulker box 

180 - Wildcards are supported for instances, tags and hosts (but not link-folders) 

181 - Not specifying instances, tags or hosts is equivalent to providing `["*"]` 

182 - When values are provided to the keyword arguments, no validation is performed 

183 to ensure that they are valid or actively in use 

184 """ 

185 if not is_valid_filename(name): 

186 CRAFT_LOGGER.error("%s is not a valid name: must be usable as a filename", name) 

187 return 

188 

189 try: 

190 folders = load_ender_chest(minecraft_root).shulker_box_folders 

191 if ( 

192 priority is None 

193 and link_folders is None 

194 and instances is None 

195 and tags is None 

196 and hosts is None 

197 and not overwrite 

198 ): 

199 try: 

200 shulker_box = specify_shulker_box_from_prompt(minecraft_root, name) 

201 except FileExistsError as seat_taken: 

202 CRAFT_LOGGER.error(seat_taken) 

203 CRAFT_LOGGER.error("Aborting") 

204 return 

205 else: 

206 config_path = fs.shulker_box_config(minecraft_root, name) 

207 if config_path.exists(): 

208 exist_message = ( 

209 "There is already a shulker box named %s in %s", 

210 name, 

211 fs.ender_chest_folder(minecraft_root), 

212 ) 

213 if overwrite: 

214 CRAFT_LOGGER.warning(*exist_message) 

215 else: 

216 CRAFT_LOGGER.error(*exist_message) 

217 CRAFT_LOGGER.error("Aborting") 

218 return 

219 match_criteria: list[tuple[str, tuple[str, ...]]] = [] 

220 if instances is not None: 

221 match_criteria.append(("instances", tuple(instances))) 

222 if tags is not None: 

223 match_criteria.append(("tags", tuple(tags))) 

224 if hosts is not None: 

225 match_criteria.append(("hosts", tuple(hosts))) 

226 shulker_box = ShulkerBox( 

227 priority=priority or 0, 

228 name=name, 

229 root=minecraft_root, 

230 match_criteria=tuple(match_criteria), 

231 link_folders=tuple(link_folders or ()), 

232 ) 

233 except FileNotFoundError as no_ender_chest: 

234 CRAFT_LOGGER.error(no_ender_chest) 

235 return 

236 

237 create_shulker_box(minecraft_root, shulker_box, folders) 

238 

239 

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

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

242 

243 Parameters 

244 ---------- 

245 minecraft_root : Path 

246 The root directory that your minecraft stuff is in (or, at least, the 

247 one inside which you want to create your EnderChest) 

248 

249 Returns 

250 ------- 

251 EnderChest 

252 The resulting EnderChest 

253 """ 

254 try: 

255 root = fs.ender_chest_folder(minecraft_root) 

256 CRAFT_LOGGER.info( 

257 "This will overwrite the EnderChest configuration at %s.", root 

258 ) 

259 if not confirm(default=False): 

260 message = f"Aborting: {fs.ender_chest_config(minecraft_root)} exists." 

261 raise FileExistsError(message) 

262 except FileNotFoundError: 

263 # good! Then we don't already have an EnderChest here 

264 CRAFT_LOGGER.debug("%s does not already contain an EnderChest", minecraft_root) 

265 

266 instances: list[InstanceSpec] = [] 

267 

268 while True: 

269 search_home = prompt( 

270 "Would you like to search your home directory for the official launcher?", 

271 suggestion="Y/n", 

272 ).lower() 

273 if search_home == "" or search_home in YES: 

274 instances.extend( 

275 gather_minecraft_instances(minecraft_root, Path.home(), official=True) 

276 ) 

277 elif search_home not in NO: 

278 continue 

279 break 

280 

281 while True: 

282 search_here = prompt( 

283 "Would you like to search the current directory for MultiMC-type instances?", 

284 suggestion="Y/n", 

285 ).lower() 

286 if search_here == "" or search_here in YES: 

287 instances.extend( 

288 gather_minecraft_instances(minecraft_root, Path(), official=False) 

289 ) 

290 elif search_here not in NO: 

291 continue 

292 break 

293 

294 if minecraft_root.absolute() != Path().absolute(): 

295 while True: 

296 search_mc_folder = prompt( 

297 f"Would you like to search {minecraft_root} for MultiMC-type instances?", 

298 suggestion="Y/n", 

299 ).lower() 

300 if search_mc_folder == "" or search_here in YES: 

301 instances.extend( 

302 gather_minecraft_instances( 

303 minecraft_root, minecraft_root, official=False 

304 ) 

305 ) 

306 elif search_mc_folder not in NO: 

307 continue 

308 break 

309 

310 CRAFT_LOGGER.info( 

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

312 "\n$ enderchest gather minecraft\n" 

313 ) 

314 

315 while True: 

316 remotes: list[tuple[ParseResult, str]] = [] 

317 remote_uri = prompt( 

318 "Would you like to grab the list of remotes from another EnderChest?" 

319 "\nIf so, enter the URI of that EnderChest now (leave empty to skip)." 

320 ) 

321 if remote_uri == "": 

322 break 

323 try: 

324 remotes.extend(fetch_remotes_from_a_remote_ender_chest(remote_uri)) 

325 except Exception as fetch_fail: 

326 CRAFT_LOGGER.error( 

327 "Could not fetch remotes from %s\n %s", remote_uri, fetch_fail 

328 ) 

329 if not confirm(default=True): 

330 continue 

331 break 

332 

333 CRAFT_LOGGER.info( 

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

335 "\n$ enderchest gather enderchest\n" 

336 ) 

337 

338 while True: 

339 protocol = ( 

340 prompt( 

341 ( 

342 "Specify the method for syncing with this EnderChest." 

343 "\nSupported protocols are: " + ", ".join(sync.SUPPORTED_PROTOCOLS) 

344 ), 

345 suggestion=sync.DEFAULT_PROTOCOL, 

346 ).lower() 

347 or sync.DEFAULT_PROTOCOL 

348 ) 

349 

350 if protocol not in sync.SUPPORTED_PROTOCOLS: 

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

352 continue 

353 break 

354 

355 while True: 

356 default_netloc = sync.get_default_netloc() 

357 netloc = ( 

358 prompt( 

359 ( 

360 "What's the address for accessing this machine?" 

361 "\n(hostname or IP address, plus often a username)" 

362 ), 

363 suggestion=default_netloc, 

364 ) 

365 or default_netloc 

366 ) 

367 

368 uri = ParseResult( 

369 scheme=protocol, 

370 netloc=netloc, 

371 path=minecraft_root.as_posix(), 

372 params="", 

373 query="", 

374 fragment="", 

375 ) 

376 if not uri.hostname: 

377 CRAFT_LOGGER.error("Invalid hostname") 

378 continue 

379 break 

380 

381 while True: 

382 name = ( 

383 prompt("Provide a name for this EnderChest", suggestion=uri.hostname) 

384 or uri.hostname 

385 ) 

386 if name in (alias for _, alias in remotes): 

387 CRAFT_LOGGER.error( 

388 "The name %s is already in use. Choose a different name.", name 

389 ) 

390 continue 

391 break 

392 

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

394 

395 CRAFT_LOGGER.info( 

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

397 ender_chest.write_to_cfg(), 

398 ) 

399 

400 if not confirm(default=True): 

401 raise RuntimeError("EnderChest creation aborted.") 

402 

403 return ender_chest 

404 

405 

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

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

408 

409 Parameters 

410 ---------- 

411 minecraft_root : Path 

412 The root directory that your minecraft stuff (or, at least, the one 

413 that's the parent of your EnderChest folder) 

414 name : str 

415 The name to give to the shulker box 

416 

417 Returns 

418 ------- 

419 ShulkerBox 

420 The resulting ShulkerBox 

421 """ 

422 ender_chest = load_ender_chest(minecraft_root) 

423 shulker_root = fs.shulker_box_root(minecraft_root, name) 

424 if shulker_root in shulker_root.parent.iterdir(): 

425 if not shulker_root.is_dir(): 

426 raise FileExistsError( 

427 f"A file named {name} already exists in your EnderChest folder." 

428 ) 

429 CRAFT_LOGGER.warning( 

430 "There is already a folder named %s in your EnderChest folder.", name 

431 ) 

432 if not confirm(default=False): 

433 raise FileExistsError( 

434 f"There is already a folder named {name} in your EnderChest folder." 

435 ) 

436 

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

438 

439 def refresh_ender_chest_instance_list() -> Sequence[InstanceSpec]: 

440 """The primary reason to lambda-fy this is to re-print the instance list.""" 

441 return load_ender_chest_instances(minecraft_root) 

442 

443 instances = refresh_ender_chest_instance_list() 

444 

445 explicit_type = "name" 

446 if len(instances) > 0: 

447 explicit_type = "number" 

448 while True: 

449 selection_type = prompt( 

450 f"Would you like to specify instances by [F]ilter or by [N]{explicit_type[1:]}?" 

451 ).lower() 

452 match selection_type: 

453 case "f" | "filter": 

454 shulker_box = _prompt_for_filters(shulker_box, instances) 

455 case "n": 

456 if explicit_type == "name": 

457 shulker_box = _prompt_for_instance_names(shulker_box) 

458 else: # if explicit_type == "number" 

459 shulker_box = _prompt_for_instance_numbers( 

460 shulker_box, instances, refresh_ender_chest_instance_list 

461 ) 

462 case "name": 

463 # yeah, this is always available 

464 shulker_box = _prompt_for_instance_names(shulker_box) 

465 case "number": 

466 if explicit_type == "name": 

467 continue 

468 shulker_box = _prompt_for_instance_numbers( 

469 shulker_box, instances, refresh_ender_chest_instance_list 

470 ) 

471 case _: 

472 continue 

473 break 

474 

475 while True: 

476 selection_type = prompt( 

477 "Folders to Link?" 

478 "\nThe [G]lobal set is:" 

479 f' {", ".join(ender_chest.global_link_folders) or "(none)"}' 

480 "\nThe [S]tandard set is:" 

481 f' {", ".join(ender_chest.standard_link_folders) or "(none)"}' 

482 "\nYou can also choose [N]one or to [M]anually specify the folders to link", 

483 suggestion="S", 

484 ).lower() 

485 match selection_type: 

486 case "n" | "none": 

487 link_folders: tuple[str, ...] = () 

488 case "g" | "global" | "global set": 

489 link_folders = tuple(ender_chest.global_link_folders) 

490 case "s" | "standard" | "standard set" | "": 

491 link_folders = tuple(ender_chest.standard_link_folders) 

492 case "m" | "manual" | "manually specify": 

493 folder_choices = prompt( 

494 "Specify the folders to link using a comma-separated list" 

495 " (wildcards are not allowed)" 

496 ) 

497 link_folders = tuple( 

498 folder.strip() for folder in folder_choices.split(",") 

499 ) 

500 case _: 

501 continue 

502 break 

503 

504 while True: 

505 # this is such a kludge 

506 existing_shulker_boxes = load_shulker_boxes( 

507 minecraft_root, log_level=logging.DEBUG 

508 ) 

509 if existing_shulker_boxes: 

510 report_shulker_boxes( 

511 existing_shulker_boxes, logging.INFO, "the current EnderChest" 

512 ) 

513 

514 value = ( 

515 prompt( 

516 ( 

517 "What priority value should be assigned to this shulker box?" 

518 "\nhigher number = applied later" 

519 ), 

520 suggestion="0", 

521 ) 

522 or "0" 

523 ) 

524 try: 

525 priority = int(value) 

526 except ValueError: 

527 continue 

528 break 

529 

530 while True: 

531 _ = load_ender_chest_remotes(minecraft_root) # to display some log messages 

532 values = ( 

533 prompt( 

534 ( 

535 "What hosts (EnderChest installations) should use this shulker box?" 

536 "\nProvide a comma-separated list (wildcards are allowed)" 

537 "\nand remember to include the name of this EnderChest" 

538 f' ("{ender_chest.name}")' 

539 ), 

540 suggestion="*", 

541 ) 

542 or "*" 

543 ) 

544 hosts = tuple(host.strip() for host in values.split(",")) 

545 

546 host = ender_chest.name 

547 

548 if not shulker_box._replace(match_criteria=(("hosts", hosts),)).matches_host( 

549 host 

550 ): 

551 CRAFT_LOGGER.warning( 

552 "This shulker box will not link to any instances on this machine" 

553 ) 

554 if not confirm(default=False): 

555 continue 

556 break 

557 

558 shulker_box = shulker_box._replace( 

559 priority=priority, 

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

561 link_folders=link_folders, 

562 ) 

563 

564 CRAFT_LOGGER.info( 

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

566 shulker_box.write_to_cfg(), 

567 ) 

568 

569 if not confirm(default=True): 

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

571 

572 return shulker_box 

573 

574 

575def _prompt_for_filters( 

576 shulker_box: ShulkerBox, instances: Sequence[InstanceSpec] 

577) -> ShulkerBox: 

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

579 

580 Parameters 

581 ---------- 

582 shulker_box : ShulkerBox 

583 The starting ShulkerBox (with no match critera) 

584 instances : list of InstanceSpec 

585 The list of instances registered to the EnderChest 

586 

587 Returns 

588 ------- 

589 ShulkerBox 

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

591 

592 Notes 

593 ----- 

594 When instances is non-empty, the prompt will check in with the user at 

595 each step to make sure that the specified filters are filtering as intended. 

596 If that list is empty (or at the point that the user has filtered down to 

597 an empty list) then the user's gonna be flying blind. 

598 """ 

599 

600 def selected_instances(tester: ShulkerBox) -> list[InstanceSpec]: 

601 matching = [instance for instance in instances if tester.matches(instance)] 

602 CRAFT_LOGGER.info( 

603 "Specified filters match the instances:\n%s", 

604 "\n".join( 

605 [ 

606 f" {i + 1}. {render_instance(instance)}" 

607 for i, instance in enumerate(matching) 

608 ] 

609 ), 

610 ) 

611 return matching 

612 

613 def check_progress( 

614 new_condition: str, values: Iterable[str] 

615 ) -> tuple[ShulkerBox | None, list[InstanceSpec]]: 

616 """As we add new conditions, report to the user the list of instances 

617 that this shulker will match and confirm with them that they want 

618 to continue. 

619 

620 Parameters 

621 ---------- 

622 new_condition : str 

623 The type of condition being added 

624 values : list-like of str 

625 The values for the new condition 

626 

627 Returns 

628 ------- 

629 ShulkerBox or None 

630 If the user confirms that things look good, this will return the 

631 shulker box, updated with the new condition. Otherwise, the first 

632 returned value will be None 

633 list of str 

634 The names of the instances matching the updated set of filters 

635 

636 Notes 

637 ----- 

638 If instances is empty, then this method won't check in with the user 

639 and will just return the updated shulker box. 

640 """ 

641 tester = shulker_box._replace( 

642 match_criteria=shulker_box.match_criteria 

643 + ((new_condition, tuple(values)),) 

644 ) 

645 

646 if len(instances) == 0: 

647 return tester, [] 

648 

649 matches = selected_instances(tester) 

650 

651 default = True 

652 if len(matches) == 0: 

653 CRAFT_LOGGER.warning("Filters do not match any registered instance.") 

654 default = False 

655 return tester if confirm(default=default) else None, matches 

656 

657 while True: 

658 version_spec = ( 

659 prompt( 

660 ( 

661 "Minecraft versions:" 

662 ' (e.g: "*", "1.19.1, 1.19.2, 1.19.3", "1.19.*", ">=1.19.0,<1.20")' 

663 ), 

664 suggestion="*", 

665 ) 

666 or "*" 

667 ) 

668 

669 updated, matches = check_progress( 

670 "minecraft", (version.strip() for version in version_spec.split(", ")) 

671 ) 

672 if updated: 

673 shulker_box = updated 

674 instances = matches 

675 break 

676 

677 while True: 

678 modloader = ( 

679 prompt( 

680 ( 

681 "Modloader?" 

682 "\n[N]one, For[G]e, Fa[B]ric, [Q]uilt, [L]iteLoader" 

683 ' (or multiple, e.g: "B,Q", or any using "*")' 

684 ), 

685 suggestion="*", 

686 ) 

687 or "*" 

688 ) 

689 

690 modloaders: set[str] = set() 

691 for entry in modloader.split(","): 

692 match entry.strip().lower(): 

693 case "" | "n" | "none" | "vanilla": 

694 modloaders.update(normalize_modloader(None)) 

695 case "g" | "forge": 

696 modloaders.update(normalize_modloader("Forge")) 

697 case "b" | "fabric" | "fabric loader": 

698 modloaders.update(normalize_modloader("Fabric Loader")) 

699 case "q" | "quilt": 

700 modloaders.update(normalize_modloader("Quilt Loader")) 

701 case "l" | "liteloader": 

702 modloaders.update(normalize_modloader("LiteLoader")) 

703 case _: 

704 modloaders.update(normalize_modloader(entry)) 

705 

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

707 if updated: 

708 shulker_box = updated 

709 instances = matches 

710 break 

711 

712 while True: 

713 tag_count = Counter(sum((instance.tags for instance in instances), ())) 

714 # TODO: should this be most common among matches? 

715 example_tags: list[str] = [tag for tag, _ in tag_count.most_common(5)] 

716 CRAFT_LOGGER.debug( 

717 "Tag counts:\n%s", 

718 "\n".join(f" - {tag}: {count}" for tag, count in tag_count.items()), 

719 ) 

720 

721 if len(example_tags) == 0: 

722 # provide examples if the user isn't using tags 

723 example_tags = [ 

724 "vanilla-plus", 

725 "multiplayer", 

726 "modded", 

727 "dev", 

728 "april-fools", 

729 ] 

730 

731 tags = ( 

732 prompt( 

733 "Tags?" 

734 f'\ne.g.{", ".join(example_tags)}' 

735 "\n(or multiple using comma-separated lists or wildcards)" 

736 "\nNote: tag-matching is not case-sensitive." 

737 '\nNote: you can also specify a tag to exclude by prefacing it with "!"', 

738 suggestion="*", 

739 ) 

740 or "*" 

741 ) 

742 

743 updated, matches = check_progress( 

744 "tags", (tag.strip() for tag in tags.split(",")) 

745 ) 

746 if updated: 

747 shulker_box = updated 

748 instances = matches 

749 break 

750 

751 if len(instances) > 0: 

752 shulker_box = _prompt_for_instance_numbers( 

753 shulker_box, 

754 selected_instances(shulker_box), # display the list again 

755 lambda: selected_instances(shulker_box), 

756 exclude=True, 

757 ) 

758 

759 return shulker_box 

760 

761 

762def _prompt_for_instance_names(shulker_box: ShulkerBox) -> ShulkerBox: 

763 """Prompt a user for the names of specific instances, then add that list 

764 to the shulker box spec. 

765 

766 Parameters 

767 ---------- 

768 shulker_box : ShulkerBox 

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

770 

771 Returns 

772 ------- 

773 ShulkerBox 

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

775 

776 Notes 

777 ----- 

778 This method does not validate against lists of known instances 

779 """ 

780 instances = tuple( 

781 entry.strip() 

782 for entry in prompt( 

783 "Specify instances by name, separated by commas." 

784 "\nNote: this is case-sensitive." 

785 "\nNote: You can also use wildcards (? and *)" 

786 '\nNote: you can also specify an instance to exclude by prefacing it with "!"', 

787 suggestion="*", 

788 ).split(",") 

789 ) 

790 if instances == ("",): 

791 instances = ("*",) 

792 

793 if instances == ("*",): 

794 CRAFT_LOGGER.warning( 

795 "This shulker box will be applied to all instances," 

796 " including ones you create in the future." 

797 ) 

798 default = False 

799 else: 

800 CRAFT_LOGGER.info( 

801 "You specified the following instances:\n%s", 

802 "\n".join([f" - {name}" for name in instances]), 

803 ) 

804 default = True 

805 

806 if not confirm(default=default): 

807 CRAFT_LOGGER.debug("Trying again to prompt for instance names") 

808 return _prompt_for_instance_names(shulker_box) 

809 

810 return shulker_box._replace( 

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

812 ) 

813 

814 

815def _prompt_for_instance_numbers( 

816 shulker_box: ShulkerBox, 

817 instances: Sequence[InstanceSpec], 

818 instance_loader: Callable[[], Sequence[InstanceSpec]], 

819 exclude: bool = False, 

820) -> ShulkerBox: 

821 """Prompt the user to specify the instances they'd like by number 

822 

823 Parameters 

824 ---------- 

825 shulker_box : ShulkerBox 

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

827 instances : list of InstanceSpec 

828 The list of instances registered to the EnderChest 

829 instance_loader : method that returns a list of InstanceSpec 

830 A method that when called, prints and returns a refreshed list of instances 

831 registered to the EnderChest 

832 exclude: bool, optional 

833 To modify this method to prompt for and return a list of shulker boxes to _exclude_ 

834 instead of _include_, pass in `exclude=True` 

835 

836 Returns 

837 ------- 

838 ShulkerBox 

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

840 """ 

841 selections = prompt( 

842 ( 

843 "Which instances would you like to {}?".format( 

844 "explicitly exclude" if exclude else "include" 

845 ) 

846 + '\ne.g. "1,2,3", "1-3", "1-6"' 

847 + (' or "*" to specify all' if not exclude else "") 

848 ), 

849 suggestion="" if "exclude" else "*", 

850 ) 

851 selections = re.sub("/s", " ", selections) # normalize whitespace 

852 if selections == "": 

853 if exclude: 

854 return shulker_box 

855 selections = "*" 

856 

857 if re.search("[^0-9-,* ]", selections): # check for invalid characters 

858 CRAFT_LOGGER.error("Invalid selection.\n") 

859 return _prompt_for_instance_numbers( 

860 shulker_box, instance_loader(), instance_loader, exclude=exclude 

861 ) 

862 

863 selected_instances: set[str] = set() 

864 for entry in selections.split(","): 

865 match entry.replace(" ", ""): 

866 case "*": 

867 selected_instances.update(instance.name for instance in instances) 

868 break # because it's not like there's any that can be added 

869 case value if value.isdigit(): 

870 # luckily we don't need to worry about negative numbers 

871 index = int(value) - 1 

872 if index < 0 or index >= len(instances): 

873 CRAFT_LOGGER.error("Invalid selection: %s is out of range\n", entry) 

874 return _prompt_for_instance_numbers( 

875 shulker_box, instance_loader(), instance_loader, exclude=exclude 

876 ) 

877 selected_instances.add(instances[index].name) 

878 case value if match := re.match("([0-9]+)-([0-9]+)$", value): 

879 bounds = tuple(int(bound) for bound in match.groups()) 

880 if bounds[0] > bounds[1]: 

881 CRAFT_LOGGER.error( 

882 "Invalid selection: %s is not a valid range\n", entry 

883 ) 

884 return _prompt_for_instance_numbers( 

885 shulker_box, instance_loader(), instance_loader, exclude=exclude 

886 ) 

887 if max(bounds) > len(instances) or min(bounds) < 1: 

888 CRAFT_LOGGER.error("Invalid selection: %s is out of range\n", entry) 

889 return _prompt_for_instance_numbers( 

890 shulker_box, instance_loader(), instance_loader, exclude=exclude 

891 ) 

892 selected_instances.update( 

893 instance.name for instance in instances[bounds[0] - 1 : bounds[1]] 

894 ) 

895 case _: 

896 CRAFT_LOGGER.error("Invalid selection.\n") 

897 return _prompt_for_instance_numbers( 

898 shulker_box, instance_loader(), instance_loader, exclude=exclude 

899 ) 

900 

901 choices = tuple( 

902 instance.name for instance in instances if instance.name in selected_instances 

903 ) 

904 if len(choices) == 0: # this might not be possible to trigger 

905 return shulker_box 

906 

907 CRAFT_LOGGER.info( 

908 "You selected to %s the instances:\n%s", 

909 "explicitly exclude" if exclude else "include", 

910 "\n".join([f" - {name}" for name in choices]), 

911 ) 

912 if not confirm(default=True): 

913 CRAFT_LOGGER.debug("Trying again to prompt for instance numbers") 

914 CRAFT_LOGGER.info("") # just making a newline 

915 return _prompt_for_instance_numbers( 

916 shulker_box, instance_loader(), instance_loader, exclude=exclude 

917 ) 

918 if exclude: 

919 choices = tuple(f"!{choice}" for choice in choices) 

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 )