Coverage for enderchest/cli.py: 86%
140 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-03 20:14 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-03 20:14 +0000
1"""Command-line interface"""
2import inspect
3import logging
4import os
5import sys
6from argparse import ArgumentParser, RawTextHelpFormatter
7from pathlib import Path
8from typing import Any, Protocol, Sequence
10from . import craft, gather, loggers, place, remote
11from ._version import get_versions
13# mainly because I think I'm gonna forget what names are canonical (it's the first ones)
14_create_aliases = ("craft", "create")
15_instance_aliases = tuple(
16 alias + plural for alias in ("minecraft", "instance") for plural in ("", "s")
17)
18_shulker_aliases = ("shulker_box", "shulkerbox", "shulker")
19_remote_aliases = tuple(
20 alias + plural for alias in ("enderchest", "remote") for plural in ("s", "")
21)
22_list_aliases = ("inventory", "list")
25class Action(Protocol): # pragma: no cover
26 """Common protocol for CLI actions"""
28 def __call__(self, minecraft_root: Path, /) -> Any:
29 ...
32def _place(
33 minecraft_root: Path,
34 errors: str = "prompt",
35 keep_broken_links: bool = False,
36 keep_stale_links: bool = False,
37 keep_level: int = 0,
38 stop_at_first_failure: bool = False,
39 ignore_errors: bool = False,
40 absolute: bool = False,
41 relative: bool = False,
42) -> None:
43 """Wrapper sort through all the various argument groups"""
45 if stop_at_first_failure:
46 errors = "abort"
47 if ignore_errors: # elif?
48 errors = "ignore"
49 # else: errors = errors
51 if absolute is True:
52 # technically we get this for free already
53 relative = False
55 if keep_level > 0:
56 keep_stale_links = True
57 if keep_level > 1:
58 keep_broken_links = True
60 place.place_ender_chest(
61 minecraft_root,
62 keep_broken_links=keep_broken_links,
63 keep_stale_links=keep_stale_links,
64 error_handling=errors,
65 relative=relative,
66 )
69def _craft_shulker_box(minecraft_root: Path, name: str | None = None, **kwargs):
70 """Wrapper to handle the fact that name is a required argument"""
71 assert name # it's required by the parser, so this should be fine
72 craft.craft_shulker_box(minecraft_root, name, **kwargs)
75def _list_instance_boxes(
76 minecraft_root: Path, instance_name: str | None = None, **kwargs
77):
78 """Wrapper to handle the fact that name is a required argument"""
79 assert instance_name # it's required by the parser, so this should be fine
80 gather.get_shulker_boxes_matching_instance(minecraft_root, instance_name, **kwargs)
83def _list_shulker_box(
84 minecraft_root: Path, shulker_box_name: str | None = None, **kwargs
85):
86 """Wrapper to handle the fact that name is a required argument"""
87 assert shulker_box_name # it's required by the parser, so this should be fine
88 gather.get_instances_matching_shulker_box(
89 minecraft_root, shulker_box_name, **kwargs
90 )
93def _update_ender_chest(
94 minecraft_root: Path,
95 official: bool | None = None,
96 mmc: bool | None = None,
97 **kwargs,
98):
99 """Wrapper to resolve the official vs. MultiMC flag"""
100 if mmc:
101 official = False
102 gather.update_ender_chest(minecraft_root, official=official, **kwargs)
105def _open(minecraft_root: Path, verbosity: int = 0, **kwargs):
106 """Router for open verb"""
107 remote.sync_with_remotes(minecraft_root, "pull", verbosity=verbosity, **kwargs)
110def _close(minecraft_root: Path, verbosity: int = 0, **kwargs):
111 """Router for close verb"""
112 remote.sync_with_remotes(minecraft_root, "push", verbosity=verbosity, **kwargs)
115ACTIONS: tuple[tuple[tuple[str, ...], str, Action], ...] = (
116 # action names (first one is canonical), action description, action method
117 (
118 sum(((verb, verb + " enderchest") for verb in _create_aliases), ()),
119 "create and configure a new EnderChest installation",
120 craft.craft_ender_chest,
121 ),
122 (
123 tuple(
124 f"{verb} {alias}" for verb in _create_aliases for alias in _shulker_aliases
125 ),
126 "create and configure a new shulker box",
127 _craft_shulker_box,
128 ),
129 (
130 ("place",),
131 "link (or update the links) from your instances to your EnderChest",
132 _place,
133 ),
134 (
135 tuple("gather " + alias for alias in _instance_aliases),
136 "register (or update the registry of) a Minecraft installation",
137 _update_ender_chest,
138 ),
139 (
140 tuple("gather " + alias for alias in _remote_aliases),
141 "register (or update the registry of) a remote EnderChest",
142 _update_ender_chest,
143 ),
144 (
145 # I freely admit this is ridiculous
146 sum(
147 (
148 (
149 verb,
150 *(
151 f"{verb} {alias}"
152 # pluralization is hard
153 for alias in ("shulker_boxes", "shulkerboxes", "shulkers")
154 ),
155 )
156 for verb in _list_aliases
157 ),
158 (),
159 ),
160 "list the shulker boxes inside your Enderchest",
161 gather.load_shulker_boxes,
162 ),
163 (
164 tuple(
165 f"{verb} {alias}"
166 for verb in _list_aliases
167 for alias in _instance_aliases
168 if alias.endswith("s")
169 ),
170 "list the minecraft instances registered with your Enderchest",
171 gather.load_ender_chest_instances,
172 ),
173 (
174 tuple(
175 f"{verb} {alias}"
176 for verb in _list_aliases
177 for alias in _instance_aliases
178 if not alias.endswith("s")
179 ),
180 "list the shulker boxes that the specified instance links to",
181 _list_instance_boxes,
182 ),
183 (
184 tuple(
185 f"{verb} {alias}" for verb in _list_aliases for alias in _shulker_aliases
186 ),
187 "list the minecraft instances that match the specified shulker box",
188 _list_shulker_box,
189 ),
190 (
191 tuple(f"{verb} {alias}" for verb in _list_aliases for alias in _remote_aliases),
192 "list the other EnderChest installations registered with this EnderChest",
193 gather.load_ender_chest_remotes,
194 ),
195 (
196 ("open",),
197 "pull changes from other EnderChests",
198 _open,
199 ),
200 (
201 ("close",),
202 "push changes to other EnderChests",
203 _close,
204 ),
205)
208def generate_parsers() -> tuple[ArgumentParser, dict[str, ArgumentParser]]:
209 """Generate the command-line parsers
211 Returns
212 -------
213 enderchest_parser : ArgumentParser
214 The top-level argument parser responsible for routing arguments to
215 specific action parsers
216 action_parsers : dict of str to ArgumentParser
217 The verb-specific argument parsers
218 """
219 descriptions: dict[str, str] = {}
220 root_description: str = ""
221 for commands, description, _ in ACTIONS:
222 descriptions[commands[0]] = description
223 root_description += f"\n\t{commands[0]}\n\t\tto {description}"
225 enderchest_parser = ArgumentParser(
226 prog="enderchest",
227 description=(
228 f"v{get_versions()['version']}\n"
229 "\nsyncing and linking for all your Minecraft instances"
230 ),
231 formatter_class=RawTextHelpFormatter,
232 )
234 enderchest_parser.add_argument(
235 "-v", # don't worry--this doesn't actually conflict with --verbose
236 "-V",
237 "--version",
238 action="version",
239 version=f"%(prog)s v{get_versions()['version']}",
240 )
242 # these are really just for the sake of --help
243 # (the parsed args aren't actually used)
244 enderchest_parser.add_argument(
245 "action",
246 help=f"the action to perform. Options are:{root_description}",
247 type=str,
248 )
249 enderchest_parser.add_argument(
250 "arguments",
251 nargs="*",
252 help="any additional arguments for the specific action."
253 " To learn more, try: enderchest {action} -h",
254 )
256 action_parsers: dict[str, ArgumentParser] = {}
257 for verb, description in descriptions.items():
258 parser = ArgumentParser(
259 prog=f"enderchest {verb}",
260 description=description,
261 )
262 root = parser.add_mutually_exclusive_group()
263 root.add_argument(
264 "root",
265 nargs="?",
266 help=(
267 "optionally specify your root minecraft directory."
268 " If no path is given, the current working directory will be used."
269 ),
270 type=Path,
271 )
272 root.add_argument(
273 "--root",
274 dest="root_flag",
275 help="specify your root minecraft directory",
276 type=Path,
277 )
279 # I'm actually okay with -vvqvqqv hilarity
280 parser.add_argument(
281 "--verbose",
282 "-v",
283 action="count",
284 default=0,
285 help="increase the amount of information that's printed",
286 )
287 parser.add_argument(
288 "--quiet",
289 "-q",
290 action="count",
291 default=0,
292 help="decrease the amount of information that's printed",
293 )
294 action_parsers[verb] = parser
296 # craft options
297 craft_parser = action_parsers[_create_aliases[0]]
298 craft_parser.add_argument(
299 "--from",
300 dest="copy_from",
301 help=(
302 "provide the URI (e.g. rsync://deck@my-steam-deck/home/deck/) of a"
303 " remote EnderChest installation that can be used"
304 " to boostrap the creation of this one."
305 ),
306 )
307 craft_parser.add_argument(
308 "-r",
309 "--remote",
310 dest="remotes",
311 action="append",
312 help=(
313 "provide the URI (e.g. rsync://deck@my-steam-deck/home/deck/) of a"
314 " remote EnderChest installation to register with this one"
315 ),
316 )
317 craft_parser.add_argument(
318 "-i",
319 "--instance",
320 dest="instance_search_paths",
321 action="append",
322 type=Path,
323 help="specify a folder to search for Minecraft installations in",
324 )
325 craft_parser.add_argument(
326 "--overwrite",
327 action="store_true",
328 help=(
329 "if there's already an EnderChest installation in this location,"
330 " overwrite its configuration"
331 ),
332 )
334 # shulker box craft options
335 shulker_craft_parser = action_parsers[f"{_create_aliases[0]} {_shulker_aliases[0]}"]
336 shulker_craft_parser.add_argument(
337 "name",
338 help="specify the name for this shulker box",
339 )
340 shulker_craft_parser.add_argument(
341 "--priority",
342 "-p",
343 help="specify the link priority for this shulker box (higher = linked later)",
344 )
345 shulker_craft_parser.add_argument(
346 "-i",
347 "--instance",
348 dest="instances",
349 action="append",
350 help="only link instances with one of the provided names to this shulker box",
351 )
352 shulker_craft_parser.add_argument(
353 "-t",
354 "--tag",
355 dest="tags",
356 action="append",
357 help="only link instances with one of the provided tags to this shulker box",
358 )
359 shulker_craft_parser.add_argument(
360 "-e",
361 "--enderchest",
362 dest="hosts",
363 action="append",
364 help=(
365 "only link instances registered to one of the provided EnderChest"
366 " installations with this shulker box"
367 ),
368 )
369 shulker_craft_parser.add_argument(
370 "-l",
371 "--folder",
372 dest="link_folders",
373 action="append",
374 help=(
375 "specify the name of a folder inside this shulker box"
376 " that should be linked completely"
377 ),
378 )
379 shulker_craft_parser.add_argument(
380 "--overwrite",
381 action="store_true",
382 help=(
383 "if there's already a shulker box with the specified name,"
384 " overwrite its configuration"
385 ),
386 )
388 # place options
389 place_parser = action_parsers["place"]
390 cleanup = place_parser.add_argument_group()
391 cleanup.add_argument(
392 "--keep-broken-links",
393 action="store_true",
394 help="do not remove broken links from instances",
395 )
396 cleanup.add_argument(
397 "--keep-stale-links",
398 action="store_true",
399 help=(
400 "do not remove existing links into the EnderChest,"
401 " even if the shulker box or instance spec has changed"
402 ),
403 )
404 cleanup.add_argument(
405 "-k",
406 dest="keep_level",
407 action="count",
408 default=0,
409 help=(
410 "Shorthand for the above cleanup options:"
411 " -k will --keep-stale-links,"
412 " and -kk will --keep-broken-links as well"
413 ),
414 )
415 error_handling = place_parser.add_mutually_exclusive_group()
416 error_handling.add_argument(
417 "--stop-at-first-failure",
418 "-x",
419 action="store_true",
420 help="stop linking at the first issue",
421 )
422 error_handling.add_argument(
423 "--ignore-errors", action="store_true", help="ignore any linking errors"
424 )
425 error_handling.add_argument(
426 "--errors",
427 "-e",
428 choices=(
429 "prompt",
430 "ignore",
431 "skip",
432 "skip-instance",
433 "skip-shulker-box",
434 "abort",
435 ),
436 default="prompt",
437 help=(
438 "specify how to handle linking errors"
439 " (default behavior is to prompt after every error)"
440 ),
441 )
442 link_type = place_parser.add_mutually_exclusive_group()
443 link_type.add_argument(
444 "--absolute",
445 "-a",
446 action="store_true",
447 help="use absolute paths for all link targets",
448 )
449 link_type.add_argument(
450 "--relative",
451 "-r",
452 action="store_true",
453 help="use relative paths for all link targets",
454 )
456 # gather instance options
457 gather_instance_parser = action_parsers[f"gather {_instance_aliases[0]}"]
458 gather_instance_parser.add_argument(
459 "search_paths",
460 nargs="+",
461 action="extend",
462 type=Path,
463 help="specify a folder or folders to search for Minecraft installations",
464 )
465 instance_type = gather_instance_parser.add_mutually_exclusive_group()
466 instance_type.add_argument(
467 "--official",
468 "-o",
469 action="store_true",
470 help="specify that these are instances managed by the official launcher",
471 )
472 instance_type.add_argument(
473 "--mmc",
474 "-m",
475 action="store_true",
476 help="specify that these are MultiMC-like instances",
477 )
479 # gather remote options
480 gather_remote_parser = action_parsers[f"gather {_remote_aliases[0]}"]
481 gather_remote_parser.add_argument(
482 "remotes",
483 nargs="+",
484 action="extend",
485 help=(
486 "provide URIs (e.g. rsync://deck@my-steam-deck/home/deck/) of any"
487 " remote EnderChest installation to register with this one."
488 "Note: you should not use this method if the alias (name) of the"
489 "remote does not match the remote's hostname (in this example,"
490 '"my-steam-deck").'
491 ),
492 )
494 # list instances options
496 # list shulkers options
498 # list instance box options
499 list_instance_boxes_parser = action_parsers[
500 f"{_list_aliases[0]} {_instance_aliases[0]}"
501 ]
502 list_instance_boxes_parser.add_argument(
503 "instance_name", help="The name of the minecraft instance to query"
504 )
506 # list shulker options
507 list_shulker_parser = action_parsers[f"{_list_aliases[0]} {_shulker_aliases[0]}"]
508 list_shulker_parser.add_argument(
509 "shulker_box_name", help="The name of the shulker box to query"
510 )
512 # open / close options
513 for action in ("open", "close"):
514 sync_parser = action_parsers[action]
516 sync_parser.add_argument(
517 "--dry-run",
518 action="store_true",
519 help=(
520 "Perform a dry run of the sync operation,"
521 " reporting the operations that will be performed"
522 " but not actually carrying them out"
523 ),
524 )
525 sync_parser.add_argument(
526 "--exclude",
527 "-e",
528 nargs="+",
529 help="Provide any file patterns you would like to skip syncing",
530 )
531 sync_parser.add_argument(
532 "--timeout",
533 "-t",
534 type=int,
535 help=(
536 "Set a maximum number of seconds to try to sync to a remote chest"
537 " before giving up and going on to the next one"
538 ),
539 )
540 sync_confirm_wait = sync_parser.add_mutually_exclusive_group()
541 sync_confirm_wait.add_argument(
542 "--wait",
543 "-w",
544 dest="sync_confirm_wait",
545 type=int,
546 help=(
547 "The default behavior when syncing EnderChests is to first perform a"
548 " dry run of every sync operation and then wait 5 seconds before"
549 " proceeding with the real sync. The idea is to give you time to"
550 " interrupt the sync if the dry run looks wrong. You can raise or"
551 " lower that wait time through this flag. You can also modify it"
552 " by editing the enderchest.cfg file."
553 ),
554 )
556 return enderchest_parser, action_parsers
559def parse_args(argv: Sequence[str]) -> tuple[Action, Path, int, dict[str, Any]]:
560 """Parse the provided command-line options to determine the action to perform and
561 the arguments to pass to the action
563 Parameters
564 ----------
565 argv : list-like of str (sys.argv)
566 The options passed into the command line
568 Returns
569 -------
570 Callable
571 The action method that will be called
572 str
573 The root of the minecraft folder (parent of the EnderChest)
574 where the action will be performed
575 int
576 The verbosity level of the operation (in terms of log levels)
577 dict
578 Any additional options that will be given to the action method
580 """
581 actions: dict[str, Action] = {}
582 aliases: dict[str, str] = {}
583 for commands, _, method in ACTIONS:
584 for command in commands:
585 aliases[command] = commands[0]
586 actions[commands[0]] = method
588 enderchest_parser, action_parsers = generate_parsers()
590 _ = enderchest_parser.parse_args(argv[1:2]) # check for --help and --version
592 for command in sorted(aliases.keys(), key=lambda x: -len(x)): # longest first
593 if " ".join((*argv[1:], "")).startswith(command + " "):
594 action_kwargs = vars(
595 action_parsers[aliases[command]].parse_args(
596 argv[1 + len(command.split()) :]
597 )
598 )
600 action = actions[aliases[command]]
602 root_arg = action_kwargs.pop("root")
603 root_flag = action_kwargs.pop("root_flag")
605 verbosity = action_kwargs.pop("verbose") - action_kwargs.pop("quiet")
607 argspec = inspect.getfullargspec(action)
608 if "verbosity" in argspec.args + argspec.kwonlyargs:
609 action_kwargs["verbosity"] = verbosity
611 log_level = loggers.verbosity_to_log_level(verbosity)
613 MINECRAFT_ROOT = os.getenv("MINECRAFT_ROOT")
615 return (
616 actions[aliases[command]],
617 Path(root_arg or root_flag or MINECRAFT_ROOT or os.getcwd()),
618 log_level,
619 action_kwargs,
620 )
622 enderchest_parser.print_help(sys.stderr)
623 sys.exit(1)
626def main():
627 """CLI Entrypoint"""
628 logger = logging.getLogger(__package__)
629 cli_handler = logging.StreamHandler()
630 cli_handler.setFormatter(loggers.CLIFormatter())
631 logger.addHandler(cli_handler)
633 action, root, log_level, kwargs = parse_args(sys.argv)
635 # TODO: set log levels per logger based on the command
636 cli_handler.setLevel(log_level)
638 # TODO: when we add log files, set this to minimum log level across all handlers
639 logger.setLevel(log_level)
641 action(root, **kwargs)