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: StringArgument, IntegerArgument, FloatArgument and PathArgument.

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.

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:
  ...

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.

Consumers

This package is used by: