Annotated Argument Processing
Experimental
The @with_annotated decorator and its supporting Argument / Option metadata classes are
experimental. The public API, the surface of accepted type annotations, and the generated
argparse behavior may all change in future releases without a deprecation cycle. Pin a specific
cmd2 version if you depend on the exact current semantics, and expect to revisit your usage on
upgrades.
For production code that needs stable behavior, use @with_argparser instead.
The @with_annotated decorator builds an argparse parser automatically from
the decorated function's type annotations. No manual add_argument() calls are required, and the
command body receives typed keyword arguments directly instead of an argparse.Namespace.
The two decorators are interchangeable -- here is the same command written both ways:
parser = Cmd2ArgumentParser()
parser.add_argument('name', help='person to greet')
parser.add_argument('--count', type=int, default=1, help='repetitions')
parser.add_argument('--loud', action='store_true', help='shout')
@with_argparser(parser)
def do_greet(self, args):
for _ in range(args.count):
msg = f"Hello {args.name}"
self.poutput(msg.upper() if args.loud else msg)
The annotated version is more concise, gives you typed parameters, and supports several advanced
cmd2 features directly, including ns_provider, with_unknown_args, and typed subcommands. Pick
@with_argparser when you need a stable, well-established API or fine-grained control over the
parser; pick @with_annotated when you want type-hint-driven ergonomics and can accept the
experimental status.
Basic usage
Parameters without defaults become positional arguments. Parameters with defaults become --option
flags. Keyword-only parameters (after *) always become options, and without a default they become
required options.
Underscores in parameter names are converted to dashes in the generated flag, so dry_run becomes
--dry-run. The Python identifier you read inside the function body keeps its underscored form
(args.dry_run). To opt out, pass explicit names via Option("--my_flag", ...).
from cmd2.annotated import with_annotated
class MyApp(cmd2.Cmd):
@with_annotated
def do_greet(self, name: str, count: int = 1, loud: bool = False):
"""Greet someone."""
for _ in range(count):
msg = f"Hello {name}"
self.poutput(msg.upper() if loud else msg)
The command greet Alice --count 3 --loud parses name="Alice", count=3, loud=True and passes
them as keyword arguments.
How annotations map to argparse
The decorator converts Python type annotations into add_argument() calls:
| Type annotation | Generated argparse setting |
|---|---|
str |
default (no type= needed) |
int, float |
type=int or type=float |
bool with a default |
boolean optional flag via BooleanOptionalAction |
positional bool |
parsed from true/false, yes/no, on/off, 1/0 |
Path |
type=Path |
Enum subclass |
type=converter, choices from member values |
decimal.Decimal |
type=decimal.Decimal |
Literal[...] |
type=literal-converter, choices from values |
list[T] / set[T] / tuple[T, ...] |
nargs='+' (or '*' if it has a default or is \| None) |
tuple[T, T] |
fixed nargs=N with type=T |
T \| None (no default) |
positional with nargs='?' (accepts 0-or-1 tokens) |
T \| None = None |
--flag option with default=None |
When collection types are used with @with_annotated, parsed values are passed to the command
function as:
list[T]aslistset[T]assettuple[T, ...]astuple
Unsupported patterns raise TypeError, including:
- unions with multiple non-
Nonemembers such asstr | int - mixed-type tuples such as
tuple[int, str] Annotated[T, meta] | None; writeAnnotated[T | None, meta]insteadAnnotated[T, Argument(nargs=N)]whereNis'*','+', or an integer>= 1andTis not a collection type.nargsvalues that produce a list of values need a collection annotation such aslist[T]ortuple[T, ...].- an optional fixed-arity positional, such as
Annotated[tuple[int, int], Argument()] = (1, 2),Annotated[tuple[int, int] | None, Argument()], or any positionalArgument(nargs=N)with a default or| None. argparse cannot make a fixed-arity positional optional (there is nonargsfor "absent or exactlyNtokens"), so use a variable-arity type liketuple[T, ...], drop the default, or make it an option (give it a default withoutArgument()). Annotated[tuple[T, T], Argument(nargs=N)]whereNdiffers from the number of elements declared by the tuple. The tuple type already pinsnargs; user metadata cannot change it.
The parameter names dest and subcommand are reserved and may not be used as annotated parameter
names. cmd2_statement receives the parsed cmd2.Statement object, and cmd2_subcommand_func
(only on a command decorated with @with_annotated(base_command=True)) receives the subcommand
handler.
Annotated metadata
For finer control, use typing.Annotated with Argument or
Option metadata:
from typing import Annotated
from cmd2.annotated import Argument, Option, with_annotated
class MyApp(cmd2.Cmd):
def sport_choices(self) -> cmd2.Choices:
return cmd2.Choices.from_values(["football", "basketball"])
@with_annotated
def do_play(
self,
sport: Annotated[str, Argument(
choices_provider=sport_choices,
help_text="Sport to play",
)],
venue: Annotated[str, Option(
"--venue", "-v",
help_text="Where to play",
completer=cmd2.Cmd.path_complete,
)] = "home",
):
self.poutput(f"Playing {sport} at {venue}")
Both Argument and Option accept the same cmd2-specific fields as add_argument(): choices,
choices_provider, completer, table_columns, suppress_tab_hint, metavar, nargs, and
help_text.
Option additionally accepts action, required, and positional *names for custom flag strings
(e.g. Option("--color", "-c")).
Actions
When an Option(action=...) uses a zero-argument argparse action that takes no value from the
command line (count, store_true, store_false, store_const, append_const),
@with_annotated strips the value-oriented metadata it inferred from the type before calling
add_argument():
- the
typeconverter, - the static
choices, and - any inferred tab-completer (such as the path completer for
Path) orchoices_provider.
This matches argparse behavior (which rejects a completer on a value-less action) and avoids
parser-construction errors such as combining action='count' with type=int. Actions that do
consume values (append / extend on a list[T], or a plain value option) keep the inferred
converter and completer.
Pairing const with an explicit nargs on a scalar Option selects argparse's optional-value
idiom instead of store_const. Annotated[str | None, Option("--log", nargs='?', const="CONSOLE")]
keeps the store action and the inferred type converter, so the flag is three-way:
- absent yields the default,
- a bare
--logyields theconst, and --log VALUEyields the convertedVALUE.
The const is stored verbatim (it is not run through the converter), so it must already match the
declared type. Without an explicit nargs, const alone still infers the value-less store_const
(present yields the const, and supplying a value is an error).
Option(action=...) also accepts a custom argparse.Action subclass. The class is passed straight
through to add_argument() and owns storage of the parsed value, so the type-inferred collection
casting and the action-specific type/const/shape constraints are skipped; the inferred type=
converter, default, and required are still applied so the class receives them like any hand-built
add_argument() call.
class UpperAction(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
setattr(namespace, self.dest, values.upper())
@with_annotated
def do_shout(self, name: Annotated[str, Option("--name", action=UpperAction)] = ""):
self.poutput(name)
action='help' and action='version' are not supported by @with_annotated; use @with_argparser
if you need them.
Reserved keyword arguments
Argument() and Option() refuse a handful of add_argument() kwargs that the decorator derives
from the function signature itself, so misusing them surfaces as a clear TypeError instead of a
silent override. The refused kwargs are:
type-- comes from the parameter annotationdest-- comes from the parameter nameactionandrequiredonArgument-- onlyOptionaccepts them; positional arguments have no action and are required unless they carry a default or| None
Every other add_argument() parameter passes through, including any custom parameter registered via
register_argparse_argument_parameter.
Defaults
A default may be supplied either through the function signature or as a metadata kwarg. The two
forms are equivalent:
# Signature default
def do_x(self, name: Annotated[str, Option("--name")] = "HI"): ...
# Metadata default (same behaviour)
def do_x(self, name: Annotated[str, Option("--name", default="HI")]): ...
Specifying both at the same time is a conflict and raises TypeError. argparse.SUPPRESS is
rejected as a default from either source, because suppressing the namespace attribute would call the
function without the keyword argument it expects.
Parser-construction kwargs such as add_help, prefix_chars, fromfile_prefix_chars,
argument_default, conflict_handler, and allow_abbrev are not exposed by @with_annotated. Set
them on a custom parser_class subclass and pass it via parser_class=.
Choices and enums
When a user-supplied choices_provider or completer is given for an inferred Enum or Literal,
the inferred static choices list is dropped so completion is driven by the provider or completer.
The inferred type converter is preserved, so parsed values still coerce to the declared type
(Literal[1, 2] yields an int, an Enum yields its member) and values outside the type are
rejected at parse time.
An explicit choices= is reconciled with the inferred type rather than fighting it:
- The values are run through the inferred
typeconverter so they match argparse's post-conversion comparison.Annotated[int, Option("--n", choices=["1", "2"])]is normalized tochoices=[1, 2], so--n 1is accepted. A choice the converter rejects (choices=["1", "nope"]on anint) is a build-timeTypeError. Values already of the declared type are left as-is. - An explicit
choices=takes precedence over a type-inferred completer (such as thePathcompleter): the choices are kept (so they validate and drive completion) and the inferred completer is dropped. Achoices_provider/completeryou pass yourself still wins overchoices=.
An Enum parameter accepts both member values and member names on the command line
(Color.RED with value "red" is selected by either red or RED); tab-completion and --help
list the values.
Decorator options
@with_annotated currently supports:
ns_provider-- prepopulate the namespace before parsing, mirroring@with_argparserpreserve_quotes-- ifTrue, quotes in arguments are preservedwith_unknown_args-- ifTrue, unrecognised arguments are passed as_unknownsubcommand_to-- register the function as an annotated subcommand under a parent commandbase_command-- create a base command whose parser also adds subparsers and exposescmd2_subcommand_func. Acmd2_subcommand_funcparameter is only valid on a command decorated withbase_command=True; declaring one elsewhere raisesTypeError.subcommand_required-- whether a subcommand must be supplied (only withbase_command=True, defaultTrue)subcommand_metavar-- metavar shown for the subcommands group (only withbase_command=True, default"SUBCOMMAND")subcommand_title-- title for the subcommands--helpsection (only withbase_command=True)subcommand_description-- description for the subcommands--helpsection (only withbase_command=True)help-- help text for an annotated subcommand (only valid withsubcommand_to)aliases-- aliases for an annotated subcommand (only valid withsubcommand_to)deprecated-- mark the subcommand as deprecated in--help(only valid withsubcommand_to)groups--Groupinstances assigning parameter names to argument groupsmutually_exclusive_groups--Groupinstances of mutually exclusive parametersparser_class-- a custom parser class (defaults to the configured default)**parser_kwargs-- every otherCmd2ArgumentParserconstructor kwarg, forwarded through PEP 692Unpack[Cmd2ParserKwargs]. See Parser customization below for the full list and thedescription/progspecial cases.
@with_annotated(with_unknown_args=True)
def do_rawish(self, name: str, _unknown: list[str] | None = None):
self.poutput((name, _unknown))
Parser customization
Every Cmd2ArgumentParser constructor kwarg flows straight through @with_annotated and
build_parser_from_function via PEP 692
Unpack[Cmd2ParserKwargs]. The
Cmd2ParserKwargs TypedDict is the single source of truth for
the forwarded kwargs and gives type-checkers/IDEs autocomplete on the decorator's call site: adding
a new ctor kwarg to Cmd2ArgumentParser only needs a matching field on Cmd2ParserKwargs, and the
annotated decorator picks it up automatically.
The forwarded kwargs are description, epilog, prog, usage, parents, argument_default,
prefix_chars, fromfile_prefix_chars, conflict_handler, add_help, allow_abbrev,
exit_on_error, formatter_class, completer_class, and on Python ≥ 3.14 suggest_on_error /
color. Two of them layer extra behavior on top of the raw passthrough:
description-- when omitted, it is filled from the function's docstring (detailed below); pass an explicit value to override.prog-- rejected whensubcommand_tois set; cmd2's subcommand machinery rewritesprogfrom the parent command hierarchy, so any value here would be silently overwritten.
parser_class stays as its own explicit kwarg because it selects the class itself rather than a
value passed to it. Argument groups are declared with Group; pass title
and description for a titled help section (omit them for an untitled group):
from cmd2.annotated import Group, with_annotated
class App(cmd2.Cmd):
@with_annotated(
description="Open a network connection.",
epilog="Example: connect example.com --port 2222",
groups=(Group("host", "port", title="connection", description="where to connect"),),
)
def do_connect(self, host: str, port: int = 22, verbose: bool = False):
self.poutput(f"connecting to {host}:{port}")
If you omit description, the first paragraph of the function's docstring (everything up to the
first blank line) is used as the parser description; subsequent paragraphs are dropped so rst field
directives like :param name: don't leak into --help. Pass description="" to suppress the
automatic fill, or description="..." to override it.
@with_annotated
def do_greet(self, name: str):
"""Greet someone by name.
:param name: who to greet
"""
self.poutput(f"hello {name}")
# parser.description == "Greet someone by name."
mutually_exclusive_groups also takes Group instances (their title/description are ignored,
since argparse mutually-exclusive groups have no header). Pass Group(..., required=True) to make
the mutex group itself required -- argparse will then enforce that exactly one of its members must
be supplied. required=True is rejected on a plain (non-mutex) Group because add_argument_group
has no required flag.
@with_annotated(
mutually_exclusive_groups=(Group("verbose", "quiet", required=True),),
)
def do_run(self, verbose: bool = False, quiet: bool = False): ...
parents= mirrors argparse's standard parents mechanism for sharing argument definitions across
parsers. argument_default=argparse.SUPPRESS is not supported and raises TypeError. It removes an
absent argument from the parsed namespace, but @with_annotated builds the call from the function
signature, so every declared parameter is expected at invocation; an argument vanishing from the
namespace can never be valid here (mirroring the per-argument default=argparse.SUPPRESS
rejection). Any other argument_default value is forwarded to the parser unchanged.
The remaining argparse kwargs cover less-common needs but are wired through unchanged:
prefix_chars="+-"accepts options that start with+(e.g.+verbose); pair with an explicitOption("+verbose")to declare such flags.fromfile_prefix_chars="@"lets a user writemycmd @args.txtand have the file's contents spliced in as arguments.conflict_handler="resolve"lets a parent parser's option be redefined locally without an error -- useful withparents=when you want to override an inherited flag.add_help=Falsedrops the auto-added-h/--helpaction (cmd2's standard parser keeps it on by default).allow_abbrev=Falserequires users to type the full long-option name (no--verbfor--verbose).exit_on_error=Falsemakes parse failures raiseargparse.ArgumentErrorinstead of callingsys.exit; useful when embedding the parser inside another flow.
Annotated subcommands
@with_annotated can also build typed subcommand trees without manually constructing subparsers.
@with_annotated(base_command=True)
def do_manage(self, *, cmd2_subcommand_func):
if cmd2_subcommand_func:
cmd2_subcommand_func()
@with_annotated(subcommand_to="manage", help="list projects")
def manage_list(self):
self.poutput("listing")
For nested subcommands, subcommand_to can be space-delimited, for example
subcommand_to="manage project". The intermediate level must also be declared as a subcommand that
creates its own subparsers:
@with_annotated(subcommand_to="manage", base_command=True, help="manage projects")
def manage_project(self, *, cmd2_subcommand_func):
if cmd2_subcommand_func:
cmd2_subcommand_func()
@with_annotated(subcommand_to="manage project", help="add a project")
def manage_project_add(self, name: str):
self.poutput(f"added {name}")
Lower-level parser building
cmd2.annotated.build_parser_from_function builds the
parser directly from a function without registering a command. It accepts the same groups,
mutually_exclusive_groups, parser_class, and forwarded
Unpack[Cmd2ParserKwargs] as @with_annotated. Like the
decorator, it skips the first parameter as the method receiver (self/cls).
from cmd2.annotated import build_parser_from_function
def greet(self, name: str, count: int = 1):
"""Greet someone."""
parser = build_parser_from_function(greet)
namespace = parser.parse_args(["Alice", "--count", "3"])
# namespace.name == "Alice", namespace.count == 3
Automatic completion from types
With @with_annotated, arguments annotated as Path or Enum get automatic completion without
needing an explicit choices_provider or completer.
Specifically:
Path(or anyPathsubclass) triggers filesystem path completionMyEnum(anyenum.Enumsubclass) triggers completion from enum member values
With @with_argparser, provide choices, choices_provider, or completer explicitly when you
want completion behavior.
Stability and feedback
Because this feature is experimental:
- Behavior of edge cases (mixed-type tuples, deeply-nested
Annotated, conflicting metadata) may change. - Diagnostic error messages may be reworded.
- The set of supported type annotations may be expanded or trimmed.
If you depend on @with_annotated, please share feedback and edge cases via the
issue tracker so behavior can be locked in before the
feature graduates out of experimental.