Coverage for enderchest/cli.py: 83%

182 statements  

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

1"""Command-line interface""" 

2 

3import argparse 

4import inspect 

5import logging 

6import os 

7import sys 

8from argparse import ArgumentParser, RawTextHelpFormatter 

9from pathlib import Path 

10from typing import Any, Iterable, Protocol, Sequence 

11 

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

13from ._version import get_versions 

14 

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

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

17_instance_aliases = tuple( 

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

19) 

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

21_remote_aliases = tuple( 

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

23) 

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

25 

26 

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

28 """Common protocol for CLI actions""" 

29 

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

31 

32 

33def _place( 

34 minecraft_root: Path, 

35 errors: str = "prompt", 

36 keep_broken_links: bool = False, 

37 keep_stale_links: bool = False, 

38 keep_level: int = 0, 

39 stop_at_first_failure: bool = False, 

40 ignore_errors: bool = False, 

41 absolute: bool = False, 

42 relative: bool = False, 

43) -> None: 

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

45 

46 if stop_at_first_failure: 

47 errors = "abort" 

48 if ignore_errors: # elif? 

49 errors = "ignore" 

50 # else: errors = errors 

51 

52 if absolute is True: 

53 # technically we get this for free already 

54 relative = False 

55 

56 if keep_level > 0: 

57 keep_stale_links = True 

58 if keep_level > 1: 

59 keep_broken_links = True 

60 

61 place.cache_placements( 

62 minecraft_root, 

63 place.place_ender_chest( 

64 minecraft_root, 

65 keep_broken_links=keep_broken_links, 

66 keep_stale_links=keep_stale_links, 

67 error_handling=errors, 

68 relative=relative, 

69 ), 

70 ) 

71 

72 

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

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

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

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

77 

78 

79def _list_instance_boxes( 

80 minecraft_root: Path, 

81 instance_name: str | None = None, 

82 path: str | None = None, 

83 **kwargs, 

84): 

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

86 if path is not None: 

87 place.list_placements( 

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

89 ) 

90 elif instance_name is not None: 

91 inventory.get_shulker_boxes_matching_instance( 

92 minecraft_root, instance_name, **kwargs 

93 ) 

94 else: 

95 inventory.load_shulker_boxes(minecraft_root, **kwargs) 

96 

97 

98def _list_shulker_box( 

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

100): 

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

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

103 inventory.get_instances_matching_shulker_box( 

104 minecraft_root, shulker_box_name, **kwargs 

105 ) 

106 

107 

108def _update_ender_chest( 

109 minecraft_root: Path, 

110 official: bool | None = None, 

111 mmc: bool | None = None, 

112 **kwargs, 

113): 

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

115 if mmc: 

116 instance_type = "mmc" 

117 elif official: 

118 instance_type = "official" 

119 else: 

120 instance_type = None 

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

122 

123 

124def _gather_server( 

125 minecraft_root: Path, 

126 server_home: Path | None = None, 

127 jar: Path | None = None, 

128 name: str | None = None, 

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

130): 

131 """Wrapper to route the server flags""" 

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

133 gather.update_ender_chest( 

134 minecraft_root, 

135 (server_home,), 

136 instance_type="server", 

137 server_jar=jar, 

138 name=name, 

139 tags=tags, 

140 ) 

141 

142 

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

144 """Router for open verb""" 

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

146 

147 

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

149 """Router for close verb""" 

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

151 

152 

153def _test( 

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

155): 

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

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

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

159 import pytest 

160 

161 from enderchest.test import plugin 

162 

163 if use_local_ssh: 

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

165 if exit_code := pytest.main( 

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

167 plugins=(plugin,), 

168 ): 

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

170 

171 

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

173 """Router for the break verb""" 

174 if not instances: 

175 uninstall.break_ender_chest(minecraft_root) 

176 else: 

177 uninstall.break_instances(minecraft_root, instances) 

178 

179 

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

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

182 ( 

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

184 "create and configure a new EnderChest installation", 

185 craft.craft_ender_chest, 

186 ), 

187 ( 

188 tuple( 

189 f"{verb} {alias}" 

190 for verb in _create_aliases 

191 for alias in _shulker_box_aliases 

192 ), 

193 "create and configure a new shulker box", 

194 _craft_shulker_box, 

195 ), 

196 ( 

197 ("place",), 

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

199 _place, 

200 ), 

201 ( 

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

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

204 _update_ender_chest, 

205 ), 

206 ( 

207 ("gather server",), 

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

209 _gather_server, 

210 ), 

