Coverage for enderchest/cli.py: 83%
182 statements
« prev ^ index » next coverage.py v7.5.0, created at 2024-05-04 01:41 +0000
« prev ^ index » next coverage.py v7.5.0, created at 2024-05-04 01:41 +0000
1"""Command-line interface"""
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
12from . import craft, gather, inventory, loggers, place, remote, uninstall
13from ._version import get_versions
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")
27class Action(Protocol): # pragma: no cover
28 """Common protocol for CLI actions"""
30 def __call__(self, minecraft_root: Path, /) -> Any: ...
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"""
46 if stop_at_first_failure:
47 errors = "abort"
48 if ignore_errors: # elif?
49 errors = "ignore"
50 # else: errors = errors
52 if absolute is True:
53 # technically we get this for free already
54 relative = False
56 if keep_level > 0:
57 keep_stale_links = True
58 if keep_level > 1:
59 keep_broken_links = True
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 )
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)
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)
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 )
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)
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 )
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)
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)
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
161 from enderchest.test import plugin
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}")
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)
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)
293def generate_parsers() -> tuple[ArgumentParser, dict[str, ArgumentParser]]:
294 """Generate the command-line parsers
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}"
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 )
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 )
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 )
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 )
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
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 )
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 )
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 )
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 )
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 )
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 )
611 # list shulker box options
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 ]
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)
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 )
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 )
644 # open / close options
645 for action in ("open", "close"):
646 sync_parser = action_parsers[action]
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 )
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 )
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 )
728 return enderchest_parser, action_parsers
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
735 Parameters
736 ----------
737 argv : list-like of str (sys.argv)
738 The options passed into the command line
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
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
760 enderchest_parser, action_parsers = generate_parsers()
762 _ = enderchest_parser.parse_args(argv[1:2]) # check for --help and --version
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 )
783 action = actions[aliases[command]]
785 root_arg = None if command == "break" else action_kwargs.pop("root")
786 root_flag = action_kwargs.pop("root_flag")
788 verbosity = action_kwargs.pop("verbose") - action_kwargs.pop("quiet")
790 argspec = inspect.getfullargspec(action)
791 if "verbosity" in argspec.args + argspec.kwonlyargs:
792 action_kwargs["verbosity"] = verbosity
794 log_level = loggers.verbosity_to_log_level(verbosity)
796 MINECRAFT_ROOT = os.getenv("MINECRAFT_ROOT")
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 )
805 enderchest_parser.print_help(sys.stderr)
806 sys.exit(1)
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)
816 action, root, log_level, kwargs = parse_args(sys.argv)
818 # TODO: set log levels per logger based on the command
819 cli_handler.setLevel(log_level)
821 # TODO: when we add log files, set this to minimum log level across all handlers
822 logger.setLevel(log_level)
824 action(root, **kwargs)