Skip to content

cli

Command-line interface

Action

Bases: Protocol

Common protocol for CLI actions

Source code in enderchest/cli.py
class Action(Protocol):  # pragma: no cover
    """Common protocol for CLI actions"""

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

generate_parsers()

Generate the command-line parsers

Returns:

Name Type Description
enderchest_parser ArgumentParser

The top-level argument parser responsible for routing arguments to specific action parsers

action_parsers dict of str to ArgumentParser

The verb-specific argument parsers

Source code in enderchest/cli.py
def generate_parsers() -> tuple[ArgumentParser, dict[str, ArgumentParser]]:
    """Generate the command-line parsers

    Returns
    -------
    enderchest_parser : ArgumentParser
        The top-level argument parser responsible for routing arguments to
        specific action parsers
    action_parsers : dict of str to ArgumentParser
        The verb-specific argument parsers
    """
    descriptions: dict[str, str] = {}
    root_description: str = ""
    for commands, description, _ in ACTIONS:
        descriptions[commands[0]] = description
        root_description += f"\n\t{commands[0]}\n\t\tto {description}"

    enderchest_parser = ArgumentParser(
        prog="enderchest",
        description=(
            f"v{get_versions()['version']}\n"
            "\nsyncing and linking for all your Minecraft instances"
        ),
        formatter_class=RawTextHelpFormatter,
    )

    enderchest_parser.add_argument(
        "-v",  # don't worry--this doesn't actually conflict with --verbose
        "-V",
        "--version",
        action="version",
        version=f"%(prog)s v{get_versions()['version']}",
    )

    # these are really just for the sake of --help
    # (the parsed args aren't actually used)
    enderchest_parser.add_argument(
        "action",
        help=f"the action to perform. Options are:{root_description}",
        type=str,
    )
    enderchest_parser.add_argument(
        "arguments",
        nargs="*",
        help="any additional arguments for the specific action."
        " To learn more, try: enderchest {action} -h",
    )

    action_parsers: dict[str, ArgumentParser] = {}
    for verb, description in descriptions.items():
        parser = ArgumentParser(
            prog=f"enderchest {verb}",
            description=description,
        )
        root = parser.add_mutually_exclusive_group()
        root.add_argument(
            "root",
            nargs="?",
            help=(
                "optionally specify your root minecraft directory."
                "  If no path is given, the current working directory will be used."
            ),
            type=Path,
        )
        root.add_argument(
            "--root",
            dest="root_flag",
            help="specify your root minecraft directory",
            type=Path,
        )

        # I'm actually okay with -vvqvqqv hilarity
        parser.add_argument(
            "--verbose",
            "-v",
            action="count",
            default=0,
            help="increase the amount of information that's printed",
        )
        parser.add_argument(
            "--quiet",
            "-q",
            action="count",
            default=0,
            help="decrease the amount of information that's printed",
        )
        action_parsers[verb] = parser

    # craft options
    craft_parser = action_parsers[_create_aliases[0]]
    craft_parser.add_argument(
        "--from",
        dest="copy_from",
        help=(
            "provide the URI (e.g. rsync://deck@my-steam-deck/home/deck/) of a"
            " remote EnderChest installation that can be used"
            " to boostrap the creation of this one."
        ),
    )
    craft_parser.add_argument(
        "-r",
        "--remote",
        dest="remotes",
        action="append",
        help=(
            "provide the URI (e.g. rsync://deck@my-steam-deck/home/deck/) of a"
            " remote EnderChest installation to register with this one"
        ),
    )
    craft_parser.add_argument(
        "-i",
        "--instance",
        dest="instance_search_paths",
        action="append",
        type=Path,
        help="specify a folder to search for Minecraft installations in",
    )
    craft_parser.add_argument(
        "--overwrite",
        action="store_true",
        help=(
            "if there's already an EnderChest installation in this location,"
            " overwrite its configuration"
        ),
    )

    # shulker box craft options
    shulker_craft_parser = action_parsers[f"{_create_aliases[0]} {_shulker_aliases[0]}"]
    shulker_craft_parser.add_argument(
        "name",
        help="specify the name for this shulker box",
    )
    shulker_craft_parser.add_argument(
        "--priority",
        "-p",
        help="specify the link priority for this shulker box (higher = linked later)",
    )
    shulker_craft_parser.add_argument(
        "-i",
        "--instance",
        dest="instances",
        action="append",
        help="only link instances with one of the provided names to this shulker box",
    )
    shulker_craft_parser.add_argument(
        "-t",
        "--tag",
        dest="tags",
        action="append",
        help="only link instances with one of the provided tags to this shulker box",
    )
    shulker_craft_parser.add_argument(
        "-e",
        "--enderchest",
        dest="hosts",
        action="append",
        help=(
            "only link instances registered to one of the provided EnderChest"
            " installations with this shulker box"
        ),
    )
    shulker_craft_parser.add_argument(
        "-l",
        "--folder",
        dest="link_folders",
        action="append",
        help=(
            "specify the name of a folder inside this shulker box"
            " that should be linked completely"
        ),
    )
    shulker_craft_parser.add_argument(
        "--overwrite",
        action="store_true",
        help=(
            "if there's already a shulker box with the specified name,"
            " overwrite its configuration"
        ),
    )

    # place options
    place_parser = action_parsers["place"]
    cleanup = place_parser.add_argument_group()
    cleanup.add_argument(
        "--keep-broken-links",
        action="store_true",
        help="do not remove broken links from instances",
    )
    cleanup.add_argument(
        "--keep-stale-links",
        action="store_true",
        help=(
            "do not remove existing links into the EnderChest,"
            " even if the shulker box or instance spec has changed"
        ),
    )
    cleanup.add_argument(
        "-k",
        dest="keep_level",
        action="count",
        default=0,
        help=(
            "Shorthand for the above cleanup options:"
            " -k will --keep-stale-links,"
            " and -kk will --keep-broken-links as well"
        ),
    )
    error_handling = place_parser.add_mutually_exclusive_group()
    error_handling.add_argument(
        "--stop-at-first-failure",
        "-x",
        action="store_true",
        help="stop linking at the first issue",
    )
    error_handling.add_argument(
        "--ignore-errors", action="store_true", help="ignore any linking errors"
    )
    error_handling.add_argument(
        "--errors",
        "-e",
        choices=(
            "prompt",
            "ignore",
            "skip",
            "skip-instance",
            "skip-shulker-box",
            "abort",
        ),
        default="prompt",
        help=(
            "specify how to handle linking errors"
            " (default behavior is to prompt after every error)"
        ),
    )
    link_type = place_parser.add_mutually_exclusive_group()
    link_type.add_argument(
        "--absolute",
        "-a",
        action="store_true",
        help="use absolute paths for all link targets",
    )
    link_type.add_argument(
        "--relative",
        "-r",
        action="store_true",
        help="use relative paths for all link targets",
    )

    # gather instance options
    gather_instance_parser = action_parsers[f"gather {_instance_aliases[0]}"]
    gather_instance_parser.add_argument(
        "search_paths",
        nargs="+",
        action="extend",
        type=Path,
        help="specify a folder or folders to search for Minecraft installations",
    )
    instance_type = gather_instance_parser.add_mutually_exclusive_group()
    instance_type.add_argument(
        "--official",
        "-o",
        action="store_true",
        help="specify that these are instances managed by the official launcher",
    )
    instance_type.add_argument(
        "--mmc",
        "-m",
        action="store_true",
        help="specify that these are MultiMC-like instances",
    )

    # gather remote options
    gather_remote_parser = action_parsers[f"gather {_remote_aliases[0]}"]
    gather_remote_parser.add_argument(
        "remotes",
        nargs="+",
        action="extend",
        help=(
            "provide URIs (e.g. rsync://deck@my-steam-deck/home/deck/) of any"
            " remote EnderChest installation to register with this one."
            "Note: you should not use this method if the alias (name) of the"
            "remote does not match the remote's hostname (in this example,"
            '"my-steam-deck").'
        ),
    )

    # list instances options

    # list shulkers options

    # list instance box options
    list_instance_boxes_parser = action_parsers[
        f"{_list_aliases[0]} {_instance_aliases[0]}"
    ]
    list_instance_boxes_parser.add_argument(
        "instance_name", help="The name of the minecraft instance to query"
    )

    # list shulker options
    list_shulker_parser = action_parsers[f"{_list_aliases[0]} {_shulker_aliases[0]}"]
    list_shulker_parser.add_argument(
        "shulker_box_name", help="The name of the shulker box to query"
    )

    # open / close options
    for action in ("open", "close"):
        sync_parser = action_parsers[action]

        sync_parser.add_argument(
            "--dry-run",
            action="store_true",
            help=(
                "Perform a dry run of the sync operation,"
                " reporting the operations that will be performed"
                " but not actually carrying them out"
            ),
        )
        sync_parser.add_argument(
            "--exclude",
            "-e",
            nargs="+",
            help="Provide any file patterns you would like to skip syncing",
        )
        sync_parser.add_argument(
            "--timeout",
            "-t",
            type=int,
            help=(
                "Set a maximum number of seconds to try to sync to a remote chest"
                " before giving up and going on to the next one"
            ),
        )
        sync_confirm_wait = sync_parser.add_mutually_exclusive_group()
        sync_confirm_wait.add_argument(
            "--wait",
            "-w",
            dest="sync_confirm_wait",
            type=int,
            help=(
                "The default behavior when syncing EnderChests is to first perform a"
                " dry run of every sync operation and then wait 5 seconds before"
                " proceeding with the real sync. The idea is to give you time to"
                " interrupt the sync if the dry run looks wrong. You can raise or"
                " lower that wait time through this flag. You can also modify it"
                " by editing the enderchest.cfg file."
            ),
        )

    return enderchest_parser, action_parsers

