Coverage for enderchest/gather.py: 82%

255 statements  

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

1"""Functionality for onboarding and updating new installations and instances""" 

2 

3import itertools 

4import json 

5import logging 

6import os 

7import re 

8from collections.abc import Iterable 

9from configparser import ConfigParser, ParsingError 

10from pathlib import Path 

11from typing import Any, TypedDict 

12from urllib.parse import ParseResult 

13 

14from . import filesystem as fs 

15from .enderchest import EnderChest, create_ender_chest 

16from .instance import InstanceSpec, normalize_modloader, parse_version 

17from .inventory import load_ender_chest 

18from .loggers import GATHER_LOGGER 

19from .prompt import prompt 

20from .shulker_box import _matches_version 

21 

22 

23def gather_minecraft_instances( 

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

25) -> list[InstanceSpec]: 

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

27 any that are can be found and parsed 

28 

29 Parameters 

30 ---------- 

31 minecraft_root : Path 

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

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

34 construct relative paths. 

35 search_path : Path 

36 The path to search 

37 official : bool or None 

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

39 - from the official launcher (official=True) 

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

41 - a mix / unsure (official=None) 

42 

43 Returns 

44 ------- 

45 list of InstanceSpec 

46 A list of parsed instances 

47 

48 Notes 

49 ----- 

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

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

52 continue on. 

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

54 method will return an empty list. 

55 """ 

56 try: 

57 ender_chest = load_ender_chest(minecraft_root) 

58 except FileNotFoundError: 

59 # because this method can be called during crafting 

60 ender_chest = EnderChest(minecraft_root) 

61 GATHER_LOGGER.debug("Searching for Minecraft folders inside %s", search_path) 

62 instances: list[InstanceSpec] = [] 

63 for folder in fs.minecraft_folders(search_path): 

64 folder_path = folder.absolute() 

65 GATHER_LOGGER.debug("Found minecraft installation at %s", folder) 

66 if official is not False: 

67 try: 

68 instances.append(gather_metadata_for_official_instance(folder_path)) 

69 GATHER_LOGGER.info( 

70 "Gathered official Minecraft installation from %s", folder 

71 ) 

72 _check_for_allowed_symlinks(ender_chest, instances[-1]) 

73 continue 

74 except ValueError as not_official: 

75 GATHER_LOGGER.log( 

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

77 ("%s is not an official instance:\n%s", folder, not_official), 

78 ) 

79 if official is not True: 

80 try: 

81 instances.append(gather_metadata_for_mmc_instance(folder_path)) 

82 GATHER_LOGGER.info( 

83 "Gathered MMC-like Minecraft installation from %s", folder 

84 ) 

85 _check_for_allowed_symlinks(ender_chest, instances[-1]) 

86 continue 

87 except ValueError as not_mmc: 

88 GATHER_LOGGER.log( 

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

90 "%s is not an MMC-like instance:\n%s", 

91 folder, 

92 not_mmc, 

93 ) 

94 GATHER_LOGGER.warning( 

95 "%s does not appear to be a valid Minecraft instance", folder_path 

96 ) 

97 for i, mc_instance in enumerate(instances): 

98 try: 

99 instances[i] = mc_instance._replace( 

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

101 ) 

102 except ValueError: 

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

104 pass # instance isn't inside the minecraft root 

105 if not instances: 

106 GATHER_LOGGER.warning( 

107 "Could not find any Minecraft instances inside %s", search_path 

108 ) 

109 return instances 

110 

111 

112def gather_metadata_for_official_instance( 

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

114) -> InstanceSpec: 

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

116 

117 Parameters 

118 ---------- 

119 minecraft_folder : Path 

120 The path to the installation's .minecraft folder 

121 name : str, optional 

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

123 default name is "official" 

124 

125 Returns 

126 ------- 

127 InstanceSpec 

128 The metadata for this instance 

129 

130 Raises 

131 ------ 

132 ValueError 

133 If this is not a valid official Minecraft installation 

134 

135 Notes 

136 ----- 

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

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

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

140 will be included in the version list. 

141 """ 

142 launcher_profile_file = minecraft_folder / "launcher_profiles.json" 

143 try: 

144 with launcher_profile_file.open() as lp_json: 

145 launcher_profiles = json.load(lp_json) 

146 raw_versions: list[str] = [ 

147 profile["lastVersionId"] 

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

149 ] 

150 except FileNotFoundError as no_json: 

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

152 except json.JSONDecodeError as bad_json: 

153 raise ValueError( 

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

155 ) from bad_json 

156 except KeyError as weird_json: 

157 raise ValueError( 

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

159 ) from weird_json 

