argparse_flags.py 13.8 KB
# Copyright 2018 The Abseil Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""This module provides argparse integration with absl.flags.

argparse_flags.ArgumentParser is a drop-in replacement for
argparse.ArgumentParser. It takes care of collecting and defining absl flags
in argparse.


Here is a simple example:

    # Assume the following absl.flags is defined in another module:
    #
    #     from absl import flags
    #     flags.DEFINE_string('echo', None, 'The echo message.')
    #
    parser = argparse_flags.ArgumentParser(
        description='A demo of absl.flags and argparse integration.')
    parser.add_argument('--header', help='Header message to print.')

    # The parser will also accept the absl flag `--echo`.
    # The `header` value is available as `args.header` just like a regular
    # argparse flag. The absl flag `--echo` continues to be available via
    # `absl.flags.FLAGS` if you want to access it.
    args = parser.parse_args()

    # Example usages:
    # ./program --echo='A message.' --header='A header'
    # ./program --header 'A header' --echo 'A message.'


Here is another example demonstrates subparsers:

    parser = argparse_flags.ArgumentParser(description='A subcommands demo.')
    parser.add_argument('--header', help='The header message to print.')

    subparsers = parser.add_subparsers(help='The command to execute.')

    roll_dice_parser = subparsers.add_parser(
        'roll_dice', help='Roll a dice.',
        # By default, absl flags can also be specified after the sub-command.
        # To only allow them before sub-command, pass
        # `inherited_absl_flags=None`.
        inherited_absl_flags=None)
    roll_dice_parser.add_argument('--num_faces', type=int, default=6)
    roll_dice_parser.set_defaults(command=roll_dice)

    shuffle_parser = subparsers.add_parser('shuffle', help='Shuffle inputs.')
    shuffle_parser.add_argument(
        'inputs', metavar='I', nargs='+', help='Inputs to shuffle.')
    shuffle_parser.set_defaults(command=shuffle)

    args = parser.parse_args(argv[1:])
    args.command(args)

    # Example usages:
    # ./program --echo='A message.' roll_dice --num_faces=6
    # ./program shuffle --echo='A message.' 1 2 3 4


There are several differences between absl.flags and argparse_flags:

1. Flags defined with absl.flags are parsed differently when using the
   argparse parser. Notably:

   1) absl.flags allows both single-dash and double-dash for any flag, and
      doesn't distinguish them; argparse_flags only allows double-dash for
      flag's regular name, and single-dash for flag's `short_name`.
   2) Boolean flags in absl.flags can be specified with `--bool`, `--nobool`,
      as well as `--bool=true/false` (though not recommended);
      in argparse_flags, it only allows `--bool`, `--nobool`.

2. Help related flag differences:
   1) absl.flags does not define help flags, absl.app does that; argparse_flags
      defines help flags unless passed with `add_help=False`.
   2) absl.app supports `--helpxml`; argparse_flags does not.
   3) argparse_flags supports `-h`; absl.app does not.
