Coverage for enderchest/cli.py: 86%

140 statements  

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

1"""Command-line interface""" 

2import inspect 

3import logging 

4import os 

5import sys 

6from argparse import ArgumentParser, RawTextHelpFormatter 

7from pathlib import Path 

8from typing import Any, Protocol, Sequence 

9 

10from . import craft, gather, loggers, place, remote 

11from ._version import get_versions 

12 

13# mainly because I think I'm gonna forget what names are canonical (it's the first ones) 

14_create_aliases = ("craft", "create") 

15_instance_aliases = tuple( 

16 alias + plural for alias in ("minecraft", "instance") for plural in ("", "s") 

17) 

18_shulker_aliases = ("shulker_box", "shulkerbox", "shulker") 

19_remote_aliases = tuple( 

20 alias + plural for alias in ("enderchest", "remote") for plural in ("s", "") 

21) 

22_list_aliases = ("inventory", "list") 

23 

24 

25class Action(Protocol): # pragma: no cover 

26 """Common protocol for CLI actions""" 

27 

28 def __call__(self, minecraft_root: Path, /) -> Any: 

29 ... 

30 

31 

32def _place( 

33 minecraft_root: Path, 

34 errors: str = "prompt", 

35 keep_broken_links: bool = False, 

36 keep_stale_links: bool = False, 

37 keep_level: int = 0, 

38 stop_at_first_failure: bool = False, 

39 ignore_errors: bool = False, 

40 absolute: bool = False, 

41 relative: bool = False, 

42) -> None: 

43 """Wrapper sort through all the various argument groups""" 

44 

45 if stop_at_first_failure: 

46 errors = "abort" 

47 if ignore_errors: # elif? 

48 errors = "ignore" 

49 # else: errors = errors 

50 

51 if absolute is True: 

52 # technically we get this for free already 

53 relative = False 

54 

55 if keep_level > 0: 

56 keep_stale_links = True 

57 if keep_level > 1: 

58 keep_broken_links = True 

59 

60 place.place_ender_chest( 

61 minecraft_root, 

62 keep_broken_links=keep_broken_links, 

63 keep_stale_links=keep_stale_links, 

64 error_handling=errors, 

65 relative=relative, 

66 ) 

67 

68 

69def _craft_shulker_box(minecraft_root: Path, name: str | None = None, **kwargs): 

70 """Wrapper to handle the fact that name is a required argument""" 

71 assert name # it's required by the parser, so this should be fine 

72 craft.craft_shulker_box(minecraft_root, name, **kwargs) 

73 

74 

75def _list_instance_boxes( 

76 minecraft_root: Path, instance_name: str | None = None, **kwargs 

77): 

78 """Wrapper to handle the fact that name is a required argument""" 

79 assert instance_name # it's required by the parser, so this should be fine 

80 gather.get_shulker_boxes_matching_instance(minecraft_root, instance_name, **kwargs) 

81 

82 

83def _list_shulker_box( 

84 minecraft_root: Path, shulker_box_name: str | None = None, **kwargs 

85): 

86 """Wrapper to handle the fact that name is a required argument""" 

87 assert shulker_box_name # it's required by the parser, so this should be fine 

88 gather.get_instances_matching_shulker_box( 

89 minecraft_root, shulker_box_name, **kwargs 

90 ) 

91 

92 

93def _update_ender_chest( 

94 minecraft_root: Path, 

95 official: bool | None = None, 

96 mmc: bool | None = None, 

97 **kwargs, 

98): 

99 """Wrapper to resolve the official vs. MultiMC flag""" 

100 if mmc: 

101 official = False 

102 gather.update_ender_chest(minecraft_root, official=official, **kwargs) 

103 

104 

105def _open(minecraft_root: Path, verbosity: int = 0, **kwargs): 

106 """Router for open verb""" 

107 remote.sync_with_remotes(minecraft_root, "pull", verbosity=verbosity, **kwargs) 

108 

109 

110def _close(minecraft_root: Path, verbosity: int = 0, **kwargs): 

111 """Router for close verb""" 

112 remote.sync_with_remotes(minecraft_root, "push", verbosity=verbosity, **kwargs) 

113 

114 

