Coverage for enderchest/gather.py: 80%

307 statements  

« prev     ^ index     » next       coverage.py v7.4.0, created at 2024-01-11 17:09 +0000

1"""Functionality for finding, resolving and parsing local installations and instances""" 

2import json 

3import logging 

4import os 

5import re 

6from configparser import ConfigParser, ParsingError 

7from pathlib import Path 

8from typing import Iterable, Sequence 

9from urllib.parse import ParseResult 

10 

11from enderchest.sync import render_remote 

12 

13from . import filesystem as fs 

14from .enderchest import EnderChest, create_ender_chest 

15from .instance import InstanceSpec, normalize_modloader, parse_version 

16from .loggers import GATHER_LOGGER 

17from .prompt import prompt 

18from .shulker_box import ShulkerBox, _matches_version 

19 

20 

21def load_ender_chest(minecraft_root: Path) -> EnderChest: 

22 """Load the configuration from the enderchest.cfg file in the EnderChest 

23 folder. 

24 

25 Parameters 

26 ---------- 

27 minecraft_root : Path 

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

29 that's the parent of your EnderChest folder) 

30 

31 Returns 

32 ------- 

33 EnderChest 

34 The EnderChest configuration 

35 

36 Raises 

37 ------ 

38 FileNotFoundError 

39 If no EnderChest folder exists in the given minecraft root or if no 

40 enderchest.cfg file exists within that EnderChest folder 

41 ValueError 

42 If the EnderChest configuration is invalid and could not be parsed 

43 """ 

44 config_path = fs.ender_chest_config(minecraft_root) 

45 GATHER_LOGGER.debug(f"Loading {config_path}") 

46 ender_chest = EnderChest.from_cfg(config_path) 

47 GATHER_LOGGER.debug(f"Parsed EnderChest installation from {minecraft_root}") 

48 return ender_chest 

49 

50 

51def load_ender_chest_instances( 

52 minecraft_root: Path, log_level: int = logging.INFO 

53) -> Sequence[InstanceSpec]: 

54 """Get the list of instances registered with the EnderChest located in the 

55 minecraft root 

56 

57 Parameters 

58 ---------- 

59 minecraft_root : Path 

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

61 that's the parent of your EnderChest folder) 

62 log_level : int, optional 

63 By default, this method will report out the minecraft instances it 

64 finds at the INFO level. You can optionally pass in a lower (or higher) 

65 level if this method is being called from another method where that 

66 information is redundant or overly verbose. 

67 

68 Returns 

69 ------- 

70 list of InstanceSpec 

71 The instances registered with the EnderChest 

72 

73 Notes 

74 ----- 

75 If no EnderChest is installed in the given location, then this will return 

76 an empty list rather than failing outright. 

77 """ 

78 try: 

79 ender_chest = load_ender_chest(minecraft_root) 

80 instances: Sequence[InstanceSpec] = ender_chest.instances 

81 except (FileNotFoundError, ValueError) as bad_chest: 

82 GATHER_LOGGER.error( 

83 f"Could not load EnderChest from {minecraft_root}:\n {bad_chest}" 

84 ) 

85 instances = [] 

86 if len(instances) == 0: 

87 GATHER_LOGGER.warning( 

88 f"There are no instances registered to the {minecraft_root} EnderChest", 

89 ) 

90 else: 

91 GATHER_LOGGER.log( 

92 log_level, 

93 "These are the instances that are currently registered" 

94 f" to the {minecraft_root} EnderChest:\n%s", 

95 "\n".join( 

96 [ 

97 f" {i + 1}. {_render_instance(instance)}" 

98 for i, instance in enumerate(instances) 

99 ] 

100 ), 

101 ) 

102 return instances 

103 

104 

105def _render_instance(instance: InstanceSpec) -> str: 

106 """Render an instance spec to a descriptive string 

107 

108 Parameters 

109 ---------- 

110 instance : InstanceSpec 

111 The instance spec to render 

112 

113 Returns 

114 ------- 

115 str 

116 {instance.name} ({instance.root}) 

117 """ 

