Coverage for enderchest/cli.py: 83%

183 statements  

« prev     ^ index     » next       coverage.py v7.7.1, created at 2025-03-28 20:32 +0000

1"""Command-line interface""" 

2 

3import argparse 

4import inspect 

5import logging 

6import os 

7import sys 

8from argparse import ArgumentParser, RawTextHelpFormatter 

9from collections.abc import Iterable, Sequence 

10from pathlib import Path 

11from typing import Any, Protocol 

12 

13from . import craft, gather, inventory, loggers, place, remote, uninstall 

14from ._version import get_versions 

15 

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

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

18_instance_aliases = tuple( 

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

20) 

21_shulker_box_aliases = ("shulker_box", "shulkerbox", "shulker") 

22_remote_aliases = tuple( 

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

24) 

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

26 

27 

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

29 """Common protocol for CLI actions""" 

30 

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

32 

33 

34def _place( 

35 minecraft_root: Path, 

36 errors: str = "prompt", 

37 keep_broken_links: bool = False, 

38 keep_stale_links: bool = False, 

39 keep_level: int = 0, 

40 stop_at_first_failure: bool = False, 

41 ignore_errors: bool = False, 

42 absolute: bool = False, 

43 relative: bool = False, 

44) -> None: 

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

46 

47 if stop_at_first_failure: 

48 errors = "abort" 

49 if ignore_errors: # elif? 

50 errors = "ignore" 

51 # else: errors = errors 

52 

53 if absolute is True: 

54 # technically we get this for free already 

55 relative = False 

56 

57 if keep_level > 0: 

58 keep_stale_links = True 

59 if keep_level > 1: 

60 keep_broken_links = True 

61 

62 place.cache_placements( 

63 minecraft_root, 

64 place.place_ender_chest( 

65 minecraft_root, 

66 keep_broken_links=keep_broken_links, 

67 keep_stale_links=keep_stale_links, 

68 error_handling=errors, 

69 relative=relative, 

70 ), 

71 ) 

72 

73 

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

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

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

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

78 

79 

80def _list_instance_boxes( 

81 minecraft_root: Path, 

82 instance_name: str | None = None, 

83 path: str | None = None, 

84 **kwargs, 

85): 

86 """Wrapper to route --path flag and instance_name arg""" 

87 if path is not None: 

88 place.list_placements( 

89 minecraft_root, pattern=path, instance_name=instance_name, **kwargs 

90 ) 

91 elif instance_name is not None: 

92 inventory.get_shulker_boxes_matching_instance( 

93 minecraft_root, instance_name, **kwargs 

94 ) 

95 else: 

96 inventory.load_shulker_boxes(minecraft_root, **kwargs) 

97 

98 

99def _list_shulker_box( 

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

101): 

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

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

104 inventory.get_instances_matching_shulker_box( 

105 minecraft_root, shulker_box_name, **kwargs 

106 ) 

107 

108 

109def _update_ender_chest( 

110 minecraft_root: Path, 

111 official: bool | None = None, 

112 mmc: bool | None = None, 

113 **kwargs, 

114): 

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

116 if mmc: 

117 instance_type = "mmc" 

118 elif official: 

119 instance_type = "official" 

120 else: 

121 instance_type = None 

122 gather.update_ender_chest(minecraft_root, instance_type=instance_type, **kwargs) 

123 

124 

125def _gather_server( 

126 minecraft_root: Path, 

127 server_home: Path | None = None, 

128 jar: Path | None = None, 

129 name: str | None = None, 

130 tags: list[str] | None = None, 

131): 

132 """Wrapper to route the server flags""" 

133 assert server_home # it's required by the parser, so this should be fine 

134 gather.update_ender_chest( 

135 minecraft_root, 

136 (server_home,), 

137 instance_type="server", 

138 server_jar=jar, 

139 name=name, 

140 tags=tags, 

141 ) 

142 

143 

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

145 """Router for open verb""" 

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

147 

148 

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

150 """Router for close verb""" 

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

152 

153 

154def _test( 

155 minecraft_root: Path, use_local_ssh: bool = False, pytest_args: Iterable[str] = () 

156): 

157 """Run the EnderChest test suite to ensure that it is running correctly on your 

158 system. Requires you to have installed GSB with the test extra 

159 (i.e. pipx install enderchest[test]).""" 