211 ( 

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

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

214 _update_ender_chest, 

215 ), 

216 ( 

217 # I freely admit this is ridiculous 

218 sum( 

219 ( 

220 ( 

221 verb, 

222 *( 

223 f"{verb} {alias}" 

224 # pluralization is hard 

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

226 ), 

227 ) 

228 for verb in _list_aliases 

229 ), 

230 (), 

231 ), 

232 "list the shulker boxes inside your Enderchest", 

233 _list_instance_boxes, 

234 ), 

235 ( 

236 tuple( 

237 f"{verb} {alias}" 

238 for verb in _list_aliases 

239 for alias in _instance_aliases 

240 if alias.endswith("s") 

241 ), 

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

243 inventory.load_ender_chest_instances, 

244 ), 

245 ( 

246 tuple( 

247 f"{verb} {alias}" 

248 for verb in _list_aliases 

249 for alias in _instance_aliases 

250 if not alias.endswith("s") 

251 ), 

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

253 _list_instance_boxes, 

254 ), 

255 ( 

256 tuple( 

257 f"{verb} {alias}" 

258 for verb in _list_aliases 

259 for alias in _shulker_box_aliases 

260 ), 

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

262 _list_shulker_box, 

263 ), 

264 ( 

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

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

267 inventory.load_ender_chest_remotes, 

268 ), 

269 ( 

270 ("open",), 

271 "pull changes from other EnderChests", 

272 _open, 

273 ), 

274 ( 

275 ("close",), 

276 "push changes to other EnderChests", 

277 _close, 

278 ), 

279 ( 

280 ("break",), 

281 "uninstall EnderChest by copying linked resources" 

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

283 _break, 

284 ), 

285 ( 

286 ("test",), 

287 "run the EnderChest test suite", 

288 _test, 

289 ), 

290) 

291 

292 

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

294 """Generate the command-line parsers 

295 

296 Returns 

297 ------- 

298 enderchest_parser : ArgumentParser 

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

300 specific action parsers 

301 action_parsers : dict of str to ArgumentParser 

302 The verb-specific argument parsers 

303 """ 

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

305 root_description: str = "" 

306 for commands, description, _ in ACTIONS: 

307 descriptions[commands[0]] = description 

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

309 

310 enderchest_parser = ArgumentParser( 

311 prog="enderchest", 

312 description=( 

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

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

315 ), 

316 formatter_class=RawTextHelpFormatter, 

317 ) 

318 

319 enderchest_parser.add_argument( 

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

321 "-V", 

322 "--version", 

323 action="version", 

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

325 ) 

326 

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

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

329 enderchest_parser.add_argument( 

330 "action", 

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

332 type=str, 

333 ) 

334 enderchest_parser.add_argument( 

335 "arguments", 

336 nargs="*", 

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

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

339 ) 

340 

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

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

343 parser = ArgumentParser( 

344 prog=f"enderchest {verb}", 

345 description=description, 

346 ) 

347 if verb != "test": 

348 root = parser.add_mutually_exclusive_group() 

349 if verb != "break": 

350 root.add_argument( 

351 "root", 

352 nargs="?", 

353 help=( 

354 "optionally specify your root minecraft directory." 

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

356 ), 

357 type=Path, 

358 ) 

359 root.add_argument( 

360 "--root", 

361 dest="root_flag", 

362 help="specify your root minecraft directory", 

363 type=Path, 

364 ) 

365 

366 # I'm actually okay with -vvqvqqv hilarity 

367 parser.add_argument( 

368 "--verbose", 

369 "-v", 

370 action="count", 

371 default=0, 

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

373 ) 

374 parser.add_argument( 

375 "--quiet", 

376 "-q", 

377 action="count", 

378 default=0, 

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

380 ) 

381 action_parsers[verb] = parser 

382 

383 # craft options 

384 craft_parser = action_parsers[_create_aliases[0]] 

385 craft_parser.add_argument( 

386 "--from", 

387 dest="copy_from", 

388 help=( 

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

390 " remote EnderChest installation that can be used" 

391 " to boostrap the creation of this one." 

392 ), 

393 ) 

394 craft_parser.add_argument( 

395 "-r", 

396 "--remote", 

397 dest="remotes", 

398 action="append", 

399 help=( 

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

401 " remote EnderChest installation to register with this one" 

402 ), 

403 ) 

404 craft_parser.add_argument( 

405 "-i", 

406 "--instance", 

407 dest="instance_search_paths", 

408 action="append", 

409 type=Path, 

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

411 ) 

