Coverage for enderchest/gather.py: 82%

255 statements  

« prev     ^ index     » next       coverage.py v7.7.1, created at 2025-03-28 20:32 +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(f"Searching for Minecraft folders inside {search_path}") 

62 instances: list[InstanceSpec] = [] 

63 for folder in fs.minecraft_folders(search_path): 

64 folder_path = folder.absolute() 

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

66 if official is not False: 

67 try: 

68 instances.append(gather_metadata_for_official_instance(folder_path)) 

69 GATHER_LOGGER.info( 

70 f"Gathered official Minecraft installation from {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 (f"{folder} is not an official instance:" f"\n{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 f"Gathered MMC-like Minecraft installation from {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 f"{folder} is not an MMC-like instance:\n{not_mmc}", 

91 ) 

92 GATHER_LOGGER.warning( 

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

94 ) 

95 for i, mc_instance in enumerate(instances): 

96 try: 

97 instances[i] = mc_instance._replace( 

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

99 ) 

100 except ValueError: 

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

102 pass # instance isn't inside the minecraft root 

103 if not instances: 

104 GATHER_LOGGER.warning( 

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

106 ) 

107 return instances 

108 

109 

110def gather_metadata_for_official_instance( 

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

112) -> InstanceSpec: 

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

114 

115 Parameters 

116 ---------- 

117 minecraft_folder : Path 

118 The path to the installation's .minecraft folder 

119 name : str, optional 

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

121 default name is "official" 

122 

123 Returns 

124 ------- 

125 InstanceSpec 

126 The metadata for this instance 

127 

128 Raises 

129 ------ 

130 ValueError 

131 If this is not a valid official Minecraft installation 

132 

133 Notes 

134 ----- 

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

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

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

138 will be included in the version list. 

139 """ 

140 launcher_profile_file = minecraft_folder / "launcher_profiles.json" 

141 try: 

142 with launcher_profile_file.open() as lp_json: 

143 launcher_profiles = json.load(lp_json) 

144 raw_versions: list[str] = [ 

145 profile["lastVersionId"] 

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

147 ] 

148 except FileNotFoundError as no_json: 

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

150 except json.JSONDecodeError as bad_json: 

151 raise ValueError( 

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

153 ) from bad_json 

154 except KeyError as weird_json: 

155 raise ValueError( 

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

157 ) from weird_json 

158 

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

160 try: 

161 with version_manifest_file.open() as vm_json: 

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

163 except FileNotFoundError as no_json: 

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

165 except json.JSONDecodeError as bad_json: 

166 raise ValueError( 

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

168 ) from bad_json 

169 except KeyError as weird_json: 

170 GATHER_LOGGER.warning( 

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

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

173 ) 

174 version_lookup = {} 

175 

176 versions: list[str] = [] 

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

178 for version in raw_versions: 

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

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

181 if mapped_version is not None: 

182 versions.append(parse_version(mapped_version)) 

183 groups.append(version) 

184 continue 

185 versions.append(parse_version(version)) 

186 

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

188 

189 

190def gather_metadata_for_mmc_instance( 

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

192) -> InstanceSpec: 

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

194 

195 Parameters 

196 ---------- 

197 minecraft_folder : Path 

198 The path to the installation's .minecraft folder 

199 instgroups_file : Path 

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

201 look for it two directories up from the minecraft folder 

202 

203 Returns 

204 ------- 

205 InstanceSpec 

206 The metadata for this instance 

207 

208 Raises 

209 ------ 

210 ValueError 

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

212 

213 Notes 

214 ----- 

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

216 to try ensuring that minecraft_folder is an absolute path. 

217 """ 

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

219 try: 

220 with mmc_pack_file.open() as mmc_json: 

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

222 

223 version: str | None = None 

224 modloader: str | None = None 

225 

226 for component in components: 

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

228 case "net.minecraft", _: 

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

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

231 modloader = "Fabric Loader" 

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

233 modloader = "Quilt Loader" 

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

235 modloader = "Forge" 

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

237 modloader = name 

238 case _: 

239 continue 

240 modloader = normalize_modloader(modloader)[0] 

241 if version is None: 

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

243 except FileNotFoundError as no_json: 

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

245 except json.JSONDecodeError as bad_json: 

246 raise ValueError( 

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

248 ) from bad_json 

249 except KeyError as weird_json: 

250 raise ValueError( 

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

252 ) from weird_json 

253 

254 name = minecraft_folder.parent.name 

255 

256 instance_groups: list[str] = [] 

257 

258 if name == "": 

259 GATHER_LOGGER.warning( 

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

261 " and thus could not load tags." 

262 ) 

263 else: 

264 instgroups_file = ( 

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

266 ) 

267 

268 try: 

269 with instgroups_file.open() as groups_json: 

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

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

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

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

274 instance_groups.append(group) 

275 

276 except FileNotFoundError as no_json: 

277 GATHER_LOGGER.warning( 

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

279 ) 

280 except json.JSONDecodeError as bad_json: 

281 GATHER_LOGGER.warning( 

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

283 ) 

284 except KeyError as weird_json: 

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

286 

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

288 

289 try: 

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

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

292 name = parser["instance"]["name"] 

293 except FileNotFoundError as no_cfg: 

294 GATHER_LOGGER.warning( 

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

296 ) 

297 except ParsingError as no_cfg: 

298 GATHER_LOGGER.warning( 

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

300 ) 

301 except KeyError as weird_json: 

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

303 

304 if name == "": 

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

306 

307 return InstanceSpec( 

308 name, 

309 minecraft_folder, 

310 (version,), 

311 modloader or "", 

312 tuple(instance_groups), 

313 (), 

314 ) 

315 

316 

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

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

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

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

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

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

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

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

325) 

