Skip to content

High-level API

Getting started

Writing apps with typed_argparse involves three steps, emphasizing a clear separation of concerns:

  1. Argument definition: This defines how argument parsing should work.
  2. Business logic: This defines what your app does.
  3. Bind & run argument definition with business logic, typically in your top-level main() function.
basic_usage.py
from typing import List, Optional

import typed_argparse as tap


# 1. Argument definition
class Args(tap.TypedArgs):
    my_arg: str = tap.arg(help="some help")
    number_a: int = tap.arg(default=42, help="some help")
    number_b: Optional[int] = tap.arg(help="some help")
    verbose: bool = tap.arg(help="some help")
    names: List[str] = tap.arg(help="some help")


# 2. Business logic
def runner(args: Args):
    print(f"Running my app with args:\n{args}")


# 3. Bind argument definition + business logic & run
def main() -> None:
    tap.Parser(Args).bind(runner).run()


if __name__ == "__main__":
    main()

Let's see it in action:

$ python basic_usage.py -h
usage: basic_usage.py [-h] --my-arg MY_ARG [--number-a NUMBER_A]
                      [--number-b NUMBER_B] [--verbose] --names
                      [NAMES [NAMES ...]]

optional arguments:
  -h, --help            show this help message and exit
  --my-arg MY_ARG       some help
  --number-a NUMBER_A   some help [default: 42]
  --number-b NUMBER_B   some help
  --verbose             some help
  --names [NAMES [NAMES ...]]
                        some help

$ python basic_usage.py --my-arg foo --names a b c
Running my app with args:
Args(my_arg='foo', number_a=42, number_b=None, verbose=False, names=['a', 'b', 'c'])

Some observations:

  • By default, arguments are required and option-like. The command line option corresponds to the Python variable name, with underscores replaces by hyphens. For instance, my_arg: str is specified via --my-arg <value> on the command line.
  • If an argument has a default it becomes optional (number_a).
  • If an argument is type-annotated as Optional[T] it also becomes optional, but can take the value None (number_b)
  • If an argument is type-annotated as bool it becomes as boolean switch.
  • If an argument is type-annotated as List[T], it becomes an nargs="*" argument, i.e., it allows for zero or more values.

Positional arguments

By default arguments are option-like, i.e., if the field name is some_argument can be specified as --some-argument <value> on the command line.

In order to create positional (unnamed) argument, positional=True can be passed to the tap.arg function. For instance:

positional_arguments.py
from pathlib import Path

import typed_argparse as tap


class Args(tap.TypedArgs):
    src: Path = tap.arg(positional=True, help="Source file")
    dst: Path = tap.arg(positional=True, help="Destination file")


def runner(args: Args):
    print(f"Print copying from '{args.src}' to '{args.dst}'")


def main() -> None:
    tap.Parser(Args).bind(runner).run()


if __name__ == "__main__":
    main()
$ python positional_arguments.py -h
usage: positional_arguments.py [-h] src dst

positional arguments:
  src         Source file
  dst         Destination file

optional arguments:
  -h, --help  show this help message and exit

$ python positional_arguments.py my_source_file my_destination_file
Print copying from 'my_source_file' to 'my_destination_file'

Multiple positional arguments

Positional arguments can be turned into lists:

  • nargs=* corresponds to a list that can be empty, i.e., it is optional to specify any arguments.
  • nargs=+ corresponds to a list that cannot be empty, i.e., at least one argument must be given.

Using nargs implies that in terms of the type signature the arguments becomes a some_argument: List[T] instead of some_argument: T. Correct usage is verified by type_argparse's type signatures.

For example the following would result in similar semantics like mv (multiple but non-zero inputs, single output):

positional_arguments.py
from pathlib import Path
from typing import List

import typed_argparse as tap


class Args(tap.TypedArgs):
    sources: List[Path] = tap.arg(
        positional=True,
        help="Source path(s)",
        nargs="+",
    )
    dest: Path = tap.arg(
        positional=True,
        help="Destination path",
    )


def runner(args: Args):
    print(f"Moving sources '{repr(args.sources)}' to dest '{repr(args.dest)}'")


def main() -> None:
    tap.Parser(Args).bind(runner).run()


if __name__ == "__main__":
    main()
$ python positional_arguments_non_optional.py -h
usage: positional_arguments_non_optional.py [-h] sources [sources ...] dest

positional arguments:
  sources     Source path(s)
  dest        Destination path

optional arguments:
  -h, --help  show this help message and exit

$ python positional_arguments_non_optional.py source_a source_b my_dest
Moving sources '[PosixPath('source_a'), PosixPath('source_b')]' to dest 'PosixPath('my_dest')'

Flags & renaming

The first argument passed to tap.arg(...) can be used to define short option short names, also known as flags. For instance foo: str = tap.arg("-f") introduces the flag -f as a shorthand for --foo. Similar to regular argparse, multiple names/flags can be specified.

Note

