ArgParse
Many people use Python’s argparse command line argument parser. This parser
can handle sub-commands like git commit -m "message" where commit is a
sub-command and -m <message> is an argument of this sub-command parser. It’s
possible to assign a callback function to each individual sub-command parser.
Advantages
Declarative description instead of imperative form.
All options from argparse can be used.
Declare accepted command-line arguments close to the responsible handler method
Complex parsers can be distributed accross multiple classes and merged via multiple inheritance.
Pre-defined argument templates like switch parameters (
--help).
Comparison
pyTooling.Attributes.ArgParse
class Program:
@DefaultHandler()
@FlagArgument(short="-v", long="--verbose", dest="verbose", help="Show verbose messages.")
def HandleDefault(self, args) -> None:
pass
@CommandHandler("new-user", help="Add a new user.")
@StringArgument(dest="username", metaName="username", help="Name of the new user.")
@LongValuedFlag("--quota", dest="quota", help="Max usable disk space.")
def NewUserHandler(self, args) -> None:
pass
@CommandHandler("delete-user", help="Delete a user.")
@StringArgument(dest="username", metaName="username", help="Name of the user.")
@FlagArgument(short="-f", long="--force", dest="force", help="Ignore internal checks.")
def DeleteUserHandler(self, args) -> None:
pass
@CommandHandler("list-user", help="List all users.")
def ListUserHandler(self, args) -> None:
pass
Traditional ArgParse
class Program:
def __init__(self):
mainParser = argparse.ArgumentParser()
mainParser.set_defaults(func=self.HandleDefault)
mainParser.add_argument("-v", "--verbose")
subParsers = mainParser.add_subparsers()
newUserParser = subParsers.add_parser("new-user", help="Add a new user.")
newUserParser.add_argument(dest="username", metaName="username", help="Name of the new user.")
newUserParser.add_argument("--quota", dest="quota", help="Max usable disk space.")
newUserParser.set_defaults(func=self.NewUserHandler)
deleteUserParser = subParsers.add_parser("delete-user", help="Delete a user.")
deleteUserParser.add_argument(dest="username", metaName="username", help="Name of the user.")
deleteUserParser.add_argument("-f", "--force", dest="force", help="Ignore internal checks.")
deleteUserParser.set_defaults(func=self.DeleteUserHandler)
listUserParser = subParsers.add_parser("list-user", help="List all users.")
listUserParser.set_defaults(func=self.ListUserHandler)
def HandleDefault(self, args) -> None:
pass
def NewUserHandler(self, args) -> None:
pass
def DeleteUserHandler(self, args) -> None:
pass
def ListUserHandler(self, args) -> None:
pass
Arguments
An argument attribute is written on the handler method that receives it, and each one becomes exactly one
add_argument() call on the parser belonging to that handler. The attribute’s
parameters are the ones argparse already uses - dest, help, metaName - so
nothing new has to be learned to say what a parser already knows how to do.
They form a hierarchy, and the leaves are what a program writes:
Base-class |
What it stands for |
|---|---|
An argument with a name - |
|
An argument with a value. |
|
Both - |
|
A value with no name, identified by its position. |
|
The |
The positional leaves are typed, and the type is what argparse converts the string with - see
Positional Arguments.
Positional Arguments
Each typed variant converts and validates the value before the handler sees it, so a handler never parses a string itself:
Attribute |
|
|---|---|
a |
|
an |
|
a |
|
a |
All four take (dest, metaName, optional=False, help=""), and metaName is the placeholder
--help shows in place of the value.
@CommandHandler("create", help="Create a new user.")
@StringArgument(dest="username", metaName="username", help="Name of the user to create.")
def HandleCreate(self, args) -> None:
print(f"Creating user '{args.username}'.")
Flags
A FlagArgument is a switch: it carries no value, and the handler
receives bool - True when the switch was given, False otherwise. The attribute sets
action="store_const" with const=True and default=False, which is what makes that
boolean appear.
@FlagArgument(short="-v", long="--verbose", dest="verbose", help="Show verbose messages.")
def HandleDefault(self, args) -> None:
if args.verbose:
...
ShortFlag and
LongFlag are the same switch restricted to one spelling, for a program
that offers only -v or only --verbose.
A switch that has to express three states - on, off, and not given - is a
BooleanFlag, which registers a pair of option strings.
ShortBooleanFlag,
LongBooleanFlag and
WindowsBooleanFlag differ only in how they spell that pair.
ValuedFlags
A ValuedFlag is a named argument followed by a value in the
same token - --quota=5GiB. metaName is what the help page shows in place of the value.
@LongValuedFlag("--quota", dest="quota", metaName="size", help="Max usable disk space.")
def NewUserHandler(self, args) -> None:
quota = args.quota
ShortValuedFlag and
LongValuedFlag fix the spelling, as with the flags above.
Two variants exist for values that aren’t a single string:
OptionalValuedFlag- the value may be omitted, so the option carries a default when it is given bare.NamedKeyValuePairsArgument- the value is itself akey=valuepair, so an option may be repeated to build a mapping, as-D name=valuedoes for a compiler.ShortKeyValueFlagandLongKeyValueFlagare its two spellings.
ValuedTupleFlags
A tuple flag is a named argument whose value is a separate token - --width 100 rather than
--width=100, so name and value reach the program as two arguments.
Attention
Only the base-class NamedTupledArgument exists so far. There
is no concrete ShortTupleFlag / LongTupleFlag attribute to apply yet, so this form has to be
written as a ValuedFlag with nargs passed through
to argparse in the meantime.
Argument Lists
A ListArgument collects more than one value into a list, and
the typed variants convert each element:
StringListArgument,
IntegerListArgument,
FloatListArgument and
PathListArgument.
@CommandHandler("build", help="Build the given source files.")
@PathListArgument(dest="sources", metaName="source", help="Source files to build.")
def BuildHandler(self, args) -> None:
for source in args.sources: # a list of 'Path'
...
The handler always receives a list, including when the command line named a single value.
Values naming a format and a file
An option that writes a file often accepts more than one format for it. Naming the format in front of the path keeps that to one option instead of two that can disagree:
program convert --output=json:report.json # instead of --output=report.json --output-format=json
splitFormat() splits such a value into the format and the file. The formats
are a StringEnum, so a member is the string the command line spells.
class ReportFormat(StringEnum):
JSON = "json"
YAML = "yaml"
DEFAULT = JSON
@CommandHandler("convert", help="Convert the report.")
@LongValuedFlag("--output", dest="output", metaName="[format:]file", help="Write the report.")
def HandleConvert(self, args: Namespace) -> None:
reportFormat, file = splitFormat(args.output, ReportFormat)
A colon alone doesn’t make a format. A format is more than one character long and contains no path
separator, so a Windows drive (C:\report\out.json) and a colon deeper down a path are paths. A prefix
that looks like a format but isn’t one raises a ValueError naming the formats that exist, rather than
writing a file with a strange name.
What a value naming no format gets
The enumeration’s DEFAULT - see The default is declared as an alias. An enumeration declaring none insists on
being told: a value without a format raises a ValueError, whose note shows how to write it.
splitFormat("report.txt", ReportFormat) # (ReportFormat.JSON, Path("report.txt"))
splitFormat("yaml:report.txt", ReportFormat) # (ReportFormat.YAML, Path("report.txt"))
The format is never read off the file’s suffix. Where a format and the file’s suffix have to agree, the handler checks them.
Commands
A sub-command is a method marked with
CommandHandler, which creates a sub-parser of that name and routes to the
method when the command is used. The argument attributes below it belong to that sub-parser, which is what keeps
a command’s arguments next to the code handling them.
Exactly one method may be marked DefaultHandler. It receives the arguments
of the main parser and runs when no sub-command was given. Marking a second one raises
ArgParseError while the class is being constructed, rather than at the first
call.
@DefaultHandler()
@FlagArgument(short="-v", long="--verbose", dest="verbose", help="Show verbose messages.")
def HandleDefault(self, args) -> None:
...
@CommandHandler("list-user", help="List all users.")
def ListUserHandler(self, args) -> None:
...
Attention
An argument declared on the default handler is global; an argument declared on a command handler belongs to
that command. UserManager.py --verbose create alice is therefore right and UserManager.py create alice
--verbose is not - the same rule git follows.
The class itself mixes in ArgParseHelperMixin, whose constructor builds all
the parsers from the attributes it finds.
Run() then parses and dispatches, and
MainParser and
SubParsers expose the underlying
ArgumentParser objects for anything the attributes don’t cover.
Hint
The mixin passes **kwargs on to ArgumentParser, and changes two of its defaults:
allow_abbrev=False, so an abbreviated option isn’t silently accepted, and
exit_on_error=False, so a parse error raises instead of ending the process.
Grouping Arguments
CommandGroupAttribute collects sub-commands under a named group, so a long
prog.py --help lists related commands together instead of in one flat sequence.
@CommandGroupAttribute("User management")
@CommandHandler("new-user", help="Add a new user.")
def NewUserHandler(self, args) -> None:
...
Attention
This attribute is marked experimental in the source and affects the help page only - it changes no parsing behaviour.
Split Handlers into multiple classes
Because the handlers are found through attributes rather than through one constructor that builds every parser, a parser can be assembled from several classes. Each class carries the commands it is responsible for, and the program inherits from all of them:
class UserCommands(metaclass=ExtendedType):
@CommandHandler("new-user", help="Add a new user.")
def NewUserHandler(self, args) -> None:
...
class GroupCommands(metaclass=ExtendedType):
@CommandHandler("new-group", help="Add a new group.")
def NewGroupHandler(self, args) -> None:
...
class Program(UserCommands, GroupCommands, ArgParseHelperMixin):
def __init__(self) -> None:
ArgParseHelperMixin.__init__(self, prog="usermgr")
This is what the Advantages list above means by distributed across multiple classes, and it is the reason the attribute lookup is a class query rather than a scan: the meta-class already collected the annotated methods of every base-class by the time the mixin’s constructor runs.
A complete program
Both programs offer the same command line. The first is written with the attributes of this package, the second with
argparse directly.
pyTooling.Attributes.ArgParse
1# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany
2# SPDX-License-Identifier: Apache-2.0
3"""
4A user manager, with its command line described declaratively by :mod:`pyTooling.Attributes.ArgParse`.
5
6The commands, their options and the methods handling them are the same declaration: a handler is a method, and
7what it accepts on the command line is written above it. Compare with :file:`OldStyle.py`, which builds the same
8command line with :mod:`argparse` directly.
9"""
10from pyTooling.Attributes.ArgParse import ArgParseHelperMixin, CommandHandler, DefaultHandler
11from pyTooling.Attributes.ArgParse.Argument import StringArgument
12from pyTooling.Attributes.ArgParse.Flag import LongFlag
13from pyTooling.Attributes.ArgParse.ValuedFlag import LongValuedFlag
14
15
16class UserManager(ArgParseHelperMixin):
17 """Manage the users of a system."""
18
19 def __init__(self) -> None:
20 """Initialize the user manager and the parser it declares."""
21 super().__init__(prog="UserManager.py", description="Manage the users of a system.")
22
23 @DefaultHandler()
24 @LongFlag("--verbose", dest="verbose", help="Print verbose messages.")
25 def HandleDefault(self, args) -> None:
26 """Handle the program being called without a command."""
27 print(f"Called without a command. (verbose={args.verbose})")
28 self.MainParser.print_help()
29
30 @CommandHandler("create", help="Create a new user.")
31 @StringArgument(dest="username", metaName="username", help="Name of the user to create.")
32 @LongValuedFlag("--quota", dest="quota", help="Disk quota of the new user.")
33 def HandleCreate(self, args) -> None:
34 """Handle the 'create' command."""
35 print(f"Creating user '{args.username}' with a quota of {args.quota}.")
36
37 @CommandHandler("delete", help="Delete a user.")
38 @StringArgument(dest="username", metaName="username", help="Name of the user to delete.")
39 def HandleDelete(self, args) -> None:
40 """Handle the 'delete' command."""
41 print(f"Deleting user '{args.username}'.")
42
43 @CommandHandler("list", help="List all users.")
44 def HandleList(self, args) -> None:
45 """Handle the 'list' command."""
46 print("Listing all users.")
47
48
49def main() -> None:
50 """Parse the command line and route it to the handler that declared it."""
51 program = UserManager()
52 program.Run()
53
54
55if __name__ == "__main__":
56 main()
Traditional ArgParse
1# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany
2# SPDX-License-Identifier: Apache-2.0
3"""
4The same user manager as :file:`UserManager.py`, built with :mod:`argparse` directly.
5
6Every command appears twice - once where the parser is built, and once where it is dispatched - and nothing ties
7the two together but a string. That separation is what :mod:`pyTooling.Attributes.ArgParse` removes.
8"""
9from argparse import ArgumentParser
10
11
12def HandleDefault(args) -> None:
13 """Handle the program being called without a command."""
14 print(f"Called without a command. (verbose={args.verbose})")
15
16
17def HandleCreate(args) -> None:
18 """Handle the 'create' command."""
19 print(f"Creating user '{args.username}' with a quota of {args.quota}.")
20
21
22def HandleDelete(args) -> None:
23 """Handle the 'delete' command."""
24 print(f"Deleting user '{args.username}'.")
25
26
27def HandleList(args) -> None:
28 """Handle the 'list' command."""
29 print("Listing all users.")
30
31
32def main() -> None:
33 """Build the parser, parse the command line, and dispatch to the matching handler."""
34 mainParser = ArgumentParser(prog="OldStyle.py", description="Manage the users of a system.")
35 mainParser.add_argument("--verbose", dest="verbose", action="store_true", help="Print verbose messages.")
36 subParsers = mainParser.add_subparsers(dest="command", help="sub-command help")
37
38 createParser = subParsers.add_parser("create", help="Create a new user.")
39 createParser.add_argument(dest="username", metavar="username", help="Name of the user to create.")
40 createParser.add_argument("--quota", dest="quota", help="Disk quota of the new user.")
41
42 deleteParser = subParsers.add_parser("delete", help="Delete a user.")
43 deleteParser.add_argument(dest="username", metavar="username", help="Name of the user to delete.")
44
45 subParsers.add_parser("list", help="List all users.")
46
47 args = mainParser.parse_args()
48
49 # the dispatch table repeats every command name a third time
50 handlers = {
51 None: HandleDefault,
52 "create": HandleCreate,
53 "delete": HandleDelete,
54 "list": HandleList,
55 }
56 handlers[args.command](args)
57
58
59if __name__ == "__main__":
60 main()
Consumers
This package is used by:
✅ Command line interface of pyEDAA.Reports.
pyEDAA.Reports.CLI✅ Command line interface of pyVersioning.
pyVersioning.CLI