Coverage for enderchest/gather.py: 82%

254 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-05-04 01:41 +0000

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

2 

3import itertools 

4import json 

5import logging 

6import os 

7import re 

8from configparser import ConfigParser, ParsingError 

9from pathlib import Path 

10from typing import Any, Iterable, TypedDict 

11from urllib.parse import ParseResult 

12 

13from . import filesystem as fs 

14from .enderchest import EnderChest, create_ender_chest 

15from .instance import InstanceSpec, normalize_modloader, parse_version 

16from .inventory import load_ender_chest 

17from .loggers import GATHER_LOGGER 

18from .prompt import prompt 

19from .shulker_box import _matches_version 

20 

21 

22def gather_minecraft_instances( 

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

24) -> list[InstanceSpec]: 

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

26 any that are can be found and parsed 

27 

28 Parameters 

29 ---------- 

30 minecraft_root : Path 

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

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

33 construct relative paths. 

34 search_path : Path 

35 The path to search 

36 official : bool or None 

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

38 - from the official launcher (official=True) 

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

40 - a mix / unsure (official=None) 

41 

42 Returns 

43 ------- 

44 list of InstanceSpec 

45 A list of parsed instances 

46 

47 Notes 

48 ----- 

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

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

51 continue on. 

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

53 method will return an empty list. 

54 """ 

55 try: 

56 ender_chest = load_ender_chest(minecraft_root) 

57 except FileNotFoundError: 

58 # because this method can be called during crafting 

59 ender_chest = EnderChest(minecraft_root) 

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

61 instances: list[InstanceSpec] = [] 

62 for folder in fs.minecraft_folders(search_path): 

63 folder_path = folder.absolute() 

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

65 if official is not False: 

66 try: 

67 instances.append(gather_metadata_for_official_instance(folder_path)) 

68 GATHER_LOGGER.info( 

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

70 ) 

71 _check_for_allowed_symlinks(ender_chest, instances[-1]) 

72 continue 

73 except ValueError as not_official: 

74 GATHER_LOGGER.log( 

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

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

77 ) 

78 if official is not True: 

79 try: 

80 instances.append(gather_metadata_for_mmc_instance(folder_path)) 

81 GATHER_LOGGER.info( 

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

83 ) 

84 _check_for_allowed_symlinks(ender_chest, instances[-1]) 

85 continue 

86 except ValueError as not_mmc: 

87 GATHER_LOGGER.log( 

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

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

90 ) 

91 GATHER_LOGGER.warning( 

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

93 ) 

94 for i, mc_instance in enumerate(instances): 

95 try: 

96 instances[i] = mc_instance._replace( 

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

98 ) 

99 except ValueError: 

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

101 pass # instance isn't inside the minecraft root 

102 if not instances: 

103 GATHER_LOGGER.warning( 

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

105 ) 

106 return instances 

107 

108 

109def gather_metadata_for_official_instance( 

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

111) -> InstanceSpec: 

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

113 

114 Parameters 

115 ---------- 

116 minecraft_folder : Path 

117 The path to the installation's .minecraft folder 

118 name : str, optional 

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

120 default name is "official" 

121 

122 Returns 

123 ------- 

124 InstanceSpec 

125 The metadata for this instance 

126 

127 Raises 

128 ------ 

129 ValueError 

130 If this is not a valid official Minecraft installation 

131 

132 Notes 

133 ----- 

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

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

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

137 will be included in the version list. 

138 """ 

139 launcher_profile_file = minecraft_folder / "launcher_profiles.json" 

140 try: 

141 with launcher_profile_file.open() as lp_json: 

142 launcher_profiles = json.load(lp_json) 

143 raw_versions: list[str] = [ 

144 profile["lastVersionId"] 

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

146 ] 

147 except FileNotFoundError as no_json: 

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

149 except json.JSONDecodeError as bad_json: 

150 raise ValueError( 

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

152 ) from bad_json 

153 except KeyError as weird_json: 

154 raise ValueError( 

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

156 ) from weird_json 

157 

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

159 try: 

160 with version_manifest_file.open() as vm_json: 

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

162 except FileNotFoundError as no_json: 

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

164 except json.JSONDecodeError as bad_json: 

165 raise ValueError( 

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

167 ) from bad_json 

168 except KeyError as weird_json: 

169 GATHER_LOGGER.warning( 

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

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

172 ) 

173 version_lookup = {} 

174 

175 versions: list[str] = [] 

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

177 for version in raw_versions: 

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

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

180 if mapped_version is not None: 

181 versions.append(parse_version(mapped_version)) 

182 groups.append(version) 

183 continue 

184 versions.append(parse_version(version)) 

185 

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

187 

188 

