Source code for face.parser

import sys
import shlex
import codecs
import os.path
from collections import OrderedDict
from typing import Optional

from boltons.iterutils import split, unique
from boltons.dictutils import OrderedMultiDict as OMD
from boltons.funcutils import format_exp_repr, format_nonexp_repr

from face.utils import (ERROR,
                        get_type_desc,
                        flag_to_identifier,
                        normalize_flag_name,
                        process_command_name,
                        get_minimal_executable)
from face.errors import (FaceException,
                         ArgumentParseError,
                         ArgumentArityError,
                         InvalidSubcommand,
                         UnknownFlag,
                         DuplicateFlag,
                         InvalidFlagArgument,
                         InvalidPositionalArgument,
                         MissingRequiredFlags)


def _arg_to_subcmd(arg):
    return arg.lower().replace('-', '_')


def _multi_error(flag, arg_val_list):
    "Raise a DuplicateFlag if more than one value is specified for an argument"
    if len(arg_val_list) > 1:
        raise DuplicateFlag.from_parse(flag, arg_val_list)
    return arg_val_list[0]


def _multi_extend(flag, arg_val_list):
    "Return a list of all arguments specified for a flag"
    ret = [v for v in arg_val_list if v is not flag.missing]
    return ret


def _multi_override(flag, arg_val_list):
    "Return only the last argument specified for a flag"
    return arg_val_list[-1]

# TODO: _multi_ignore?

_MULTI_SHORTCUTS = {'error': _multi_error,
                    False: _multi_error,
                    'extend': _multi_extend,
                    True: _multi_extend,
                    'override': _multi_override}


_VALID_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!*+./?@_'
def _validate_char(char):
    orig_char = char
    if char[0] == '-' and len(char) > 1:
        char = char[1:]
    if len(char) > 1:
        raise ValueError('char flags must be exactly one character, optionally'
                         ' prefixed by a dash, not: %r' % orig_char)
    if char not in _VALID_CHARS:
        raise ValueError('expected valid flag character (ASCII letters, numbers,'
                         ' or shell-compatible punctuation), not: %r' % orig_char)
    return char


def _posargs_to_provides(posargspec, posargs):
    '''Automatically unwrap injectable posargs into a more intuitive
    format, similar to an API a human might design. For instance, a
    function which takes exactly one argument would not take a list of
    exactly one argument.

    Cases as follows:

    1. min_count > 1 or max_count > 1, pass through posargs as a list
    2. max_count == 1 -> single argument or None

    Even if min_count == 1, you can get a None back. This compromise
    was made necessary to keep "to_cmd_scope" robust enough to pass to
    help/error handler funcs when validation fails.
    '''
    # all of the following assumes a valid posargspec, with min_count
    # <= max_count, etc.
    pas = posargspec
    if pas.max_count is None or pas.min_count > 1 or pas.max_count > 1:
        return posargs
    if pas.max_count == 1:
        # None is considered sufficiently unambiguous, even for cases when pas.min_count==1
        return posargs[0] if posargs else None
    raise RuntimeError('invalid posargspec/posargs configuration %r -- %r'
                       % (posargspec, posargs))  # pragma: no cover (shouldn't get here)


