Command line interfaces

Exploring Yuio apps and CLI interfaces.

Creating and using apps

Yuio provides a decorator that turns functions into CLI applications: yuio.app.app(). Function parameters become application’s CLI arguments; you can configure them the same way as you configure config fields:

import yuio.app

@yuio.app.app
def main(
    #: Who are we greeting?
    greeting: str = yuio.app.field(
        default="world",
        flags=["-g", "--greeting"],
        metavar="<name>",
    )
):
    print(f"Hello, {greeting}!")

Positional-only arguments become positional CLI options, while keyword arguments become CLI flags. **kwargs are not supported.

You can run the app with method run():

if __name__ == "__main__":
    main.run()

run() sets up Yuio and logging, parses CLI arguments, and invokes the main function.

You can invoke the original main function using wrapped():

main.wrapped(greeting="sunshine")

Tip

Set App.is_dev_mode to True to see helpful warnings from Yuio.

Positional arguments

By default, all app arguments become flags. You can use positional-only arguments to create positional CLI options:

@yuio.app.app
def main(
    #: Who are we greeting?
    greeting: str,
    /,  # [1]_
):
    print(f"Hello, {greeting}!")
  1. This syntax separates positional-only arguments from normal arguments. See PEP 570 for details.

Mutually exclusive arguments

Sometimes you need to ensure that only one of several arguments can be passed. For this, create a MutuallyExclusiveGroup and pass it to all such arguments:

GROUP = yuio.app.MutuallyExclusiveGroup()

@yuio.app.app
def main(
    release: str,
    alpha: bool = yuio.app.field(default=False, mutex_group=GROUP),
    beta: bool = yuio.app.field(default=False, mutex_group=GROUP),
    rc: bool = yuio.app.field(default=False, mutex_group=GROUP),
):
    ...

Using configs in CLI

You can use Config to group CLI arguments. This can help with encapsulation and reduce code duplication:

import pathlib
import yuio.app
import yuio.config

class ExecutorConfig(yuio.config.Config):
    #: Number of threads to use.
    threads: int = 4
    #: Enable gpu usage.
    use_gpu: bool = True

@yuio.app.app
def main(
    #: File to process.
    input: pathlib.Path,
    /,
    #: Executor options.
    executor_config: ExecutorConfig,
):
    ...

By default, Yuio will prefix all flags in the nested config with flag of config’s field. That is, ExecutorConfig.threads will be loaded from --executor-config-threads.

You can override this prefix by passing flag to yuio.app.field(), or disable prefixing by using yuio.app.inline():

@yuio.app.app
def main(
    #: File to process.
    input: pathlib.Path,
    /,
    #: Executor options.
    executor_config: ExecutorConfig = yuio.app.inline(),
):
    ...

Argument groups

Yuio automatically groups CLI options when you use nested Configs:

class ExecutorConfig(yuio.config.Config):
    #: Number of threads to use.
    threads: int = 4

    #: Enable gpu usage.
    use_gpu: bool = True

@yuio.app.app
def main(
    #: Executor options.  [1]_
    #:
    #: These options control algorithm execution,
    #: resource usage, and acceleration. [2]_
    executor_config: ExecutorConfig,
):
    ...
  1. First paragraph becomes group’s title.

  2. All consequent paragraphs are shown in group’s help section.

CLI options are not grouped when no documentation comment is available. Additionally, you can disable grouping by setting help_group to None:

@yuio.app.app
def main(
    executor_config: ExecutorConfig = yuio.app.inline(
        help_group=None  # [1]_
    ),
):
    ...
  1. This moves config’s fields to the main group.

You can also assign groups for individual fields by passing HelpGroup to yuio.app.field():

import pathlib
import yuio.app

CONFIG_GROUP = yuio.app.HelpGroup("Config")

@yuio.app.app
def main(
    #: Override default path to config.
    config: pathlib.Path | None = yuio.app.field(
        default=None,
        help_group=CONFIG_GROUP,
    ),
    #: Print output in machine readable format.
    machine_readable: bool = yuio.app.field(
        default=False,
        help_group=yuio.app.MISC_GROUP,
    ),
):
    ...

if __name__ == "__main__":
    main.run()

Subcommands

We can easily build apps with multiple subcommands by using App.subcommand:

@yuio.app.app
def main():
    pass

@main.subcommand
def backup(file: pathlib.Path, /):
    bak = file.parent / (file.name + ".bak")
    shutil.copy(file, bak)

@main.subcommand
def restore(file: pathlib.Path, /):
    bak = file.parent / (file.name + ".bak")
    shutil.rmtree(file, ignore_errors=True)
    shutil.move(str(bak), file)

When you run app backup ..., main() will be called first, then backup(). This lets you run code that’s needed for all sub-commands, such as loading configs. See details in Controlling how sub-commands are invoked.

App settings

We can further customize our app and subcommands using the decorator’s arguments or directly setting app properties.

For example, let’s add aliases for subcommands, and also an epilog section to our app’s help:

@yuio.app.app
def main(
    #: Overwrite old backup file if it exists.
    force: bool = False,
):
    """
    A simple tool for creating ``.bak`` files.

    """

main.epilog = """
Usage examples
--------------

-   This will copy ``./prod.sqlite3`` to ``./prod.sqlite3.bak``:

    .. code-block:: bash

        python main.py backup ./prod.sqlite3

-   This will move ``./prod.sqlite3.bak`` to ``./prod.sqlite3``:

    .. code-block:: bash

        python main.py restore ./prod.sqlite3

"""

@main.subcommand(aliases=["b"])
def backup(file: pathlib.Path, /):
    """
    Move ``file`` to ``file.bak``.

    """

    bak = file.parent / (file.name + ".bak")
    shutil.copy(file, bak)

@main.subcommand(aliases=["r"])
def restore(file: pathlib.Path, /):
    """
    Move ``file.bak`` to ``file``.

    """
    bak = file.parent / (file.name + ".bak")
    shutil.rmtree(file, ignore_errors=True)
    shutil.move(str(bak), file)

This will result in the following help message:

Autocompletion

Yuio can generate autocompletion for Bash, Fish, Zsh, and PowerShell. It installs completions automatically, without any need to pipe shell scripts into specific files. Just run your app with --completions flag:

$ ./app --completions

CLI options with custom behavior

If default behavior doesn’t meet your needs, you can provide your own option implementation by passing option_ctor to yuio.app.field(). This will let you manually configure option’s parsing and help formatting:

@yuio.app.app
def main(
    quiet: int = yuio.app.field(
        default=0,
        flags=["-q", "--quiet"],
        option_ctor=yuio.app.count_option(),
    )
):
    ...

See also

See Creating custom CLI options, yuio.app.OptionCtor, and yuio.cli for API reference.