118 return f"{instance.name} ({instance.root})" 

119 

120 

121def load_shulker_boxes( 

122 minecraft_root: Path, log_level: int = logging.INFO 

123) -> list[ShulkerBox]: 

124 """Load all shulker boxes in the EnderChest folder and return them in the 

125 order in which they should be linked. 

126 

127 Parameters 

128 ---------- 

129 minecraft_root : Path 

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

131 that's the parent of your EnderChest folder) 

132 log_level : int, optional 

133 By default, this method will report out the minecraft instances it 

134 finds at the INFO level. You can optionally pass in a lower (or higher) 

135 level if this method is being called from another method where that 

136 information is redundant or overly verbose. 

137 

138 Returns 

139 ------- 

140 list of ShulkerBoxes 

141 The shulker boxes found in the EnderChest folder, ordered in terms of 

142 the sequence in which they should be linked 

143 

144 Notes 

145 ----- 

146 If no EnderChest is installed in the given location, then this will return 

147 an empty list rather than failing outright. 

148 """ 

149 shulker_boxes: list[ShulkerBox] = [] 

150 

151 try: 

152 for shulker_config in fs.shulker_box_configs(minecraft_root): 

153 try: 

154 shulker_boxes.append(_load_shulker_box(shulker_config)) 

155 except (FileNotFoundError, ValueError) as bad_shulker: 

156 GATHER_LOGGER.warning( 

157 f"{bad_shulker}\n Skipping shulker box {shulker_config.parent.name}" 

158 ) 

159 

160 except FileNotFoundError: 

161 GATHER_LOGGER.error(f"There is no EnderChest installed within {minecraft_root}") 

162 return [] 

163 

164 shulker_boxes = sorted(shulker_boxes) 

165 

166 if len(shulker_boxes) == 0: 

167 if log_level >= logging.INFO: 

168 GATHER_LOGGER.warning( 

169 f"There are no shulker boxes within the {minecraft_root} EnderChest" 

170 ) 

171 else: 

172 _report_shulker_boxes( 

173 shulker_boxes, log_level, f"the {minecraft_root} EnderChest" 

174 ) 

175 return shulker_boxes 

176 

177 

178def _report_shulker_boxes( 

179 shulker_boxes: Iterable[ShulkerBox], log_level: int, ender_chest_name: str 

180) -> None: 

181 """Log the list of shulker boxes in the order they'll be linked""" 

182 GATHER_LOGGER.log( 

183 log_level, 

184 f"These are the shulker boxes within {ender_chest_name}" 

185 "\nlisted in the order in which they are linked:\n%s", 

186 "\n".join( 

187 f" {shulker_box.priority}. {_render_shulker_box(shulker_box)}" 

188 for shulker_box in shulker_boxes 

189 ), 

190 ) 

191 

192 

193def _load_shulker_box(config_file: Path) -> ShulkerBox: 

194 """Attempt to load a shulker box from a config file, and if you can't, 

195 at least log why the loading failed. 

196 

197 Parameters 

198 ---------- 

199 config_file : Path 

200 Path to the config file 

201 

202 Returns 

203 ------- 

204 ShulkerBox | None 

205 The parsed shulker box or None, if the shulker box couldn't be parsed 

206 

207 Raises 

208 ------ 

209 FileNotFoundError 

210 If the given config file could not be found 

211 ValueError 

212 If there was a problem parsing the config file 

213 """ 

214 GATHER_LOGGER.debug(f"Attempting to parse {config_file}") 

215 shulker_box = ShulkerBox.from_cfg(config_file) 

216 GATHER_LOGGER.debug(f"Successfully parsed {_render_shulker_box(shulker_box)}") 

217 return shulker_box 

218 

219 

220def _render_shulker_box(shulker_box: ShulkerBox) -> str: 