[docs] class CommandParseResult: """The result of :meth:`Parser.parse`, instances of this type semantically store all that a command line can contain. Each argument corresponds 1:1 with an attribute. Args: name (str): Top-level program name, typically the first argument on the command line, i.e., ``sys.argv[0]``. subcmds (tuple): Sequence of subcommand names. flags (OrderedDict): Mapping of canonical flag names to matched values. posargs (tuple): Sequence of parsed positional arguments. post_posargs (tuple): Sequence of parsed post-positional arguments (args following ``--``) parser (Parser): The Parser instance that parsed this result. Defaults to None. argv (tuple): The sequence of strings parsed by the Parser to yield this result. Defaults to ``()``. Instances of this class can be injected by accepting the ``args_`` builtin in their Command handler function. """ def __init__(self, parser, argv=()): self.parser = parser self.argv = tuple(argv) self.name = None # str self.subcmds = None # tuple self.flags = None # OrderedDict self.posargs = None # tuple self.post_posargs = None # tuple
[docs] def to_cmd_scope(self): "returns a dict which can be used as kwargs in an inject call" _subparser = self.parser.subprs_map[self.subcmds] if self.subcmds else self.parser if not self.argv: cmd_ = self.parser.name else: cmd_ = self.argv[0] path, basename = os.path.split(cmd_) if basename == '__main__.py': pkg_name = os.path.basename(path) executable_path = get_minimal_executable() cmd_ = f'{executable_path} -m {pkg_name}' else: cmd_ = get_minimal_executable(cmd_) ret = {'args_': self, 'cmd_': cmd_, 'subcmds_': self.subcmds, 'flags_': self.flags, 'posargs_': self.posargs, 'post_posargs_': self.post_posargs, 'subcommand_': _subparser, 'command_': self.parser} if self.flags: ret.update(self.flags) prs = self.parser if not self.subcmds else self.parser.subprs_map[self.subcmds] if prs.posargs.provides: posargs_provides = _posargs_to_provides(prs.posargs, self.posargs) ret[prs.posargs.provides] = posargs_provides if prs.post_posargs.provides: posargs_provides = _posargs_to_provides(prs.posargs, self.post_posargs) ret[prs.post_posargs.provides] = posargs_provides return ret
def __repr__(self): return format_nonexp_repr(self, ['name', 'argv', 'parser'])
# TODO: allow name="--flag / -F" and do the split for automatic # char form?
[docs] class Flag: """The Flag object represents all there is to know about a resource that can be parsed from argv and consumed by a Command function. It also references a FlagDisplay, used by HelpHandlers to control formatting of the flag during --help output Args: name (str): A string name for the flag, starting with a letter, and consisting of only ASCII letters, numbers, '-', and '_'. parse_as: How to interpret the flag. If *parse_as* is a callable, it will be called with the argument to the flag, the return value of which is stored in the parse result. If *parse_as* is not a callable, then the flag takes no argument, and the presence of the flag will produce this value in the parse result. Defaults to ``str``, meaning a default flag will take one string argument. missing: How to interpret the absence of the flag. Can be any value, which will be in the parse result when the flag is not present. Can also be the special value ``face.ERROR``, which will make the flag required. Defaults to ``None``. multi (str): How to handle multiple instances of the same flag. Pass 'overwrite' to accept the last flag's value. Pass 'extend' to collect all values into a list. Pass 'error' to get the default behavior, which raises a DuplicateFlag exception. *multi* can also take a callable, which accepts a list of flag values and returns the value to be stored in the :class:`CommandParseResult`. char (str): A single-character short form for the flag. Can be user-friendly for commonly-used flags. Defaults to ``None``. doc (str): A summary of the flag's behavior, used in automatic help generation. display: Controls how the flag is displayed in automatic help generation. Pass False to hide the flag, pass a string to customize the label, and pass a FlagDisplay instance for full customizability. """ def __init__(self, name, parse_as=str, missing=None, multi='error', char=None, doc=None, display=None): self.name = flag_to_identifier(name) self.doc = doc self.parse_as = parse_as self.missing = missing if missing is ERROR and not callable(parse_as): raise ValueError('cannot make an argument-less flag required.' ' expected non-ERROR for missing, or a callable' ' for parse_as, not: %r' % parse_as) self.char = _validate_char(char) if char else None if callable(multi): self.multi = multi elif multi in _MULTI_SHORTCUTS: self.multi = _MULTI_SHORTCUTS[multi] else: raise ValueError('multi expected callable, bool, or one of %r, not: %r' % (list(_MULTI_SHORTCUTS.keys()), multi)) self.set_display(display) def set_display(self, display): """Controls how the flag is displayed in automatic help generation. Pass False to hide the flag, pass a string to customize the label, and pass a FlagDisplay instance for full customizability. """ if display is None: display = {} elif isinstance(display, bool): display = {'hidden': not display} elif isinstance(display, str): display = {'label': display} if isinstance(display, dict): display = FlagDisplay(self, **display) if not isinstance(display, FlagDisplay): raise TypeError('expected bool, text name, dict of display' ' options, or FlagDisplay instance, not: %r' % display) self.display = display def __repr__(self): return format_nonexp_repr(self, ['name', 'parse_as'], ['missing', 'multi'], opt_key=lambda v: v not in (None, _multi_error))
[docs] class FlagDisplay: """Provides individual overrides for most of a given flag's display settings, as used by HelpFormatter instances attached to Parser and Command objects. Pass an instance of this to Flag.set_display() for full control of help output. FlagDisplay instances are meant to be used 1:1 with Flag instances, as they maintain a reference back to their associated Flag. They are generally automatically created by a Flag constructor, based on the "display" argument. Args: flag (Flag): The Flag instance to which this FlagDisplay applies. label (str): The formatted version of the string used to represent the flag in help and error messages. Defaults to None, which allows the label to be autogenerated by the HelpFormatter. post_doc (str): An addendum string added to the Flag's own doc. Defaults to a parenthetical describing whether the flag takes an argument, and whether the argument is required. full_doc (str): A string of the whole flag's doc, overriding the doc + post_doc default. value_name (str): For flags which take an argument, the string to use as the placeholder of the flag argument in help and error labels. hidden (bool): Pass True to hide this flag in general help and error messages. Defaults to False. group: An integer or string indicating how this flag should be grouped in help messages, improving readability. Integers are unnamed groups, strings are for named groups. Defaults to 0. sort_key: Flags are sorted in help output, pass an integer or string to override the sort order. """ # value_name -> arg_name? def __init__(self, flag, *, label: Optional[str] = None, post_doc: Optional[str] = None, full_doc: Optional[str] = None, value_name: Optional[str] = None, group: int = 0, hidden: bool = False, sort_key: int = 0): self.flag = flag self.doc = flag.doc if self.doc is None and callable(flag.parse_as): _prep, desc = get_type_desc(flag.parse_as) self.doc = 'Parsed with ' + desc if _prep == 'as': self.doc = desc self.post_doc = post_doc self.full_doc = full_doc self.value_name = '' if callable(flag.parse_as): # TODO: use default when it's set and it's a basic renderable type self.value_name = value_name or self.flag.name.upper() self.group = group self._hide = hidden self.label = label # see hidden property below for more info self.sort_key = sort_key # TODO: sort_key is gonna need to be partitioned on type for py3 # TODO: maybe sort_key should be a counter so that flags sort # in the order they are created return @property def hidden(self): return self._hide or self.label == '' def __repr__(self): return format_nonexp_repr(self, ['label', 'doc'], ['group', 'hidden'], opt_key=bool)
[docs] class PosArgDisplay: """Provides individual overrides for PosArgSpec display in automated help formatting. Pass to a PosArgSpec constructor, which is in turn passed to a Command/Parser. Args: spec (PosArgSpec): The associated PosArgSpec. name (str): The string name of an individual positional argument. Automatically pluralized in the label according to PosArgSpec values. Defaults to 'arg'. label (str): The full display label for positional arguments, bypassing the automatic formatting of the *name* parameter. doc (str): A summary description of the positional arguments. post_doc (str): An informational addendum about the arguments, often describes default behavior. """ def __init__(self, *, name: Optional[str] = None, doc: str = '', post_doc: Optional[str] = None, hidden: bool = False, label: Optional[str] = None) -> None: self.name = name or 'arg' self.doc = doc self.post_doc = post_doc self._hide = hidden self.label = label @property def hidden(self): return self._hide or self.label == '' def __repr__(self): return format_nonexp_repr(self, ['name', 'label'])
[docs] class PosArgSpec: """Passed to Command/Parser as posargs and post_posargs parameters to configure the number and type of positional arguments. Args: parse_as (callable): A function to call on each of the passed arguments. Also accepts special argument ERROR, which will raise an exception if positional arguments are passed. Defaults to str. min_count (int): A minimimum number of positional arguments. Defaults to 0. max_count (int): A maximum number of positional arguments. Also accepts None, meaning no maximum. Defaults to None. display: Pass a string to customize the name in help output, or False to hide it completely. Also accepts a PosArgDisplay instance, or a dict of the respective arguments. provides (str): name of an argument to be passed to a receptive handler function. name (str): A shortcut to set *display* name and *provides* count (int): A shortcut to set min_count and max_count to a single value when an exact number of arguments should be specified. PosArgSpec instances are stateless and safe to be used multiple times around the application. """ def __init__(self, parse_as=str, min_count=None, max_count=None, display=None, provides=None, *, name: Optional[str] = None, count: Optional[int] = None): if not callable(parse_as) and parse_as is not ERROR: raise TypeError(f'expected callable or ERROR for parse_as, not {parse_as!r}') self.parse_as = parse_as # count convenience alias min_count = count if min_count is None else min_count max_count = count if max_count is None else max_count self.min_count = int(min_count) if min_count else 0 self.max_count = int(max_count) if max_count is not None else None if self.min_count < 0: raise ValueError(f'expected min_count >= 0, not: {self.min_count!r}') if self.max_count is not None and self.max_count <= 0: raise ValueError(f'expected max_count > 0, not: {self.max_count!r}') if self.max_count and self.min_count > self.max_count: raise ValueError('expected min_count > max_count, not: %r > %r' % (self.min_count, self.max_count)) provides = name if provides is None else provides self.provides = provides if display is None: display = {} elif isinstance(display, bool): display = {'hidden': not display} elif isinstance(display, str): display = {'name': display} if isinstance(display, dict): display.setdefault('name', name) display = PosArgDisplay(**display) if not isinstance(display, PosArgDisplay): raise TypeError('expected bool, text name, dict of display' ' options, or PosArgDisplay instance, not: %r' % display) self.display = display # TODO: default? type check that it's a sequence matching min/max reqs def __repr__(self): return format_nonexp_repr(self, ['parse_as', 'min_count', 'max_count', 'display']) @property def accepts_args(self): """True if this PosArgSpec is configured to accept one or more arguments. """ return self.parse_as is not ERROR
[docs] def parse(self, posargs): """Parse a list of strings as positional arguments. Args: posargs (list): List of strings, likely parsed by a Parser instance from sys.argv. Raises an ArgumentArityError if there are too many or too few arguments. Raises InvalidPositionalArgument if the argument doesn't match the configured *parse_as*. See PosArgSpec for more info. Returns a list of arguments, parsed with *parse_as*. """ len_posargs = len(posargs) if posargs and not self.accepts_args: # TODO: check for likely subcommands raise ArgumentArityError(f'unexpected positional arguments: {posargs!r}') min_count, max_count = self.min_count, self.max_count if min_count == max_count: # min_count must be >0 because max_count cannot be 0 arg_range_text = f'{min_count} argument' if min_count > 1: arg_range_text += 's' else: if min_count == 0: arg_range_text = f'up to {max_count} argument' arg_range_text += 's' if (max_count and max_count > 1) else '' elif max_count is None: arg_range_text = f'at least {min_count} argument' arg_range_text += 's' if min_count > 1 else '' else: arg_range_text = f'{min_count} - {max_count} arguments' if len_posargs < min_count: raise ArgumentArityError('too few arguments, expected %s, got %s' % (arg_range_text, len_posargs)) if max_count is not None and len_posargs > max_count: raise ArgumentArityError('too many arguments, expected %s, got %s' % (arg_range_text, len_posargs)) ret = [] for pa in posargs: try: val = self.parse_as(pa) except Exception as exc: raise InvalidPositionalArgument.from_parse(self, pa, exc) else: ret.append(val) return ret
FLAGFILE_ENABLED = Flag('--flagfile', parse_as=str, multi='extend', missing=None, display=False, doc='') def _ensure_posargspec(posargs, posargs_name): if not posargs: # take no posargs posargs = PosArgSpec(parse_as=ERROR) elif posargs is True: # take any number of posargs posargs = PosArgSpec() elif isinstance(posargs, int): # take an exact number of posargs # (True and False are handled above, so only real nonzero ints get here) posargs = PosArgSpec(min_count=posargs, max_count=posargs) elif isinstance(posargs, str): posargs = PosArgSpec(display=posargs, provides=posargs) elif isinstance(posargs, dict): posargs = PosArgSpec(**posargs) elif callable(posargs): # take any number of posargs of a given format posargs = PosArgSpec(parse_as=posargs) if not isinstance(posargs, PosArgSpec): raise TypeError('expected %s as True, False, number of args, text name of args,' ' dict of PosArgSpec options, or instance of PosArgSpec, not: %r' % (posargs_name, posargs)) return posargs
[docs] class Parser: """The Parser lies at the center of face, primarily providing a configurable validation logic on top of the conventional grammar for CLI argument parsing. Args: name (str): A name used to identify this command. Important when the command is embedded as a subcommand of another command. doc (str): An optional summary description of the command, used to generate help and usage information. flags (list): A list of Flag instances. Optional, as flags can be added with :meth:`~Parser.add()`. posargs (bool): Defaults to disabled, pass ``True`` to enable the Parser to accept positional arguments. Pass a callable to parse the positional arguments using that function/type. Pass a :class:`PosArgSpec` for full customizability. post_posargs (bool): Same as *posargs*, but refers to the list of arguments following the ``--`` conventional marker. See ``git`` and ``tox`` for examples of commands using this style of positional argument. flagfile (bool): Defaults to enabled, pass ``False`` to disable flagfile support. Pass a :class:`Flag` instance to use a custom flag instead of ``--flagfile``. Read more about Flagfiles below. Once initialized, parsing is performed by calling :meth:`Parser.parse()` with ``sys.argv`` or any other list of strings. """ def __init__(self, name, doc=None, flags=None, posargs=None, post_posargs=None, flagfile=True): self.name = process_command_name(name) self.doc = doc flags = list(flags or []) self.posargs = _ensure_posargspec(posargs, 'posargs') self.post_posargs = _ensure_posargspec(post_posargs, 'post_posargs') if flagfile is True: self.flagfile_flag = FLAGFILE_ENABLED elif isinstance(flagfile, Flag): self.flagfile_flag = flagfile elif not flagfile: self.flagfile_flag = None else: raise TypeError('expected True, False, or Flag instance for' ' flagfile, not: %r' % flagfile) self.subprs_map = OrderedDict() self._path_flag_map = OrderedDict() self._path_flag_map[()] = OrderedDict() for flag in flags: self.add(flag) if self.flagfile_flag: self.add(self.flagfile_flag) return def get_flag_map(self, path, with_hidden=True): flag_map = self._path_flag_map[path] return OrderedDict([(k, f) for k, f in flag_map.items() if with_hidden or not f.display.hidden]) def get_flags(self, path=(), with_hidden=True): flag_map = self.get_flag_map(path=path, with_hidden=with_hidden) return unique(flag_map.values()) def __repr__(self): cn = self.__class__.__name__ return ('<%s name=%r subcmd_count=%r flag_count=%r posargs=%r>' % (cn, self.name, len(self.subprs_map), len(self.get_flags()), self.posargs)) def _add_subparser(self, subprs): """Process subcommand name, check for subcommand conflicts, check for subcommand flag conflicts, then finally add subcommand. To add a command under a different name, simply make a copy of that parser or command with a different name. """ if self.posargs.accepts_args: raise ValueError('commands accepting positional arguments' ' cannot take subcommands') # validate that the subparser's name can be used as a subcommand subprs_name = process_command_name(subprs.name) # then, check for conflicts with existing subcommands and flags for prs_path in self.subprs_map: if prs_path[0] == subprs_name: raise ValueError(f'conflicting subcommand name: {subprs_name!r}') parent_flag_map = self._path_flag_map[()] check_no_conflicts = lambda parent_flag_map, subcmd_path, subcmd_flags: True for path, flags in subprs._path_flag_map.items(): if not check_no_conflicts(parent_flag_map, path, flags): # TODO raise ValueError(f'subcommand flags conflict with parent command: {flags!r}') # with checks complete, add parser and all subparsers self.subprs_map[(subprs_name,)] = subprs for path, cur_subprs in list(subprs.subprs_map.items()): new_path = (subprs_name,) + path self.subprs_map[new_path] = cur_subprs # Flags inherit down (a parent's flags are usable by the child) for path, flags in subprs._path_flag_map.items(): new_flags = parent_flag_map.copy() new_flags.update(flags) self._path_flag_map[(subprs_name,) + path] = new_flags # If two flags have the same name, as long as the "parse_as" # is the same, things should be ok. Need to watch for # overlapping aliases, too. This may allow subcommands to # further document help strings. Should the same be allowed # for defaults?
[docs] def add(self, *a, **kw): """Add a flag or subparser. Unless the first argument is a Parser or Flag object, the arguments are the same as the Flag constructor, and will be used to create a new Flag instance to be added. May raise ValueError if arguments are not recognized as Parser, Flag, or Flag parameters. ValueError may also be raised on duplicate definitions and other conflicts. """ if isinstance(a[0], Parser): subprs = a[0] self._add_subparser(subprs) return if isinstance(a[0], Flag): flag = a[0] else: try: flag = Flag(*a, **kw) except TypeError as te: raise ValueError('expected Parser, Flag, or Flag parameters,' ' not: %r, %r (got %r)' % (a, kw, te)) return self._add_flag(flag)
def _add_flag(self, flag): # first check there are no conflicts... for subcmds, flag_map in self._path_flag_map.items(): conflict_flag = flag_map.get(flag.name) or (flag.char and flag_map.get(flag.char)) if conflict_flag is None: continue if flag.name in (conflict_flag.name, conflict_flag.char): raise ValueError('pre-existing flag %r conflicts with name of new flag %r' % (conflict_flag, flag.name)) if flag.char and flag.char in (conflict_flag.name, conflict_flag.char): raise ValueError('pre-existing flag %r conflicts with short form for new flag %r' % (conflict_flag, flag)) # ... then we add the flags for flag_map in self._path_flag_map.values(): flag_map[flag.name] = flag if flag.char: flag_map[flag.char] = flag return
[docs] def parse(self, argv): """This method takes a list of strings and converts them into a validated :class:`CommandParseResult` according to the flags, subparsers, and other options configured. Args: argv (list): A required list of strings. Pass ``None`` to use ``sys.argv``. This method may raise ArgumentParseError (or one of its subtypes) if the list of strings fails to parse. .. note:: The *argv* parameter does not automatically default to using ``sys.argv`` because it's best practice for implementing codebases to perform that sort of defaulting in their ``main()``, which should accept an ``argv=None`` parameter. This simple step ensures that the Python CLI application has some sort of programmatic interface that doesn't require subprocessing. See here for an example. """ if argv is None: argv = sys.argv cpr = CommandParseResult(parser=self, argv=argv) if not argv: ape = ArgumentParseError(f'expected non-empty sequence of arguments, not: {argv!r}') ape.prs_res = cpr raise ape for arg in argv: if not isinstance(arg, str): raise TypeError(f'parse expected all args as strings, not: {arg!r} ({type(arg).__name__})') ''' for subprs_path, subprs in self.subprs_map.items(): if len(subprs_path) == 1: # _add_subparser takes care of recurring so we only # need direct subparser descendants self._add_subparser(subprs, overwrite=True) ''' flag_map = None # first snip off the first argument, the command itself cmd_name, args = argv[0], list(argv)[1:] cpr.name = cmd_name # we record our progress as we parse to provide the most # up-to-date info possible to the error and help handlers try: # then figure out the subcommand path subcmds, args = self._parse_subcmds(args) cpr.subcmds = tuple(subcmds) prs = self.subprs_map[tuple(subcmds)] if subcmds else self # then look up the subcommand's supported flags # NOTE: get_flag_map() is used so that inheritors, like Command, # can filter by actually-used arguments, not just # available arguments. cmd_flag_map = self.get_flag_map(path=tuple(subcmds)) # parse supported flags and validate their arguments flag_map, flagfile_map, posargs = self._parse_flags(cmd_flag_map, args) cpr.flags = OrderedDict(flag_map) cpr.posargs = tuple(posargs) # take care of dupes and check required flags resolved_flag_map = self._resolve_flags(cmd_flag_map, flag_map, flagfile_map) cpr.flags = OrderedDict(resolved_flag_map) # separate out any trailing arguments from normal positional arguments post_posargs = None # TODO: default to empty list? parsed_post_posargs = None if '--' in posargs: posargs, post_posargs = split(posargs, '--', 1) cpr.posargs, cpr.post_posargs = posargs, post_posargs parsed_post_posargs = prs.post_posargs.parse(post_posargs) cpr.post_posargs = tuple(parsed_post_posargs) parsed_posargs = prs.posargs.parse(posargs) cpr.posargs = tuple(parsed_posargs) except ArgumentParseError as ape: ape.prs_res = cpr raise return cpr
def _parse_subcmds(self, args): """Expects arguments after the initial command (i.e., argv[1:]) Returns a tuple of (list_of_subcmds, remaining_args). Raises on unknown subcommands.""" ret = [] for arg in args: if arg.startswith('-'): break # subcmd parsing complete arg = _arg_to_subcmd(arg) if tuple(ret + [arg]) not in self.subprs_map: prs = self.subprs_map[tuple(ret)] if ret else self if prs.posargs.parse_as is not ERROR or not prs.subprs_map: # we actually have posargs from here break raise InvalidSubcommand.from_parse(prs, arg) ret.append(arg) return ret, args[len(ret):] def _parse_single_flag(self, cmd_flag_map, args): advance = 1 arg = args[0] arg_text = None try: arg, arg_text = arg.split('=', maxsplit=1) except ValueError: pass flag = cmd_flag_map.get(normalize_flag_name(arg)) if flag is None: raise UnknownFlag.from_parse(cmd_flag_map, arg) parse_as = flag.parse_as if not callable(parse_as): if arg_text: raise InvalidFlagArgument.from_parse(cmd_flag_map, flag, arg_text) # e.g., True is effectively store_true, False is effectively store_false return flag, parse_as, args[1:] try: if arg_text is None: arg_text = args[1] advance = 2 except IndexError: raise InvalidFlagArgument.from_parse(cmd_flag_map, flag, arg=None) try: arg_val = parse_as(arg_text) except Exception as e: raise InvalidFlagArgument.from_parse(cmd_flag_map, flag, arg_text, exc=e) return flag, arg_val, args[advance:] def _parse_flags(self, cmd_flag_map, args): """Expects arguments after the initial command and subcommands (i.e., the second item returned from _parse_subcmds) Returns a tuple of (multidict of flag names to parsed and validated values, remaining_args). Raises on unknown subcommands. """ flag_value_map = OMD() ff_path_res_map = OrderedDict() ff_path_seen = set() orig_args = args while args: arg = args[0] if not arg or arg[0] != '-' or arg == '-' or arg == '--': # posargs or post_posargs beginning ('-' is a conventional pos arg for stdin) break flag, value, args = self._parse_single_flag(cmd_flag_map, args) flag_value_map.add(flag.name, value) if flag is self.flagfile_flag: self._parse_flagfile(cmd_flag_map, value, res_map=ff_path_res_map) for path, ff_flag_value_map in ff_path_res_map.items(): if path in ff_path_seen: continue flag_value_map.update_extend(ff_flag_value_map) ff_path_seen.add(path) return flag_value_map, ff_path_res_map, args def _parse_flagfile(self, cmd_flag_map, path_or_file, res_map=None): ret = res_map if res_map is not None else OrderedDict() if callable(getattr(path_or_file, 'read', None)): # enable StringIO and custom flagfile opening f_name = getattr(path_or_file, 'name', None) path = os.path.abspath(f_name) if f_name else repr(path_or_file) ff_text = path_or_file.read() else: path = os.path.abspath(path_or_file) try: with codecs.open(path_or_file, 'r', 'utf-8') as f: ff_text = f.read() except (UnicodeError, OSError) as ee: raise ArgumentParseError(f'failed to load flagfile "{path}", got: {ee!r}') if path in res_map: # we've already seen this file return res_map ret[path] = cur_file_res = OMD() lines = ff_text.splitlines() for lineno, line in enumerate(lines, 1): try: args = shlex.split(line, comments=True) if not args: continue # comment or empty line flag, value, leftover_args = self._parse_single_flag(cmd_flag_map, args) if leftover_args: raise ArgumentParseError('excessive flags or arguments for flag "%s",' ' expected one flag per line' % flag.name) cur_file_res.add(flag.name, value) if flag is self.flagfile_flag: self._parse_flagfile(cmd_flag_map, value, res_map=ret) except FaceException as fe: fe.args = (fe.args[0] + f' (on line {lineno} of flagfile "{path}")',) raise return ret def _resolve_flags(self, cmd_flag_map, parsed_flag_map, flagfile_map=None): ret = OrderedDict() cfm, pfm = cmd_flag_map, parsed_flag_map flagfile_map = flagfile_map or {} # check requireds and set defaults and then... missing_flags = [] for flag_name, flag in cfm.items(): if flag.name in pfm: continue if flag.missing is ERROR: missing_flags.append(flag.name) else: pfm[flag.name] = flag.missing if missing_flags: raise MissingRequiredFlags.from_parse(cfm, pfm, missing_flags) # ... resolve dupes for flag_name in pfm: flag = cfm[flag_name] arg_val_list = pfm.getlist(flag_name) try: ret[flag_name] = flag.multi(flag, arg_val_list) except FaceException as fe: ff_paths = [] for ff_path, ff_value_map in flagfile_map.items(): if flag_name in ff_value_map: ff_paths.append(ff_path) if ff_paths: ff_label = 'flagfiles' if len(ff_paths) > 1 else 'flagfile' msg = ('\n\t(check %s with definitions for flag "%s": %s)' % (ff_label, flag_name, ', '.join(ff_paths))) fe.args = (fe.args[0] + msg,) raise return ret
def parse_sv_line(line, sep=','): """Parse a single line of values, separated by the delimiter *sep*. Supports quoting. """ # TODO: this doesn't support unicode, which is intended to be # handled at the layer above. from csv import reader, Dialect, QUOTE_MINIMAL class _face_dialect(Dialect): delimiter = sep escapechar = '\\' quotechar = '"' doublequote = True skipinitialspace = False lineterminator = '\n' quoting = QUOTE_MINIMAL parsed = list(reader([line], dialect=_face_dialect)) return parsed[0]
[docs] class ListParam: """The ListParam takes an argument as a character-separated list, and produces a Python list of parsed values. Basically, the argument equivalent of CSV (Comma-Separated Values):: --flag a1,b2,c3 By default, this yields a ``['a1', 'b2', 'c3']`` as the value for ``flag``. The format is also similar to CSV in that it supports quoting when values themselves contain the separator:: --flag 'a1,"b,2",c3' Args: parse_one_as (callable): Turns a single value's text into its parsed value. sep (str): A single-character string representing the list value separator. Defaults to ``,``. strip (bool): Whether or not each value in the list should have whitespace stripped before being passed to *parse_one_as*. Defaults to False. .. note:: Aside from using ListParam, an alternative method for accepting multiple arguments is to use the ``multi=True`` on the :class:`Flag` constructor. The approach tends to be more verbose and can be confusing because arguments can get spread across the command line. """ def __init__(self, parse_one_as=str, sep=',', strip=False): # TODO: min/max limits? self.parse_one_as = parse_one_as self.sep = sep self.strip = strip def parse(self, list_text): "Parse a single string argument into a list of arguments." split_vals = parse_sv_line(list_text, self.sep) if self.strip: split_vals = [v.strip() for v in split_vals] return [self.parse_one_as(v) for v in split_vals] __call__ = parse def __repr__(self): return format_exp_repr(self, ['parse_one_as'], ['sep', 'strip'])
[docs] class ChoicesParam: """Parses a single value, limited to a set of *choices*. The actual converter used to parse is inferred from *choices* by default, but an explicit one can be set *parse_as*. """ def __init__(self, choices, parse_as=None): if not choices: raise ValueError(f'expected at least one choice, not: {choices!r}') try: self.choices = sorted(choices) except Exception: # in case choices aren't sortable self.choices = list(choices) if parse_as is None: parse_as = type(self.choices[0]) # TODO: check for builtins, raise if not a supported type self.parse_as = parse_as def parse(self, text): choice = self.parse_as(text) if choice not in self.choices: raise ArgumentParseError(f'expected one of {self.choices!r}, not: {text!r}') return choice __call__ = parse def __repr__(self): return format_exp_repr(self, ['choices'], ['parse_as'])
class FilePathParam: """TODO ideas: exists, minimum permissions, can create, abspath, type=d/f (technically could also support socket, named pipe, and symlink) could do missing=TEMP, but that might be getting too fancy tbh. """ class FileValueParam: """ TODO: file with a single value in it, like a pidfile or a password file mounted in. Read in and treated like it was on the argv. """