2

I have a program which takes multiple arguments, e.g.

breakfast.py --customer=vikings eggs sausage bacon

where "eggs", "sausage" and "bacon" can be specified from a list of specific choices.

Now I like the output of breakfast.py --help to look like this:

usage: breakfast.py [-h] [--customer CUSTOMER] INGREDIENT [INGREDIENT ...]

positional arguments:
  your choice of ingredients:
    bacon              Lovely bacon
    egg                The runny kind
    sausage            Just a roll
    spam               Glorious SPAM
    tomato             Sliced and diced

optional arguments:
  -h, --help           show this help message and exit
  --customer CUSTOMER  salutation for addressing the customer

I tried two approaches, but so far both failed for me.

Using argument choices:

import argparse

parser = argparse.ArgumentParser()

toppings = {
    'bacon': "Lovely bacon",
    'egg': 'The runny kind',
    'sausage': 'Just a roll',
    'spam': 'Glorious SPAM',
    'tomato': 'Sliced and diced',
}
parser.add_argument('--customer', default='Mr. and Mrs. Bun', action='store',
                    help='salutation for addressing the customer')
parser.add_argument('ingredients', nargs='+', choices=toppings.keys(),
                    help='your choice of ingredients')

options = parser.parse_args('--customer=Vikings egg sausage bacon'.split())
print("Dear {}, we are happy to serve you {}" \
      .format(options.customer, ', '.join(options.ingredients)))

The usage of the above program prints a dict-formated list without details:

usage: breakfast.py [-h] [--customer CUSTOMER]
                      {bacon,egg,sausage,spam,tomato}
                      [{bacon,egg,sausage,spam,tomato} ...]

positional arguments:
  {bacon,egg,sausage,spam,tomato}
                        your choice of ingredients

Adding metavar='INGREDIENT' to add_argument('ingredients', ...) does not list the choices at all:

usage: breakfast.py [-h] [--customer CUSTOMER] INGREDIENT [INGREDIENT ...]

positional arguments:
  INGREDIENT           your choice of ingredients

I briefly tried to use subprograms:

import argparse

parser = argparse.ArgumentParser()

parser.add_argument('--customer', default='Mr. and Mrs. Bun', action='store',
                    help='salutation for addressing the customer')
ingredients = parser.add_subparsers(title='your choice of an ingredient',
                    dest='ingredient', metavar='ingredient')
ingredients.add_parser('bacon', help="Lovely bacon")
ingredients.add_parser('egg', help="The runny kind")
ingredients.add_parser('sausage', help="Just a roll")
ingredients.add_parser('spam', help="Glorious SPAM")
ingredients.add_parser('tomato', help="Sliced and diced")

options = parser.parse_args('--customer=Vikings spam'.split())
print("Dear {}, we are happy to serve you {}" \
      .format(options.customer, options.ingredient))

Which does list the usage in the way I like it:

usage: breakfast.py [-h] [--customer CUSTOMER] ingredient ...

optional arguments:
  -h, --help           show this help message and exit
  --customer CUSTOMER  salutation for addressing the customer

your choice of an ingredient:
  ingredient
    bacon              Lovely bacon
    egg                The runny kind
    sausage            Just a roll
    spam               Glorious SPAM
    tomato             Sliced and diced

By default, subprograms only allow one options to be picked. Fortunately this answer shows it is possible to allow multiple subcommands), but this feels like a hack just to get the formatting right. I recently moved from argparse to ConfigArgParse, and this approach failed there.

I think I better revert to using a single argument with multiple choices, and use customat formatting.

Unfortunately, the documentation on adjusting the formatting of argparse is scarce, so I appreciate some help how to approach this.

MacFreek
  • 3,207
  • 2
  • 31
  • 41
  • Not an answer to your question but you might be interested in click: http://click.pocoo.org/6/ It's a library built on top of argparse which offers some extra features when it comes to both options and subcommands. But I don't see any help formatting options: http://click.pocoo.org/6/options/#options – Wolph Apr 22 '18 at 19:06
  • 1
    I think all the formatting parameters are documented. To make bigger changes you have to dig into the code, and construct modified formatters subclasses. `RawTextHelpFormatter` for example, modifies a couple of deeply buried methods to turn off line wrapping in the help lines. The code is sufficiently modular that you can make a lot of changes, but it requires some study. – hpaulj Apr 22 '18 at 19:20

3 Answers3

7