160 import pytest 

161 

162 from enderchest.test import plugin 

163 

164 if use_local_ssh: 

165 pytest_args = ("--use-local-ssh", *pytest_args) 

166 if exit_code := pytest.main( 

167 ["--pyargs", "enderchest.test", *pytest_args], 

168 plugins=(plugin,), 

169 ): 

170 raise SystemExit(f"Tests Failed with exit code: {exit_code}") 

171 

172 

173def _break(minecraft_root: Path, instances: Iterable[str] | None = None): 

174 """Router for the break verb""" 

175 if not instances: 

176 uninstall.break_ender_chest(minecraft_root) 

177 else: 

178 uninstall.break_instances(minecraft_root, instances) 

179 

180 

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

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

183 ( 

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

185 "create and configure a new EnderChest installation", 

186 craft.craft_ender_chest, 

187 ), 

188 ( 

189 tuple( 

190 f"{verb} {alias}" 

191 for verb in _create_aliases 

192 for alias in _shulker_box_aliases 

193 ), 

194 "create and configure a new shulker box", 

195 _craft_shulker_box, 

196 ), 

197 ( 

198 ("place",), 

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

200 _place, 

201 ), 

202 ( 

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

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

205 _update_ender_chest, 

206 ), 

207 ( 

208 ("gather server",), 

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

210 _gather_server, 

211 ), 

212 ( 

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

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

215 _update_ender_chest, 

216 ), 

217 ( 

218 # I freely admit this is ridiculous 

219 sum( 

220 ( 

221 ( 

222 verb, 

223 *( 

224 f"{verb} {alias}" 

225 # pluralization is hard 

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

227 ), 

228 ) 

229 for verb in _list_aliases 

230 ), 

231 (), 

232 ), 

233 "list the shulker boxes inside your Enderchest", 

234 _list_instance_boxes, 

235 ), 

236 ( 

237 tuple( 

238 f"{verb} {alias}" 

239 for verb in _list_aliases 

240 for alias in _instance_aliases 

241 if alias.endswith("s") 

242 ), 

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

244 inventory.load_ender_chest_instances, 

245 ), 

246 ( 

247 tuple( 

248 f"{verb} {alias}" 

249 for verb in _list_aliases 

250 for alias in _instance_aliases 

251 if not alias.endswith("s") 

252 ), 

253 "list the shulker boxes that the specified instance links into", 

254 _list_instance_boxes, 

255 ), 

256 ( 

257 tuple( 

258 f"{verb} {alias}" 

259 for verb in _list_aliases 

260 for alias in _shulker_box_aliases 

261 ), 

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

263 _list_shulker_box, 

264 ), 

265 ( 

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

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

268 inventory.load_ender_chest_remotes, 

269 ), 

270 ( 

271 ("open",), 

272 "pull changes from other EnderChests", 

273 _open, 

274 ), 

275 ( 

276 ("close",), 

277 "push changes to other EnderChests", 

278 _close, 

279 ), 

280 ( 

281 ("break",), 

282 "uninstall EnderChest by copying linked resources" 

283 " into some or all of the registered instances", 

284 _break, 

285 ), 

286 ( 

287 ("test",), 

288 "run the EnderChest test suite", 

289 _test, 

290 ), 

291) 

292 

293 

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

295 """Generate the command-line parsers 

296 

297 Returns 

298 ------- 

299 enderchest_parser : ArgumentParser 

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

301 specific action parsers 

302 action_parsers : dict of str to ArgumentParser 

303 The verb-specific argument parsers 

304 """ 

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

306 root_description: str = "" 

307 for commands, description, _ in ACTIONS: 

308 descriptions[commands[0]] = description 

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

310 

311 enderchest_parser = ArgumentParser( 

312 prog="enderchest", 

313 description=( 

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

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

316 ), 

317 formatter_class=RawTextHelpFormatter, 

318 ) 

319 

320 enderchest_parser.add_argument( 

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

322 "-V", 

323 "--version", 

324 action="version", 

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

326 ) 

327 

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

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

330 enderchest_parser.add_argument( 

331 "action", 

332 help=f"The action to perform. Options are:{root_description}", 

333 type=str, 

334 ) 

