Subcommands
Subcommands provide a way of organizing Commands by separating distinct functionalities and options that are specific to those functionalities.
For a Command
to support subcommands, it must have a SubCommand
Parameter. If other
Positional Parameters are present in the Command, the position of the SubCommand
Parameter determines the relative position for the argument(s) that select which subcommand should be executed.
A given Command may only contain one SubCommand Parameter, but multiple Commands can be registered with it as subcommands, and each of those subcommands can have their own SubCommand Parameter. It is possible to have multiple levels of nested subcommands.
Initialization parameters for SubCommand Parameters:
- title:
The title to use for help text sections containing the choices for the Parameter. Defaults to
Subcommands
.- description:
The description to be used in help text for the Parameter.
- local_choices:
If some choices should be handled in the Command that the SubCommand Parameter is in, they should be specified here. Supports either a mapping of
{choice: help text}
or a collection of choice values.- nargs:
Not supported. Automatically calculated / maintained based on registered choices (subcommand target Commands).
- type:
Not supported.
- choices:
Not supported - all other choices are populated by registering subcommands.
Automatic Registration
Given a Command
class has a SubCommand
Parameter, any classes that extend that Command will
automatically be registered as subcommands of the Command that they extend.
When defining a subcommand class that extends a base Command, in addition to the other options that are supported when initializing Commands, the following keyword-only parameters may also be provided along with the class that it extends:
- choice:
The value a user must provide to choose the target subcommand. Spaces are supported. By default, the lower-case (snake_case) name of the subcommand class is used. I.e., if the class is called
Foo
, then the automatically generated choice value will befoo
. If the class is calledFooBar
, then the choice will befoo_bar
.- choices:
If multiple choices should be supported as aliases for selecting a given target subcommand, they can be provided via
choices
instead ofchoice
.- help:
The help text to display for the subcommand when viewing the parent Command’s help text.
Given the following overly-simplistic basic example:
class Base(Command):
sub_cmd = SubCommand()
class Foo(Base, help='Print foo'):
def main(self):
print('foo')
class Bar(Base, help='Print bar'):
def main(self):
print('bar')
We can see from the help text that it is aware of its subcommands:
$ basic_subcommand.py -h
usage: basic_subcommand.py {foo,bar} [--help]
Subcommands:
{foo,bar}
foo Print foo
bar Print bar
Optional arguments:
--help, -h Show this help message and exit
Usage examples:
$ basic_subcommand.py foo
foo
$ basic_subcommand.py bar
bar
Nested Subcommands
Using the example script that is a fake wrapper around a hypothetical REST API, we can see an example of two levels of subcommands, and another way that we can take advantage of inheritance:
class ApiWrapper(Command):
sub_cmd = SubCommand(help='The command to run')
with ParamGroup('Common'):
verbose = Counter('-v', help='Increase logging verbosity (can specify multiple times)')
env = Option('-e', choices=('dev', 'qa', 'uat', 'prod'), default='prod', help='Environment to connect to')
...
class Show(ApiWrapper, help='Show an object'):
...
# region Find subcommands
class Find(ApiWrapper, help='Find objects'):
sub_cmd = SubCommand(help='What to find')
limit: int = Option('-L', default=10, help='The number of results to show')
def main(self):
for obj in self.find_objects():
print(obj)
def find_objects(self):
raise NotImplementedError
class FindFoo(Find, choice='foo', help='Find foo objects'):
query = Positional(help='Find foo objects that match the specified query')
def find_objects(self):
log.debug(f'Would have run query={self.query!r} in env={self.env}, returning fake results')
return ['a', 'b', 'c']
class FindBar(Find, choice='bar', help='Find bar objects'):
pattern = Option('-p', help='Pattern to find')
show_all = Flag('--all', '-a', help='Show all (default: only even)')
def find_objects(self):
objects = {chr(i): i % 2 == 0 for i in range(97, 123)}
if not self.show_all:
objects = {c: even for c, even in objects.items() if even}
if self.pattern:
objects = {c: even for c, even in objects.items() if fnmatch(c, self.pattern)}
return objects
class FindBaz(Find, choices=('baz', 'bazs'), help='Find baz objects'):
...
# endregion
In that example, both the Show
and Find
subcommands share the common logging initialization, and they share the
common env
Option for selecting an environment to connect to:
$ rest_api_wrapper.py -h
usage: rest_api_wrapper.py {show,sync,find} [--verbose [VERBOSE]] [--env {dev,qa,uat,prod}] [--help]
Subcommands:
{show,sync,find}
show Show an object
sync Sync group members
find Find objects
Optional arguments:
--help, -h Show this help message and exit
Common options:
--verbose [VERBOSE], -v [VERBOSE]
Increase logging verbosity (can specify multiple times) (default: 0)
--env {dev,qa,uat,prod}, -e {dev,qa,uat,prod}
Environment to connect to (default: 'prod')
Since the different types of objects have different criteria for finding them, it helps to split the Find
subcommand further so that each one only has the Parameters relevant for finding objects of that type. To avoid name
conflicts with other type-specific subcommands related to the same types, each Find
subcommand uses a prefix for
its name, and the choice=
param to specify what should be provided on the CLI:
$ rest_api_wrapper.py find -h
usage: rest_api_wrapper.py find {foo,bar,baz} [--verbose [VERBOSE]] [--env {dev,qa,uat,prod}] [--help] [--limit LIMIT]
Subcommands:
{foo,bar,baz}
foo Find foo objects
bar Find bar objects
baz Find baz objects
...
We’re able to take advantage of inheritance again in Find
where we only need to define main
once, and we can
have each subcommand define the method that is called by main
to produce results.
Explicit Registration
While subcommands will be automatically registered with their parent class as long as the parent class has a SubCommand parameter, it is also possible to have more control over that process.
class Base(Command):
sub_cmd = SubCommand()
verbose = Counter('-v', help='Increase logging verbosity (can specify multiple times)')
def __init__(self):
if self.verbose > 1:
log_fmt = '%(asctime)s %(levelname)s %(name)s %(lineno)d %(message)s'
else:
log_fmt = '%(message)s'
level = logging.DEBUG if self.verbose else logging.INFO
logging.basicConfig(level=level, format=log_fmt)
@Base.sub_cmd.register('run foo', help='Run foo') # Aliases can have their own help text
class Foo(Base, help='Print foo'):
# This is registered with both ``run foo`` and ``foo`` as names for this command - both can be used
def main(self):
print('foo')
log.debug('[foo] this is a debug log')
class Bar(Base, choice='run bar', help='Print bar'):
# This is registered with ``run bar`` as the name for this command instead of ``bar``
def main(self):
print('bar')
log.debug('[bar] this is a debug log')
@Base.sub_cmd.register(help='Print baz')
class Baz(Command):
# This is registered as a subcommand of Base, named ``baz``, but it does not share parameters with Base
def main(self):
print('baz')
# The next line will never appear in output because Base.__init__ will not be called for this subcommand
log.debug('[baz] this is a debug log')
if __name__ == '__main__':
Base.parse_and_run()
When multiple top-level Commands exist, as they do in this example, then the main()
convenience
function can no longer be used as the main entry point for the program. Instead, the
parse_and_run() method needs to be called on the primary Command subclass.
Top level --help
text for the above example:
$ advanced_subcommand.py -h
usage: advanced_subcommand.py {foo,run foo,run bar,baz} [--help]
Subcommands:
{foo,run foo,run bar,baz}
foo Print foo
run foo Run foo
run bar Print bar
baz Print baz
Optional arguments:
--verbose [VERBOSE], -v [VERBOSE]
Increase logging verbosity (can specify multiple times) (default: 0)
--help, -h Show this help message and exit
Each subcommand has its own command-specific help text as well:
$ advanced_subcommand.py foo -h
usage: advanced_subcommand.py foo [--verbose [VERBOSE]] [--help]
Optional arguments:
--verbose [VERBOSE], -v [VERBOSE]
Increase logging verbosity (can specify multiple times) (default: 0)
--help, -h Show this help message and exit
$ advanced_subcommand.py baz -h
usage: advanced_subcommand.py baz [--help]
Optional arguments:
--help, -h Show this help message and exit
Note that the baz
subcommand, which does not extend Base
, does not include verbose
because it does not
extend Base
. Additionally, while Base.__init__
will be called to initialize logging for both the Foo
and Bar
subcommands, it will not be called for Baz
. Regardless of where --verbose
/ -v
is specified,
however, it will not cause a parsing error for Baz
since it is registered as a subcommand of a Command that expects
that argument:
$ advanced_subcommand.py foo -v
foo
[foo] this is a debug log
$ advanced_subcommand.py -v foo
foo
[foo] this is a debug log
$ advanced_subcommand.py baz -v
baz
$ advanced_subcommand.py -v baz
baz
$ advanced_subcommand.py foo -x
unrecognized arguments: -x
This set of commands also contains an example of using a subcommand name that contains a space. It can be provided without needing to escape the space or put it in quotes:
$ advanced_subcommand.py run bar
bar