Commands

Commands provide a way to organize CLI applications in an intuitively object-oriented way.

Having parameters defined as attributes in the class results in a better developer experience when writing code that references those attributes in an IDE. You can take advantage of type annotations and variable name completion. Changing the name of a parameter can take advantage of builtin renaming tools, instead of needing to hunt for references to args.foo to be updated, for example. Further, there’s no need to keep function signatures up to date with parameters defined in decorators.

Since subcommands can extend their parent command, they can take advantage of standard class inheritance to share common parameters, methods, and initialization steps with minimal extra work or code.

Defining Commands

All commands must extend the Command class.

Multiple keyword-only arguments are supported when defining a subclass of Command (or a subclass thereof). Some of these options provide a way to include additional Command Metadata in help text / documentation, while other Configuration Options exist to control error handling and parsing / formatting.

Example command that uses some of those options:

class HelloWorld(
    Command,
    description='Simple greeting example',
    epilog='Contact <example@fake.org> with any issues'
):
    name = Option('-n', default='World', help='The person to say hello to')

    def main(self):
        print(f'Hello {self.name}!')

Command Methods & Attributes

The primary method used to define what should happen when a command is run is main. Simple commands don’t need to implement anything else:

class HelloWorld(Command):
    def main(self):
        print('Hello World!')

The only other method names that are used by the base Command class[1] are parse and parse_and_run, which are classmethods that are automatically used during parsing and Command initialization. Any[2] other attribute or method can be defined and used without affecting functionality.

Parse & Run

When only one Command direct subclass is present, the main() convenience function can be used as the primary entry point for the program:

from cli_command_parser import Command, Positional, main

class Echo(Command):
    text = Positional(nargs='*', help='The text to print')

    def main(self):
        print(' '.join(self.text))

if __name__ == '__main__':
    main()

main() automatically[3] finds the top-level Command that you defined, parses arguments from sys.argv, and runs your command.

There’s no need to call parse or parse_and_run() directly, but parse_and_run can be used as a drop-in replacement for main() for a specific Command. Using the same Echo command as in the above example:

if __name__ == '__main__':
    Echo.parse_and_run()

By default, main() and parse_and_run() will use sys.argv as the source of arguments to parse. If desired for testing purposes, or if there is a need to modify arguments before letting them be parsed, a list of strings may also be provided when using either approach:

>>> class Foo(Command):
...     bar = Flag('--no-bar', '-B', default=True)
...     baz = Positional(nargs='+')
...
...     def main(self):
...         print(f'{self.bar=}, {self.baz=}')
...

>>> Foo.parse_and_run(['test', 'one', '-B'])
self.bar=False, self.baz=['test', 'one']


Asyncio Applications

Commands in applications that use asyncio should extend AsyncCommand instead of Command. The main method within Command classes that extend AsyncCommand should generally be defined as an async method / coroutine. For example:

class MyAsyncCommand(AsyncCommand):
    async def main(self):
        ...

To run an AsyncCommand, both main() and parse_and_run() can be used as if running a synchronous Command (as described above). The asynchronous version of parse_and_run() handles calling asyncio.run().

For applications that need more direct control over how the event loop is run, parse_and_await() can be used instead.

All of the supported _sunder_ methods may be overridden with either synchronous or async versions, and Action methods may similarly be defined either way as well.

Advanced

Inheritance

One of the benefits of defining Commands as classes is that we can take advantage of the standard inheritance that Python already provides for common Parameters, methods, or initialization steps.

The preferred way to define a subcommand takes advantage of this in that it can be defined by extending a parent Command. This helps to avoid parameter name conflicts, and it enables users to provide common options anywhere in their CLI arguments without needing to be aware of parser behavior or how nested commands were defined.