221 """Render a shulker box to a descriptive string 

222 

223 Parameters 

224 ---------- 

225 shulker_box : ShulkerBox 

226 The shulker box spec to render 

227 

228 Returns 

229 ------- 

230 str 

231 {priority}. {folder_name} [({name})] 

232 (if different from folder name) 

233 """ 

234 stringified = f"{shulker_box.root.name}" 

235 if shulker_box.root.name != shulker_box.name: # pragma: no cover 

236 # note: this is not a thing 

237 stringified += f" ({shulker_box.name})" 

238 return stringified 

239 

240 

241def load_ender_chest_remotes( 

242 minecraft_root: Path, log_level: int = logging.INFO 

243) -> list[tuple[ParseResult, str]]: 

244 """Load all remote EnderChest installations registered with this one 

245 

246 Parameters 

247 ---------- 

248 minecraft_root : Path 

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

250 that's the parent of your EnderChest folder) 

251 log_level : int, optional 

252 By default, this method will report out the minecraft instances it 

253 finds at the INFO level. You can optionally pass in a lower (or higher) 

254 level if this method is being called from another method where that 

255 information is redundant or overly verbose. 

256 

257 Returns 

258 ------- 

259 list of (URI, str) tuples 

260 The URIs of the remote EnderChests, paired with their aliases 

261 

262 Notes 

263 ----- 

264 If no EnderChest is installed in the given location, then this will return 

265 an empty list rather than failing outright. 

266 """ 

267 try: 

268 ender_chest = load_ender_chest(minecraft_root) 

269 remotes: Sequence[tuple[ParseResult, str]] = ender_chest.remotes 

270 except (FileNotFoundError, ValueError) as bad_chest: 

271 GATHER_LOGGER.error( 

272 f"Could not load EnderChest from {minecraft_root}:\n {bad_chest}" 

273 ) 

274 remotes = () 

275 

276 if len(remotes) == 0: 

277 if log_level >= logging.INFO: 

278 GATHER_LOGGER.warning( 

279 f"There are no remotes registered to the {minecraft_root} EnderChest" 

280 ) 

281 return [] 

282 

283 report = ( 

284 "These are the remote EnderChest installations registered" 

285 f" to the one installed at {minecraft_root}" 

286 ) 

287 remote_list: list[tuple[ParseResult, str]] = [] 

288 log_args: list[str] = [] 

289 for remote, alias in remotes: 

290 report += "\n - %s" 

291 log_args.append(render_remote(alias, remote)) 

292 remote_list.append((remote, alias)) 

293 GATHER_LOGGER.log(log_level, report, *log_args) 

294 return remote_list 

295 

296 

297def get_shulker_boxes_matching_instance( 

298 minecraft_root: Path, instance_name: str 

299) -> list[ShulkerBox]: 

300 """Get the list of shulker boxes that the specified instance links to 

301 

302 Parameters 

303 ---------- 

304 minecraft_root : Path 

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

306 that's the parent of your EnderChest folder) 

307 instance_name : str 

308 The name of the instance you're asking about 

309 

310 Returns 

311 ------- 

312 list of ShulkerBox 

313 The shulker boxes that are linked to by the specified instance 

314 """ 

315 try: 

316 chest = load_ender_chest(minecraft_root) 

317 except (FileNotFoundError, ValueError) as bad_chest: 

318 GATHER_LOGGER.error( 

319 f"Could not load EnderChest from {minecraft_root}:\n {bad_chest}" 

320 ) 

321 return [] 

322 for mc in chest.instances: 

323 if mc.name == instance_name: 

324 break 

325 else: 

326 GATHER_LOGGER.error( 

327 "No instance named %s is registered to this EnderChest", instance_name 

328 ) 

329 return [] 

330 

331 matches = [ 

332 box 

333 for box in load_shulker_boxes(minecraft_root, log_level=logging.DEBUG) 

334 if box.matches(mc) and box.matches_host(chest.name) 

335 ] 