326 

327 

328class _JarFileMeta(TypedDict): 

329 modloader: str 

330 minecraft_versions: tuple[str] 

331 

332 

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

334 """ 

335 

336 Parameters 

337 ---------- 

338 jar_name : str 

339 The filename of the server jar 

340 

341 Returns 

342 ------- 

343 dict with two entries : 

344 modloader : str 

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

346 minecraft_versions : tuple of single str 

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

348 compatibility). 

349 

350 Notes 

351 ----- 

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

353 That metadata is ignored. 

354 

355 Raises 

356 ------ 

357 ValueError 

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

359 metadata cannot be extracted). 

360 """ 

361 for pattern in SERVER_JAR_PATTERNS: 

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

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

364 break 

365 else: 

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

367 return { 

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

369 "minecraft_versions": (version,), 

370 } 

371 

372 

373def gather_metadata_for_minecraft_server( 

374 server_home: Path, 

375 name: str | None = None, 

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

377 server_jar: Path | None = None, 

378) -> InstanceSpec: 

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

380 installation 

381 

382 Parameters 

383 ---------- 

384 server_home : Path 

385 The working directory of the Minecraft server 

386 name : str, optional 

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

388 will be prompted to enter it. 

389 tags : list of str, optional 

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

391 be prompted to enter them. 

392 server_jar : Path, optional 

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

394 attempt to locate it within the `server_home`. 

395 

396 Returns 

397 ------- 

398 InstanceSpec 

399 The metadata for this instance 

400 

401 Raises 

402 ------ 

403 ValueError 

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

405 metadata could not be parsed 

406 

407 Notes 

408 ----- 

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

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

411 require their metadata be added manually. 

