I often experience this limitation of argparse as well, it's sometimes important to have parallel, hierarchical subcommands. There are some nice tools out there to help with this (a lot of them in this conversation). However, I felt like many of them still had limitations or required learning a new way of building a parser, so wanted to build another solution. Turns out, a few extensions to argparser make this functionality easy and with only one extra piece to the API.
The trick comes down to building a new parser on demand based on the arguments that are present and any "conditional" that are added by the user. Omitting a few details for clarity, see below.
You can make a new class that extends argument parser to include a method that I called add_conditional(dest, cond, *args, **kwargs) that allows you to add a new argument (with add_argument(*args, **kwargs)) whenever dest==cond. For example:
parser.add_argument("--use-regularization", default=False, action="store_true")
parser.add_conditional("use_regularization", True, "--regularization-lambda", default=1e-3, type=float)
Here's the overall structure of how to do that. First, build a new class that extends ArgumentParser:
class ConditionalArgumentParser(ArgumentParser):
def __init__(self, *args, **kwargs):
super(ConditionalArgumentParser, self).__init__(*args, **kwargs)
self._conditional_parent = []
self._conditional_condition = []
self._conditional_args = []
self._conditional_kwargs = []
self._num_conditional = 0
Add conditional arguments with a new method:
def add_conditional(self, dest, cond, *args, **kwargs):
# attempt to add the conditional argument to a dummy parser to check for errors right away
_dummy = deepcopy(self)
_dummy.add_argument(*args, **kwargs)
# if it passes, store the details to the conditional argument
assert type(dest) == str, "dest must be a string corresponding to one of the destination attributes"
self._conditional_parent.append(dest)
self._conditional_condition.append(self._make_callable(cond))
self._conditional_args.append(args)
self._conditional_kwargs.append(kwargs)
self._num_conditional += 1
Then, overwrite the parse_args method to recursively add and rebuild any conditional arguments as required, then return the arg_parse results from this complete parser:
def parse_args(self, args=None, namespace=None):
"""Parse the arguments and return the namespace."""
# if args not provided, use sys.argv
if args is None:
args = sys.argv[1:]
# make a list of booleans to track which conditionals have been added
already_added = [False for _ in range(self._num_conditional)]
# prepare the conditionals in a dummy parser so the user can reuse self
_parser = deepcopy(self)
_parser = self._prepare_conditionals(_parser, args, already_added)
# parse the arguments with the conditionals added in the dummy parser
return ArgumentParser.parse_args(_parser, args=args, namespace=namespace)
I'm omitting some details here, but wanted to just share the basic idea for how this can work. The nice thing about how this is structured is that it's flexible, easy to add conditional arguments, provides useful help messages dependent on other conditionals, and of course only requires the user to learn one new method- add_conditional.
Full disclosure, I wrote a pip-installable package you can download with the above functionality if you want to use it without rewriting it yourself. (I'm an academic researcher at UCL and just wanted to share something I thought was useful).
pip install conditional-parser
./setup.pyalso has this style CLI interface, would be interesting to look into their source code.