As long as only a single letter flag like -f is introduced, the original option name (e.g. --foo) is still added. If any option name is not that is longer than one letter, the original optional name is omitted, i.e., the specified values become an override. This feature allows to use different names internally (Python) vs externally (CLI).

option_names.py
import typed_argparse as tap


class Args(tap.TypedArgs):
    foo: str = tap.arg("-f")
    internal_name: str = tap.arg("--external-name")


def runner(args: Args):
    print(f"args = {args}")


def main() -> None:
    tap.Parser(Args).bind(runner).run()


if __name__ == "__main__":
    main()
$ python option_names.py -h
usage: option_names.py [-h] -f FOO --external-name INTERNAL_NAME

optional arguments:
  -h, --help            show this help message and exit
  -f FOO, --foo FOO
  --external-name INTERNAL_NAME

$ python option_names.py -f xxx --external-name yyy
args = Args(foo='xxx', internal_name='yyy')

Sub-commands

Let's assume we want to write a complex CLI involving many, possible deeply nested, sub-commands (think git). For instance, imagine the an app that takes either foo or bar as the first level sub-command, and the foo sub-command is further split into start and stop, i.e. the possible command paths are:

$ demo_app foo start
$ demo_app foo stop
$ demo_app bar

In typed_argparse such a tree-like command structure can be directly modeled as a tree of parsers:

sub_commands_basic.py
import typed_argparse as tap


class FooStartArgs(tap.TypedArgs):
    ...


class FooStopArgs(tap.TypedArgs):
    ...


class BarArgs(tap.TypedArgs):
    ...


def run_foo_start(args: FooStartArgs) -> None:
    print(f"Running foo start: {args}")


def run_foo_stop(args: FooStopArgs) -> None:
    print(f"Running foo stop: {args}")


def run_bar(args: BarArgs) -> None:
    print(f"Running bar: {args}")


def main() -> None:
    # The tree-like structure of the CLI (foo -> start, foo -> stop, bar)
    # is directly reflected in the parser structure:
    tap.Parser(
        tap.SubParserGroup(
            tap.SubParser(
                "foo",
                tap.SubParserGroup(
                    tap.SubParser("start", FooStartArgs, help="Help of foo -> start"),
                    tap.SubParser("stop", FooStopArgs, help="Help of foo -> stop"),
                ),
                help="Help of foo",
            ),
            tap.SubParser(
                "bar",
                BarArgs,
                help="Help of bar",
            ),
        ),
    ).bind(
        run_foo_start,
        run_foo_stop,
        run_bar,
    ).run()


if __name__ == "__main__":
    main()
$ python sub_commands_basic.py -h
usage: sub_commands_basic.py [-h] {foo,bar} ...

optional arguments:
  -h, --help  show this help message and exit

subcommands:
  {foo,bar}   Available sub commands
    foo       Help of foo
    bar       Help of bar

$ python sub_commands_basic.py foo -h
usage: sub_commands_basic.py foo [-h] {start,stop} ...

optional arguments:
  -h, --help    show this help message and exit

subcommands:
  {start,stop}  Available sub commands
    start       Help of foo -> start
    stop        Help of foo -> stop

$ python sub_commands_basic.py foo start
Running foo start: FooStartArgs()

$ python sub_commands_basic.py foo stop
Running foo stop: FooStopArgs()

$ python sub_commands_basic.py bar
Running bar: BarArgs()

Some observations:

  • In general a Parser or a SubParser can either take a TypedArg object directly (with leads to no further nesting) or a SubParserGroup container which in turn contains one or more SubParser commands (with adds one level of nesting).
  • The general Parser.bind().run() pattern is the same as with shallow CLIs. The main difference is that sub-commands CLIs bind a runner method for each "leaf" in the argument tree.

Note

typed_argparse internally performs a correctness and completeness check on the functions passed to Parser.bind(). This makes sure that you cannot accidentally forget to bind a leaf of the argument tree, and that all argument types have a matching binding. If you plan to write unit tests for your CLI, including a call to Parser.bind() is therefore a sensible test that makes sure that everything is bound properly.

Common arguments in sub-commands

If some arguments should be shared between multiple sub-commands, provide a base class with those arguments and have the class defining the arguments subclass this.

In the example below, the common argumen is --verbose and CommonArgs just describes this argument. There are two options for how these options need to be passed on the command line. In the example, the two sub-commands foo and bar use the two options different options:

  • For foo the CommonArgs are just used as subclass and not passed via the common_args=... argument to the subparser group. The common arguments will be added to each command individually, i.e., usage becomes <myapp> foo start --verbose and <myapp> foo stop --verbose.
  • For bar the CommonArgs are used as subclass and additionally passed as the common_args=... argument to the subparser group. The common argument will be added at the parent level, i.e., usage becomes <myapp> bar --verbose start and <myapp> bar --verbose stop.
sub_commands_common_arguments.py
import typed_argparse as tap