336 

337 if len(matches) == 0: 

338 report = "does not link to any shulker boxes in this chest" 

339 else: 

340 report = "links to the following shulker boxes:\n" + "\n".join( 

341 f" - {_render_shulker_box(box)}" for box in matches 

342 ) 

343 

344 GATHER_LOGGER.info(f"The instance {_render_instance(mc)} {report}") 

345 

346 return matches 

347 

348 

349def get_instances_matching_shulker_box( 

350 minecraft_root: Path, shulker_box_name: str 

351) -> list[InstanceSpec]: 

352 """Get the list of registered instances that link to the specified shulker box 

353 

354 Parameters 

355 ---------- 

356 minecraft_root : Path 

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

358 that's the parent of your EnderChest folder) 

359 shulker_box_name : str 

360 The name of the shulker box you're asking about 

361 

362 Returns 

363 ------- 

364 list of InstanceSpec 

365 The instances that are / should be linked to the specified shulker box 

366 """ 

367 try: 

368 config_file = fs.shulker_box_config(minecraft_root, shulker_box_name) 

369 except FileNotFoundError: 

370 GATHER_LOGGER.error(f"No EnderChest is installed in {minecraft_root}") 

371 return [] 

372 try: 

373 shulker_box = _load_shulker_box(config_file) 

374 except (FileNotFoundError, ValueError) as bad_box: 

375 GATHER_LOGGER.error( 

376 f"Could not load shulker box {shulker_box_name}\n {bad_box}" 

377 ) 

378 return [] 

379 

380 chest = load_ender_chest(minecraft_root) 

381 

382 if not shulker_box.matches_host(chest.name): 

383 GATHER_LOGGER.warning( 

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

385 ) 

386 return [] 

387 

388 if not chest.instances: 

389 GATHER_LOGGER.warning( 

390 "This EnderChest does not have any instances registered." 

391 " To register some, run the command:" 

392 "\nenderchest gather minecraft", 

393 ) 

394 return [] 

395 

396 GATHER_LOGGER.debug( 

397 "These are the instances that are currently registered" 

398 f" to the {minecraft_root} EnderChest:\n%s", 

399 "\n".join( 

400 [ 

401 f" {i + 1}. {_render_instance(instance)}" 

402 for i, instance in enumerate(chest.instances) 

403 ] 

404 ), 

405 ) 

406 

407 matches = [ 

408 instance for instance in chest.instances if shulker_box.matches(instance) 

409 ] 

410 

411 if len(matches) == 0: 

412 report = "is not linked to by any registered instances" 

413 else: 

414 report = "is linked to by the following instances:\n" + "\n".join( 

415 f" - {_render_instance(instance)}" for instance in matches 

416 ) 

417 

418 GATHER_LOGGER.info(f"The shulker box {_render_shulker_box(shulker_box)} {report}") 

419 

420 return matches 

421 

422 

423def gather_minecraft_instances( 

424 minecraft_root: Path, search_path: Path, official: bool | None 

425) -> list[InstanceSpec]: 

426 """Search the specified directory for Minecraft installations and return 

427 any that are can be found and parsed 

428 

429 Parameters 

430 ---------- 

431 minecraft_root : Path 

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

433 that's the parent of your EnderChest folder). This will be used to 

434 construct relative paths. 

435 search_path : Path 

436 The path to search 

437 official : bool or None 

438 Whether we expect that the instances found in this location will be: 

439 - from the official launcher (official=True) 

440 - from a MultiMC-style launcher (official=False) 

441 - a mix / unsure (official=None) 

442 

443 Returns 

444 ------- 

445 list of InstanceSpec 

446 A list of parsed instances 

447 

448 Notes 

449 ----- 

450 - If a minecraft installation is found but cannot be parsed 

451 (or parsed as specified) this method will report that failure but then 

452 continue on. 

453 - As a corollary, if _no_ valid Minecraft installations can be found, this 

454 method will return an empty list. 

455 """ 

