Coverage for enderchest/craft.py: 78%

336 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-10-03 20:14 +0000

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

2import logging 

3import re 

4from collections import Counter 

5from pathlib import Path 

6from typing import Callable, Iterable, Sequence 

7from urllib.parse import ParseResult 

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 STANDARD_LINK_FOLDERS, 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 if ( 

187 priority is None 

188 and link_folders is None 

189 and instances is None 

190 and tags is None 

191 and hosts is None 

192 and not overwrite 

193 ): 

194 try: 

195 shulker_box = specify_shulker_box_from_prompt(minecraft_root, name) 

196 except FileExistsError as seat_taken: 

197 CRAFT_LOGGER.error(seat_taken) 

198 CRAFT_LOGGER.error("Aborting") 

199 return 

200 else: 

201 config_path = fs.shulker_box_config(minecraft_root, name) 

202 if config_path.exists(): 

203 exist_message = ( 

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

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

206 ) 

207 if overwrite: 

208 CRAFT_LOGGER.warning(exist_message) 

209 else: 

210 CRAFT_LOGGER.error(exist_message) 

211 CRAFT_LOGGER.error("Aborting") 

212 return 

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

214 if instances is not None: 

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

216 if tags is not None: 

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

218 if hosts is not None: 

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

220 shulker_box = ShulkerBox( 

221 priority=priority or 0, 

222 name=name, 

223 root=minecraft_root, 

224 match_criteria=tuple(match_criteria), 

225 link_folders=tuple(link_folders or ()), 

226 ) 

227 except FileNotFoundError as no_ender_chest: 

228 CRAFT_LOGGER.error(no_ender_chest) 

229 return 

230 

231 create_shulker_box(minecraft_root, shulker_box) 

232 

233 

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

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

236 

237 Parameters 

238 ---------- 

239 minecraft_root : Path 

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

241 one inside which you want to create your EnderChest) 

242 

243 Returns 

244 ------- 

245 EnderChest 

246 The resulting EnderChest 

247 """ 

248 try: 

249 root = fs.ender_chest_folder(minecraft_root) 

250 CRAFT_LOGGER.info( 

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

252 ) 

253 if not confirm(default=False): 

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

255 raise FileExistsError(message) 

256 except FileNotFoundError: 

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

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

259 

260 instances: list[InstanceSpec] = [] 

261 

262 while True: 

263 search_home = prompt( 

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

265 suggestion="Y/n", 

266 ).lower() 

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

268 instances.extend( 

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

270 ) 

271 elif search_home not in NO: 

272 continue 

273 break 

274 

275 while True: 

276 search_here = prompt( 

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

278 suggestion="Y/n", 

279 ).lower() 

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

281 instances.extend( 

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

283 ) 

284 elif search_here not in NO: 

285 continue 

286 break 

287 

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

289 while True: 

290 search_mc_folder = prompt( 

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

292 suggestion="Y/n", 

293 ).lower() 

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

295 instances.extend( 

296 gather_minecraft_instances( 

297 minecraft_root, minecraft_root, official=False 

298 ) 

299 ) 

300 elif search_mc_folder not in NO: 

301 continue 

302 break 

303 

304 CRAFT_LOGGER.info( 

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

306 "\n$ enderchest gather minecraft\n" 

307 ) 

308 

309 while True: 

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

311 remote_uri = prompt( 

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

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

314 ) 

315 if remote_uri == "": 

316 break 

317 try: 

318 remotes.extend(fetch_remotes_from_a_remote_ender_chest(remote_uri)) 

319 except Exception as fetch_fail: 

320 CRAFT_LOGGER.error( 

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

322 ) 

323 if not confirm(default=True): 

324 continue 

325 break 

326 

327 CRAFT_LOGGER.info( 

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

329 "\n$ enderchest gather enderchest\n" 

330 ) 

331 

332 while True: 

333 protocol = ( 

334 prompt( 

335 ( 

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

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

338 ), 

339 suggestion=sync.DEFAULT_PROTOCOL, 

340 ).lower() 

341 or sync.DEFAULT_PROTOCOL 

342 ) 

343 

344 if protocol not in sync.SUPPORTED_PROTOCOLS: 

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

346 continue 

347 break 

348 

349 while True: 

350 default_netloc = sync.get_default_netloc() 

351 netloc = ( 

352 prompt( 

353 ( 

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

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

356 ), 

357 suggestion=default_netloc, 

358 ) 

359 or default_netloc 

360 ) 

361 

362 uri = ParseResult( 

363 scheme=protocol, 

364 netloc=netloc, 

365 path=minecraft_root.as_posix(), 

366 params="", 

367 query="", 

368 fragment="", 

369 ) 

370 if not uri.hostname: 

371 CRAFT_LOGGER.error("Invalid hostname") 

372 continue 

373 break 

374 

375 while True: 

376 name = ( 

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

378 or uri.hostname 

379 ) 

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

381 CRAFT_LOGGER.error( 

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

383 ) 

384 continue 

385 break 

386 

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

388 

389 CRAFT_LOGGER.info( 

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

391 ender_chest.write_to_cfg(), 

392 ) 

393 

394 if not confirm(default=True): 

395 raise RuntimeError("EnderChest creation aborted.") 

396 

397 return ender_chest 

398 

399 

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

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

402 

403 Parameters 

404 ---------- 

405 minecraft_root : Path 

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

407 that's the parent of your EnderChest folder) 

408 name : str 

409 The name to give to the shulker box 

410 

411 Returns 

412 ------- 

413 ShulkerBox 

414 The resulting ShulkerBox 

415 """ 