335 enderchest_parser.add_argument( 

336 "arguments", 

337 nargs="*", 

338 help="Any additional arguments for the specific action." 

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

340 ) 

341 

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

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

344 parser = ArgumentParser( 

345 prog=f"enderchest {verb}", 

346 description=description, 

347 ) 

348 if verb != "test": 

349 root = parser.add_mutually_exclusive_group() 

350 if verb != "break": 

351 root.add_argument( 

352 "root", 

353 nargs="?", 

354 help=( 

355 "optionally specify your root minecraft directory." 

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

357 ), 

358 type=Path, 

359 ) 

360 root.add_argument( 

361 "--root", 

362 dest="root_flag", 

363 help="specify your root minecraft directory", 

364 type=Path, 

365 ) 

366 

367 # I'm actually okay with -vvqvqqv hilarity 

368 parser.add_argument( 

369 "--verbose", 

370 "-v", 

371 action="count", 

372 default=0, 

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

374 ) 

375 parser.add_argument( 

376 "--quiet", 

377 "-q", 

378 action="count", 

379 default=0, 

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

381 ) 

382 action_parsers[verb] = parser 

383 

384 # craft options 

385 craft_parser = action_parsers[_create_aliases[0]] 

386 craft_parser.add_argument( 

387 "--from", 

388 dest="copy_from", 

389 help=( 

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

391 " remote EnderChest installation that can be used" 

392 " to boostrap the creation of this one." 

393 ), 

394 ) 

395 craft_parser.add_argument( 

396 "-r", 

397 "--remote", 

398 dest="remotes", 

399 action="append", 

400 help=( 

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

402 " remote EnderChest installation to register with this one" 

403 ), 

404 ) 

405 craft_parser.add_argument( 

406 "-i", 

407 "--instance", 

408 dest="instance_search_paths", 

409 action="append", 

410 type=Path, 

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

412 ) 

413 craft_parser.add_argument( 

414 "--overwrite", 

415 action="store_true", 

416 help=( 

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

418 " overwrite its configuration" 

419 ), 

420 ) 

421 

422 # shulker box craft options 

423 shulker_craft_parser = action_parsers[ 

424 f"{_create_aliases[0]} {_shulker_box_aliases[0]}" 

425 ] 

426 shulker_craft_parser.add_argument( 

427 "name", 

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

429 ) 

430 shulker_craft_parser.add_argument( 

431 "--priority", 

432 "-p", 

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

434 ) 

435 shulker_craft_parser.add_argument( 

436 "-i", 

437 "--instance", 

438 dest="instances", 

439 action="append", 

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

441 ) 

442 shulker_craft_parser.add_argument( 

443 "-t", 

444 "--tag", 

445 dest="tags", 

446 action="append", 

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

448 ) 

449 shulker_craft_parser.add_argument( 

450 "-e", 

451 "--enderchest", 

452 dest="hosts", 

453 action="append", 

454 help=( 

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

456 " installations with this shulker box" 

457 ), 

458 ) 

459 shulker_craft_parser.add_argument( 

460 "-l", 

461 "--folder", 

462 dest="link_folders", 

463 action="append", 

464 help=( 

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

466 " that should be linked completely" 

467 ), 

468 ) 

469 shulker_craft_parser.add_argument( 

470 "--overwrite", 

471 action="store_true", 

472 help=( 

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

474 " overwrite its configuration" 

475 ), 

476 ) 

477 

478 # place options 

479 place_parser = action_parsers["place"] 

480 cleanup = place_parser.add_argument_group() 

481 cleanup.add_argument( 

482 "--keep-broken-links", 

483 action="store_true", 

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

485 ) 

486 cleanup.add_argument( 

487 "--keep-stale-links", 

488 action="store_true", 

489 help=( 

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

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

492 ), 

493 ) 

494 cleanup.add_argument( 

495 "-k", 

496 dest="keep_level", 

497 action="count", 

498 default=0, 

499 help=( 

500 "shorthand for the above cleanup options:" 

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

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

503 ), 

504 ) 

505 error_handling = place_parser.add_argument_group( 

506 title="error handling" 

507 ).add_mutually_exclusive_group() 

