Coverage for enderchest/craft.py: 77%

340 statements  

« prev     ^ index     » next       coverage.py v7.4.1, created at 2024-02-06 16:00 +0000

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

2import logging 

3import re 

4from collections import Counter 

5from pathlib import Path 

6from typing import Callable, Iterable, Sequence 

7from urllib.parse import ParseResult 

8 

9from pathvalidate import is_valid_filename 

10 

11from . import filesystem as fs 

12from . import sync 

13from .enderchest import EnderChest, create_ender_chest 

14from .gather import ( 

15 _report_shulker_boxes, 

16 gather_minecraft_instances, 

17 load_ender_chest, 

18 load_ender_chest_instances, 

19 load_ender_chest_remotes, 

20 load_shulker_boxes, 

21) 

22from .instance import InstanceSpec, normalize_modloader 

23from .loggers import CRAFT_LOGGER 

24from .prompt import NO, YES, confirm, prompt 

25from .remote import fetch_remotes_from_a_remote_ender_chest 

26from .shulker_box import ShulkerBox, create_shulker_box 

27 

28 

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 

39 

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` 

58 

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! 

99 

100 ender_chest = EnderChest(minecraft_root) 

101 

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) 

107 

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 

120 

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) 

126 

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 ) 

131 

132 

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 

145 

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` 

168 

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 

184 

185 try: 

186 folders = load_ender_chest(minecraft_root).shulker_box_folders 

187 if ( 

188 priority is None 

189 and link_folders is None 

190 and instances is None 

191 and tags is None 

192 and hosts is None 

193 and not overwrite 

194 ): 

195 try: 

196 shulker_box = specify_shulker_box_from_prompt(minecraft_root, name) 

197 except FileExistsError as seat_taken: 

198 CRAFT_LOGGER.error(seat_taken) 

199 CRAFT_LOGGER.error("Aborting") 

200 return 

201 else: 

202 config_path = fs.shulker_box_config(minecraft_root, name) 

203 if config_path.exists(): 

204 exist_message = ( 

205 f"There is already a shulker box named {name}" 

206 f" in {fs.ender_chest_folder(minecraft_root)}" 

207 ) 

208 if overwrite: 

209 CRAFT_LOGGER.warning(exist_message) 

210 else: 

211 CRAFT_LOGGER.error(exist_message) 

212 CRAFT_LOGGER.error("Aborting") 

213 return 

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

215 if instances is not None: 

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

217 if tags is not None: 

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

219 if hosts is not None: 

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

221 shulker_box = ShulkerBox( 

222 priority=priority or 0, 

223 name=name, 

224 root=minecraft_root, 

225 match_criteria=tuple(match_criteria), 

226 link_folders=tuple(link_folders or ()), 

227 ) 

228 except FileNotFoundError as no_ender_chest: 

229 CRAFT_LOGGER.error(no_ender_chest) 

230 return 

231 

232 create_shulker_box(minecraft_root, shulker_box, folders) 

233 

234 

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

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

237 

238 Parameters 

239 ---------- 

240 minecraft_root : Path 

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

242 one inside which you want to create your EnderChest) 

243 

244 Returns 

245 ------- 

246 EnderChest 

247 The resulting EnderChest 

