73

I have done as much research as possible but I haven't found the best way to make certain cmdline arguments necessary only under certain conditions, in this case only if other arguments have been given. Here's what I want to do at a very basic level:

p = argparse.ArgumentParser(description='...')
p.add_argument('--argument', required=False)
p.add_argument('-a', required=False) # only required if --argument is given
p.add_argument('-b', required=False) # only required if --argument is given

From what I have seen, other people seem to just add their own check at the end:

if args.argument and (args.a is None or args.b is None):
    # raise argparse error here

Is there a way to do this natively within the argparse package?

DJMcCarthy12
  • 3,819
  • 8
  • 28
  • 34
  • 3
    Have you looked at `argparse` subparsers? They will allow you to do things like `$ git commit ` or `$ git merge `. – Joel Cornett Sep 02 '14 at 14:43
  • 1
    Joel, thanks for the comment. I have seen the subparser aspect of argparse but I was hoping to do this without positional arguments. If that's the only way though it's not a big deal – DJMcCarthy12 Sep 02 '14 at 14:49
  • Can `--a` and `--b` be given independently? – hpaulj Sep 02 '14 at 19:41

8 Answers8

109

I've been searching for a simple answer to this kind of question for some time. All you need to do is check if '--argument' is in sys.argv, so basically for your code sample you could just do:

import argparse
import sys

if __name__ == '__main__':
    p = argparse.ArgumentParser(description='...')
    p.add_argument('--argument', required=False)
    p.add_argument('-a', required='--argument' in sys.argv) #only required if --argument is given
    p.add_argument('-b', required='--argument' in sys.argv) #only required if --argument is given
    args = p.parse_args()

This way required receives either True or False depending on whether the user as used --argument. Already tested it, seems to work and guarantees that -a and -b have an independent behavior between each other.

Mira
  • 1,983
  • 1
  • 11
  • 10
  • 13
    Simple, concise, and FREAKING WORKS! – Benj May 16 '19 at 10:26
  • Is there a way to make this throw an error if `-a` is given but `--argument` is not present? – Vivek Subramanian May 25 '19 at 20:05
  • @VivekSubramanian You could do that after `args = p.parse_args()` – Guus Jun 28 '19 at 14:19
  • 4
    This would be incompatible with positional arguments cause it ignores the end-of-options marker, `--`. E.g. `sys.argv[1:] = ['--', '--argument', 'foobar']` -> `-a` and `-b` shouldn't be required, but they are. – wjandrea May 31 '20 at 16:00
  • 3
    also this will not show in -h/--help message correctly as it does not have sufficient information at that time! so it might be a little confusing for the user – Majo Feb 23 '21 at 17:49
  • 1
    This solution is great, but it can't satisfy situations where the parser is constructed before its usage as in Django commands – artu-hnrq Oct 10 '21 at 03:24
  • Another corner-case problem with this solution: `--arg` can also be used in place of `--argument` and if that happens, the subsequent conditions are completely bypassed. – Shi Aug 02 '22 at 22:10
16

You can implement a check by providing a custom action for --argument, which will take an additional keyword argument to specify which other action(s) should become required if --argument is used.

import argparse

class CondAction(argparse.Action):
    def __init__(self, option_strings, dest, nargs=None, **kwargs):
        x = kwargs.pop('to_be_required', [])
        super(CondAction, self).__init__(option_strings, dest, **kwargs)
        self.make_required = x

    def __call__(self, parser, namespace, values, option_string=None):
        for x in self.make_required:
            x.required = True
        try:
            return super(CondAction, self).__call__(parser, namespace, values, option_string)
        except NotImplementedError:
            pass

p = argparse.ArgumentParser()
x = p.add_argument("--a")
p.add_argument("--argument", action=CondAction, to_be_required=[x])

The exact definition of CondAction will depend on what, exactly, --argument should do. But, for example, if --argument is a regular, take-one-argument-and-save-it type of action, then just inheriting from argparse._StoreAction should be sufficient.

In the example parser, we save a reference to the --a option inside the --argument option, and when --argument is seen on the command line, it sets the required flag on --a to True. Once all the options are processed, argparse verifies that any option marked as required has been set.

chepner
  • 497,756
  • 71
  • 530
  • 681
  • 2
    As written it doesn't work because `Action.__call__` returns a `not implement` error. But the basic idea of tweaking the `required` attribute of `x` should work. – hpaulj Sep 02 '14 at 19:35
  • 1
    Good point. If you inherit from `argparse.Action`, then there's no need to call the (unimplemented) parent-class `__call__`. If you inherit from one of the other `Action` subclass's, you should. (As a compromise, I edited the answer to leave the superclass call in place, but catch and ignore the `NotImplementedError`.) – chepner Sep 02 '14 at 19:38
7

Here is a simple and clean solution with these advantages:

  • No ambiguity and loss of functionality caused by oversimplified parsing using the in sys.argv test.
  • No need to implement a special argparse.Action or argparse.UsageGroup class.
  • Simple usage even for multiple and complex deciding arguments.

I noticed just one considerable drawback (which some may find desirable): The help text changes according to the state of the deciding arguments.

The idea is to use argparse twice:

  1. Parse the deciding arguments instead of the oversimplified use of the in sys.argv test. For this we use a short parser not showing help and the method .parse_known_args() which ignores unknown arguments.
  2. Parse everything normally while reusing the parser from the first step as a parent and having the results from the first parser available.
import argparse

# First parse the deciding arguments.
deciding_args_parser = argparse.ArgumentParser(add_help=False)
deciding_args_parser.add_argument(
        '--argument', required=False, action='store_true')
deciding_args, _ = deciding_args_parser.parse_known_args()

