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 [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 valueNone
(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 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
Parser
or aSubParser
can either take aTypedArg
object directly (with leads to no further nesting) or aSubParserGroup
container which in turn contains one or moreSubParser
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
theCommonArgs
are 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 --verbose
and<myapp> foo stop --verbose
. - For
bar
theCommonArgs
are 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 start
and<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.)
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>)