12

Is there any intuitive alternative for argparse/optparse for subcommands? They both are bad - it is either insane config or insane output.

Real world example (stolen, not wanted):

>>> parser = argparse.ArgumentParser()
>>> subparsers = parser.add_subparsers(title='subcommands',
...                                    description='valid subcommands',
...                                    help='additional help')
>>> subparsers.add_parser('foo')
>>> subparsers.add_parser('bar')
>>> parser.parse_args(['-h'])
usage:  [-h] {foo,bar} ...

optional arguments:
  -h, --help  show this help message and exit

subcommands:
  valid subcommands

  {foo,bar}   additional help

Wanted:

>>> parser = cmdline.Parser(
...   tplheader='Usage: tool [command] [options]',
...   tplcommandhead='Available commands:',
...   tplfooter='Use \"tool help\" to get full list of supported commands.')
>>> parser.add('foo', help='foo.')
>>> parser.add('bar', help='bar.')
>>> parser.parse(['-h'])
Usage: tool [command] [options]
Available commands:

  foo        foo.
  bar        bar.

Use "tool help" to get full list of supported commands.

UPDATE: I would accept the answer that provides command validation and parsing example that gives the help message exactly as the last snippet.

Community
  • 1
  • 1
anatoly techtonik
  • 19,847
  • 9
  • 124
  • 140

5 Answers5

8

You can get pretty close to your requested output just by changing your argparse code a little bit:

  1. Set the usage text by specifying the usage parameter to ArgumentParser.
  2. Omit the description and help arguments to add_subparsers.
  3. Change the title parameter to Available subcommands.
  4. Use the metavar parameter to override the unsightly {foo,bar} text.
  5. Use the help arguments available in add_parser.

Here's the finished product:

import argparse
parser = argparse.ArgumentParser(usage='tool [command] [options]')
subparsers = parser.add_subparsers(title='Available commands', metavar='')
subparsers.add_parser('foo', help='foo.')
subparsers.add_parser('bar', help='bar.')
parser.parse_args(['-h'])

That code prints this:

usage: tool [command] [options]

optional arguments:
  -h, --help  show this help message and exit

Available commands:

    foo       foo.
    bar       bar.
Rob Kennedy
  • 161,384
  • 21
  • 275
  • 467
  • Add an 'epilog' and 'add_help=False' and you will get even closer. I was over thinking the problem with my answer. – hpaulj Mar 06 '15 at 23:10
  • I intentionally omitted the epilogue text because it will take additional work to make the requested text be an accurate description of the behavior, @Hpaulj. Existing commands seem split on whether the help is accessed by a dedicated subcommand or by an option, and the option method is already built in to the argparse library. – Rob Kennedy Mar 06 '15 at 23:20
  • @RobKennedy, nice try. It could be a good answer to illustrate why `argparse` can not do the job. It looks almost ok, but I suspect that getting the desired output exactly as requested is not easy. – anatoly techtonik Mar 08 '15 at 09:20
  • @hpaulj, it shows `optional arguments` garbage and no footer is displayed. – anatoly techtonik Mar 09 '15 at 09:51
1

Sounds like you are looking for argh.

Here's a snippet from the presentation on the home page.

A potentially modular application with multiple commands:

import argh

# declaring:

def echo(text):
    "Returns given word as is."
    return text

def greet(name, greeting='Hello'):
    "Greets the user with given name. The greeting is customizable."
    return greeting + ', ' + name

# assembling:

parser = argh.ArghParser()
parser.add_commands([echo, greet])

# dispatching:

if __name__ == '__main__':
    parser.dispatch()

Of course it works:

$ ./app.py greet Andy
Hello, Andy

$ ./app.py greet Andy -g Arrrgh
Arrrgh, Andy

The help message on the site is slightly abridged. Here is what it actually outputs for me (argh 0.26.1).

$ ./app.py --help
usage: app.py [-h] {greet,echo} ...

