High-level API
Getting started
Writing apps with typed_argparse involves three steps, emphasizing a clear separation of concerns:
- Argument definition: This defines how argument parsing should work.
- Business logic: This defines what your app does.
- Bind & run argument definition with business logic, typically in your top-level
main()function.
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 [90m[default: 42][39m
--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: stris 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 valueNone(number_b) - If an argument is type-annotated as
boolit becomes as boolean switch. - If an argument is type-annotated as
List[T], it becomes annargs="*"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:
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):
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).
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()
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:
In typed_argparse such a tree-like command structure can be directly modeled as a tree of parsers:
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
Parseror aSubParsercan either take aTypedArgobject directly (with leads to no further nesting) or aSubParserGroupcontainer which in turn contains one or moreSubParsercommands (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
footheCommonArgsare just used as subclass and not passed via thecommon_args=...argument to the subparser group. The common arguments will be added to each command individually, i.e., usage becomes<myapp> foo start --verboseand<myapp> foo stop --verbose. - For
bartheCommonArgsare used as subclass and additionally passed as thecommon_args=...argument to the subparser group. The common argument will be added at the parent level, i.e., usage becomes<myapp> bar --verbose startand<myapp> bar --verbose stop.
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.)
For Python versions prior to 3.12 it also makes sense to overwrite __repr__ to
print the value, as repr is used to generate the error output.
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>)