416 shulker_root = fs.shulker_box_root(minecraft_root, name) 

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

418 if not shulker_root.is_dir(): 

419 raise FileExistsError( 

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

421 ) 

422 CRAFT_LOGGER.warning( 

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

424 ) 

425 if not confirm(default=False): 

426 raise FileExistsError( 

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

428 ) 

429 

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

431 

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

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

434 return load_ender_chest_instances(minecraft_root) 

435 

436 instances = refresh_ender_chest_instance_list() 

437 

438 explicit_type = "name" 

439 if len(instances) > 0: 

440 explicit_type = "number" 

441 while True: 

442 selection_type = prompt( 

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

444 ).lower() 

445 match selection_type: 

446 case "f" | "filter": 

447 shulker_box = _prompt_for_filters(shulker_box, instances) 

448 case "n": 

449 if explicit_type == "name": 

450 shulker_box = _prompt_for_instance_names(shulker_box) 

451 else: # if explicit_type == "number" 

452 shulker_box = _prompt_for_instance_numbers( 

453 shulker_box, instances, refresh_ender_chest_instance_list 

454 ) 

455 case "name": 

456 # yeah, this is always available 

457 shulker_box = _prompt_for_instance_names(shulker_box) 

458 case "number": 

459 if explicit_type == "name": 

460 continue 

461 shulker_box = _prompt_for_instance_numbers( 

462 shulker_box, instances, refresh_ender_chest_instance_list 

463 ) 

464 case _: 

465 continue 

466 break 

467 

468 while True: 

469 selection_type = prompt( 

470 "Folders to Link?" 

471 "\nUse the [S]tandard set, [M]anually specify or do [N]one?" 

472 "\nThe standard set is: " + ", ".join(STANDARD_LINK_FOLDERS) 

473 ).lower() 

474 match selection_type: 

475 case "n" | "none": 

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

477 case "s" | "standard" | "standard set": 

478 link_folders = STANDARD_LINK_FOLDERS 

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

480 folder_choices = prompt( 

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

482 " (wildcards are not allowed)" 

483 ) 

484 link_folders = tuple( 

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

486 ) 

487 case _: 

488 continue 

489 break 

490 

491 while True: 

492 # this is such a kludge 

493 existing_shulker_boxes = load_shulker_boxes( 

494 minecraft_root, log_level=logging.DEBUG 

495 ) 

496 if existing_shulker_boxes: 

497 _report_shulker_boxes( 

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

499 ) 

500 

501 value = ( 

502 prompt( 

503 ( 

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

505 "\nhigher number = applied later" 

506 ), 

507 suggestion="0", 

508 ) 

509 or "0" 

510 ) 

511 try: 

512 priority = int(value) 

513 except ValueError: 

514 continue 

515 break 

516 

517 while True: 

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

519 values = ( 

520 prompt( 

521 ( 

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

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

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

525 f' ("{load_ender_chest(minecraft_root).name}")' 

526 ), 

527 suggestion="*", 

528 ) 

529 or "*" 

530 ) 

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

532 

533 # TODO: stop wastefully reloading the cfg 

534 host = load_ender_chest(minecraft_root).name 

535 

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

537 host 

538 ): 

539 CRAFT_LOGGER.warning( 

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

541 ) 

542 if not confirm(default=False): 

543 continue 

544 break 

545 

546 shulker_box = shulker_box._replace( 

547 priority=priority, 

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

549 link_folders=link_folders, 

550 ) 

551 

552 CRAFT_LOGGER.info( 

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

554 shulker_box.write_to_cfg(), 

555 ) 

556 

557 if not confirm(default=True): 

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

559 

560 return shulker_box 

561 

562 

563def _prompt_for_filters( 

564 shulker_box: ShulkerBox, instances: Sequence[InstanceSpec] 

565) -> ShulkerBox: 

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

567 

568 Parameters 

569 ---------- 

570 shulker_box : ShulkerBox 

571 The starting ShulkerBox (with no match critera) 

572 instances : list of InstanceSpec 

573 The list of instances registered to the EnderChest 

574 

575 Returns 

576 ------- 

577 ShulkerBox 

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

579 

580 Notes 

581 ----- 

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

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

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

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

586 """ 

587 

588 def check_progress( 

589 new_condition: str, values: Iterable[str] 

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

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

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

593 to continue. 

594 

595 Parameters 

596 ---------- 

597 new_condition : str 

598 The type of condition being added 

599 values : list-like of str 

600 The values for the new condition 

601 

602 Returns 

603 ------- 

604 ShulkerBox or None 

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

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

607 returned value will be None 

608 list of str 

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

610 

611 Notes 

612 ----- 

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

614 and will just return the updated shulker box. 

615 """ 