160 

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

162 try: 

163 with version_manifest_file.open() as vm_json: 

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

165 except FileNotFoundError as no_json: 

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

167 except json.JSONDecodeError as bad_json: 

168 raise ValueError( 

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

170 ) from bad_json 

171 except KeyError: 

172 GATHER_LOGGER.warning( 

173 "%s has no latest-version lookup." 

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

175 version_manifest_file, 

176 ) 

177 version_lookup = {} 

178 

179 versions: list[str] = [] 

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

181 for version in raw_versions: 

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

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

184 if mapped_version is not None: 

185 versions.append(parse_version(mapped_version)) 

186 groups.append(version) 

187 continue 

188 versions.append(parse_version(version)) 

189 

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

191 

192 

193def gather_metadata_for_mmc_instance( 

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

195) -> InstanceSpec: 

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

197 

198 Parameters 

199 ---------- 

200 minecraft_folder : Path 

201 The path to the installation's .minecraft folder 

202 instgroups_file : Path 

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

204 look for it two directories up from the minecraft folder 

205 

206 Returns 

207 ------- 

208 InstanceSpec 

209 The metadata for this instance 

210 

211 Raises 

212 ------ 

213 ValueError 

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

215 

216 Notes 

217 ----- 

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

219 to try ensuring that minecraft_folder is an absolute path. 

220 """ 

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

222 try: 

223 with mmc_pack_file.open() as mmc_json: 

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

225 

226 version: str | None = None 

227 modloader: str | None = None 

228 

229 for component in components: 

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

231 case "net.minecraft", _: 

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

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

234 modloader = "Fabric Loader" 

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

236 modloader = "Quilt Loader" 

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

238 modloader = "Forge" 

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

240 modloader = name 

241 case _: 

242 continue 

243 modloader = normalize_modloader(modloader)[0] 

244 if version is None: 

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

246 except FileNotFoundError as no_json: 

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

248 except json.JSONDecodeError as bad_json: 

249 raise ValueError( 

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

251 ) from bad_json 

252 except KeyError as weird_json: 

253 raise ValueError( 

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

255 ) from weird_json 

256 

257 name = minecraft_folder.parent.name 

258 

259 instance_groups: list[str] = [] 

260 

261 if name == "": 

262 GATHER_LOGGER.warning( 

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

264 " and thus could not load tags." 

265 ) 

266 else: 

267 instgroups_file = ( 

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

269 ) 

270 

271 try: 

272 with instgroups_file.open() as groups_json: 

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

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

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

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

277 instance_groups.append(group) 

278 

279 except FileNotFoundError: 

280 GATHER_LOGGER.warning( 

281 "Could not find %s and thus could not load tags", instgroups_file 

282 ) 

283 except json.JSONDecodeError: 

284 GATHER_LOGGER.warning( 

285 "%s is corrupt and could not be parsed for tags", instgroups_file 

286 ) 

287 except KeyError: 

288 GATHER_LOGGER.warning("Could not parse tags from %s", instgroups_file) 

289 

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

291 

292 try: 

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

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

295 name = parser["instance"]["name"] 

296 except FileNotFoundError: 

297 GATHER_LOGGER.warning( 

298 "Could not find %s and thus could not load the instance name", instance_cfg 

299 ) 

300 except ParsingError: 

301 GATHER_LOGGER.warning( 

302 "is corrupt and could not be parsed the instance name", instance_cfg 

303 ) 

304 except KeyError: 

305 GATHER_LOGGER.warning(f"Could not parse instance name from %s", instance_cfg) 

306 

307 if name == "": 

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

309 

310 return InstanceSpec( 

311 name, 

312 minecraft_folder, 

313 (version,), 

314 modloader or "", 

315 tuple(instance_groups), 

316 (), 

317 ) 

318 

319 

320SERVER_JAR_PATTERNS: tuple[str, ...] = ( 

321 r"^(minecraft_server).([^-]*).jar$", # vanilla naming as per official docs 

322 # (not much we can do with server.jar) 

323 r"^(forge)-([0-9\.]*)-([0-9\.]*).*\.jar$", 

324 r"^(fabric)-server-mc.([^-]*)-loader.([0-9\.]*)-launcher.([0-9\.]*).jar$", 

325 r"^(paper)-([^-]*)-([0-9]*).jar$", 

326 r"^(purpur)-([^-]*)-([0-9]*).jar$", 

327 r"^(spigot)-([^-]*).jar$", 

328) 

329 

330 

331class _JarFileMeta(TypedDict): 

332 modloader: str 

333 minecraft_versions: tuple[str] 

334 

335 

336def _gather_metadata_from_jar_filename(jar_name: str) -> _JarFileMeta: 

337 """ 