508 error_handling.add_argument( 

509 "--stop-at-first-failure", 

510 "-x", 

511 action="store_true", 

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

513 ) 

514 error_handling.add_argument( 

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

516 ) 

517 error_handling.add_argument( 

518 "--errors", 

519 "-e", 

520 choices=( 

521 "prompt", 

522 "ignore", 

523 "skip", 

524 "skip-instance", 

525 "skip-shulker-box", 

526 "abort", 

527 ), 

528 default="prompt", 

529 help=( 

530 "specify how to handle linking errors" 

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

532 ), 

533 ) 

534 link_type = place_parser.add_mutually_exclusive_group() 

535 link_type.add_argument( 

536 "--absolute", 

537 "-a", 

538 action="store_true", 

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

540 ) 

541 link_type.add_argument( 

542 "--relative", 

543 "-r", 

544 action="store_true", 

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

546 ) 

547 

548 # gather instance options 

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

550 gather_instance_parser.add_argument( 

551 "search_paths", 

552 nargs="+", 

553 action="extend", 

554 type=Path, 

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

556 ) 

557 instance_type = gather_instance_parser.add_mutually_exclusive_group() 

558 instance_type.add_argument( 

559 "--official", 

560 "-o", 

561 action="store_true", 

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

563 ) 

564 instance_type.add_argument( 

565 "--mmc", 

566 "-m", 

567 action="store_true", 

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

569 ) 

570 

571 # gather server options 

572 gather_server_parser = action_parsers["gather server"] 

573 gather_server_parser.add_argument( 

574 "server_home", type=Path, help="the working directory of the Minecraft server" 

575 ) 

576 gather_server_parser.add_argument( 

577 "--jar", 

578 "-j", 

579 type=Path, 

580 help=( 

581 "explicitly specify the path to the server JAR (in case it's outside" 

582 " of the server's working directory of if there are multiple server" 

583 " JAR files inside that folder)" 

584 ), 

585 ) 

586 gather_server_parser.add_argument( 

587 "--name", "-n", help="specify the name (alias) for the server" 

588 ) 

589 gather_server_parser.add_argument( 

590 "--tags", 

591 "-t", 

592 nargs="+", 

593 action="extend", 

594 help="specify any tags you want to apply to the server", 

595 ) 

596 

597 # gather remote options 

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

599 gather_remote_parser.add_argument( 

600 "remotes", 

601 nargs="+", 

602 action="extend", 

603 help=( 

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

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

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

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

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

609 ), 

610 ) 

611 

612 # list shulker box options 

613 

614 # list [instance] boxes options 

615 list_boxes_parser = action_parsers[f"{_list_aliases[0]}"] 

616 list_instance_boxes_parser = action_parsers[ 

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

618 ] 

619 

620 instance_name_docs = "The name of the minecraft instance to query" 

621 list_boxes_parser.add_argument( 

622 "--instance", "-i", dest="instance_name", help=instance_name_docs 

623 ) 

624 list_instance_boxes_parser.add_argument("instance_name", help=instance_name_docs) 

625 

626 for parser in (list_boxes_parser, list_instance_boxes_parser): 

627 parser.add_argument( 

628 "--path", 

629 "-p", 

630 help=( 

631 "optionally, specify a specific path" 

632 " (absolute, relative, filename or glob pattern" 

633 " to get a report of the shulker box(es) that provide that resource" 

634 ), 

635 ) 

636 

637 # list shulker options 

638 list_shulker_box_parser = action_parsers[ 

639 f"{_list_aliases[0]} {_shulker_box_aliases[0]}" 

640 ] 

641 list_shulker_box_parser.add_argument( 

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

643 ) 

644 

645 # open / close options 

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

647 sync_parser = action_parsers[action] 

648 

649 sync_parser.add_argument( 

650 "--dry-run", 

651 action="store_true", 

652 help=( 

653 "perform a dry run of the sync operation," 

654 " reporting the operations that will be performed" 

655 " but not actually carrying them out" 

656 ), 

657 ) 

658 sync_parser.add_argument( 

659 "--exclude", 

660 "-e", 

661 action="extend", 

662 nargs="+", 

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

664 ) 

665 sync_parser.add_argument( 

666 "--timeout", 

667 "-t", 

668 type=int, 

669 help=( 

670 "set a maximum number of seconds to try to sync to a remote chest" 

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

672 ), 

673 ) 