# Create the main parser with the knowledge of the deciding arguments.
parser = argparse.ArgumentParser(
        description='...', parents=[deciding_args_parser])
parser.add_argument('-a', required=deciding_args.argument)
parser.add_argument('-b', required=deciding_args.argument)
arguments = parser.parse_args()

print(arguments)
6

Your post parsing test is fine, especially if testing for defaults with is None suits your needs.

http://bugs.python.org/issue11588 'Add "necessarily inclusive" groups to argparse' looks into implementing tests like this using the groups mechanism (a generalization of mutuall_exclusive_groups).

I've written a set of UsageGroups that implement tests like xor (mutually exclusive), and, or, and not. I thought those where comprehensive, but I haven't been able to express your case in terms of those operations. (looks like I need nand - not and, see below)

This script uses a custom Test class, that essentially implements your post-parsing test. seen_actions is a list of Actions that the parse has seen.

class Test(argparse.UsageGroup):
    def _add_test(self):
        self.usage = '(if --argument then -a and -b are required)'
        def testfn(parser, seen_actions, *vargs, **kwargs):
            "custom error"
            actions = self._group_actions
            if actions[0] in seen_actions:
                if actions[1] not in seen_actions or actions[2] not in seen_actions:
                    msg = '%s - 2nd and 3rd required with 1st'
                    self.raise_error(parser, msg)
            return True
        self.testfn = testfn
        self.dest = 'Test'
p = argparse.ArgumentParser(formatter_class=argparse.UsageGroupHelpFormatter)
g1 = p.add_usage_group(kind=Test)
g1.add_argument('--argument')
g1.add_argument('-a')
g1.add_argument('-b')
print(p.parse_args())

Sample output is:

1646:~/mypy/argdev/usage_groups$ python3 issue25626109.py --arg=1 -a1
usage: issue25626109.py [-h] [--argument ARGUMENT] [-a A] [-b B]
                        (if --argument then -a and -b are required)
issue25626109.py: error: group Test: argument, a, b - 2nd and 3rd required with 1st

usage and error messages still need work. And it doesn't do anything that post-parsing test can't.


Your test raises an error if (argument & (!a or !b)). Conversely, what is allowed is !(argument & (!a or !b)) = !(argument & !(a and b)). By adding a nand test to my UsageGroup classes, I can implement your case as:

p = argparse.ArgumentParser(formatter_class=argparse.UsageGroupHelpFormatter)
g1 = p.add_usage_group(kind='nand', dest='nand1')
arg = g1.add_argument('--arg', metavar='C')
g11 = g1.add_usage_group(kind='nand', dest='nand2')
g11.add_argument('-a')
g11.add_argument('-b')

The usage is (using !() to mark a 'nand' test):

usage: issue25626109.py [-h] !(--arg C & !(-a A & -b B))

I think this is the shortest and clearest way of expressing this problem using general purpose usage groups.


In my tests, inputs that parse successfully are:

''
'-a1'
'-a1 -b2'
'--arg=3 -a1 -b2'

Ones that are supposed to raise errors are:

'--arg=3'
'--arg=3 -a1'
'--arg=3 -b2'
hpaulj
  • 221,503
  • 14
  • 230
  • 353
5

For arguments I've come up with a quick-n-dirty solution like this. Assumptions: (1) '--help' should display help and not complain about required argument and (2) we're parsing sys.argv

p = argparse.ArgumentParser(...)
p.add_argument('-required', ..., required = '--help' not in sys.argv )

This can easily be modified to match a specific setting. For required positionals (which will become unrequired if e.g. '--help' is given on the command line) I've come up with the following: [positionals do not allow for a required=... keyword arg!]

p.add_argument('pattern', ..., narg = '+' if '--help' not in sys.argv else '*' )

basically this turns the number of required occurrences of 'pattern' on the command line from one-or-more into zero-or-more in case '--help' is specified.

emvee
  • 4,371
  • 23
  • 23
  • I don't mind a downvote or two but I'd like to know why, especially since another answer https://stackoverflow.com/a/44210638/26083 (with upvote 5 at this point) basically does the same – emvee Nov 03 '17 at 13:13
2

Until http://bugs.python.org/issue11588 is solved, I'd just use nargs:

p = argparse.ArgumentParser(description='...')
p.add_argument('--arguments', required=False, nargs=2, metavar=('A', 'B'))

This way, if anybody supplies --arguments, it will have 2 values.

Maybe its CLI result is less readable, but code is much smaller. You can fix that with good docs/help.

Yajo
  • 5,808
  • 2
  • 30
  • 34
1

This is really the same as @Mira 's answer but I wanted to show it for the case where when an option is given that an extra arg is required:

For instance, if --option foo is given then some args are also required that are not required if --option bar is given:

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument('--option', required=True,
        help='foo and bar need different args')

    if 'foo' in sys.argv:
        parser.add_argument('--foo_opt1', required=True,
           help='--option foo requires "--foo_opt1"')
        parser.add_argument('--foo_opt2', required=True,
           help='--option foo requires "--foo_opt2"')
        ...

    if 'bar' in sys.argv:
        parser.add_argument('--bar_opt', required=True,
           help='--option bar requires "--bar_opt"')
        ...

It's not perfect - for instance proggy --option foo --foo_opt1 bar is ambiguous but for what I needed to do its ok.

keithpjolley
  • 2,089
  • 1
  • 17
  • 20
0

Add additional simple "pre"parser to check --argument, but use parse_known_args() .

pre = argparse.ArgumentParser()
pre.add_argument('--argument', required=False, action='store_true', default=False)
args_pre=pre.parse_known_args()

p = argparse.ArgumentParser()
p.add_argument('--argument', required=False)
p.add_argument('-a', required=args_pre.argument)
p.add_argument('-b', required=not args_pre.argument)