248 """ 

249 try: 

250 root = fs.ender_chest_folder(minecraft_root) 

251 CRAFT_LOGGER.info( 

252 f"This will overwrite the EnderChest configuration at {root}." 

253 ) 

254 if not confirm(default=False): 

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

256 raise FileExistsError(message) 

257 except FileNotFoundError: 

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

259 CRAFT_LOGGER.debug(f"{minecraft_root} does not already contain an EnderChest") 

260 

261 instances: list[InstanceSpec] = [] 

262 

263 while True: 

264 search_home = prompt( 

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

266 suggestion="Y/n", 

267 ).lower() 

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

269 instances.extend( 

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

271 ) 

272 elif search_home not in NO: 

273 continue 

274 break 

275 

276 while True: 

277 search_here = prompt( 

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

279 suggestion="Y/n", 

280 ).lower() 

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

282 instances.extend( 

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

284 ) 

285 elif search_here not in NO: 

286 continue 

287 break 

288 

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

290 while True: 

291 search_mc_folder = prompt( 

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

293 suggestion="Y/n", 

294 ).lower() 

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

296 instances.extend( 

297 gather_minecraft_instances( 

298 minecraft_root, minecraft_root, official=False 

299 ) 

300 ) 

301 elif search_mc_folder not in NO: 

302 continue 

303 break 

304 

305 CRAFT_LOGGER.info( 

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

307 "\n$ enderchest gather minecraft\n" 

308 ) 

309 

310 while True: 

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

312 remote_uri = prompt( 

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

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

315 ) 

316 if remote_uri == "": 

317 break 

318 try: 

319 remotes.extend(fetch_remotes_from_a_remote_ender_chest(remote_uri)) 

320 except Exception as fetch_fail: 

321 CRAFT_LOGGER.error( 

322 f"Could not fetch remotes from {remote_uri}\n {fetch_fail}" 

323 ) 

324 if not confirm(default=True): 

325 continue 

326 break 

327 

328 CRAFT_LOGGER.info( 

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

330 "\n$ enderchest gather enderchest\n" 

331 ) 

332 

333 while True: 

334 protocol = ( 

335 prompt( 

336 ( 

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

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

339 ), 

340 suggestion=sync.DEFAULT_PROTOCOL, 

341 ).lower() 

342 or sync.DEFAULT_PROTOCOL 

343 ) 

344 

345 if protocol not in sync.SUPPORTED_PROTOCOLS: 

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

347 continue 

348 break 

349 

350 while True: 

351 default_netloc = sync.get_default_netloc() 

352 netloc = ( 

353 prompt( 

354 ( 

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

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

357 ), 

358 suggestion=default_netloc, 

359 ) 

360 or default_netloc 

361 ) 

362 

363 uri = ParseResult( 

364 scheme=protocol, 

365 netloc=netloc, 

366 path=minecraft_root.as_posix(), 

367 params="", 

368 query="", 

369 fragment="", 

370 ) 

371 if not uri.hostname: 

372 CRAFT_LOGGER.error("Invalid hostname") 

373 continue 

374 break 

375 

376 while True: 

377 name = ( 

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

379 or uri.hostname 

380 ) 

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

382 CRAFT_LOGGER.error( 

383 f"The name {name} is already in use. Choose a different name." 

384 ) 

385 continue 

386 break 

387 

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

389 

390 CRAFT_LOGGER.info( 

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

392 ender_chest.write_to_cfg(), 

393 ) 

394 

395 if not confirm(default=True): 

396 raise RuntimeError("EnderChest creation aborted.") 

397 

398 return ender_chest 

399 

400 

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

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

403 

404 Parameters 

405 ---------- 

406 minecraft_root : Path 

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

408 that's the parent of your EnderChest folder) 

409 name : str 

410 The name to give to the shulker box 

411 

412 Returns 

413 ------- 

414 ShulkerBox 

415 The resulting ShulkerBox 

416 """ 

417 ender_chest = load_ender_chest(minecraft_root) 

418 shulker_root = fs.shulker_box_root(minecraft_root, name) 

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

420 if not shulker_root.is_dir(): 

421 raise FileExistsError( 

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

423 ) 

424 CRAFT_LOGGER.warning( 

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

426 ) 

427 if not confirm(default=False): 

428 raise FileExistsError( 

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

430 ) 

431 

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

433 

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

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

436 return load_ender_chest_instances(minecraft_root) 

437 

438 instances = refresh_ender_chest_instance_list() 

439 

440 explicit_type = "name" 

441 if len(instances) > 0: 

442 explicit_type = "number" 

443 while True: 

444 selection_type = prompt( 

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

446 ).lower() 

447 match selection_type: 

448 case "f" | "filter": 

449 shulker_box = _prompt_for_filters(shulker_box, instances) 

450 case "n": 

451 if explicit_type == "name": 

452 shulker_box = _prompt_for_instance_names(shulker_box) 

453 else: # if explicit_type == "number" 

454 shulker_box = _prompt_for_instance_numbers( 

455 shulker_box, instances, refresh_ender_chest_instance_list 

456 ) 

457 case "name": 

458 # yeah, this is always available 

459 shulker_box = _prompt_for_instance_names(shulker_box) 

460 case "number": 

461 if explicit_type == "name": 

462 continue 

463 shulker_box = _prompt_for_instance_numbers( 

464 shulker_box, instances, refresh_ender_chest_instance_list 

465 ) 

466 case _: 

467 continue 

468 break 

469 

470 while True: 

471 selection_type = prompt( 

472 "Folders to Link?" 

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

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

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

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

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

478 suggestion="S", 

479 ).lower() 

480 match selection_type: 

481 case "n" | "none": 

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

483 case "g" | "global" | "global set": 