456 try: 

457 ender_chest = load_ender_chest(minecraft_root) 

458 except FileNotFoundError: 

459 # because this method can be called during crafting 

460 ender_chest = EnderChest(minecraft_root) 

461 GATHER_LOGGER.debug(f"Searching for Minecraft folders inside {search_path}") 

462 instances: list[InstanceSpec] = [] 

463 for folder in fs.minecraft_folders(search_path): 

464 folder_path = folder.absolute() 

465 GATHER_LOGGER.debug(f"Found minecraft installation at {folder}") 

466 if official is not False: 

467 try: 

468 instances.append(gather_metadata_for_official_instance(folder_path)) 

469 GATHER_LOGGER.info( 

470 f"Gathered official Minecraft installation from {folder}" 

471 ) 

472 _check_for_allowed_symlinks(ender_chest, instances[-1]) 

473 continue 

474 except ValueError as not_official: 

475 GATHER_LOGGER.log( 

476 logging.DEBUG if official is None else logging.WARNING, 

477 (f"{folder} is not an official instance:" f"\n{not_official}",), 

478 ) 

479 if official is not True: 

480 try: 

481 instances.append(gather_metadata_for_mmc_instance(folder_path)) 

482 GATHER_LOGGER.info( 

483 f"Gathered MMC-like Minecraft installation from {folder}" 

484 ) 

485 _check_for_allowed_symlinks(ender_chest, instances[-1]) 

486 continue 

487 except ValueError as not_mmc: 

488 GATHER_LOGGER.log( 

489 logging.DEBUG if official is None else logging.WARNING, 

490 f"{folder} is not an MMC-like instance:\n{not_mmc}", 

491 ) 

492 GATHER_LOGGER.warning( 

493 f"{folder_path} does not appear to be a valid Minecraft instance" 

494 ) 

495 for i, mc_instance in enumerate(instances): 

496 try: 

497 instances[i] = mc_instance._replace( 

498 root=mc_instance.root.relative_to(minecraft_root.resolve()) 

499 ) 

500 except ValueError: 

501 # TODO: if not Windows, try making relative to "~" 

502 pass # instance isn't inside the minecraft root 

503 if not instances: 

504 GATHER_LOGGER.warning( 

505 f"Could not find any Minecraft instances inside {search_path}" 

506 ) 

507 return instances 

508 

509 

510def gather_metadata_for_official_instance( 

511 minecraft_folder: Path, name: str = "official" 

512) -> InstanceSpec: 

513 """Parse files to generate metadata for an official Minecraft installation 

514 

515 Parameters 

516 ---------- 

517 minecraft_folder : Path 

518 The path to the installation's .minecraft folder 

519 name : str, optional 

520 A name or alias to give to the instance. If None is provided, the 

521 default name is "official" 

522 

523 Returns 

524 ------- 

525 InstanceSpec 

526 The metadata for this instance 

527 

528 Raises 

529 ------ 

530 ValueError 

531 If this is not a valid official Minecraft installation 

532 

533 Notes 

534 ----- 

535 This method will always consider this instance to be vanilla, with no 

536 modloader. If a Forge or Fabric executable is installed inside this 

537 instance, the precise name of that version of that modded minecraft 

538 will be included in the version list. 

539 """ 

540 launcher_profile_file = minecraft_folder / "launcher_profiles.json" 

541 try: 

542 with launcher_profile_file.open() as lp_json: 

543 launcher_profiles = json.load(lp_json) 

544 raw_versions: list[str] = [ 

545 profile["lastVersionId"] 

546 for profile in launcher_profiles["profiles"].values() 

547 ] 

548 except FileNotFoundError as no_json: 

549 raise ValueError(f"Could not find {launcher_profile_file}") from no_json 

550 except json.JSONDecodeError as bad_json: 

551 raise ValueError( 

552 f"{launcher_profile_file} is corrupt and could not be parsed" 

553 ) from bad_json 

