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 someaction_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 anybefore_main
action_flags
are defined, the original implementation should be called viasuper()._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 viasuper()
depends on the presence ofafter_main
action flags. This method may be used analogously to afinally:
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)
Why _sunder_
names? Mostly for the same reason that the Enum module uses them.