115ACTIONS: tuple[tuple[tuple[str, ...], str, Action], ...] = ( 

116 # action names (first one is canonical), action description, action method 

117 ( 

118 sum(((verb, verb + " enderchest") for verb in _create_aliases), ()), 

119 "create and configure a new EnderChest installation", 

120 craft.craft_ender_chest, 

121 ), 

122 ( 

123 tuple( 

124 f"{verb} {alias}" for verb in _create_aliases for alias in _shulker_aliases 

125 ), 

126 "create and configure a new shulker box", 

127 _craft_shulker_box, 

128 ), 

129 ( 

130 ("place",), 

131 "link (or update the links) from your instances to your EnderChest", 

132 _place, 

133 ), 

134 ( 

135 tuple("gather " + alias for alias in _instance_aliases), 

136 "register (or update the registry of) a Minecraft installation", 

137 _update_ender_chest, 

138 ), 

139 ( 

140 tuple("gather " + alias for alias in _remote_aliases), 

141 "register (or update the registry of) a remote EnderChest", 

142 _update_ender_chest, 

143 ), 

144 ( 

145 # I freely admit this is ridiculous 

146 sum( 

147 ( 

148 ( 

149 verb, 

150 *( 

151 f"{verb} {alias}" 

152 # pluralization is hard 

153 for alias in ("shulker_boxes", "shulkerboxes", "shulkers") 

154 ), 

155 ) 

156 for verb in _list_aliases 

157 ), 

158 (), 

159 ), 

160 "list the shulker boxes inside your Enderchest", 

161 gather.load_shulker_boxes, 

162 ), 

163 ( 

164 tuple( 

165 f"{verb} {alias}" 

166 for verb in _list_aliases 

167 for alias in _instance_aliases 

168 if alias.endswith("s") 

169 ), 

170 "list the minecraft instances registered with your Enderchest", 

171 gather.load_ender_chest_instances, 

172 ), 

173 ( 

174 tuple( 

175 f"{verb} {alias}" 

176 for verb in _list_aliases 

177 for alias in _instance_aliases 

178 if not alias.endswith("s") 

179 ), 

180 "list the shulker boxes that the specified instance links to", 

181 _list_instance_boxes, 

182 ), 

183 ( 

184 tuple( 

185 f"{verb} {alias}" for verb in _list_aliases for alias in _shulker_aliases 

186 ), 

187 "list the minecraft instances that match the specified shulker box", 

188 _list_shulker_box, 

189 ), 

190 ( 

191 tuple(f"{verb} {alias}" for verb in _list_aliases for alias in _remote_aliases), 

192 "list the other EnderChest installations registered with this EnderChest", 

193 gather.load_ender_chest_remotes, 

194 ), 

195 ( 

196 ("open",), 

197 "pull changes from other EnderChests", 

198 _open, 

199 ), 

200 ( 

201 ("close",), 

202 "push changes to other EnderChests", 

203 _close, 

204 ), 

205) 

206 

207 

208def generate_parsers() -> tuple[ArgumentParser, dict[str, ArgumentParser]]: 

209 """Generate the command-line parsers 

210 

211 Returns 

212 ------- 

213 enderchest_parser : ArgumentParser 

214 The top-level argument parser responsible for routing arguments to 

215 specific action parsers 

216 action_parsers : dict of str to ArgumentParser 

217 The verb-specific argument parsers 

218 """ 

219 descriptions: dict[str, str] = {} 

220 root_description: str = "" 

221 for commands, description, _ in ACTIONS: 

222 descriptions[commands[0]] = description 

223 root_description += f"\n\t{commands[0]}\n\t\tto {description}" 

224 

225 enderchest_parser = ArgumentParser( 

226 prog="enderchest", 

227 description=( 

228 f"v{get_versions()['version']}\n" 

229 "\nsyncing and linking for all your Minecraft instances" 

230 ), 

231 formatter_class=RawTextHelpFormatter, 

232 ) 

233 

234 enderchest_parser.add_argument( 

235 "-v", # don't worry--this doesn't actually conflict with --verbose 

236 "-V", 

237 "--version", 

238 action="version", 

239 version=f"%(prog)s v{get_versions()['version']}", 

240 ) 

241 

242 # these are really just for the sake of --help 

243 # (the parsed args aren't actually used) 

244 enderchest_parser.add_argument( 

245 "action", 

246 help=f"the action to perform. Options are:{root_description}", 

247 type=str, 

248 ) 