554 except KeyError as weird_json: 

555 raise ValueError( 

556 f"Could not parse metadata from {launcher_profile_file}" 

557 ) from weird_json 

558 

559 version_manifest_file = minecraft_folder / "versions" / "version_manifest_v2.json" 

560 try: 

561 with version_manifest_file.open() as vm_json: 

562 version_lookup: dict[str, str] = json.load(vm_json)["latest"] 

563 except FileNotFoundError as no_json: 

564 raise ValueError(f"Could not find {version_manifest_file}") from no_json 

565 except json.JSONDecodeError as bad_json: 

566 raise ValueError( 

567 f"{version_manifest_file} is corrupt and could not be parsed" 

568 ) from bad_json 

569 except KeyError as weird_json: 

570 GATHER_LOGGER.warning( 

571 f"{version_manifest_file} has no latest-version lookup." 

572 "\nPlease check the parsed metadata to ensure that it's accurate.", 

573 ) 

574 version_lookup = {} 

575 

576 versions: list[str] = [] 

577 groups: list[str] = ["vanilla"] 

578 for version in raw_versions: 

579 if version.startswith("latest-"): 

580 mapped_version = version_lookup.get(version[len("latest-") :]) 

581 if mapped_version is not None: 

582 versions.append(parse_version(mapped_version)) 

583 groups.append(version) 

584 continue 

585 versions.append(parse_version(version)) 

586 

587 return InstanceSpec(name, minecraft_folder, tuple(versions), "", tuple(groups), ()) 

588 

589 

590def gather_metadata_for_mmc_instance( 

591 minecraft_folder: Path, instgroups_file: Path | None = None 

592) -> InstanceSpec: 

593 """Parse files to generate metadata for a MultiMC-like instance 

594 

595 Parameters 

596 ---------- 

597 minecraft_folder : Path 

598 The path to the installation's .minecraft folder 

599 instgroups_file : Path 

600 The path to instgroups.json. If None is provided, this method will 

601 look for it two directories up from the minecraft folder 

602 

603 Returns 

604 ------- 

605 InstanceSpec 

606 The metadata for this instance 

607 

608 Raises 

609 ------ 

610 ValueError 

611 If this is not a valid MMC-like Minecraft instance 

612 

613 Notes 

614 ----- 

615 If this method is failing to find the appropriate files, you may want 

616 to try ensuring that minecraft_folder is an absolute path. 

617 """ 

618 mmc_pack_file = minecraft_folder.parent / "mmc-pack.json" 

619 try: 

620 with mmc_pack_file.open() as mmc_json: 

621 components: list[dict] = json.load(mmc_json)["components"] 

622 

623 version: str | None = None 

624 modloader: str | None = None 

625 

626 for component in components: 

627 match component.get("uid"), component.get("cachedName", ""): 

628 case "net.minecraft", _: 

629 version = parse_version(component["version"]) 

630 case "net.fabricmc.fabric-loader", _: 

631 modloader = "Fabric Loader" 

632 case "org.quiltmc.quilt-loader", _: 

633 modloader = "Quilt Loader" 

634 case ("net.minecraftforge", _) | (_, "Forge"): 

635 modloader = "Forge" 

636 case _, name if name.endswith("oader"): 

637 modloader = name 

638 case _: 

639 continue 

640 modloader = normalize_modloader(modloader)[0] 

641 if version is None: 

642 raise KeyError("Could not find a net.minecraft component") 

643 except FileNotFoundError as no_json: 

644 raise ValueError(f"Could not find {mmc_pack_file}") from no_json 

645 except json.JSONDecodeError as bad_json: 

646 raise ValueError( 

647 f"{mmc_pack_file} is corrupt and could not be parsed" 

648 ) from bad_json 

649 except KeyError as weird_json: 

650 raise ValueError( 

651 f"Could not parse metadata from {mmc_pack_file}" 

652 ) from weird_json 

653 