positional arguments:
  {greet,echo}
    echo        Returns given word as is.
    greet       Greets the user with given name. The greeting is customizable.

optional arguments:
  -h, --help    show this help message and exit
tripleee
  • 175,061
  • 34
  • 275
  • 318
1

Does this win the prize? :)

Custom parameters

Rob Kennedy has a better customization.

In [158]: parser=argparse.ArgumentParser(usage='tool [command] [options]',
  description= "Available commands:\n\n   foo    foo.\n   bar    bar.\n",
  epilog= 'Use "tool help" to get full list of supported commands',
  formatter_class=argparse.RawDescriptionHelpFormatter, add_help=False)

In [159]: parser.print_help()
usage: tool [command] [options]

Available commands:

   foo    foo.
   bar    bar.

Use "tool help" to get full list of supported commands

What I've done is customize the help with available parameters.

Alternative API and/or parser?

But your other lines, the parse.add() ones suggest you don't like the argparse method of defining 'commands'. You could add some methods to your parser that use this more compact syntax, but still end up calling the existing subparser mechanism.

But maybe you want to replace the whole parsing scheme with your own. One, for example, that expects the first argument to be a 'command'. What about other 'positionals'? Who or what handles the 'options'?

Do you realize that the argparse subparser scheme is built on top of the more basic optionals and positionals parsing scheme. The parser.add_subparsers command is a specialized form of add_argument. The subparsers object is a positional argument, with a special Action class. {foo,bar} is actually a list of the choices values that you defined for this argument (names or aliases of the subcommands). The subcommands themselves are parsers.

Custom front end command parser

If the sys.argv[1] item will always be a command name, you could set up something like this:

if sys.argv[1:]:
    cmd = sys.argv[1]
    rest = sys.argv[2:]
    parser = parser_dict.get(cmd, None)
    if parser:
        args = parser.parse_args(rest)
else:
    print_default_help()

Where parser_dict is a dictionary matching cmd strings to defined parsers. In effect this is just a front end that captures the first argument string, and dispatches the handling of the rest to other defined parsers. They could be a mix of argparse, optparse, and custom parsers. This front end does not have to be fancy if all it handles is the first 'command' string.

print_default_help would be little more than a pretty print of the parser_dict.

On further thought, I realized that the sp.choices attribute of an argparse subparsers object is just such a dictionary - with command strings as keys, and parsers as values.

Custom format_help methods

Here are a couple of custom help formatters.

A simple one that only gets the prog and _choices_actions from the parser. subparsers._choices_actions is a list of objects that contain help and aliases information for the individual sub parsers.

def simple_help(parser, subparsers):
    # format a help message with just the subparser choices
    usage = "Usage: %s command [options]"%parser.prog
    desc = "Available commands:\n"
    epilog = '\nUse "%s help" to get full list of supported commands.'%parser.prog
    choices = fmt_choices(subparsers._choices_actions)
    astr = [usage]
    astr.append(desc)
    astr.extend(choices)
    astr.append(epilog)
    return '\n'.join(astr)

def fmt_choices(choices):
    # format names and help in 2 columns
    x = max(len(k.metavar) for k in choices)
    fmt = '   {:<%s}   {}'%x
    astr = []
    for k in choices:
        # k.metavar lists aliases as well
        astr.append(fmt.format(k.dest, k.help))
    return astr

This one is modeled on parser.format_help, and makes uses of the Formatter and all of its wrapping and spacing information. I wrote it to use non-default parameters where possible. It is hard, though, to suppress blank lines.