484 link_folders = tuple(ender_chest.global_link_folders) 

485 case "s" | "standard" | "standard set" | "": 

486 link_folders = tuple(ender_chest.standard_link_folders) 

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

488 folder_choices = prompt( 

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

490 " (wildcards are not allowed)" 

491 ) 

492 link_folders = tuple( 

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

494 ) 

495 case _: 

496 continue 

497 break 

498 

499 while True: 

500 # this is such a kludge 

501 existing_shulker_boxes = load_shulker_boxes( 

502 minecraft_root, log_level=logging.DEBUG 

503 ) 

504 if existing_shulker_boxes: 

505 _report_shulker_boxes( 

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

507 ) 

508 

509 value = ( 

510 prompt( 

511 ( 

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

513 "\nhigher number = applied later" 

514 ), 

515 suggestion="0", 

516 ) 

517 or "0" 

518 ) 

519 try: 

520 priority = int(value) 

521 except ValueError: 

522 continue 

523 break 

524 

525 while True: 

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

527 values = ( 

528 prompt( 

529 ( 

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

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

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

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

534 ), 

535 suggestion="*", 

536 ) 

537 or "*" 

538 ) 

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

540 

541 host = ender_chest.name 

542 

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

544 host 

545 ): 

546 CRAFT_LOGGER.warning( 

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

548 ) 

549 if not confirm(default=False): 

550 continue 

551 break 

552 

553 shulker_box = shulker_box._replace( 

554 priority=priority, 

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

556 link_folders=link_folders, 

557 ) 

558 

559 CRAFT_LOGGER.info( 

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

561 shulker_box.write_to_cfg(), 

562 ) 

563 

564 if not confirm(default=True): 

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

566 

567 return shulker_box 

568 

569 

570def _prompt_for_filters( 

571 shulker_box: ShulkerBox, instances: Sequence[InstanceSpec] 

572) -> ShulkerBox: 

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

574 

575 Parameters 

576 ---------- 

577 shulker_box : ShulkerBox 

578 The starting ShulkerBox (with no match critera) 

579 instances : list of InstanceSpec 

580 The list of instances registered to the EnderChest 

581 

582 Returns 

583 ------- 

584 ShulkerBox 

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

586 

587 Notes 

588 ----- 

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

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

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

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

593 """ 

594 

595 def check_progress( 

596 new_condition: str, values: Iterable[str] 

597 ) -> tuple[ShulkerBox | None, list[str]]: 

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

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

600 to continue. 

601 

602 Parameters 

603 ---------- 

604 new_condition : str 

605 The type of condition being added 

606 values : list-like of str 

607 The values for the new condition 

608 

609 Returns 

610 ------- 

611 ShulkerBox or None 

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

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

614 returned value will be None 

615 list of str 

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

617 

618 Notes 

619 ----- 

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

621 and will just return the updated shulker box. 

622 """ 

623 tester = shulker_box._replace( 

624 match_criteria=shulker_box.match_criteria 

625 + ((new_condition, tuple(values)),) 

626 ) 

627 

628 if len(instances) == 0: 

629 return tester, [] 

630 

631 matches = [instance.name for instance in instances if tester.matches(instance)] 

632 

633 default = True 

634 if len(matches) == 0: 

635 CRAFT_LOGGER.warning("Filters do not match any known instance.") 

636 default = False 

637 elif len(matches) == 1: 

638 CRAFT_LOGGER.info(f"Filters match the instance: {matches[0]}") 

639 else: 

640 CRAFT_LOGGER.info( 

641 "Filters match the instances:\n%s", 

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

643 ) 

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

645 

646 while True: 

647 version_spec = ( 

648 prompt( 

649 ( 

650 "Minecraft versions:" 

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

652 ), 

653 suggestion="*", 

654 ) 

655 or "*" 

656 ) 

657 

658 updated, matches = check_progress( 

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

660 ) 

661 if updated: 

662 shulker_box = updated 

663 instances = [instance for instance in instances if instance.name in matches] 

664 break 

665 

666 while True: 

667 modloader = ( 

668 prompt( 

669 ( 

670 "Modloader?" 

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

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

673 ), 

674 suggestion="*", 

675 ) 

676 or "*" 

677 ) 

678 

679 modloaders: set[str] = set() 

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

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

682 case "" | "n" | "none" | "vanilla": 

683 modloaders.update(normalize_modloader(None)) 

684 case "g" | "forge": 

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

686 case "b" | "fabric" | "fabric loader": 

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