654 name = minecraft_folder.parent.name 

655 

656 instance_groups: list[str] = [] 

657 

658 if name == "": 

659 GATHER_LOGGER.warning( 

660 "Could not resolve the name of the parent folder" 

661 " and thus could not load tags." 

662 ) 

663 else: 

664 instgroups_file = ( 

665 instgroups_file or minecraft_folder.parent.parent / "instgroups.json" 

666 ) 

667 

668 try: 

669 with instgroups_file.open() as groups_json: 

670 groups: dict[str, dict] = json.load(groups_json)["groups"] 

671 for group, metadata in groups.items(): 

672 # interestingly this comes from the folder name, not the actual name 

673 if name in metadata.get("instances", ()): 

674 instance_groups.append(group) 

675 

676 except FileNotFoundError as no_json: 

677 GATHER_LOGGER.warning( 

678 f"Could not find {instgroups_file} and thus could not load tags" 

679 ) 

680 except json.JSONDecodeError as bad_json: 

681 GATHER_LOGGER.warning( 

682 f"{instgroups_file} is corrupt and could not be parsed for tags" 

683 ) 

684 except KeyError as weird_json: 

685 GATHER_LOGGER.warning(f"Could not parse tags from {instgroups_file}") 

686 

687 instance_cfg = minecraft_folder.parent / "instance.cfg" 

688 

689 try: 

690 parser = ConfigParser(allow_no_value=True, interpolation=None) 

691 parser.read_string("[instance]\n" + instance_cfg.read_text()) 

692 name = parser["instance"]["name"] 

693 except FileNotFoundError as no_cfg: 

694 GATHER_LOGGER.warning( 

695 f"Could not find {instance_cfg} and thus could not load the instance name" 

696 ) 

697 except ParsingError as no_cfg: 

698 GATHER_LOGGER.warning( 

699 f"{instance_cfg} is corrupt and could not be parsed the instance name" 

700 ) 

701 except KeyError as weird_json: 

702 GATHER_LOGGER.warning(f"Could not parse instance name from {instance_cfg}") 

703 

704 if name == "": 

705 raise ValueError("Could not determine the name of the instance.") 

706 

707 return InstanceSpec( 

708 name, 

709 minecraft_folder, 

710 (version,), 

711 modloader or "", 

712 tuple(instance_groups), 

713 (), 

714 ) 

715 

716 

717def update_ender_chest( 

718 minecraft_root: Path, 

719 search_paths: Iterable[str | Path] | None = None, 

720 official: bool | None = None, 

721 remotes: Iterable[str | ParseResult | tuple[str, str] | tuple[ParseResult, str]] 

722 | None = None, 

723) -> None: 

724 """Orchestration method that coordinates the onboarding of new instances or 

725 EnderChest installations 

726 

727 Parameters 

728 ---------- 

729 minecraft_root : Path 

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

731 that's the parent of your EnderChest folder). 

732 search_paths : list of Paths, optional 

733 The local search paths to look for Minecraft installations within. 

734 Be warned that this search is performed recursively. 

735 official : bool | None, optional 

736 Optionally specify whether the Minecraft instances you expect to find 

737 are from the official launcher (`official=True`) or a MultiMC-derivative 

738 (`official=False`). 

739 remotes : list of URIs or (URI, str) tuples, optional 

740 Any remotes you wish to register to this instance. When a (URI, str) tuple 

741 is provided, the second value will be used as the name/alias of the remote. 

742 If there is already a remote specified with the given alias, this method will 

743 replace it. 

744 """ 

745 try: 

746 ender_chest = load_ender_chest(minecraft_root) 

747 except (FileNotFoundError, ValueError) as bad_chest: 

748 GATHER_LOGGER.error( 

749 f"Could not load EnderChest from {minecraft_root}:\n {bad_chest}" 

750 ) 

751 return 

752 for search_path in search_paths or (): 

753 for instance in gather_minecraft_instances( 

754 minecraft_root, Path(search_path), official=official 

755 ): 