def special_help(parser, subparsers=None, usage=None, epilog=None):
    # format help message using a Formatter
    # modeled on parser.format_help
    # uses nondefault parameters where possible
    if usage is None:
        usage = "%(prog)s command [options]"
    if epilog is None:
        epilog = "Use '%(prog)s help' for command list"
    if subparsers is None:
        # find the subparsers action in the parser
        for action in parser._subparsers._group_actions:
            if hasattr(action, '_get_subactions'):
                subparsers = action
                break
        # if none found, subparsers is still None?
    if parser._subparsers != parser._positionals:
        title = parser._subparsers.title
        desc = parser._subparsers.description
    else:
        title = "Available commands"
        desc = None
    if subparsers.metavar is None:
        subparsers.metavar = '_________'
        # restore to None at end?
    formatter = parser._get_formatter()
    if parser.usage is None:
        formatter.add_usage(usage, [], [])
    else:
        formatter.add_usage(parser.usage,
            parser._actions, parser._mutually_exclusive_groups)
    # can I get rid of blank line here?
    formatter.start_section(title)
    formatter.add_text(desc)
    formatter.add_arguments([subparsers])
    formatter.end_section()
    formatter.add_text(epilog)
    return formatter.format_help()

These could be invoked in different ways. Either could replace the parser's format_help method, and thus be produced by the -h option, as well as with parser.print_help().

Or you could include a help subcommand. This would fit with the epilog message. -h would still produce the full, ugly help.

sp3 = sp.add_parser('help')  # help='optional help message'

and test args:

if args.cmd in ['help']:
    print(simple_help(parser, sp))
    # print(special_help(parser))

Another option is to check sys.argv before parser.parser_args, and call the help function if that list isn't long enough, or includes a help string. This is roughly what Ipython does to bypass the regular argparse help.

hpaulj
  • 221,503
  • 14
  • 230
  • 353
  • I probably could hack argparser by copy pasting multiple pages of class definitions, but I'd rather use a library that is short and intuitive and doesn't overburden my command line helpers. – anatoly techtonik Mar 08 '15 at 09:28
  • Normally the `argparse` help formatter is tweaked by changing just a few methods of the formatter class. Obviously you do have to study the code enough to know where to make those few changes. No one is going to hand you a custom formatter on a platter. – hpaulj Mar 08 '15 at 18:09
  • 1
    that's why this question is about alternatives to `argparse` where writing custom formatter is as easy as in my question and doesn't require you a days of debate to get the exact output right. – anatoly techtonik Mar 09 '15 at 09:54
0

I'm not sure that I understand what's wrong with what you describe. I use something slightly different though:

parser = argparse.ArgumentParser(description='My description')
parser.add_argument('-i', '--input', type=str, required=True, help='Inputfile')
parser.add_argument('-o', '--output', type=str, required=False, help='Output file')
args = parser.parse_args()
input_filename = args.input
if not args.output:
    output_filename = input_filename
else:
    output_filename = args.output
Hugues Fontenelle
  • 5,275
  • 2
  • 29
  • 44
0

You should take a look at Click. From the documentation, Click...

  • is lazily composable without restrictions
  • fully follows the Unix command line conventions
  • supports loading values from environment variables out of the box
  • supports for prompting of custom values
  • is fully nestable and composable
  • works the same in Python 2 and 3
  • supports file handling out of the box
  • comes with useful common helpers (getting terminal dimensions, ANSI colors, fetching direct keyboard input, screen clearing, finding config paths, launching apps and editors, etc.)

Arguments and options are pretty intuitive to create with decorators. You can create subcommands by creating groups as shown below.

import click                                                    


@click.command()                                                
@click.option('--count', default=1, help='number of greetings') 
@click.argument('name')                                         
def hello(count, name):                                         
    for i in range(count):                                      
        print(f"{i}. Hello {name}")                             

@click.group()                                                  
def cli():                                                      
    pass                                                        

@cli.command()                                                  
def initdb():                                                   
    click.echo('Initialized the database')                      

@cli.command()                                                  
def dropdb():                                                   
    click.echo('Dropped the database')                          

if __name__ == "__main__":                                      
    cli() 

The output from this code is:

$ python click-example.py --help
Usage: click-example.py [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  dropdb
  initdb
Thane Plummer
  • 7,966
  • 3
  • 26
  • 30