249 enderchest_parser.add_argument( 

250 "arguments", 

251 nargs="*", 

252 help="any additional arguments for the specific action." 

253 " To learn more, try: enderchest {action} -h", 

254 ) 

255 

256 action_parsers: dict[str, ArgumentParser] = {} 

257 for verb, description in descriptions.items(): 

258 parser = ArgumentParser( 

259 prog=f"enderchest {verb}", 

260 description=description, 

261 ) 

262 root = parser.add_mutually_exclusive_group() 

263 root.add_argument( 

264 "root", 

265 nargs="?", 

266 help=( 

267 "optionally specify your root minecraft directory." 

268 " If no path is given, the current working directory will be used." 

269 ), 

270 type=Path, 

271 ) 

272 root.add_argument( 

273 "--root", 

274 dest="root_flag", 

275 help="specify your root minecraft directory", 

276 type=Path, 

277 ) 

278 

279 # I'm actually okay with -vvqvqqv hilarity 

280 parser.add_argument( 

281 "--verbose", 

282 "-v", 

283 action="count", 

284 default=0, 

285 help="increase the amount of information that's printed", 

286 ) 

287 parser.add_argument( 

288 "--quiet", 

289 "-q", 

290 action="count", 

291 default=0, 

292 help="decrease the amount of information that's printed", 

293 ) 

294 action_parsers[verb] = parser 

295 

296 # craft options 

297 craft_parser = action_parsers[_create_aliases[0]] 

298 craft_parser.add_argument( 

299 "--from", 

300 dest="copy_from", 

301 help=( 

302 "provide the URI (e.g. rsync://deck@my-steam-deck/home/deck/) of a" 

303 " remote EnderChest installation that can be used" 

304 " to boostrap the creation of this one." 

305 ), 

306 ) 

307 craft_parser.add_argument( 

308 "-r", 

309 "--remote", 

310 dest="remotes", 

311 action="append", 

312 help=( 

313 "provide the URI (e.g. rsync://deck@my-steam-deck/home/deck/) of a" 

314 " remote EnderChest installation to register with this one" 

315 ), 

316 ) 

317 craft_parser.add_argument( 

318 "-i", 

319 "--instance", 

320 dest="instance_search_paths", 

321 action="append", 

322 type=Path, 

323 help="specify a folder to search for Minecraft installations in", 

324 ) 

325 craft_parser.add_argument( 

326 "--overwrite", 

327 action="store_true", 

328 help=( 

329 "if there's already an EnderChest installation in this location," 

330 " overwrite its configuration" 

331 ), 

332 ) 

333 

334 # shulker box craft options 

335 shulker_craft_parser = action_parsers[f"{_create_aliases[0]} {_shulker_aliases[0]}"] 

336 shulker_craft_parser.add_argument( 

337 "name", 

338 help="specify the name for this shulker box", 

339 ) 

340 shulker_craft_parser.add_argument( 

341 "--priority", 

342 "-p", 

343 help="specify the link priority for this shulker box (higher = linked later)", 

344 ) 

345 shulker_craft_parser.add_argument( 

346 "-i", 

347 "--instance", 

348 dest="instances", 

349 action="append", 

350 help="only link instances with one of the provided names to this shulker box", 

351 ) 

352 shulker_craft_parser.add_argument( 

353 "-t", 

354 "--tag", 

355 dest="tags", 

356 action="append", 

357 help="only link instances with one of the provided tags to this shulker box", 

358 ) 

359 shulker_craft_parser.add_argument( 

360 "-e", 

361 "--enderchest", 

362 dest="hosts", 

363 action="append", 

364 help=( 

365 "only link instances registered to one of the provided EnderChest" 

366 " installations with this shulker box" 

367 ), 

368 ) 

369 shulker_craft_parser.add_argument( 

370 "-l", 

371 "--folder", 

372 dest="link_folders", 

373 action="append", 

374 help=( 

375 "specify the name of a folder inside this shulker box" 

376 " that should be linked completely" 

377 ), 

378 ) 

379 shulker_craft_parser.add_argument( 

380 "--overwrite", 

381 action="store_true", 

382 help=( 

383 "if there's already a shulker box with the specified name," 

384 " overwrite its configuration" 

385 ), 

386 ) 

387 

388 # place options 