412 """ 

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

414 if server_jar is not None: 

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

416 else: 

417 jars = sorted( 

418 filter( 

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

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

421 ), 

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

423 ) 

424 

425 failed_parses: list[Path] = [] 

426 for jar in jars: 

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

428 try: 

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

430 break 

431 except ValueError as parse_fail: 

432 GATHER_LOGGER.debug(parse_fail) 

433 failed_parses.append(jar) 

434 else: 

435 GATHER_LOGGER.warning( 

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

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

438 ) 

439 if "modloader" not in instance_spec: 

440 instance_spec["modloader"] = normalize_modloader( 

441 prompt( 

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

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

444 ) 

445 .lower() 

446 .strip() 

447 )[0] 

448 instance_spec["minecraft_versions"] = ( 

449 prompt( 

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

451 ) 

452 .lower() 

453 .strip(), 

454 ) 

455 if name is None: 

456 name = prompt( 

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

458 ) 

459 if name == "": 

460 name = server_home.name 

461 instance_spec["name"] = name 

462 

463 if tags is None: 

464 tags = prompt( 

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

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

467 ) 

468 if tags == "": 

469 tags = () 

470 else: 

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

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

473 

474 return InstanceSpec(**instance_spec) 

475 

476 

477def update_ender_chest( 

478 minecraft_root: Path, 

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

480 instance_type: str | None = None, 

481 remotes: ( 

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

483 ) = None, 

484 **server_meta, 

485) -> None: 

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

487 EnderChest installations 

488 

489 Parameters 

490 ---------- 

491 minecraft_root : Path 

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

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

494 search_paths : list of Paths, optional 

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

496 Be warned that this search is performed recursively. 

497 instance_type : str, optional 

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

499 Options are: 

500 

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

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

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

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

505 

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

507 MMC-style instances (but not servers). 

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

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

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

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

512 replace it. 

513 **server_meta 

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

515 as name or jar location) 

516 """ 

517 try: 

518 ender_chest = load_ender_chest(minecraft_root) 

519 except (FileNotFoundError, ValueError) as bad_chest: 

520 GATHER_LOGGER.error( 

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

522 ) 

523 return 

524 for search_path in search_paths or (): 

525 match instance_type: 

526 case "server": 

527 instance = gather_metadata_for_minecraft_server( 

528 Path(search_path), **server_meta 

529 ) 

530 _ = ender_chest.register_instance(instance) 

531 continue 

532 case "official": 

533 official: bool | None = True 

534 case "mmc": 

535 official = False 

536 case None: 

537 official = None 

538 case _: 

539 raise NotImplementedError( 

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

541 ) 

542 for instance in gather_minecraft_instances( 

543 minecraft_root, Path(search_path), official=official 

544 ): 

545 _ = ender_chest.register_instance(instance) 

546 

547 for remote in remotes or (): 

548 try: 

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

550 ender_chest.register_remote(remote) 

551 else: 

552 ender_chest.register_remote(*remote) 

553 except ValueError as bad_remote: 

554 GATHER_LOGGER.warning(bad_remote) 

555 

556 create_ender_chest(minecraft_root, ender_chest) 

557 

558 

559def _check_for_allowed_symlinks( 

560 ender_chest: EnderChest, instance: InstanceSpec 

561) -> None: 

562 """Check if the instance: 

563 - is 1.20+ 

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

565 

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

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

568 

569 Parameters 

570 ---------- 

571 ender_chest : EnderChest 

572 This EnderChest 

573 instance : InstanceSpec 

574 The instance spec to check 

575 """ 

576 if ender_chest.offer_to_update_symlink_allowlist is False: 

577 return 

578 

579 if not any( 

580 _needs_symlink_allowlist(version) for version in instance.minecraft_versions 

581 ): 

582 return 

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

584 

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

586 

587 try: 

588 allowlist_contents = symlink_allowlist.read_text() 

589 already_allowed = ender_chest_abspath in allowlist_contents.splitlines() 

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

591 except FileNotFoundError: 

592 already_allowed = False 

593 allowlist_needs_newline = False 

594 

595 if already_allowed: 

596 return 

597 

598 GATHER_LOGGER.warning( 

599 """ 

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

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

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

603 ) 

604 

605 response = prompt( 

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

607 "Y/n", 

608 ) 

609 

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

611 return 

612 

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

614 if allowlist_needs_newline: 

615 allow_file.write("\n") 

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

617 

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

619 

620 

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

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

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

624 necessary. 

625 

626 Parameters 

627 ---------- 

628 version: str 

629 The version string to check against 

630 

631 Returns 

632 ------- 

633 bool 

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

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

636 

637 Notes 

638 ----- 

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

640 toucans? 

641 """ 

642 # first see if it follows basic semver 

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

644 return True 

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

646 return True 

647 # is it a snapshot? 

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

649 year, week = match.groups() 

650 if int(year) > 23: 

651 return True 

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

653 return True 

654 

655 return False