189def gather_metadata_for_mmc_instance( 

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

191) -> InstanceSpec: 

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

193 

194 Parameters 

195 ---------- 

196 minecraft_folder : Path 

197 The path to the installation's .minecraft folder 

198 instgroups_file : Path 

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

200 look for it two directories up from the minecraft folder 

201 

202 Returns 

203 ------- 

204 InstanceSpec 

205 The metadata for this instance 

206 

207 Raises 

208 ------ 

209 ValueError 

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

211 

212 Notes 

213 ----- 

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

215 to try ensuring that minecraft_folder is an absolute path. 

216 """ 

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

218 try: 

219 with mmc_pack_file.open() as mmc_json: 

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

221 

222 version: str | None = None 

223 modloader: str | None = None 

224 

225 for component in components: 

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

227 case "net.minecraft", _: 

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

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

230 modloader = "Fabric Loader" 

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

232 modloader = "Quilt Loader" 

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

234 modloader = "Forge" 

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

236 modloader = name 

237 case _: 

238 continue 

239 modloader = normalize_modloader(modloader)[0] 

240 if version is None: 

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

242 except FileNotFoundError as no_json: 

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

244 except json.JSONDecodeError as bad_json: 

245 raise ValueError( 

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

247 ) from bad_json 

248 except KeyError as weird_json: 

249 raise ValueError( 

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

251 ) from weird_json 

252 

253 name = minecraft_folder.parent.name 

254 

255 instance_groups: list[str] = [] 

256 

257 if name == "": 

258 GATHER_LOGGER.warning( 

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

260 " and thus could not load tags." 

261 ) 

262 else: 

263 instgroups_file = ( 

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

265 ) 

266 

267 try: 

268 with instgroups_file.open() as groups_json: 

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

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

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

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

273 instance_groups.append(group) 

274 

275 except FileNotFoundError as no_json: 

276 GATHER_LOGGER.warning( 

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

278 ) 

279 except json.JSONDecodeError as bad_json: 

280 GATHER_LOGGER.warning( 

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

282 ) 

283 except KeyError as weird_json: 

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

285 

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

287 

288 try: 

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

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

291 name = parser["instance"]["name"] 

292 except FileNotFoundError as no_cfg: 

293 GATHER_LOGGER.warning( 

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

295 ) 

296 except ParsingError as no_cfg: 

297 GATHER_LOGGER.warning( 

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

299 ) 

300 except KeyError as weird_json: 

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

302 

303 if name == "": 

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

305 

306 return InstanceSpec( 

307 name, 

308 minecraft_folder, 

309 (version,), 

310 modloader or "", 

311 tuple(instance_groups), 

312 (), 

313 ) 

314 

315 

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

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

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

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

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

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

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

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

324) 

325 

326 

327class _JarFileMeta(TypedDict): 

328 modloader: str 

329 minecraft_versions: tuple[str] 

330 

331 

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

333 """ 

334 

335 Parameters 

336 ---------- 

337 jar_name : str 

338 The filename of the server jar 

339 

340 Returns 

341 ------- 

342 dict with two entries : 

343 modloader : str 

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

345 minecraft_versions : tuple of single str 

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

347 compatibility). 

348 

349 Notes 

350 ----- 

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

352 That metadata is ignored. 

353 

354 Raises 

355 ------ 

356 ValueError 

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

358 metadata cannot be extracted). 

359 """ 

360 for pattern in SERVER_JAR_PATTERNS: 

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

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

363 break 

364 else: 

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

366 return { 

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

368 "minecraft_versions": (version,), 

369 } 

370 

371 

372def gather_metadata_for_minecraft_server( 

373 server_home: Path, 

374 name: str | None = None, 

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

376 server_jar: Path | None = None, 

377) -> InstanceSpec: 

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

379 installation 

380 

381 Parameters 

382 ---------- 

383 server_home : Path 

384 The working directory of the Minecraft server 

385 name : str, optional 

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

387 will be prompted to enter it. 

388 tags : list of str, optional 

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

390 be prompted to enter them. 

391 server_jar : Path, optional 

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

393 attempt to locate it within the `server_home`. 

394 

395 Returns 

396 ------- 

397 InstanceSpec 

398 The metadata for this instance 

399 

400 Raises 

401 ------ 

402 ValueError 

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

404 metadata could not be parsed 

405 

406 Notes 

407 ----- 

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

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

410 require their metadata be added manually. 

411 """ 

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

413 if server_jar is not None: 

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

415 else: 

416 jars = sorted( 

417 filter( 

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

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

420 ), 

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

422 ) 

423 

424 failed_parses: list[Path] = [] 

425 for jar in jars: 

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

427 try: 

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

