2

I'm trying to make a program in Python 3.5 (the Python version shipped by Debian 9) that takes two command line arguments: an input file name and an output file name.

  • The input file name must either precede the output file name or itself be preceded by -i.
  • The output file name is optional. If present, and the input file name is not preceded by -i, it must either follow the input file name or itself be preceded by -o.

Thus I want to accept the following command lines:

programname.py infilename
programname.py -i infilename
programname.py infilename outfilename
programname.py -i infilename outfilename
programname.py infilename -o outfilename
programname.py -i infilename -o outfilename
programname.py outfilename -i infilename
programname.py -o outfilename -i infilename
programname.py -o outfilename infilename

The usage message might look like this:

programname.py [-i] infilename [[-o] outfilename]

But I can't tell from the documentation of the argparse module how to express this in arguments to add_argument(). When I give two names for a single argument, one positional and one named, add_argument() raises an exception:

ValueError: invalid option string 'infilename': must start with a character '-'

I searched Stack Overflow for similar questions and found hpaulj's answer to Python argparse - mandatory argument - either positional or optional and hpaulj's answer to argparse: let the same required argument be positional OR optional. The construction in these answers uses a group of two mutually exclusive arguments, one positional and one named. But it doesn't appear to work with multiple such arguments. Trying to parse -i infilename outfilename with a parser built this way produces a different exception:

argparse.ArgumentError: argument INFILE: not allowed with argument -i

However, argparse itself has trouble printing this exception or even showing --help:

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  [8+ layers of method calls within `argparse.py` omitted]
  File "/usr/lib/python3.5/argparse.py", line 396, in _format_actions_usage
    start = actions.index(group._group_actions[0])
IndexError: list index out of range

The deprecated optparse module stored positional arguments in a separate list, which code that runs after parsing could read to fill in each argument that is None. The direct counterpart to this list in argparse is parser.add_argument('args', nargs=argparse.REMAINDER). Is handling positional arguments manually after calling parse_args() the only way to accept all command line forms shown above using argparse?

#!/usr/bin/env python3
import argparse
import traceback

def mkparser1():
    """Raise an error.

ValueError: invalid option string 'infilename': must start with a character '-'
"""
    parser = argparse.ArgumentParser()
    parser.add_argument("infilename", "-i", metavar="INFILE")
    parser.add_argument("outfilename", "-o", required=False, metavar="INFILE")
    return parser

def mkparser2():
    """Do not raise an error but return an inadequate parser.

When asked -i infilename outfilename
"""
    parser = argparse.ArgumentParser()
    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument("infilename", nargs="?", metavar="INFILE")
    group.add_argument('-i', dest="infilename", metavar="INFILE")
    group = parser.add_mutually_exclusive_group(required=False)
    parser.add_argument("outfilename", nargs="?", metavar="OUTFILE")
    parser.add_argument("-o", dest="outfilename", metavar="OUTFILE")
    return parser

def test():
    parser = mkparser2()
    argstrings = [
        "infilename",
        "-i infilename",
        "infilename outfilename",
        "-i infilename outfilename",
        "infilename -o outfilename",
        "-i infilename -o outfilename",
        "outfilename -i infilename",
        "-o outfilename -i infilename",
        "-o outfilename infilename",
        "--help",
    ]
    for s in argstrings:
        print("for", s)
        try:
            pargs = parser.parse_args(s.split())
        except Exception as e:
            traceback.print_exc()
        else:
            print("infilename is %s and outfilename is %s"
                  % (pargs.infilename, pargs.outfilename))

if __name__=='__main__':
    test()
Damian Yerrick
  • 4,602
  • 2
  • 26
  • 64
  • Perhaps a closer approximation to `optparse` is not define any positionals, and use `parse_known_args`. When you have two positionals with '?' (or '*' or '+'), life becomes complicated. It still tries to fill the positionals in the order that they come, without any knowledge about what optionals might have already set. Keep in mind the `argparse` tries to parse optionals in an order agnostic manner. – hpaulj Mar 01 '18 at 21:48
  • I didn't find this question until after asking basically the same thing. You can find how I ended up solving it here: https://stackoverflow.com/a/49846807/5689064 – rpspringuel Apr 15 '18 at 21:12

1 Answers1

2

You could perhaps make your program accept a variable number of positional arguments (between 0 and 2) which would be added to a positional parameters list (with action="append"), and also call add_argument("-i",...) and add_argument("-o",...) to handle the flag equivalents.

Typically, argparse options fall either in the positional or optional category (but not both). So, you'll need to pass settings to argparse that allow some redundancy, and deal with conflicts after parsing. For instance, you can configure argparse to accept an input file both via -i INPUT and as positional INPUT, but then you add a custom check after parsing to make sure only one of the two forms was used.

Pseudocode:


parser.add_argument('infile', metavar="INFILE", nargs='?',
                    type=argparse.FileType('r'),
                    action='append', dest="positional_args")
parser.add_argument('outfile', metavar="OUTFILE", nargs='?', 
                    type=argparse.FileType('w'),
                    action='append', dest="positional_args")
parser.add_argument('-i', metavar="INFILE", dest="infile", default=None)
parser.add_argument('-o', metavar="OUTFILE", dest="outfile", default=None)

args = parser.parse_args([....])

# here insert check for conflicts between len(args.positional_args) and -i and -o
# example:
if sum([len(args.positional_args),
       args.infile is not None,
       args.outfile is not None]) != 2:
   parser.print_help()
   sys.exit(1)
...

infile = args.infile or args.positional_args.pop(0)
outfile = args.outfile or args.positional_args.pop(0)

init_js
  • 4,143
  • 2
  • 23
  • 53