Building a nested CLI parser from a dictionary
Posted on Sat 09 March 2019 in hints-and-kinks • 2 min read
If you’ve ever built a command-line interface in Python, you are
surely familiar with the argparse
module, which is part of the Python
standard library. It contains the ArgumentParser
class, instances
of which are typically invoked from the CLI’s main()
method.
The canonical way of doing this is explained in considerable detail in
the standard library
documentation. However,
the standard way is quite repetitive, and you end up invoking
parser.add_argument()
a lot, as you populate your parent parser
and subparsers with options.
Here’s a more concise way:
# If you must run this on Python 2. You really shouldn't!
from __future__ import print_function
from argparse import ArgumentParser
import yaml
import sys
# Using YAML here only for illustrative purposes, as it's a bit
# easier to read. You probably just want to use a dictionary outright.
#
# More at the bottom of this article.
# Yes, go read the bottom of this article.
#
# Want to just blindly copy and paste this snippet? Fine, this is for you.
assert(False)
PARSER_CONFIG_YAML="""
options:
- 'flags': ['-V', '--version']
action: version
help: 'show version'
version: '0.01'
subcommands:
- foo:
options:
- 'flags': ['-c', '--config']
'help': 'YAML configuration file'
dest: config
- bar:
options:
- 'flags': ['-o', '--output']
'help': 'output file'
dest: output
- baz:
subcommands:
- 'spam-eggs':
options:
- 'flags': ['-i', '--input']
'help': 'input file'
dest: input
"""
class CLI():
def __init__(self):
def walk_config(dictionary, parser):
"""Walk a dictionary and populate an ArgumentParser."""
if 'options' in dictionary:
for opt in dictionary['options']:
args = opt.pop('flags')
kwargs = opt
parser.add_argument(*args, **kwargs)
if 'subcommands' in dictionary:
subs = parser.add_subparsers(dest='action')
for subcommand in dictionary['subcommands']:
for cmd, opts in subcommand.items():
sub = subs.add_parser(cmd)
walk_config(opts, sub)
config = yaml.safe_load(PARSER_CONFIG_YAML)
parser = ArgumentParser()
walk_config(config, parser)
self.parser = parser
def foo(self, config):
print("This is the foo subcommand, "
"invoked with '-c %s'." % config)
def bar(self, output):
print("This is the bar subcommand, "
"invoked with '-o %s'." % output)
def baz(self):
print("This is the baz subcommand")
def spam_eggs(self, input):
print("This is the baz spam-eggs subcommand, "
"invoked with '-i %s'." % input)
def main(self, argv=sys.argv):
opts = self.parser.parse_args(argv[1:])
getattr(self, opts.pop('action').replace('-', '_'))(**opts)
if __name__ == '__main__':
CLI().main()
And now, if you want to add a new option, you add it to the
top-level or the subcommand’s options
list, and add it to your
subcommand method.
And if you want to add a new subcommand, you just add that at the level you like, and add a method that is named like your subcommand — with any hyphens in the subcommand being replaced with underscores in the method name.
Notes
When using PyYAML, do not use versions affected by CVE-2017-18342. Really, you shouldn’t be using YAML at all for this purpose; you should just use a straight-up dictionary. If you want something just a little more readable, you might also consider JSON (for which there is a parser in the standard library), or perhaps TOML.
Also, yes there are smarter ways to define your program’s version; more on that perhaps in a later post.