338 

339 Parameters 

340 ---------- 

341 jar_name : str 

342 The filename of the server jar 

343 

344 Returns 

345 ------- 

346 dict with two entries : 

347 modloader : str 

348 The (display) name of the modloader (vanilla corresponds to "") 

349 minecraft_versions : tuple of single str 

350 The minecraft version of the instance (tupled for `InstanceSpec` 

351 compatibility). 

352 

353 Notes 

354 ----- 

355 The filename may contain additional metadata (such as the modloader version). 

356 That metadata is ignored. 

357 

358 Raises 

359 ------ 

360 ValueError 

361 If the filename doesn't conform to any known patterns and thus 

362 metadata cannot be extracted). 

363 """ 

364 for pattern in SERVER_JAR_PATTERNS: 

365 if pattern_match := re.match(pattern, jar_name): 

366 modloader, version, *_ = pattern_match.groups() 

367 break 

368 else: 

369 raise ValueError(f"Could not parse metadata from jar filename {jar_name}") 

370 return { 

371 "modloader": normalize_modloader(modloader)[0], 

372 "minecraft_versions": (version,), 

373 } 

374 

375 

376def gather_metadata_for_minecraft_server( 

377 server_home: Path, 

378 name: str | None = None, 

379 tags: Iterable[str] | None = None, 

380 server_jar: Path | None = None, 

381) -> InstanceSpec: 

382 """Parse files (or user input) to generate metadata for a minecraft server 

383 installation 

384 

385 Parameters 

386 ---------- 

387 server_home : Path 

388 The working directory of the Minecraft server 

389 name : str, optional 

390 A name or alias to give to the server. If None is provided, the user 

391 will be prompted to enter it. 

392 tags : list of str, optional 

393 The tags to assign to the server. If None are specified, the user will 

394 be prompted to enter them. 

395 server_jar : Path, optional 

396 The path to the server JAR file. If None is provided, this method will 

397 attempt to locate it within the `server_home`. 

398 

399 Returns 

400 ------- 

401 InstanceSpec 

402 The metadata for this instance 

403 

404 Raises 

405 ------ 

406 ValueError 

407 If this is not a valid Minecraft server installation or the requisite 

408 metadata could not be parsed 

409 

410 Notes 

411 ----- 

412 This method extracts metadata entirely from the filename of the server jar 

413 file. Custom-named jars or executables in non-standard locations will 

414 require their metadata be added manually. 

415 """ 

416 instance_spec: dict[str, Any] = {"root": server_home, "groups_": ("server",)} 

417 if server_jar is not None: 

418 jars: Iterable[Path] = (server_jar,) 

419 else: 

420 jars = sorted( 

421 filter( 

422 lambda jar: not jar.is_relative_to(server_home / "mods"), 

423 itertools.chain(server_home.rglob("*.jar"), server_home.rglob("*.JAR")), 

424 ), 

425 key=lambda jar: (len(jar.parts), -len(str(jar))), 

426 ) 

427 

428 failed_parses: list[Path] = [] 

429 for jar in jars: 

430 GATHER_LOGGER.debug("Attempting to extract server metadata from %s", jar) 

431 try: 

432 instance_spec.update(_gather_metadata_from_jar_filename(jar.name.lower())) 

433 break 

434 except ValueError as parse_fail: 

435 GATHER_LOGGER.debug(parse_fail) 

436 failed_parses.append(jar) 

437 else: 

438 GATHER_LOGGER.warning( 

439 "Could not parse server metadata from:\n%s", 

440 "\n".join((f" - {jar}" for jar in failed_parses)), 

441 ) 

442 if "modloader" not in instance_spec: 

443 instance_spec["modloader"] = normalize_modloader( 

444 prompt( 

445 "What modloader / type of server is this?" 

446 "\ne.g. Vanilla, Fabric, Forge, Paper, Spigot, PurPur..." 

447 ) 

448 .lower() 

449 .strip() 

450 )[0] 

451 instance_spec["minecraft_versions"] = ( 

452 prompt( 

453 "What version of Minecraft is this server?\ne.g.1.20.4, 23w13a_or_b..." 

454 ) 

455 .lower() 

456 .strip(), 

457 ) 

458 if name is None: 

459 name = prompt( 

460 "Enter a name / alias for this server", suggestion=server_home.name 

461 ) 

462 if name == "": 

463 name = server_home.name 

464 instance_spec["name"] = name 

465 

466 if tags is None: 

467 tags = prompt( 

468 "Enter any tags you'd like to use to label the server, separated by commas" 

469 '(it will be tagged as "server" automatically).' 

470 ) 

471 if tags == "": 

472 tags = () 

473 else: 

474 tags = (tag.lower().strip() for tag in tags.split(",")) 

475 instance_spec["tags_"] = tuple(tags) 

476 

477 return InstanceSpec(**instance_spec) 

478 

479 

480def update_ender_chest( 

481 minecraft_root: Path, 

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

483 instance_type: str | None = None, 

484 remotes: ( 

485 Iterable[str | ParseResult | tuple[str, str] | tuple[ParseResult, str]] | None 

486 ) = None, 

487 **server_meta, 

488) -> None: 

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

490 EnderChest installations 

491 

492 Parameters 

493 ---------- 

494 minecraft_root : Path 

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

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

497 search_paths : list of Paths, optional 

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

499 Be warned that this search is performed recursively. 

500 instance_type : str, optional 

501 Optionally specify the type of the Minecraft instances you expect to find. 

502 Options are: 

503 

504 - from the official launcher (`instance_type="official"`) 

505 - from a MultiMC derivative (`instance_type="mmc"`) 

506 - server (in which case, each search path will be accepted verbatim 

507 as the server's home directory) (`instance_type="server"`) 

508 

509 If `None` is specified, this method will search for both official and 

510 MMC-style instances (but not servers). 

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

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

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

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

515 replace it. 

516 **server_meta 

517 Pass-through for metadata to pass through to any gathered servers (such 

518 as name or jar location) 

519 """ 