"""

from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

import argparse
import sys

from absl import flags


_BUILT_IN_FLAGS = frozenset({
    'help',
    'helpshort',
    'helpfull',
    'helpxml',
    'flagfile',
    'undefok',
})


class ArgumentParser(argparse.ArgumentParser):
  """Custom ArgumentParser class to support special absl flags."""

  def __init__(self, **kwargs):
    """Initializes ArgumentParser.

    Args:
      **kwargs: same as argparse.ArgumentParser, except:
          1. It also accepts `inherited_absl_flags`: the absl flags to inherit.
             The default is the global absl.flags.FLAGS instance. Pass None to
             ignore absl flags.
          2. The `prefix_chars` argument must be the default value '-'.

    Raises:
      ValueError: Raised when prefix_chars is not '-'.
    """
    prefix_chars = kwargs.get('prefix_chars', '-')
    if prefix_chars != '-':
      raise ValueError(
          'argparse_flags.ArgumentParser only supports "-" as the prefix '
          'character, found "{}".'.format(prefix_chars))

    # Remove inherited_absl_flags before calling super.
    self._inherited_absl_flags = kwargs.pop('inherited_absl_flags', flags.FLAGS)
    # Now call super to initialize argparse.ArgumentParser before calling
    # add_argument in _define_absl_flags.
    super(ArgumentParser, self).__init__(**kwargs)

    if self.add_help:
      # -h and --help are defined in super.
      # Also add the --helpshort and --helpfull flags.
      self.add_argument(
          # Action 'help' defines a similar flag to -h/--help.
          '--helpshort', action='help',
          default=argparse.SUPPRESS, help=argparse.SUPPRESS)
      self.add_argument(
          '--helpfull', action=_HelpFullAction,
          default=argparse.SUPPRESS, help='show full help message and exit')

    if self._inherited_absl_flags:
      self.add_argument('--undefok', help=argparse.SUPPRESS)
      self._define_absl_flags(self._inherited_absl_flags)

  def parse_known_args(self, args=None, namespace=None):
    if args is None:
      args = sys.argv[1:]
    if self._inherited_absl_flags:
      # Handle --flagfile.
      # Explicitly specify force_gnu=True, since argparse behaves like
      # gnu_getopt: flags can be specified after positional arguments.
      args = self._inherited_absl_flags.read_flags_from_files(
          args, force_gnu=True)

    undefok_missing = object()
    undefok = getattr(namespace, 'undefok', undefok_missing)

    namespace, args = super(ArgumentParser, self).parse_known_args(
        args, namespace)

    # For Python <= 2.7.8: https://bugs.python.org/issue9351, a bug where
    # sub-parsers don't preserve existing namespace attributes.
    # Restore the undefok attribute if a sub-parser dropped it.
    if undefok is not undefok_missing:
      namespace.undefok = undefok

    if self._inherited_absl_flags:
      # Handle --undefok. At this point, `args` only contains unknown flags,
      # so it won't strip defined flags that are also specified with --undefok.
      # For Python <= 2.7.8: https://bugs.python.org/issue9351, a bug where
      # sub-parsers don't preserve existing namespace attributes. The undefok
      # attribute might not exist because a subparser dropped it.
      if hasattr(namespace, 'undefok'):
        args = _strip_undefok_args(namespace.undefok, args)
        # absl flags are not exposed in the Namespace object. See Namespace:
        # https://docs.python.org/3/library/argparse.html#argparse.Namespace.
        del namespace.undefok
      self._inherited_absl_flags.mark_as_parsed()
      try:
        self._inherited_absl_flags._assert_all_validators()  # pylint: disable=protected-access
      except flags.IllegalFlagValueError as e:
        self.error(str(e))

    return namespace, args

  def _define_absl_flags(self, absl_flags):
    """Defines flags from absl_flags."""
    key_flags = set(absl_flags.get_key_flags_for_module(sys.argv[0]))
    for name in absl_flags:
      if name in _BUILT_IN_FLAGS:
        # Do not inherit built-in flags.
        continue
      flag_instance = absl_flags[name]
      # Each flags with short_name appears in FLAGS twice, so only define
      # when the dictionary key is equal to the regular name.
      if name == flag_instance.name:
        # Suppress the flag in the help short message if it's not a main
        # module's key flag.
        suppress = flag_instance not in key_flags
        self._define_absl_flag(flag_instance, suppress)

  def _define_absl_flag(self, flag_instance, suppress):
    """Defines a flag from the flag_instance."""
    flag_name = flag_instance.name
    short_name = flag_instance.short_name
    argument_names = ['--' + flag_name]
    if short_name:
      argument_names.insert(0, '-' + short_name)
    if suppress:
      helptext = argparse.SUPPRESS
    else:
      # argparse help string uses %-formatting. Escape the literal %'s.
      helptext = flag_instance.help.replace('%', '%%')
    if flag_instance.boolean:
      # Only add the `no` form to the long name.
      argument_names.append('--no' + flag_name)
      self.add_argument(
          *argument_names, action=_BooleanFlagAction, help=helptext,
          metavar=flag_instance.name.upper(),
          flag_instance=flag_instance)
    else:
      self.add_argument(
          *argument_names, action=_FlagAction, help=helptext,
          metavar=flag_instance.name.upper(),
          flag_instance=flag_instance)


class _FlagAction(argparse.Action):
  """Action class for Abseil non-boolean flags."""

  def __init__(self, option_strings, dest, help, metavar, flag_instance):  # pylint: disable=redefined-builtin
    """Initializes _FlagAction.

    Args:
      option_strings: See argparse.Action.
      dest: Ignored. The flag is always defined with dest=argparse.SUPPRESS.
      help: See argparse.Action.
      metavar: See argparse.Action.
      flag_instance: absl.flags.Flag, the absl flag instance.
    """
    del dest
    self._flag_instance = flag_instance
    super(_FlagAction, self).__init__(
        option_strings=option_strings,
        dest=argparse.SUPPRESS,
        help=help,
        metavar=metavar)

  def __call__(self, parser, namespace, values, option_string=None):
    """See https://docs.python.org/3/library/argparse.html#action-classes."""
    self._flag_instance.parse(values)
    self._flag_instance.using_default_value = False