389 place_parser = action_parsers["place"] 

390 cleanup = place_parser.add_argument_group() 

391 cleanup.add_argument( 

392 "--keep-broken-links", 

393 action="store_true", 

394 help="do not remove broken links from instances", 

395 ) 

396 cleanup.add_argument( 

397 "--keep-stale-links", 

398 action="store_true", 

399 help=( 

400 "do not remove existing links into the EnderChest," 

401 " even if the shulker box or instance spec has changed" 

402 ), 

403 ) 

404 cleanup.add_argument( 

405 "-k", 

406 dest="keep_level", 

407 action="count", 

408 default=0, 

409 help=( 

410 "Shorthand for the above cleanup options:" 

411 " -k will --keep-stale-links," 

412 " and -kk will --keep-broken-links as well" 

413 ), 

414 ) 

415 error_handling = place_parser.add_mutually_exclusive_group() 

416 error_handling.add_argument( 

417 "--stop-at-first-failure", 

418 "-x", 

419 action="store_true", 

420 help="stop linking at the first issue", 

421 ) 

422 error_handling.add_argument( 

423 "--ignore-errors", action="store_true", help="ignore any linking errors" 

424 ) 

425 error_handling.add_argument( 

426 "--errors", 

427 "-e", 

428 choices=( 

429 "prompt", 

430 "ignore", 

431 "skip", 

432 "skip-instance", 

433 "skip-shulker-box", 

434 "abort", 

435 ), 

436 default="prompt", 

437 help=( 

438 "specify how to handle linking errors" 

439 " (default behavior is to prompt after every error)" 

440 ), 

441 ) 

442 link_type = place_parser.add_mutually_exclusive_group() 

443 link_type.add_argument( 

444 "--absolute", 

445 "-a", 

446 action="store_true", 

447 help="use absolute paths for all link targets", 

448 ) 

449 link_type.add_argument( 

450 "--relative", 

451 "-r", 

452 action="store_true", 

453 help="use relative paths for all link targets", 

454 ) 

455 

456 # gather instance options 

457 gather_instance_parser = action_parsers[f"gather {_instance_aliases[0]}"] 

458 gather_instance_parser.add_argument( 

459 "search_paths", 

460 nargs="+", 

461 action="extend", 

462 type=Path, 

463 help="specify a folder or folders to search for Minecraft installations", 

464 ) 

465 instance_type = gather_instance_parser.add_mutually_exclusive_group() 

466 instance_type.add_argument( 

467 "--official", 

468 "-o", 

469 action="store_true", 

470 help="specify that these are instances managed by the official launcher", 

471 ) 

472 instance_type.add_argument( 

473 "--mmc", 

474 "-m", 

475 action="store_true", 

476 help="specify that these are MultiMC-like instances", 

477 ) 

478 

479 # gather remote options 

480 gather_remote_parser = action_parsers[f"gather {_remote_aliases[0]}"] 

481 gather_remote_parser.add_argument( 

482 "remotes", 

483 nargs="+", 

484 action="extend", 

485 help=( 

486 "provide URIs (e.g. rsync://deck@my-steam-deck/home/deck/) of any" 

487 " remote EnderChest installation to register with this one." 

488 "Note: you should not use this method if the alias (name) of the" 

489 "remote does not match the remote's hostname (in this example," 

490 '"my-steam-deck").' 

491 ), 

492 ) 

493 

494 # list instances options 

495 

496 # list shulkers options 

497 

498 # list instance box options 

499 list_instance_boxes_parser = action_parsers[ 

500 f"{_list_aliases[0]} {_instance_aliases[0]}" 

501 ] 

502 list_instance_boxes_parser.add_argument( 

503 "instance_name", help="The name of the minecraft instance to query" 

504 ) 

505 

506 # list shulker options 

507 list_shulker_parser = action_parsers[f"{_list_aliases[0]} {_shulker_aliases[0]}"] 

508 list_shulker_parser.add_argument( 

509 "shulker_box_name", help="The name of the shulker box to query" 

510 ) 

511 

512 # open / close options 

513 for action in ("open", "close"): 

514 sync_parser = action_parsers[action] 

515 

516 sync_parser.add_argument( 

517 "--dry-run", 

518 action="store_true", 

519 help=( 

520 "Perform a dry run of the sync operation," 

521 " reporting the operations that will be performed" 

522 " but not actually carrying them out" 

523 ), 

524 ) 