616 tester = shulker_box._replace( 

617 match_criteria=shulker_box.match_criteria 

618 + ((new_condition, tuple(values)),) 

619 ) 

620 

621 if len(instances) == 0: 

622 return tester, [] 

623 

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

625 

626 default = True 

627 if len(matches) == 0: 

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

629 default = False 

630 elif len(matches) == 1: 

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

632 else: 

633 CRAFT_LOGGER.info( 

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

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

636 ) 

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

638 

639 while True: 

640 version_spec = ( 

641 prompt( 

642 ( 

643 "Minecraft versions:" 

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

645 ), 

646 suggestion="*", 

647 ) 

648 or "*" 

649 ) 

650 

651 updated, matches = check_progress( 

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

653 ) 

654 if updated: 

655 shulker_box = updated 

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

657 break 

658 

659 while True: 

660 modloader = ( 

661 prompt( 

662 ( 

663 "Modloader?" 

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

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

666 ), 

667 suggestion="*", 

668 ) 

669 or "*" 

670 ) 

671 

672 modloaders: set[str] = set() 

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

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

675 case "" | "n" | "none" | "vanilla": 

676 modloaders.update(normalize_modloader(None)) 

677 case "g" | "forge": 

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

679 case "b" | "fabric" | "fabric loader": 

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

681 case "q" | "quilt": 

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

683 case "l" | "liteloader": 

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

685 case _: 

686 modloaders.update(normalize_modloader(entry)) 

687 

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

689 if updated: 

690 shulker_box = updated 

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

692 break 

693 

694 while True: 

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

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

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

698 CRAFT_LOGGER.debug( 

699 "Tag counts:\n%s", 

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

701 ) 

702 

703 if len(example_tags) == 0: 

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

705 example_tags = [ 

706 "vanilla-plus", 

707 "multiplayer", 

708 "modded", 

709 "dev", 

710 "april-fools", 

711 ] 

712 

713 tags = ( 

714 prompt( 

715 "Tags?" 

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

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

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

719 suggestion="*", 

720 ) 

721 or "*" 

722 ) 

723 

724 updated, matches = check_progress( 

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

726 ) 

727 if updated: 

728 shulker_box = updated 

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

730 break 

731 

732 return shulker_box 

733 

734 

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

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

737 to the shulker box spec. 

738 

739 Parameters 

740 ---------- 

741 shulker_box : ShulkerBox 

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

743 

744 Returns 

745 ------- 

746 ShulkerBox 

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

748 

749 Notes 

750 ----- 

751 This method does not validate against lists of known instances 

752 """ 

753 instances = tuple( 

754 entry.strip() 

755 for entry in prompt( 

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

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

758 suggestion="*", 

759 ).split(",") 

760 ) 

761 if instances == ("",): 

762 instances = ("*",) 

763 

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

765 CRAFT_LOGGER.warning( 

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

767 " including ones you create in the future." 

768 ) 

769 default = False 

770 else: 

771 CRAFT_LOGGER.info( 

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

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

774 ) 

775 default = True 

776 

777 if not confirm(default=default): 

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

779 return _prompt_for_instance_names(shulker_box) 

780 

781 return shulker_box._replace( 

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

783 ) 

784 

785 

786def _prompt_for_instance_numbers( 

787 shulker_box: ShulkerBox, 

788 instances: Sequence[InstanceSpec], 

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

790) -> ShulkerBox: 

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

792 

793 Parameters 

794 ---------- 

795 shulker_box : ShulkerBox 

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

797 instances : list of InstanceSpec 

798 The list of instances registered to the EnderChest 

799 instance_loader : method that returns a list of InstanceSpec 

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

801 registered to the EnderChest 

802 

803 Returns 

804 ------- 

805 ShulkerBox 

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

807 """ 

808 selections = prompt( 

809 ( 

810 "Which instances would you like to include?" 

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

812 ), 

813 suggestion="*", 

814 ) 

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

816 if selections == "": 

817 selections = "*" 

818 

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

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

821 return _prompt_for_instance_numbers( 

822 shulker_box, instance_loader(), instance_loader 

823 ) 

824 

825 selected_instances: set[str] = set() 

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

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

828 case "*": 

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

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

831 case value if value.isdigit(): 

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

833 index = int(value) - 1 

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

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

836 return _prompt_for_instance_numbers( 

837 shulker_box, instance_loader(), instance_loader 

838 ) 

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

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

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

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

843 CRAFT_LOGGER.error( 

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

845 ) 

846 return _prompt_for_instance_numbers( 

847 shulker_box, instance_loader(), instance_loader 

848 ) 

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

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

851 return _prompt_for_instance_numbers( 

852 shulker_box, instance_loader(), instance_loader 

853 ) 

854 selected_instances.update( 

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

856 ) 

857 

858 choices = tuple( 

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

860 ) 

861 

862 CRAFT_LOGGER.info( 

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

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

865 ) 

866 if not confirm(default=True): 

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

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

869 return _prompt_for_instance_numbers( 

870 shulker_box, instance_loader(), instance_loader 

871 ) 

872 

873 return shulker_box._replace( 

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

875 )