688 case "q" | "quilt": 

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

690 case "l" | "liteloader": 

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

692 case _: 

693 modloaders.update(normalize_modloader(entry)) 

694 

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

696 if updated: 

697 shulker_box = updated 

698 instances = [instance for instance in instances if instance.name in matches] 

699 break 

700 

701 while True: 

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

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

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

705 CRAFT_LOGGER.debug( 

706 "Tag counts:\n%s", 

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

708 ) 

709 

710 if len(example_tags) == 0: 

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

712 example_tags = [ 

713 "vanilla-plus", 

714 "multiplayer", 

715 "modded", 

716 "dev", 

717 "april-fools", 

718 ] 

719 

720 tags = ( 

721 prompt( 

722 "Tags?" 

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

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

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

726 suggestion="*", 

727 ) 

728 or "*" 

729 ) 

730 

731 updated, matches = check_progress( 

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

733 ) 

734 if updated: 

735 shulker_box = updated 

736 instances = [instance for instance in instances if instance.name in matches] 

737 break 

738 

739 return shulker_box 

740 

741 

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

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

744 to the shulker box spec. 

745 

746 Parameters 

747 ---------- 

748 shulker_box : ShulkerBox 

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

750 

751 Returns 

752 ------- 

753 ShulkerBox 

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

755 

756 Notes 

757 ----- 

758 This method does not validate against lists of known instances 

759 """ 

760 instances = tuple( 

761 entry.strip() 

762 for entry in prompt( 

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

764 "\nYou can also use wildcards (? and *)", 

765 suggestion="*", 

766 ).split(",") 

767 ) 

768 if instances == ("",): 

769 instances = ("*",) 

770 

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

772 CRAFT_LOGGER.warning( 

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

774 " including ones you create in the future." 

775 ) 

776 default = False 

777 else: 

778 CRAFT_LOGGER.info( 

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

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

781 ) 

782 default = True 

783 

784 if not confirm(default=default): 

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

786 return _prompt_for_instance_names(shulker_box) 

787 

788 return shulker_box._replace( 

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

790 ) 

791 

792 

793def _prompt_for_instance_numbers( 

794 shulker_box: ShulkerBox, 

795 instances: Sequence[InstanceSpec], 

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

797) -> ShulkerBox: 

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

799 

800 Parameters 

801 ---------- 

802 shulker_box : ShulkerBox 

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

804 instances : list of InstanceSpec 

805 The list of instances registered to the EnderChest 

806 instance_loader : method that returns a list of InstanceSpec 

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

808 registered to the EnderChest 

809 

810 Returns 

811 ------- 

812 ShulkerBox 

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

814 """ 

815 selections = prompt( 

816 ( 

817 "Which instances would you like to include?" 

818 '\ne.g. "1,2,3", "1-3", "1-6" or "*" to specify all' 

819 ), 

820 suggestion="*", 

821 ) 

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

823 if selections == "": 

824 selections = "*" 

825 

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

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

828 return _prompt_for_instance_numbers( 

829 shulker_box, instance_loader(), instance_loader 

830 ) 

831 

832 selected_instances: set[str] = set() 

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

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

835 case "*": 

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

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

838 case value if value.isdigit(): 

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

840 index = int(value) - 1 

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

842 CRAFT_LOGGER.error(f"Invalid selection: {entry} is out of range\n") 

843 return _prompt_for_instance_numbers( 

844 shulker_box, instance_loader(), instance_loader 

845 ) 

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

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

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

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

850 CRAFT_LOGGER.error( 

851 f"Invalid selection: {entry} is not a valid range\n" 

852 ) 

853 return _prompt_for_instance_numbers( 

854 shulker_box, instance_loader(), instance_loader 

855 ) 

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

857 CRAFT_LOGGER.error(f"Invalid selection: {entry} is out of range\n") 

858 return _prompt_for_instance_numbers( 

859 shulker_box, instance_loader(), instance_loader 

860 ) 

861 selected_instances.update( 

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

863 ) 

864 

865 choices = tuple( 

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

867 ) 

868 

869 CRAFT_LOGGER.info( 

870 "You selected the instances:\n%s", 

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

872 ) 

873 if not confirm(default=True): 

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

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

876 return _prompt_for_instance_numbers( 

877 shulker_box, instance_loader(), instance_loader 

878 ) 

879 

880 return shulker_box._replace( 

881 match_criteria=shulker_box.match_criteria + (("instances", choices),) 

882 )