756 _ = ender_chest.register_instance(instance) 

757 for remote in remotes or (): 

758 try: 

759 if isinstance(remote, (str, ParseResult)): 

760 ender_chest.register_remote(remote) 

761 else: 

762 ender_chest.register_remote(*remote) 

763 except ValueError as bad_remote: 

764 GATHER_LOGGER.warning(bad_remote) 

765 

766 create_ender_chest(minecraft_root, ender_chest) 

767 

768 

769def _check_for_allowed_symlinks( 

770 ender_chest: EnderChest, instance: InstanceSpec 

771) -> None: 

772 """Check if the instance: 

773 - is 1.20+ 

774 - has not already blanket-allowed symlinks into the EnderChest 

775 

776 and if it hasn't, offer to update the allow-list now *but only if* the user 

777 hasn't already told EnderChest "shut up I know what I'm doing." 

778 

779 Parameters 

780 ---------- 

781 ender_chest : EnderChest 

782 This EnderChest 

783 instance : InstanceSpec 

784 The instance spec to check 

785 """ 

786 if ender_chest.offer_to_update_symlink_allowlist is False: 

787 return 

788 

789 if not any( 

790 _needs_symlink_allowlist(version) for version in instance.minecraft_versions 

791 ): 

792 return 

793 ender_chest_abspath = os.path.abspath(ender_chest.root) 

794 

795 symlink_allowlist = instance.root / "allowed_symlinks.txt" 

796 

797 try: 

798 allowlist_contents = symlink_allowlist.read_text() 

799 already_allowed = ender_chest_abspath in allowlist_contents.splitlines() 

800 allowlist_needs_newline = not allowlist_contents.endswith("\n") 

801 except FileNotFoundError: 

802 already_allowed = False 

803 allowlist_needs_newline = False 

804 

805 if already_allowed: 

806 return 

807 

808 GATHER_LOGGER.warning( 

809 """ 

810Starting with Minecraft 1.20, Mojang by default no longer allows worlds 

811to load if they are or if they contain symbolic links. 

812Read more: https://help.minecraft.net/hc/en-us/articles/16165590199181""" 

813 ) 

814 

815 response = prompt( 

816 f"Would you like EnderChest to add {ender_chest_abspath} to {symlink_allowlist}?", 

817 "Y/n", 

818 ) 

819 

820 if response.lower() not in ("y", "yes", ""): 

821 return 

822 

823 with symlink_allowlist.open("a") as allow_file: 

824 if allowlist_needs_newline: 

825 allow_file.write("\n") 

826 allow_file.write(ender_chest_abspath + "\n") 

827 

828 GATHER_LOGGER.info(f"{symlink_allowlist} updated.") 

829 

830 

831def _needs_symlink_allowlist(version: str) -> bool: 

832 """Determine if a version needs `allowed_symlinks.txt` in order to link 

833 to EnderChest. Note that this is going a little broader than is strictly 

834 necessary. 

835 

836 Parameters 

837 ---------- 

838 version: str 

839 The version string to check against 

840 

841 Returns 

842 ------- 

843 bool 

844 Returns False if the Minecraft version predates the symlink ban. Returns 

845 True if it doesn't (or is marginal). 

846 

847 Notes 

848 ----- 

849 Have I mentioned that parsing Minecraft version strings is a pain in the 

850 toucans? 

851 """ 

852 # first see if it follows basic semver 

853 if _matches_version(">1.19", parse_version(version.split("-")[0])): 

854 return True 

855 if _matches_version("1.20.0*", parse_version(version.split("-")[0])): 

856 return True 

857 # is it a snapshot? 

858 if match := re.match("^([1-2][0-9])w([0-9]{1,2})", version.lower()): 

859 year, week = match.groups() 

860 if int(year) > 23: 

861 return True 

862 if int(year) == 23 and int(week) > 18: 

863 return True 

864 

865 return False