412 craft_parser.add_argument( 

413 "--overwrite", 

414 action="store_true", 

415 help=( 

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

417 " overwrite its configuration" 

418 ), 

419 ) 

420 

421 # shulker box craft options 

422 shulker_craft_parser = action_parsers[ 

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

424 ] 

425 shulker_craft_parser.add_argument( 

426 "name", 

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

428 ) 

429 shulker_craft_parser.add_argument( 

430 "--priority", 

431 "-p", 

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

433 ) 

434 shulker_craft_parser.add_argument( 

435 "-i", 

436 "--instance", 

437 dest="instances", 

438 action="append", 

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

440 ) 

441 shulker_craft_parser.add_argument( 

442 "-t", 

443 "--tag", 

444 dest="tags", 

445 action="append", 

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

447 ) 

448 shulker_craft_parser.add_argument( 

449 "-e", 

450 "--enderchest", 

451 dest="hosts", 

452 action="append", 

453 help=( 

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

455 " installations with this shulker box" 

456 ), 

457 ) 

458 shulker_craft_parser.add_argument( 

459 "-l", 

460 "--folder", 

461 dest="link_folders", 

462 action="append", 

463 help=( 

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

465 " that should be linked completely" 

466 ), 

467 ) 

468 shulker_craft_parser.add_argument( 

469 "--overwrite", 

470 action="store_true", 

471 help=( 

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

473 " overwrite its configuration" 

474 ), 

475 ) 

476 

477 # place options 

478 place_parser = action_parsers["place"] 

479 cleanup = place_parser.add_argument_group() 

480 cleanup.add_argument( 

481 "--keep-broken-links", 

482 action="store_true", 

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

484 ) 

485 cleanup.add_argument( 

486 "--keep-stale-links", 

487 action="store_true", 

488 help=( 

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

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

491 ), 

492 ) 

493 cleanup.add_argument( 

494 "-k", 

495 dest="keep_level", 

496 action="count", 

497 default=0, 

498 help=( 

499 "shorthand for the above cleanup options:" 

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

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

502 ), 

503 ) 

504 error_handling = place_parser.add_argument_group( 

505 title="error handling" 

506 ).add_mutually_exclusive_group() 

507 error_handling.add_argument( 

508 "--stop-at-first-failure", 

509 "-x", 

510 action="store_true", 

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

512 ) 

513 error_handling.add_argument( 

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

515 ) 

516 error_handling.add_argument( 

517 "--errors", 

518 "-e", 

519 choices=( 

520 "prompt", 

521 "ignore", 

522 "skip", 

523 "skip-instance", 

524 "skip-shulker-box", 

525 "abort", 

526 ), 

527 default="prompt", 

528 help=( 

529 "specify how to handle linking errors" 

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

531 ), 

532 ) 

533 link_type = place_parser.add_mutually_exclusive_group() 

534 link_type.add_argument( 

535 "--absolute", 

536 "-a", 

537 action="store_true", 

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

539 ) 

540 link_type.add_argument( 

541 "--relative", 

542 "-r", 

543 action="store_true", 

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

545 ) 

546 

547 # gather instance options 

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

549 gather_instance_parser.add_argument( 

550 "search_paths", 

551 nargs="+", 

552 action="extend", 

553 type=Path, 

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

555 ) 

556 instance_type = gather_instance_parser.add_mutually_exclusive_group() 

557 instance_type.add_argument( 

558 "--official", 

559 "-o", 

560 action="store_true", 

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

562 ) 

563 instance_type.add_argument( 

564 "--mmc", 

565 "-m", 

566 action="store_true", 

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

568 ) 

569 

570 # gather server options 

571 gather_server_parser = action_parsers["gather server"] 

572 gather_server_parser.add_argument( 

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

574 ) 

575 gather_server_parser.add_argument( 

576 "--jar", 

577 "-j", 

578 type=Path, 

579 help=( 

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

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

582 " JAR files inside that folder)" 

583 ), 

584 ) 

585 gather_server_parser.add_argument( 

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

587 ) 

588 gather_server_parser.add_argument( 

589 "--tags", 

590 "-t", 

591 nargs="+", 

592 action="extend", 

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

594 ) 

595 

596 # gather remote options 

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

598 gather_remote_parser.add_argument( 

599 "remotes", 

600 nargs="+", 

601 action="extend", 

602 help=( 

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

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

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

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

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

608 ), 

609 ) 

610 

611 # list shulker box options 

612 

613 # list [instance] boxes options 

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

615 list_instance_boxes_parser = action_parsers[ 

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

617 ] 