Some of the benefits of being able to use inheritance for Commands, and some of the patterns that it enables, that may require more work with other parsers:

  • Logger configuration and other common initialization tasks can be handled once, automatically for all subcommands.

  • Parent Commands can define common properties and methods used (or overridden) by its subcommands.

    • A parent Command may define a main method that calls a method that each subcommand is expected to implement for subcommand-specific implementations.

    • If a parent Command’s main implementation is able to do what is necessary for all subcommands except for one, only that one needs to override its parent’s implementation.

  • If multiple subcommands share a set of common Parameters between each other that would not make sense to be defined on the parent Command, and are not shared by other subcommands, then an intermediate subclass of their parent Command can be defined with those common Parameters, which those subcommands would then extend instead.

Overriding Command Methods

The number of methods defined in the base Command class is intentionally low in order to allow subclasses the freedom to define whatever attributes and methods that they need. The __call__(), parse(), and parse_and_run() methods are not intended to be overridden.

Some _sunder_[4] methods are intended to be overridden, some are not intended to be overridden, and others may be safe to override in some situations, but should otherwise be called via super() to maintain normal functionality.

Overriding main

The vast majority of commands can safely override Command.main() without calling super().main(). If, however, a command uses positional Action methods, then that command should either not define a main method (i.e., it should not override Command.main()) or it should include a call of super().main() to maintain the expected behavior. The default implementation of the main method returns an int representing the number of Action methods that were called, which can be used by subclasses calling super().main() to adjust their behavior based on that result.

Supported _sunder_ Methods

  • _pre_init_actions_: Not intended to be overridden. Handles --help (and similar special actions, if defined) and some action_flag validation.

  • _init_command_: Intended to be overridden - the base implementation does not do anything. See Using _init_command_ for more info.

  • _before_main_: If any before_main action_flags are defined, the original implementation should be called via super()._before_main_(). When not using any action flags, this method can safely be overridden without calling the original. See Using _before_main_ for more info.

  • _after_main_: Similar to _before_main_, the need to call the original via super() depends on the presence of after_main action flags. This method may be used analogously to a finally: clause for a command if the always_run_after_main option is enabled / True.

Initialization Methods

Using _init_command_

The recommended way to handle initializing logging, or other common initialization steps, is to do so in Command._init_command_() - example:

class BaseCommand(Command):
    sub_cmd = SubCommand(help='The command to run')
    verbose = Counter('-v', help='Increase logging verbosity (can specify multiple times)')

    def _init_command_(self):
        log_fmt = '%(asctime)s %(levelname)s %(name)s %(lineno)d %(message)s' if self.verbose > 1 else '%(message)s'
        level = logging.DEBUG if self.verbose else logging.INFO
        logging.basicConfig(level=level, format=log_fmt)

There is no need to call super()._init_command_() within the method - its default implementation does nothing. This method is intended to be overridden.

The primary reason that this method is provided is to improve user experience when they specify --help or an invalid command. Any initialization steps will incur some level of overhead, and generally no initialization should be necessary if the user is looking for help text or if they did not provide valid arguments. Any extra work that is not necessary will result in a (potentially very perceptibly) slower response, regardless of the parsing library that is used.

This method is called after Command._pre_init_actions_() and before Command._before_main_().

Using _before_main_

Before _init_command_ was available, this was the recommended way to handle initialization steps. That is no longer the case.

Important

If _before_main_ is overridden, it is important to make sure that super()._before_main_() is called from within it. If the super()... call is missed, then most before_main action flags will not be processed. --help and other always_available ActionFlags are not affected by this method.

This method is called after Command._init_command_() and before Command.main().

Using __init__

If you don’t mind the extra overhead before --help, or if you have always_available ActionFlags that require the same initialization steps as the rest of the Command, then you can include those initialization steps in __init__ instead. The base Command class has no __init__ method, so there is no need to call super().__init__() if you define it - example:

class Base(Command):
    sub_cmd = SubCommand()
    verbose = Counter('-v', help='Increase logging verbosity (can specify multiple times)')

    def __init__(self):
        log_fmt = '%(asctime)s %(levelname)s %(name)s %(lineno)d %(message)s' if self.verbose > 1 else '%(message)s'
        level = logging.DEBUG if self.verbose else logging.INFO
        logging.basicConfig(level=level, format=log_fmt)