520 try: 

521 ender_chest = load_ender_chest(minecraft_root) 

522 except (FileNotFoundError, ValueError) as bad_chest: 

523 GATHER_LOGGER.error( 

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

525 ) 

526 return 

527 for search_path in search_paths or (): 

528 match instance_type: 

529 case "server": 

530 instance = gather_metadata_for_minecraft_server( 

531 Path(search_path), **server_meta 

532 ) 

533 _ = ender_chest.register_instance(instance) 

534 continue 

535 case "official": 

536 official: bool | None = True 

537 case "mmc": 

538 official = False 

539 case None: 

540 official = None 

541 case _: 

542 raise NotImplementedError( 

543 f"{instance_type} instances are not currently supported." 

544 ) 

545 for instance in gather_minecraft_instances( 

546 minecraft_root, Path(search_path), official=official 

547 ): 

548 _ = ender_chest.register_instance(instance) 

549 

550 for remote in remotes or (): 

551 try: 

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

553 ender_chest.register_remote(remote) 

554 else: 

555 ender_chest.register_remote(*remote) 

556 except ValueError as bad_remote: 

557 GATHER_LOGGER.warning(bad_remote) 

558 

559 create_ender_chest(minecraft_root, ender_chest) 

560 

561 

562def _check_for_allowed_symlinks( 

563 ender_chest: EnderChest, instance: InstanceSpec 

564) -> None: 

565 """Check if the instance: 

566 - is 1.20+ 

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

568 

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

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

571 

572 Parameters 

573 ---------- 

574 ender_chest : EnderChest 

575 This EnderChest 

576 instance : InstanceSpec 

577 The instance spec to check 

578 """ 

579 if ender_chest.offer_to_update_symlink_allowlist is False: 

580 return 

581 

582 if not any( 

583 _needs_symlink_allowlist(version) for version in instance.minecraft_versions 

584 ): 

585 return 

586 ender_chest_abspath = os.path.realpath(ender_chest.root) 

587 

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

589 

590 try: 

591 allowlist_contents = symlink_allowlist.read_text() 

592 already_allowed = ender_chest_abspath in allowlist_contents.splitlines() 

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

594 except FileNotFoundError: 

595 already_allowed = False 

596 allowlist_needs_newline = False 

597 

598 if already_allowed: 

599 return 

600 

601 GATHER_LOGGER.warning( 

602 """ 

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

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

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

606 ) 

607 

608 response = prompt( 

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

610 "Y/n", 

611 ) 

612 

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

614 return 

615 

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

617 if allowlist_needs_newline: 

618 allow_file.write("\n") 

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

620 

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

622 

623 

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

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

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

627 necessary. 

628 

629 Parameters 

630 ---------- 

631 version: str 

632 The version string to check against 

633 

634 Returns 

635 ------- 

636 bool 

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

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

639 

640 Notes 

641 ----- 

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

643 toucans? 

644 """ 

645 # first see if it follows basic semver 

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

647 return True 

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

649 return True 

650 # is it a snapshot? 

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

652 year, week = match.groups() 

653 if int(year) > 23: 

654 return True 

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

656 return True 

657 

658 return False