525 sync_parser.add_argument( 

526 "--exclude", 

527 "-e", 

528 nargs="+", 

529 help="Provide any file patterns you would like to skip syncing", 

530 ) 

531 sync_parser.add_argument( 

532 "--timeout", 

533 "-t", 

534 type=int, 

535 help=( 

536 "Set a maximum number of seconds to try to sync to a remote chest" 

537 " before giving up and going on to the next one" 

538 ), 

539 ) 

540 sync_confirm_wait = sync_parser.add_mutually_exclusive_group() 

541 sync_confirm_wait.add_argument( 

542 "--wait", 

543 "-w", 

544 dest="sync_confirm_wait", 

545 type=int, 

546 help=( 

547 "The default behavior when syncing EnderChests is to first perform a" 

548 " dry run of every sync operation and then wait 5 seconds before" 

549 " proceeding with the real sync. The idea is to give you time to" 

550 " interrupt the sync if the dry run looks wrong. You can raise or" 

551 " lower that wait time through this flag. You can also modify it" 

552 " by editing the enderchest.cfg file." 

553 ), 

554 ) 

555 

556 return enderchest_parser, action_parsers 

557 

558 

559def parse_args(argv: Sequence[str]) -> tuple[Action, Path, int, dict[str, Any]]: 

560 """Parse the provided command-line options to determine the action to perform and 

561 the arguments to pass to the action 

562 

563 Parameters 

564 ---------- 

565 argv : list-like of str (sys.argv) 

566 The options passed into the command line 

567 

568 Returns 

569 ------- 

570 Callable 

571 The action method that will be called 

572 str 

573 The root of the minecraft folder (parent of the EnderChest) 

574 where the action will be performed 

575 int 

576 The verbosity level of the operation (in terms of log levels) 

577 dict 

578 Any additional options that will be given to the action method 

579 

580 """ 

581 actions: dict[str, Action] = {} 

582 aliases: dict[str, str] = {} 

583 for commands, _, method in ACTIONS: 

584 for command in commands: 

585 aliases[command] = commands[0] 

586 actions[commands[0]] = method 

587 

588 enderchest_parser, action_parsers = generate_parsers() 

589 

590 _ = enderchest_parser.parse_args(argv[1:2]) # check for --help and --version 

591 

592 for command in sorted(aliases.keys(), key=lambda x: -len(x)): # longest first 

593 if " ".join((*argv[1:], "")).startswith(command + " "): 

594 action_kwargs = vars( 

595 action_parsers[aliases[command]].parse_args( 

596 argv[1 + len(command.split()) :] 

597 ) 

598 ) 

599 

600 action = actions[aliases[command]] 

601 

602 root_arg = action_kwargs.pop("root") 

603 root_flag = action_kwargs.pop("root_flag") 

604 

605 verbosity = action_kwargs.pop("verbose") - action_kwargs.pop("quiet") 

606 

607 argspec = inspect.getfullargspec(action) 

608 if "verbosity" in argspec.args + argspec.kwonlyargs: 

609 action_kwargs["verbosity"] = verbosity 

610 

611 log_level = loggers.verbosity_to_log_level(verbosity) 

612 

613 MINECRAFT_ROOT = os.getenv("MINECRAFT_ROOT") 

614 

615 return ( 

616 actions[aliases[command]], 

617 Path(root_arg or root_flag or MINECRAFT_ROOT or os.getcwd()), 

618 log_level, 

619 action_kwargs, 

620 ) 

621 

622 enderchest_parser.print_help(sys.stderr) 

623 sys.exit(1) 

624 

625 

626def main(): 

627 """CLI Entrypoint""" 

628 logger = logging.getLogger(__package__) 

629 cli_handler = logging.StreamHandler() 

630 cli_handler.setFormatter(loggers.CLIFormatter()) 

631 logger.addHandler(cli_handler) 

632 

633 action, root, log_level, kwargs = parse_args(sys.argv) 

634 

635 # TODO: set log levels per logger based on the command 

636 cli_handler.setLevel(log_level) 

637 

638 # TODO: when we add log files, set this to minimum log level across all handlers 

639 logger.setLevel(log_level) 

640 

641 action(root, **kwargs)