Based on the feedback here, I dived into the argparse code. A reasonable solution that uses subparsers is posted at https://stackoverflow.com/a/49977713/428542.

In addition, I was able to find a solution that added a pseudo-action for each option, as well a solution that modified the formatter. Finally I present a hybrid solution that adds pseudo-action for each option, but in such a way that only the formatter uses them, by exploiting some implementation details.

The first solution defines a custom action, whose purpose is to do nothing at all, but still prints some usage information. The different options are given this NoAction class.

import argparse

class NoAction(argparse.Action):
    def __init__(self, **kwargs):
        kwargs.setdefault('default', argparse.SUPPRESS)
        kwargs.setdefault('nargs', 0)
        super(NoAction, self).__init__(**kwargs)
    def __call__(self, parser, namespace, values, option_string=None):
        pass

parser = argparse.ArgumentParser()
parser.register('action', 'none', NoAction)

parser.add_argument('--customer', default='Mr. and Mrs. Bun', action='store',
                 help='salutation for addressing the customer')
parser.add_argument('ingredients', nargs='*', metavar='INGREDIENT',
                 choices=['bacon', 'egg', 'sausage', 'spam', 'tomato'],
                 help='List of ingredients')

group = parser.add_argument_group(title='your choice of ingredients')
group.add_argument('bacon', help="Lovely bacon", action='none')
group.add_argument('egg', help="The runny kind", action='none')
group.add_argument('sausage', help="Just a roll", action='none')
group.add_argument('spam', help="Glorious SPAM", action='none')
group.add_argument('tomato', help="Sliced and diced", action='none')

options = parser.parse_args('--customer=Vikings egg sausage bacon'.split())
print("Dear {}, we are happy to serve you {}" \
      .format(options.customer, ', '.join(options.ingredients)))

options = parser.parse_args(['--help'])

which outputs:

Dear Vikings, we are happy to serve you egg, sausage, bacon

usage: customchoices.py [-h] [--customer CUSTOMER]
                        [INGREDIENT [INGREDIENT ...]]

positional arguments:
  INGREDIENT           List of ingredients

optional arguments:
  -h, --help           show this help message and exit
  --customer CUSTOMER  salutation for addressing the customer

your choice of ingredients:
  bacon                Lovely bacon
  egg                  The runny kind
  sausage              Just a roll
  spam                 Glorious SPAM
  tomato               Sliced and diced

A minor disadvantage is that the individual choices are both added to the ingredients (for parsing) as well as to the parser (for formatting). We could also define a method to add the choices to the ingredients parser directly:

import argparse

class NoAction(argparse.Action):
    def __init__(self, **kwargs):
        kwargs.setdefault('default', argparse.SUPPRESS)
        kwargs.setdefault('nargs', 0)
        super(NoAction, self).__init__(**kwargs)
    def __call__(self, parser, namespace, values, option_string=None):
        pass

class ChoicesAction(argparse._StoreAction):
    def add_choice(self, choice, help=''):
        if self.choices is None:
            self.choices = []
        self.choices.append(choice)
        self.container.add_argument(choice, help=help, action='none')

parser = argparse.ArgumentParser()
parser.register('action', 'none', NoAction)
parser.register('action', 'store_choice', ChoicesAction)

parser.add_argument('--customer', default='Mr. and Mrs. Bun', action='store',
                 help='salutation for addressing the customer')

group = parser.add_argument_group(title='your choice of ingredients')
ingredients = group.add_argument('ingredients', nargs='*', metavar='INGREDIENT',
                 action='store_choice')
ingredients.add_choice('bacon', help="Lovely bacon")
ingredients.add_choice('egg', help="The runny kind")
ingredients.add_choice('sausage', help="Just a roll")
ingredients.add_choice('spam', help="Glorious SPAM")
ingredients.add_choice('tomato', help="Sliced and diced")

The above is probably my favourite method, despite the two action subclasses. It only uses public methods.

An alternative is to modify the Formatter. This is possible, it modifies action.choices from a list ['option1', 'option2'] to a dict {'option1': 'help_for_option1', 'option2', 'help_for_option2'}, and more-or-less re-implements HelpFormatter._format_action() as HelpFormatterWithChoices.format_choices():

import argparse

class HelpFormatterWithChoices(argparse.HelpFormatter):
    def add_argument(self, action):
        if action.help is not argparse.SUPPRESS:
            if isinstance(action.choices, dict):
                for choice, choice_help in action.choices.items():
                    self._add_item(self.format_choices, [choice, choice_help])
            else:
                super(HelpFormatterWithChoices, self).add_argument(action)
    def format_choices(self, choice, choice_help):
        # determine the required width and the entry label
        help_position = min(self._action_max_length + 2,
                            self._max_help_position)
        help_width = max(self._width - help_position, 11)
        action_width = help_position - self._current_indent - 2
        choice_header = choice

        # short choice name; start on the same line and pad two spaces
        if len(choice_header) <= action_width:
            tup = self._current_indent, '', action_width, choice_header
            choice_header = '%*s%-*s  ' % tup
            indent_first = 0

        # long choice name; start on the next line
        else:
            tup = self._current_indent, '', choice_header
            choice_header = '%*s%s\n' % tup
            indent_first = help_position

        # collect the pieces of the choice help
        parts = [choice_header]

        # add lines of help text
        help_lines = self._split_lines(choice_help, help_width)
        parts.append('%*s%s\n' % (indent_first, '', help_lines[0]))
        for line in help_lines[1:]:
            parts.append('%*s%s\n' % (help_position, '', line))

        # return a single string
        return self._join_parts(parts)

parser = argparse.ArgumentParser(formatter_class=HelpFormatterWithChoices)

toppings = {
    'bacon': "Lovely bacon",
    'egg': 'The runny kind',
    'sausage': 'Just a roll',
    'spam': 'Glorious SPAM',
    'tomato': 'Sliced and diced',
}

parser.add_argument('--customer', default='Mr. and Mrs. Bun', action='store',
                 help='salutation for addressing the customer')

group = parser.add_argument_group(title='your choice of ingredients')
ingredients = group.add_argument('ingredients', nargs='*', metavar='INGREDIENT',
                 choices=toppings)

options = parser.parse_args('--customer=Vikings egg sausage bacon'.split())
print("Dear {}, we are happy to serve you {}" \
      .format(options.customer, ', '.join(options.ingredients)))

print()
options = parser.parse_args(['--help'])

which outputs:

Dear Vikings, we are happy to serve you egg, sausage, bacon

usage: helpformatter.py [-h] [--customer CUSTOMER]
                        [INGREDIENT [INGREDIENT ...]]

optional arguments:
  -h, --help           show this help message and exit
  --customer CUSTOMER  salutation for addressing the customer

your choice of ingredients:
  bacon                Lovely bacon
  egg                  The runny kind
  sausage              Just a roll
  spam                 Glorious SPAM
  tomato               Sliced and diced

It should be noted that this is the only approach that does not print a help line for "INGREDIENTS" itself, but only the choices.

Nevertheless, I dislike this approach: it re-implements too much code, and relies on too much internal implementation details of argparse.

There is also a hybrid approach possible: the subparser code in argparser makes use of a property action._choices_actions. This is normally in the _SubParsersAction class, both for parsing and for formatting. What if we use this property, but only for formatting?

import argparse

class ChoicesAction(argparse._StoreAction):
    def __init__(self, **kwargs):
        super(ChoicesAction, self).__init__(**kwargs)
        if self.choices is None:
            self.choices = []
        self._choices_actions = []
    def add_choice(self, choice, help=''):
        self.choices.append(choice)
        # self.container.add_argument(choice, help=help, action='none')
        choice_action = argparse.Action(option_strings=[], dest=choice, help=help)
        self._choices_actions.append(choice_action)
    def _get_subactions(self):
        return self._choices_actions

parser = argparse.ArgumentParser()
parser.register('action', 'store_choice', ChoicesAction)

parser.add_argument('--customer', default='Mr. and Mrs. Bun', action='store',
                 help='salutation for addressing the customer')

group = parser.add_argument_group(title='your choice of ingredients')
ingredients = group.add_argument('ingredients', nargs='*', metavar='INGREDIENT',
                 action='store_choice')
ingredients.add_choice('bacon', help="Lovely bacon")
ingredients.add_choice('egg', help="The runny kind")
ingredients.add_choice('sausage', help="Just a roll")
ingredients.add_choice('spam', help="Glorious SPAM")
ingredients.add_choice('tomato', help="Sliced and diced")

options = parser.parse_args('--customer=Vikings egg sausage bacon'.split())
print("Dear {}, we are happy to serve you {}" \
      .format(options.customer, ', '.join(options.ingredients)))

print()
options = parser.parse_args(['--help'])

which outputs:

Dear Vikings, we are happy to serve you egg, sausage, bacon

usage: helpformatter2.py [-h] [--customer CUSTOMER]
                         [INGREDIENT [INGREDIENT ...]]

optional arguments:
  -h, --help           show this help message and exit
  --customer CUSTOMER  salutation for addressing the customer

your choice of ingredients:
  INGREDIENT
    bacon              Lovely bacon
    egg                The runny kind
    sausage            Just a roll
    spam               Glorious SPAM
    tomato             Sliced and diced

This is also a nice solution, although it relies on the implementation detail of the _get_subactions() method.

MacFreek
  • 3,207
  • 2
  • 31
  • 41
  • Thanks!!! I spent a few hours trying to sort out a solution, and was ready to give up on a solution that was clean enough to still feel like python. This is great!! – jbo5112 May 04 '18 at 21:57
  • Thank you for the solutions! I decided to use Method 1 as it's the simplest. I just build a dict containing `ingredient: description`, pass its `.keys()` to `choices` and loop over its `.items()` for the description lines. – pepoluan Dec 23 '20 at 04:38
6

Changing the add_argument to:

parser.add_argument('ingredients', nargs='+', choices=toppings.keys(),
                metavar='INGREDIENT',
                help='your choice of ingredients: %(choices)s')

produces

usage: stack49969605.py [-h] [--customer CUSTOMER] INGREDIENT [INGREDIENT ...]

positional arguments:
  INGREDIENT           your choice of ingredients: bacon, egg, sausage, spam,
                       tomato

Changing formatter:

formatter_class=argparse.RawTextHelpFormatter

and the add_argument help parameter to:

                    help = """
your choice of ingredients:
bacon              Lovely bacon
egg                The runny kind
sausage            Just a roll
spam               Glorious SPAM
tomato             Sliced and diced
    """
    )

produces:

usage: stack49969605.py [-h] [--customer CUSTOMER] INGREDIENT [INGREDIENT ...]

positional arguments:
  INGREDIENT           
                       your choice of ingredients:
                       bacon              Lovely bacon
                       egg                The runny kind
                       sausage            Just a roll
                       spam               Glorious SPAM
                       tomato             Sliced and diced

Or you could use argparse.RawDescriptionHelpFormatter, and put the formatted table in the description or epilog.


Another option is to make an Action subclass that imitates aspects of the subparsers class. But that requires a deeper understanding of how this Action class is handled in the formatting.

class _SubParsersAction(Action):
    class _ChoicesPseudoAction(Action):

The subparsers Action object maintains a list of _choices_actions of this PseudoAction class solely for the purpose of tricking the help formatter into displaying the subparsers as though they were a group of nested Actions. This list is not used for parsing; only for help formatting.

hpaulj
  • 221,503
  • 14
  • 230
  • 353
0

Why not just parse the arguments yourself without using argparser? Then you could have all the freedom in formatting the help screen just the way that you like.

import sys

 if sys.argv[1] in ['-h','--help']:
     print "usage: breakfast.py [-h] [--customer CUSTOMER] INGREDIENT [INGREDIENT ...]\n\npositional arguments:\n\tyour choice of ingredients:\n\t\tbacon              Lovely bacon\n\t\tegg                The runny kind\n\t\tsausage            Just a roll\n\t\tspam               Glorious SPAM\n\t\ttomato             Sliced and diced\n\noptional arguments:\n\t-h, --help           show this help message and exit\n\t--customer CUSTOMER  salutation for addressing the customer"

customer_arg = sys.argv[1]
ingrediants = sys.argv[2:len(sys.argv)]
customer = customer_arg.split('=')[1]

This will print:

usage: breakfast.py [-h] [--customer CUSTOMER] INGREDIENT [INGREDIENT ...]

positional arguments:
        your choice of ingredients:
                bacon              Lovely bacon
                egg                The runny kind
                sausage            Just a roll
                spam               Glorious SPAM
                tomato             Sliced and diced

optional arguments:
        -h, --help           show this help message and exit
        --customer CUSTOMER  salutation for addressing the customer

Then you can do whatever's next with the ingrediant list. I hope this helps.

kamses
  • 465
  • 2
  • 10
  • Despite the downvote someone gave this, it is a valid option. `ipython` uses `argparse`, but captures the `help` argument, and displays its own help message. – hpaulj Apr 22 '18 at 19:32