674 sync_confirm_wait = sync_parser.add_argument_group( 

675 title="sync confirmation control", 

676 description=( 

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

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

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

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

681 " lower that wait time through these flags. You can also modify it" 

682 " by editing the enderchest.cfg file." 

683 ), 

684 ).add_mutually_exclusive_group() 

685 sync_confirm_wait.add_argument( 

686 "--wait", 

687 "-w", 

688 dest="sync_confirm_wait", 

689 type=int, 

690 help="set the time in seconds to wait after performing a dry run" 

691 " before the real sync is performed", 

692 ) 

693 sync_confirm_wait.add_argument( 

694 "--confirm", 

695 "-c", 

696 dest="sync_confirm_wait", 

697 action="store_true", 

698 help="after performing the dry run, explicitly ask for confirmation" 

699 " before performing the real sync", 

700 ) 

701 

702 break_parser = action_parsers["break"] 

703 break_parser.add_argument( 

704 "instances", 

705 nargs="*", 

706 help="instead of breaking your entire EnderChest, just deregister and" 

707 " copy linked resources into the specified instances (by name)", 

708 ) 

709 

710 # test pass-through 

711 test_parser = action_parsers["test"] 

712 test_parser.add_argument( 

713 "--use-local-ssh", 

714 action="store_true", 

715 dest="use_local_ssh", 

716 help=( 

717 "By default, tests of SSH functionality will be run against a mock" 

718 " SSH server. If you are running EnderChest on a machine you can SSH" 

719 " into locally (by running `ssh localhost`) without requiring a password," 

720 " running the tests with this flag will produce more accurate results." 

721 ), 

722 ) 

723 test_parser.add_argument( 

724 "pytest_args", 

725 nargs=argparse.REMAINDER, 

726 help="any additional arguments to pass through to py.test", 

727 ) 

728 

729 return enderchest_parser, action_parsers 

730 

731 

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

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

734 the arguments to pass to the action 

735 

736 Parameters 

737 ---------- 

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

739 The options passed into the command line 

740 

741 Returns 

742 ------- 

743 Callable 

744 The action method that will be called 

745 str 

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

747 where the action will be performed 

748 int 

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

750 dict 

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

752 

753 """ 

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

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

756 for commands, _, method in ACTIONS: 

757 for command in commands: 

758 aliases[command] = commands[0] 

759 actions[commands[0]] = method 

760 

761 enderchest_parser, action_parsers = generate_parsers() 

762 

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

764 

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

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

767 if command == "test": 

768 parsed, extra = action_parsers["test"].parse_known_args(argv[2:]) 

769 return ( 

770 actions["test"], 

771 Path(), 

772 0, 

773 { 

774 "use_local_ssh": parsed.use_local_ssh, 

775 "pytest_args": [*parsed.pytest_args, *extra], 

776 }, 

777 ) 

778 action_kwargs = vars( 

779 action_parsers[aliases[command]].parse_args( 

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

781 ) 

782 ) 

783 

784 action = actions[aliases[command]] 

785 

786 root_arg = None if command == "break" else action_kwargs.pop("root") 

787 root_flag = action_kwargs.pop("root_flag") 

788 

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

790 

791 argspec = inspect.getfullargspec(action) 

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

793 action_kwargs["verbosity"] = verbosity 

794 

795 log_level = loggers.verbosity_to_log_level(verbosity) 

796 

797 MINECRAFT_ROOT = os.getenv("MINECRAFT_ROOT") 

798 

799 return ( 

800 actions[aliases[command]], 

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

802 log_level, 

803 action_kwargs, 

804 ) 

805 

806 enderchest_parser.print_help(sys.stderr) 

807 sys.exit(1) 

808 

809 

810def main(): 

811 """CLI Entrypoint""" 

812 logger = logging.getLogger(__package__) 

813 cli_handler = logging.StreamHandler() 

814 cli_handler.setFormatter(loggers.CLIFormatter()) 

815 logger.addHandler(cli_handler) 

816 

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

818 

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

820 cli_handler.setLevel(log_level) 

821 

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

823 logger.setLevel(log_level) 

824 

825 action(root, **kwargs)