Search code examples
pythonargparsesubparsers

have top-level parser and subparsers act on the same variable


i have an ArgumentParser with sub-parsers. Some flags are common for all sub-parsers, and I would like to be able to specify them either before or after the sub-command, or even mix before and after (at the user's discretion).

Something like this:

$ ./test -v
Namespace(v=1)
$ ./test.py test -vv
Namespace(v=2)
$ ./test.py -vvv test
Namespace(v=3)
$ ./test.py -vv test -vv
Namespace(v=4)

So I tried something like this:

#!/usr/bin/env python3

import argparse

parser = argparse.ArgumentParser()
parser.add_argument("-v", action="count")

subparsers = parser.add_subparsers()
sub = subparsers.add_parser("test")
sub.add_argument("-v", action="count")

print(parser.parse_args())

Running this program "test.py" gives me:

 ./test.py -h
usage: test.py [-h] [-v] {test} ...

positional arguments:
  {test}

options:
  -h, --help  show this help message and exit
  -v

$ ./test.py test -h
usage: test.py test [-h] [-v]

options:
  -h, --help  show this help message and exit
  -v

$ ./test.py -v
Namespace(v=1)

$ ./test.py test -vv
Namespace(v=2)

cool.

but it also gives me:

$ ./test.py -vvv test
Namespace(v=None)
$ ./test.py -vv test -vv
Namespace(v=2)

less cool :-(

I also tried to specify parent-parsers explicitly:

common = argparse.ArgumentParser(add_help=False)
common.add_argument("-v", action="count")

parser = argparse.ArgumentParser(parents=[common])
sub = parser.add_subparsers().add_parser("test", parents=[common])

print(parser.parse_args())

but the result is the same.

So, I guess that as soon as the test subparser kicks in, it resets the value if v to None.

How do I prevent this?

(I notice that How can I define global options with sub-parsers in python argparse? is similar, and the answer there suggests to use different dest variables for the top-level and the sub-level parser. i would like to avoid that...)


Solution

  • The desired behavior for action='count' as per the question is simply not possible using a single ArgumentParser (as of Python 3.12.3), because of how sub-namespace gets merged into the main namespace at the end. Specifically, the internal _CountAction callable will find try to find the dest in the current namespace, set it to 0 if not already exist, and increment the value by 1. However, the internal _SubparserAction callable will just naively merge the resulting values in (not an exact but very much related known issue). You may verify this by tracing through the execution using following argparser (which is set up with additional default flags for disambiguation along with example input), and step through the debugger:

    import argparse
    
    common = argparse.ArgumentParser(add_help=False)
    common.add_argument("-v", action="count")
    common.add_argument("-c", default="common")
    
    parser = argparse.ArgumentParser(parents=[common])
    parser.add_argument("-p", default="parser")
    sub = parser.add_subparsers().add_parser("test", parents=[common])
    sub.add_argument("-s", default="sub")
    
    args = ['-v', '-v', 'test', '-v', '-v']
    
    # demo
    print(f"parser: {parser.parse_args(args)}")
    

    Alternate to stepping through the debugger, insert print(namespace) above the linked lines for _CountAction.__call__ and print(f"{subnamespace} merging into {namespace}") before the for block in the second code block, will produce the following output:

    Namespace(v=None, c='common', p='parser')
    Namespace(v=1, c='common', p='parser')
    Namespace(v=None, c='common', s='sub')
    Namespace(v=1, c='common', s='sub')
    Namespace(v=2, c='common', s='sub') merging into Namespace(v=2, c='common', p='parser')
    parser: Namespace(v=2, c='common', p='parser', s='sub')
    

    I've worked around this specific issue many years ago in a package I've written (specifically, this commit, which hinted at this very specific woe) by using a common parser and parse_known_args with the incoming arguments. Since your final example already has a common parser, change the demo portion to be:

    print(f"common: {common.parse_known_args(args)}")
    print(f"parser: {parser.parse_args(args)}")
    

    Should produce the following output:

    common: (Namespace(v=4, c='common'), ['test'])
    parser: Namespace(v=2, c='common', p='parser', s='sub')
    

    Pick what you need from the known args of common (and ignore the remainder), and parse the list of args as normal with parser and use the rest of it as normal.