main()

CLI Entrypoint

Source code in enderchest/cli.py
def main():
    """CLI Entrypoint"""
    logger = logging.getLogger(__package__)
    cli_handler = logging.StreamHandler()
    cli_handler.setFormatter(loggers.CLIFormatter())
    logger.addHandler(cli_handler)

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

    # TODO: set log levels per logger based on the command
    cli_handler.setLevel(log_level)

    # TODO: when we add log files, set this to minimum log level across all handlers
    logger.setLevel(log_level)

    action(root, **kwargs)

parse_args(argv)

Parse the provided command-line options to determine the action to perform and the arguments to pass to the action

Parameters:

Name Type Description Default
argv list-like of str (sys.argv)

The options passed into the command line

required

Returns:

Type Description
Callable

The action method that will be called

str

The root of the minecraft folder (parent of the EnderChest) where the action will be performed

int

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

dict

Any additional options that will be given to the action method

Source code in enderchest/cli.py
def parse_args(argv: Sequence[str]) -> tuple[Action, Path, int, dict[str, Any]]:
    """Parse the provided command-line options to determine the action to perform and
    the arguments to pass to the action

    Parameters
    ----------
    argv : list-like of str (sys.argv)
        The options passed into the command line

    Returns
    -------
    Callable
        The action method that will be called
    str
        The root of the minecraft folder (parent of the EnderChest)
        where the action will be performed
    int
        The verbosity level of the operation (in terms of log levels)
    dict
        Any additional options that will be given to the action method

    """
    actions: dict[str, Action] = {}
    aliases: dict[str, str] = {}
    for commands, _, method in ACTIONS:
        for command in commands:
            aliases[command] = commands[0]
        actions[commands[0]] = method

    enderchest_parser, action_parsers = generate_parsers()

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

    for command in sorted(aliases.keys(), key=lambda x: -len(x)):  # longest first
        if " ".join((*argv[1:], "")).startswith(command + " "):
            action_kwargs = vars(
                action_parsers[aliases[command]].parse_args(
                    argv[1 + len(command.split()) :]
                )
            )

            action = actions[aliases[command]]

            root_arg = action_kwargs.pop("root")
            root_flag = action_kwargs.pop("root_flag")

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

            argspec = inspect.getfullargspec(action)
            if "verbosity" in argspec.args + argspec.kwonlyargs:
                action_kwargs["verbosity"] = verbosity

            log_level = loggers.verbosity_to_log_level(verbosity)

            MINECRAFT_ROOT = os.getenv("MINECRAFT_ROOT")

            return (
                actions[aliases[command]],
                Path(root_arg or root_flag or MINECRAFT_ROOT or os.getcwd()),
                log_level,
                action_kwargs,
            )

    enderchest_parser.print_help(sys.stderr)
    sys.exit(1)