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
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
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,
        )
        if verb != "test":
            root = parser.add_mutually_exclusive_group()
            if verb != "break":
                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_box_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_argument_group(
        title="error handling"
    ).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 server options
    gather_server_parser = action_parsers["gather server"]
    gather_server_parser.add_argument(
        "server_home", type=Path, help="the working directory of the Minecraft server"
    )
    gather_server_parser.add_argument(
        "--jar",
        "-j",
        type=Path,
        help=(
            "explicitly specify the path to the server JAR (in case it's outside"
            " of the server's working directory of if there are multiple server"
            " JAR files inside that folder)"
        ),
    )
    gather_server_parser.add_argument(
        "--name", "-n", help="specify the name (alias) for the server"
    )
    gather_server_parser.add_argument(
        "--tags",
        "-t",
        nargs="+",
        action="extend",
        help="specify any tags you want to apply to the server",
    )

    # 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 shulker box options

    # list [instance] boxes options
    list_boxes_parser = action_parsers[f"{_list_aliases[0]}"]
    list_instance_boxes_parser = action_parsers[
        f"{_list_aliases[0]} {_instance_aliases[0]}"
    ]

    instance_name_docs = "The name of the minecraft instance to query"
    list_boxes_parser.add_argument(
        "--instance", "-i", dest="instance_name", help=instance_name_docs
    )
    list_instance_boxes_parser.add_argument("instance_name", help=instance_name_docs)

    for parser in (list_boxes_parser, list_instance_boxes_parser):
        parser.add_argument(
            "--path",
            "-p",
            help=(
                "optionally, specify a specific path"
                " (absolute, relative, filename or glob pattern"
                " to get a report of the shulker box(es) that provide that resource"
            ),
        )

    # list shulker options
    list_shulker_box_parser = action_parsers[
        f"{_list_aliases[0]} {_shulker_box_aliases[0]}"
    ]
    list_shulker_box_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",
            action="extend",
            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_argument_group(
            title="sync confirmation control",
            description=(
                "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 these flags. You can also modify it"
                " by editing the enderchest.cfg file."
            ),
        ).add_mutually_exclusive_group()
        sync_confirm_wait.add_argument(
            "--wait",
            "-w",
            dest="sync_confirm_wait",
            type=int,
            help="set the time in seconds to wait after performing a dry run"
            " before the real sync is performed",
        )
        sync_confirm_wait.add_argument(
            "--confirm",
            "-c",
            dest="sync_confirm_wait",
            action="store_true",
            help="after performing the dry run, explicitly ask for confirmation"
            " before performing the real sync",
        )

    break_parser = action_parsers["break"]
    break_parser.add_argument(
        "instances",
        nargs="*",
        help="instead of breaking your entire EnderChest, just deregister and"
        " copy linked resources into the specified instances (by name)",
    )

    # test pass-through
    test_parser = action_parsers["test"]
    test_parser.add_argument(
        "--use-local-ssh",
        action="store_true",
        dest="use_local_ssh",
        help=(
            "By default, tests of SSH functionality will be run against a mock"
            " SSH server. If you are running EnderChest on a machine you can SSH"
            " into locally (by running `ssh localhost`) without requiring a password,"
            " running the tests with this flag will produce more accurate results."
        ),
    )
    test_parser.add_argument(
        "pytest_args",
        nargs=argparse.REMAINDER,
        help="any additional arguments to pass through to py.test",
    )

    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 + " "):
            if command == "test":
                parsed, extra = action_parsers["test"].parse_known_args(argv[2:])
                return (
                    actions["test"],
                    Path(),
                    0,
                    {
                        "use_local_ssh": parsed.use_local_ssh,
                        "pytest_args": [*parsed.pytest_args, *extra],
                    },
                )
            action_kwargs = vars(
                action_parsers[aliases[command]].parse_args(
                    argv[1 + len(command.split()) :]
                )
            )

            action = actions[aliases[command]]

            root_arg = None if command == "break" else 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)