class CommonArgs(tap.TypedArgs):
    verbose: bool = tap.arg(default=False, help="Enable verbose log output.")


class FooStartArgs(CommonArgs):
    ...


class FooStopArgs(CommonArgs):
    ...


def run_foo_start(args: FooStartArgs) -> None:
    print(f"Running foo start with {args} {'verbosely!' if args.verbose else ''}")


def run_foo_stop(args: FooStopArgs) -> None:
    print(f"Running foo stop with {args}")


class BarStartArgs(CommonArgs):
    ...


class BarStopArgs(CommonArgs):
    ...


def run_bar_start(args: BarStartArgs) -> None:
    print(f"Running bar start with {args} {'verbosely!' if args.verbose else ''}")


def run_bar_stop(args: BarStopArgs) -> None:
    print(f"Running bar stop with {args}")


def main() -> None:
    tap.Parser(
        tap.SubParserGroup(
            tap.SubParser(
                "foo",
                tap.SubParserGroup(
                    tap.SubParser("start", FooStartArgs, help="Help of foo -> start"),
                    tap.SubParser("stop", FooStopArgs, help="Help of foo -> stop"),
                ),
                help="Help of foo",
            ),
            tap.SubParser(
                "bar",
                tap.SubParserGroup(
                    tap.SubParser("start", BarStartArgs, help="Help of bar -> start"),
                    tap.SubParser("stop", BarStopArgs, help="Help of bar -> stop"),
                    common_args=CommonArgs,
                ),
                help="Help of bar",
            ),
        ),
    ).bind(
        run_foo_start,
        run_foo_stop,
        run_bar_start,
        run_bar_stop,
    ).run()


if __name__ == "__main__":
    main()

Compare the help output for the two different sub-commands:

$ python examples/high_level_api/sub_commands_common_arguments.py foo -h
usage: sub_commands_common_arguments.py foo [-h] {start,stop} ...

options:
  -h, --help    show this help message and exit

subcommands:
  {start,stop}  Available sub commands
    start       Help of foo -> start
    stop        Help of foo -> stop

$ python examples/high_level_api/sub_commands_common_arguments.py foo start -h
usage: sub_commands_common_arguments.py foo start [-h] [--verbose]

options:
  -h, --help  show this help message and exit
  --verbose   Enable verbose log output. [default: False]

$ python examples/high_level_api/sub_commands_common_arguments.py bar -h
usage: sub_commands_common_arguments.py bar [-h] [--verbose] {start,stop} ...

options:
  -h, --help    show this help message and exit
  --verbose     Enable verbose log output. [default: False]

subcommands:
  {start,stop}  Available sub commands
    start       Help of bar -> start
    stop        Help of bar -> stop

$ python examples/high_level_api/sub_commands_common_arguments.py bar start -h
usage: sub_commands_common_arguments.py bar start [-h]

options:
  -h, --help  show this help message and exit


$ python examples/high_level_api/sub_commands_common_arguments.py foo start --verbose
Running foo start with FooStartArgs(verbose=True) verbosely!

$ python examples/high_level_api/sub_commands_common_arguments.py bar start --verbose
usage: sub_commands_common_arguments.py [-h] {foo,bar} ...
sub_commands_common_arguments.py: error: unrecognized arguments: --verbose
$ python examples/high_level_api/sub_commands_common_arguments.py bar --verbose start                                                                                                               2 Running bar start with BarStartArgs(verbose=True) verbosely!

Auto-completion

typed_argparse builds on top of argcomplete for auto-completion. The rule is: If you have argcomplete installed, typed_argparse detects it and automatically installs the auto-completion. Check the argcomplete documentation how to activate argcomplete for your particular shell flavor.

Enums

Passing Enum values as argument is straight forward. They are handled just like any other type.

There is just a single caveat, when it comes to the help output. If the default __str__ method of the enum is used, the help output will not display the values bur rather the names. A simple solution is hence to overwrite __str__ so that the value is printed. (Note: From Python 3.11 onwards, the StrEnum class does that automatically.)

enum_arguments.py
from enum import Enum

import typed_argparse as tap


class Color(Enum):
    RED = 1
    GREEN = "green"

    def __str__(self) -> str:
        return str(self.value)


class Make(Enum):
    BIG = 1
    SMALL = "small"


class Args(tap.TypedArgs):
    color: Color
    make: Make


def runner(args: Args):
    print(f"Running my app with args:\n{args}")


def main() -> None:
    tap.Parser(Args).bind(runner).run()


if __name__ == "__main__":
    main()

Compare the help output for the two different enums:

$ python enum_arguments.py --help
usage: enum_arguments.py [-h] --color {1,green} --make {Make.BIG,Make.SMALL}

options:
  -h, --help            show this help message and exit
  --color {1,green}
  --make {Make.BIG,Make.SMALL}

$ python examples/high_level_api/enum_arguments.py --color green --make 1
Running my app with args:
Args(color=<Color.GREEN: 'green'>, make=<Make.BIG: 1>)