Coverage for enderchest/cli.py: 83%
183 statements
« prev ^ index » next coverage.py v7.7.1, created at 2025-03-28 20:32 +0000
« prev ^ index » next coverage.py v7.7.1, created at 2025-03-28 20:32 +0000
1"""Command-line interface"""
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
13from . import craft, gather, inventory, loggers, place, remote, uninstall
14from ._version import get_versions
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")
28class Action(Protocol): # pragma: no cover
29 """Common protocol for CLI actions"""
31 def __call__(self, minecraft_root: Path, /) -> Any: ...
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"""
47 if stop_at_first_failure:
48 errors = "abort"
49 if ignore_errors: # elif?
50 errors = "ignore"
51 # else: errors = errors
53 if absolute is True:
54 # technically we get this for free already
55 relative = False
57 if keep_level > 0:
58 keep_stale_links = True
59 if keep_level > 1:
60 keep_broken_links = True
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 )
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)
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)
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 )
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)
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 )
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)
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)
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
162 from enderchest.test import plugin
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}")
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)
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)
294def generate_parsers() -> tuple[ArgumentParser, dict[str, ArgumentParser]]:
295 """Generate the command-line parsers
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}"
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 )
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 )
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 )
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 )
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
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 )
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 )
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 )
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 )
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 )
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 )
612 # list shulker box options
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 ]
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)
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 )
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 )
645 # open / close options
646 for action in ("open", "close"):
647 sync_parser = action_parsers[action]
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 )
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 )
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 )
729 return enderchest_parser, action_parsers
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
736 Parameters
737 ----------
738 argv : list-like of str (sys.argv)
739 The options passed into the command line
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
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
761 enderchest_parser, action_parsers = generate_parsers()
763 _ = enderchest_parser.parse_args(argv[1:2]) # check for --help and --version
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 )
784 action = actions[aliases[command]]
786 root_arg = None if command == "break" else action_kwargs.pop("root")
787 root_flag = action_kwargs.pop("root_flag")
789 verbosity = action_kwargs.pop("verbose") - action_kwargs.pop("quiet")
791 argspec = inspect.getfullargspec(action)
792 if "verbosity" in argspec.args + argspec.kwonlyargs:
793 action_kwargs["verbosity"] = verbosity
795 log_level = loggers.verbosity_to_log_level(verbosity)
797 MINECRAFT_ROOT = os.getenv("MINECRAFT_ROOT")
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 )
806 enderchest_parser.print_help(sys.stderr)
807 sys.exit(1)
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)
817 action, root, log_level, kwargs = parse_args(sys.argv)
819 # TODO: set log levels per logger based on the command
820 cli_handler.setLevel(log_level)
822 # TODO: when we add log files, set this to minimum log level across all handlers
823 logger.setLevel(log_level)
825 action(root, **kwargs)