class _BooleanFlagAction(argparse.Action):
  """Action class for Abseil boolean flags."""

  def __init__(self, option_strings, dest, help, metavar, flag_instance):  # pylint: disable=redefined-builtin
    """Initializes _BooleanFlagAction.

    Args:
      option_strings: See argparse.Action.
      dest: Ignored. The flag is always defined with dest=argparse.SUPPRESS.
      help: See argparse.Action.
      metavar: See argparse.Action.
      flag_instance: absl.flags.Flag, the absl flag instance.
    """
    del dest
    self._flag_instance = flag_instance
    flag_names = [self._flag_instance.name]
    if self._flag_instance.short_name:
      flag_names.append(self._flag_instance.short_name)
    self._flag_names = frozenset(flag_names)
    super(_BooleanFlagAction, self).__init__(
        option_strings=option_strings,
        dest=argparse.SUPPRESS,
        nargs=0,  # Does not accept values, only `--bool` or `--nobool`.
        help=help,
        metavar=metavar)

  def __call__(self, parser, namespace, values, option_string=None):
    """See https://docs.python.org/3/library/argparse.html#action-classes."""
    if not isinstance(values, list) or values:
      raise ValueError('values must be an empty list.')
    if option_string.startswith('--'):
      option = option_string[2:]
    else:
      option = option_string[1:]
    if option in self._flag_names:
      self._flag_instance.parse('true')
    else:
      if not option.startswith('no') or option[2:] not in self._flag_names:
        raise ValueError('invalid option_string: ' + option_string)
      self._flag_instance.parse('false')
    self._flag_instance.using_default_value = False


class _HelpFullAction(argparse.Action):
  """Action class for --helpfull flag."""

  def __init__(self, option_strings, dest, default, help):  # pylint: disable=redefined-builtin
    """Initializes _HelpFullAction.

    Args:
      option_strings: See argparse.Action.
      dest: Ignored. The flag is always defined with dest=argparse.SUPPRESS.
      default: Ignored.
      help: See argparse.Action.
    """
    del dest, default
    super(_HelpFullAction, self).__init__(
        option_strings=option_strings,
        dest=argparse.SUPPRESS,
        default=argparse.SUPPRESS,
        nargs=0,
        help=help)

  def __call__(self, parser, namespace, values, option_string=None):
    """See https://docs.python.org/3/library/argparse.html#action-classes."""
    # This only prints flags when help is not argparse.SUPPRESS.
    # It includes user defined argparse flags, as well as main module's
    # key absl flags. Other absl flags use argparse.SUPPRESS, so they aren't
    # printed here.
    parser.print_help()

    absl_flags = parser._inherited_absl_flags  # pylint: disable=protected-access
    if absl_flags:
      modules = sorted(absl_flags.flags_by_module_dict())
      main_module = sys.argv[0]
      if main_module in modules:
        # The main module flags are already printed in parser.print_help().
        modules.remove(main_module)
      print(absl_flags._get_help_for_modules(  # pylint: disable=protected-access
          modules, prefix='', include_special_flags=True))
    parser.exit()


def _strip_undefok_args(undefok, args):
  """Returns a new list of args after removing flags in --undefok."""
  if undefok:
    undefok_names = set(name.strip() for name in undefok.split(','))
    undefok_names |= set('no' + name for name in undefok_names)
    # Remove undefok flags.
    args = [arg for arg in args if not _is_undefok(arg, undefok_names)]
  return args


def _is_undefok(arg, undefok_names):
  """Returns whether we can ignore arg based on a set of undefok flag names."""
  if not arg.startswith('-'):
    return False
  if arg.startswith('--'):
    arg_without_dash = arg[2:]
  else:
    arg_without_dash = arg[1:]
  if '=' in arg_without_dash:
    name, _ = arg_without_dash.split('=', 1)
  else:
    name = arg_without_dash
  if name in undefok_names:
    return True
  return False