429 break 

430 except ValueError as parse_fail: 

431 GATHER_LOGGER.debug(parse_fail) 

432 failed_parses.append(jar) 

433 else: 

434 GATHER_LOGGER.warning( 

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

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

437 ) 

438 if "modloader" not in instance_spec: 

439 instance_spec["modloader"] = normalize_modloader( 

440 prompt( 

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

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

443 ) 

444 .lower() 

445 .strip() 

446 )[0] 

447 instance_spec["minecraft_versions"] = ( 

448 prompt( 

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

450 ) 

451 .lower() 

452 .strip(), 

453 ) 

454 if name is None: 

455 name = prompt( 

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

457 ) 

458 if name == "": 

459 name = server_home.name 

460 instance_spec["name"] = name 

461 

462 if tags is None: 

463 tags = prompt( 

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

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

466 ) 

467 if tags == "": 

468 tags = () 

469 else: 

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

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

472 

473 return InstanceSpec(**instance_spec) 

474 

475 

476def update_ender_chest( 

477 minecraft_root: Path, 

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

479 instance_type: str | None = None, 

480 remotes: ( 

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

482 ) = None, 

483 **server_meta, 

484) -> None: 

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

486 EnderChest installations 

487 

488 Parameters 

489 ---------- 

490 minecraft_root : Path 

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

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

493 search_paths : list of Paths, optional 

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

495 Be warned that this search is performed recursively. 

496 instance_type : str, optional 

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

498 Options are: 

499 

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

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

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

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

504 

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

506 MMC-style instances (but not servers). 

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

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

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

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

511 replace it. 

512 **server_meta 

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

514 as name or jar location) 

515 """ 

516 try: 

517 ender_chest = load_ender_chest(minecraft_root) 

518 except (FileNotFoundError, ValueError) as bad_chest: 

519 GATHER_LOGGER.error( 

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

521 ) 

522 return 

523 for search_path in search_paths or (): 

524 match instance_type: 

525 case "server": 

526 instance = gather_metadata_for_minecraft_server( 

527 Path(search_path), **server_meta 

528 ) 

529 _ = ender_chest.register_instance(instance) 

530 continue 

531 case "official": 

532 official: bool | None = True 

533 case "mmc": 

534 official = False 

535 case None: 

536 official = None 

537 case _: 

538 raise NotImplementedError( 

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

540 ) 

541 for instance in gather_minecraft_instances( 

542 minecraft_root, Path(search_path), official=official 

543 ): 

544 _ = ender_chest.register_instance(instance) 

545 

546 for remote in remotes or (): 

547 try: 

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

549 ender_chest.register_remote(remote) 

550 else: 

551 ender_chest.register_remote(*remote) 

552 except ValueError as bad_remote: 

553 GATHER_LOGGER.warning(bad_remote) 

554 

555 create_ender_chest(minecraft_root, ender_chest) 

556 

557 

558def _check_for_allowed_symlinks( 

559 ender_chest: EnderChest, instance: InstanceSpec 

560) -> None: 

561 """Check if the instance: 

562 - is 1.20+ 

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

564 

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

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

567 

568 Parameters 

569 ---------- 

570 ender_chest : EnderChest 

571 This EnderChest 

572 instance : InstanceSpec 

573 The instance spec to check 

574 """ 

575 if ender_chest.offer_to_update_symlink_allowlist is False: 

576 return 

577 

578 if not any( 

579 _needs_symlink_allowlist(version) for version in instance.minecraft_versions 

580 ): 

581 return 

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

583 

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

585 

586 try: 

587 allowlist_contents = symlink_allowlist.read_text() 

588 already_allowed = ender_chest_abspath in allowlist_contents.splitlines() 

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

590 except FileNotFoundError: 

591 already_allowed = False 

592 allowlist_needs_newline = False 

593 

594 if already_allowed: 

595 return 

596 

597 GATHER_LOGGER.warning( 

598 """ 

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

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

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

602 ) 

603 

604 response = prompt( 

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

606 "Y/n", 

607 ) 

608 

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

610 return 

611 

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

613 if allowlist_needs_newline: 

614 allow_file.write("\n") 

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

616 

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

618 

619 

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

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

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

623 necessary. 

624 

625 Parameters 

626 ---------- 

627 version: str 

628 The version string to check against 

629 

630 Returns 

631 ------- 

632 bool 

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

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

635 

636 Notes 

637 ----- 

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

639 toucans? 

640 """ 

641 # first see if it follows basic semver 

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

643 return True 

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

645 return True 

646 # is it a snapshot? 

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

648 year, week = match.groups() 

649 if int(year) > 23: 

650 return True 

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

652 return True 

653 

654 return False