618 

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

620 list_boxes_parser.add_argument( 

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

622 ) 

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

624 

625 for parser in (list_boxes_parser, list_instance_boxes_parser): 

626 parser.add_argument( 

627 "--path", 

628 "-p", 

629 help=( 

630 "optionally, specify a specific path" 

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

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

633 ), 

634 ) 

635 

636 # list shulker options 

637 list_shulker_box_parser = action_parsers[ 

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

639 ] 

640 list_shulker_box_parser.add_argument( 

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

642 ) 

643 

644 # open / close options 

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

646 sync_parser = action_parsers[action] 

647 

648 sync_parser.add_argument( 

649 "--dry-run", 

650 action="store_true", 

651 help=( 

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

653 " reporting the operations that will be performed" 

654 " but not actually carrying them out" 

655 ), 

656 ) 

657 sync_parser.add_argument( 

658 "--exclude", 

659 "-e", 

660 action="extend", 

661 nargs="+", 

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

663 ) 

664 sync_parser.add_argument( 

665 "--timeout", 

666 "-t", 

667 type=int, 

668 help=( 

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

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

671 ), 

672 ) 

673 sync_confirm_wait = sync_parser.add_argument_group( 

674 title="sync confirmation control", 

675 description=( 

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

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

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

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

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

681 " by editing the enderchest.cfg file." 

682 ), 

683 ).add_mutually_exclusive_group() 

684 sync_confirm_wait.add_argument( 

685 "--wait", 

686 "-w", 

687 dest="sync_confirm_wait", 

688 type=int, 

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

690 " before the real sync is performed", 

691 ) 

692 sync_confirm_wait.add_argument( 

693 "--confirm", 

694 "-c", 

695 dest="sync_confirm_wait", 

696 action="store_true", 

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

698 " before performing the real sync", 

699 ) 

700 

701 break_parser = action_parsers["break"] 

702 break_parser.add_argument( 

703 "instances", 

704 nargs="*", 

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

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

707 ) 

708 

709 # test pass-through 

710 test_parser = action_parsers["test"] 

711 test_parser.add_argument( 

712 "--use-local-ssh", 

713 action="store_true", 

714 dest="use_local_ssh", 

715 help=( 

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

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

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

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

720 ), 

721 ) 

722 test_parser.add_argument( 

723 "pytest_args", 

724 nargs=argparse.REMAINDER, 

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

726 ) 

727 

728 return enderchest_parser, action_parsers 

729 

730 

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

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

733 the arguments to pass to the action 

734 

735 Parameters 

736 ---------- 

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

738 The options passed into the command line 

739 

740 Returns 

741 ------- 

742 Callable 

743 The action method that will be called 

744 str 

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

746 where the action will be performed 

747 int 

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

749 dict 

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

751 

752 """ 

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

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

755 for commands, _, method in ACTIONS: 

756 for command in commands: 

757 aliases[command] = commands[0] 

758 actions[commands[0]] = method 

759 

760 enderchest_parser, action_parsers = generate_parsers() 

761 

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

763 

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

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

766 if command == "test": 

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

768 return ( 

769 actions["test"], 

770 Path(), 

771 0, 

772 { 

773 "use_local_ssh": parsed.use_local_ssh, 

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

775 }, 

776 ) 

777 action_kwargs = vars( 

778 action_parsers[aliases[command]].parse_args( 

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

780 ) 

781 ) 

782 

783 action = actions[aliases[command]] 

784 

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

786 root_flag = action_kwargs.pop("root_flag") 

787 

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

789 

790 argspec = inspect.getfullargspec(action) 

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

792 action_kwargs["verbosity"] = verbosity 

793 

794 log_level = loggers.verbosity_to_log_level(verbosity) 

795 

796 MINECRAFT_ROOT = os.getenv("MINECRAFT_ROOT") 

797 

798 return ( 

799 actions[aliases[command]], 

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

801 log_level, 

802 action_kwargs, 

803 ) 

804 

805 enderchest_parser.print_help(sys.stderr) 

806 sys.exit(1) 

807 

808 

809def main(): 

810 """CLI Entrypoint""" 

811 logger = logging.getLogger(__package__) 

812 cli_handler = logging.StreamHandler() 

813 cli_handler.setFormatter(loggers.CLIFormatter()) 

814 logger.addHandler(cli_handler) 

815 

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

817 

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

819 cli_handler.setLevel(log_level) 

820 

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

822 logger.setLevel(log_level) 

823 

824 action(root, **kwargs)