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

NamedArgument

An argument with a name - --verbose.

ValuedArgument

An argument with a value.

NamedAndValuedArgument

Both - --quota=5GiB.

PositionalArgument

A value with no name, identified by its position.

DelimiterArgument

The -- that ends option parsing.

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

args.<dest> is

StringArgument

a str

IntegerArgument

an int

FloatArgument

a float

PathArgument

a Path

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:

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

tests/example/UserManager.py
 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

tests/example/OldStyle.py
 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: