Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Python argparse: list individual choices in the usage

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.

like image 975
MacFreek Avatar asked Oct 17 '25 04:10

MacFreek


1 Answers

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.

like image 82
MacFreek Avatar answered Oct 19 '25 19:10

MacFreek



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!