Initial commit (Clean history)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# flake8: noqa
|
||||
from .parser import *
|
||||
from .context import ParserContext
|
||||
from .context import ParserContext as Context, to_flag, translate_underscores
|
||||
from .argument import Argument
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,178 @@
|
||||
from typing import Any, Iterable, Optional, Tuple
|
||||
|
||||
# TODO: dynamic type for kind
|
||||
# T = TypeVar('T')
|
||||
|
||||
|
||||
class Argument:
|
||||
"""
|
||||
A command-line argument/flag.
|
||||
|
||||
:param name:
|
||||
Syntactic sugar for ``names=[<name>]``. Giving both ``name`` and
|
||||
``names`` is invalid.
|
||||
:param names:
|
||||
List of valid identifiers for this argument. For example, a "help"
|
||||
argument may be defined with a name list of ``['-h', '--help']``.
|
||||
:param kind:
|
||||
Type factory & parser hint. E.g. ``int`` will turn the default text
|
||||
value parsed, into a Python integer; and ``bool`` will tell the
|
||||
parser not to expect an actual value but to treat the argument as a
|
||||
toggle/flag.
|
||||
:param default:
|
||||
Default value made available to the parser if no value is given on the
|
||||
command line.
|
||||
:param help:
|
||||
Help text, intended for use with ``--help``.
|
||||
:param positional:
|
||||
Whether or not this argument's value may be given positionally. When
|
||||
``False`` (default) arguments must be explicitly named.
|
||||
:param optional:
|
||||
Whether or not this (non-``bool``) argument requires a value.
|
||||
:param incrementable:
|
||||
Whether or not this (``int``) argument is to be incremented instead of
|
||||
overwritten/assigned to.
|
||||
:param attr_name:
|
||||
A Python identifier/attribute friendly name, typically filled in with
|
||||
the underscored version when ``name``/``names`` contain dashes.
|
||||
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: Optional[str] = None,
|
||||
names: Iterable[str] = (),
|
||||
kind: Any = str,
|
||||
default: Optional[Any] = None,
|
||||
help: Optional[str] = None,
|
||||
positional: bool = False,
|
||||
optional: bool = False,
|
||||
incrementable: bool = False,
|
||||
attr_name: Optional[str] = None,
|
||||
) -> None:
|
||||
if name and names:
|
||||
raise TypeError(
|
||||
"Cannot give both 'name' and 'names' arguments! Pick one."
|
||||
)
|
||||
if not (name or names):
|
||||
raise TypeError("An Argument must have at least one name.")
|
||||
if names:
|
||||
self.names = tuple(names)
|
||||
elif name and not names:
|
||||
self.names = (name,)
|
||||
self.kind = kind
|
||||
initial_value: Optional[Any] = None
|
||||
# Special case: list-type args start out as empty list, not None.
|
||||
if kind is list:
|
||||
initial_value = []
|
||||
# Another: incrementable args start out as their default value.
|
||||
if incrementable:
|
||||
initial_value = default
|
||||
self.raw_value = self._value = initial_value
|
||||
self.default = default
|
||||
self.help = help
|
||||
self.positional = positional
|
||||
self.optional = optional
|
||||
self.incrementable = incrementable
|
||||
self.attr_name = attr_name
|
||||
|
||||
def __repr__(self) -> str:
|
||||
nicks = ""
|
||||
if self.nicknames:
|
||||
nicks = " ({})".format(", ".join(self.nicknames))
|
||||
flags = ""
|
||||
if self.positional or self.optional:
|
||||
flags = " "
|
||||
if self.positional:
|
||||
flags += "*"
|
||||
if self.optional:
|
||||
flags += "?"
|
||||
# TODO: store this default value somewhere other than signature of
|
||||
# Argument.__init__?
|
||||
kind = ""
|
||||
if self.kind != str:
|
||||
kind = " [{}]".format(self.kind.__name__)
|
||||
return "<{}: {}{}{}{}>".format(
|
||||
self.__class__.__name__, self.name, nicks, kind, flags
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> Optional[str]:
|
||||
"""
|
||||
The canonical attribute-friendly name for this argument.
|
||||
|
||||
Will be ``attr_name`` (if given to constructor) or the first name in
|
||||
``names`` otherwise.
|
||||
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
return self.attr_name or self.names[0]
|
||||
|
||||
@property
|
||||
def nicknames(self) -> Tuple[str, ...]:
|
||||
return self.names[1:]
|
||||
|
||||
@property
|
||||
def takes_value(self) -> bool:
|
||||
if self.kind is bool:
|
||||
return False
|
||||
if self.incrementable:
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def value(self) -> Any:
|
||||
# TODO: should probably be optional instead
|
||||
return self._value if self._value is not None else self.default
|
||||
|
||||
@value.setter
|
||||
def value(self, arg: str) -> None:
|
||||
self.set_value(arg, cast=True)
|
||||
|
||||
def set_value(self, value: Any, cast: bool = True) -> None:
|
||||
"""
|
||||
Actual explicit value-setting API call.
|
||||
|
||||
Sets ``self.raw_value`` to ``value`` directly.
|
||||
|
||||
Sets ``self.value`` to ``self.kind(value)``, unless:
|
||||
|
||||
- ``cast=False``, in which case the raw value is also used.
|
||||
- ``self.kind==list``, in which case the value is appended to
|
||||
``self.value`` instead of cast & overwritten.
|
||||
- ``self.incrementable==True``, in which case the value is ignored and
|
||||
the current (assumed int) value is simply incremented.
|
||||
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
self.raw_value = value
|
||||
# Default to do-nothing/identity function
|
||||
func = lambda x: x
|
||||
# If cast, set to self.kind, which should be str/int/etc
|
||||
if cast:
|
||||
func = self.kind
|
||||
# If self.kind is a list, append instead of using cast func.
|
||||
if self.kind is list:
|
||||
func = lambda x: self.value + [x]
|
||||
# If incrementable, just increment.
|
||||
if self.incrementable:
|
||||
# TODO: explode nicely if self.value was not an int to start
|
||||
# with
|
||||
func = lambda x: self.value + 1
|
||||
self._value = func(value)
|
||||
|
||||
@property
|
||||
def got_value(self) -> bool:
|
||||
"""
|
||||
Returns whether the argument was ever given a (non-default) value.
|
||||
|
||||
For most argument kinds, this simply checks whether the internally
|
||||
stored value is non-``None``; for others, such as ``list`` kinds,
|
||||
different checks may be used.
|
||||
|
||||
.. versionadded:: 1.3
|
||||
"""
|
||||
if self.kind is list:
|
||||
return bool(self._value)
|
||||
return self._value is not None
|
||||
@@ -0,0 +1,266 @@
|
||||
import itertools
|
||||
from typing import Any, Dict, List, Iterable, Optional, Tuple, Union
|
||||
|
||||
try:
|
||||
from ..vendor.lexicon import Lexicon
|
||||
except ImportError:
|
||||
from lexicon import Lexicon # type: ignore[no-redef]
|
||||
|
||||
from .argument import Argument
|
||||
|
||||
|
||||
def translate_underscores(name: str) -> str:
|
||||
return name.lstrip("_").rstrip("_").replace("_", "-")
|
||||
|
||||
|
||||
def to_flag(name: str) -> str:
|
||||
name = translate_underscores(name)
|
||||
if len(name) == 1:
|
||||
return "-" + name
|
||||
return "--" + name
|
||||
|
||||
|
||||
def sort_candidate(arg: Argument) -> str:
|
||||
names = arg.names
|
||||
# TODO: is there no "split into two buckets on predicate" builtin?
|
||||
shorts = {x for x in names if len(x.strip("-")) == 1}
|
||||
longs = {x for x in names if x not in shorts}
|
||||
return str(sorted(shorts if shorts else longs)[0])
|
||||
|
||||
|
||||
def flag_key(arg: Argument) -> List[Union[int, str]]:
|
||||
"""
|
||||
Obtain useful key list-of-ints for sorting CLI flags.
|
||||
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
# Setup
|
||||
ret: List[Union[int, str]] = []
|
||||
x = sort_candidate(arg)
|
||||
# Long-style flags win over short-style ones, so the first item of
|
||||
# comparison is simply whether the flag is a single character long (with
|
||||
# non-length-1 flags coming "first" [lower number])
|
||||
ret.append(1 if len(x) == 1 else 0)
|
||||
# Next item of comparison is simply the strings themselves,
|
||||
# case-insensitive. They will compare alphabetically if compared at this
|
||||
# stage.
|
||||
ret.append(x.lower())
|
||||
# Finally, if the case-insensitive test also matched, compare
|
||||
# case-sensitive, but inverse (with lowercase letters coming first)
|
||||
inversed = ""
|
||||
for char in x:
|
||||
inversed += char.lower() if char.isupper() else char.upper()
|
||||
ret.append(inversed)
|
||||
return ret
|
||||
|
||||
|
||||
# Named slightly more verbose so Sphinx references can be unambiguous.
|
||||
# Got real sick of fully qualified paths.
|
||||
class ParserContext:
|
||||
"""
|
||||
Parsing context with knowledge of flags & their format.
|
||||
|
||||
Generally associated with the core program or a task.
|
||||
|
||||
When run through a parser, will also hold runtime values filled in by the
|
||||
parser.
|
||||
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: Optional[str] = None,
|
||||
aliases: Iterable[str] = (),
|
||||
args: Iterable[Argument] = (),
|
||||
) -> None:
|
||||
"""
|
||||
Create a new ``ParserContext`` named ``name``, with ``aliases``.
|
||||
|
||||
``name`` is optional, and should be a string if given. It's used to
|
||||
tell ParserContext objects apart, and for use in a Parser when
|
||||
determining what chunk of input might belong to a given ParserContext.
|
||||
|
||||
``aliases`` is also optional and should be an iterable containing
|
||||
strings. Parsing will honor any aliases when trying to "find" a given
|
||||
context in its input.
|
||||
|
||||
May give one or more ``args``, which is a quick alternative to calling
|
||||
``for arg in args: self.add_arg(arg)`` after initialization.
|
||||
"""
|
||||
self.args = Lexicon()
|
||||
self.positional_args: List[Argument] = []
|
||||
self.flags = Lexicon()
|
||||
self.inverse_flags: Dict[str, str] = {} # No need for Lexicon here
|
||||
self.name = name
|
||||
self.aliases = aliases
|
||||
for arg in args:
|
||||
self.add_arg(arg)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
aliases = ""
|
||||
if self.aliases:
|
||||
aliases = " ({})".format(", ".join(self.aliases))
|
||||
name = (" {!r}{}".format(self.name, aliases)) if self.name else ""
|
||||
args = (": {!r}".format(self.args)) if self.args else ""
|
||||
return "<parser/Context{}{}>".format(name, args)
|
||||
|
||||
def add_arg(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""
|
||||
Adds given ``Argument`` (or constructor args for one) to this context.
|
||||
|
||||
The Argument in question is added to the following dict attributes:
|
||||
|
||||
* ``args``: "normal" access, i.e. the given names are directly exposed
|
||||
as keys.
|
||||
* ``flags``: "flaglike" access, i.e. the given names are translated
|
||||
into CLI flags, e.g. ``"foo"`` is accessible via ``flags['--foo']``.
|
||||
* ``inverse_flags``: similar to ``flags`` but containing only the
|
||||
"inverse" versions of boolean flags which default to True. This
|
||||
allows the parser to track e.g. ``--no-myflag`` and turn it into a
|
||||
False value for the ``myflag`` Argument.
|
||||
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
# Normalize
|
||||
if len(args) == 1 and isinstance(args[0], Argument):
|
||||
arg = args[0]
|
||||
else:
|
||||
arg = Argument(*args, **kwargs)
|
||||
# Uniqueness constraint: no name collisions
|
||||
for name in arg.names:
|
||||
if name in self.args:
|
||||
msg = "Tried to add an argument named {!r} but one already exists!" # noqa
|
||||
raise ValueError(msg.format(name))
|
||||
# First name used as "main" name for purposes of aliasing
|
||||
main = arg.names[0] # NOT arg.name
|
||||
self.args[main] = arg
|
||||
# Note positionals in distinct, ordered list attribute
|
||||
if arg.positional:
|
||||
self.positional_args.append(arg)
|
||||
# Add names & nicknames to flags, args
|
||||
self.flags[to_flag(main)] = arg
|
||||
for name in arg.nicknames:
|
||||
self.args.alias(name, to=main)
|
||||
self.flags.alias(to_flag(name), to=to_flag(main))
|
||||
# Add attr_name to args, but not flags
|
||||
if arg.attr_name:
|
||||
self.args.alias(arg.attr_name, to=main)
|
||||
# Add to inverse_flags if required
|
||||
if arg.kind == bool and arg.default is True:
|
||||
# Invert the 'main' flag name here, which will be a dashed version
|
||||
# of the primary argument name if underscore-to-dash transformation
|
||||
# occurred.
|
||||
inverse_name = to_flag("no-{}".format(main))
|
||||
self.inverse_flags[inverse_name] = to_flag(main)
|
||||
|
||||
@property
|
||||
def missing_positional_args(self) -> List[Argument]:
|
||||
return [x for x in self.positional_args if x.value is None]
|
||||
|
||||
@property
|
||||
def as_kwargs(self) -> Dict[str, Any]:
|
||||
"""
|
||||
This context's arguments' values keyed by their ``.name`` attribute.
|
||||
|
||||
Results in a dict suitable for use in Python contexts, where e.g. an
|
||||
arg named ``foo-bar`` becomes accessible as ``foo_bar``.
|
||||
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
ret = {}
|
||||
for arg in self.args.values():
|
||||
ret[arg.name] = arg.value
|
||||
return ret
|
||||
|
||||
def names_for(self, flag: str) -> List[str]:
|
||||
# TODO: should probably be a method on Lexicon/AliasDict
|
||||
return list(set([flag] + self.flags.aliases_of(flag)))
|
||||
|
||||
def help_for(self, flag: str) -> Tuple[str, str]:
|
||||
"""
|
||||
Return 2-tuple of ``(flag-spec, help-string)`` for given ``flag``.
|
||||
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
# Obtain arg obj
|
||||
if flag not in self.flags:
|
||||
err = "{!r} is not a valid flag for this context! Valid flags are: {!r}" # noqa
|
||||
raise ValueError(err.format(flag, self.flags.keys()))
|
||||
arg = self.flags[flag]
|
||||
# Determine expected value type, if any
|
||||
value = {str: "STRING", int: "INT"}.get(arg.kind)
|
||||
# Format & go
|
||||
full_names = []
|
||||
for name in self.names_for(flag):
|
||||
if value:
|
||||
# Short flags are -f VAL, long are --foo=VAL
|
||||
# When optional, also, -f [VAL] and --foo[=VAL]
|
||||
if len(name.strip("-")) == 1:
|
||||
value_ = ("[{}]".format(value)) if arg.optional else value
|
||||
valuestr = " {}".format(value_)
|
||||
else:
|
||||
valuestr = "={}".format(value)
|
||||
if arg.optional:
|
||||
valuestr = "[{}]".format(valuestr)
|
||||
else:
|
||||
# no value => boolean
|
||||
# check for inverse
|
||||
if name in self.inverse_flags.values():
|
||||
name = "--[no-]{}".format(name[2:])
|
||||
|
||||
valuestr = ""
|
||||
# Tack together
|
||||
full_names.append(name + valuestr)
|
||||
namestr = ", ".join(sorted(full_names, key=len))
|
||||
helpstr = arg.help or ""
|
||||
return namestr, helpstr
|
||||
|
||||
def help_tuples(self) -> List[Tuple[str, Optional[str]]]:
|
||||
"""
|
||||
Return sorted iterable of help tuples for all member Arguments.
|
||||
|
||||
Sorts like so:
|
||||
|
||||
* General sort is alphanumerically
|
||||
* Short flags win over long flags
|
||||
* Arguments with *only* long flags and *no* short flags will come
|
||||
first.
|
||||
* When an Argument has multiple long or short flags, it will sort using
|
||||
the most favorable (lowest alphabetically) candidate.
|
||||
|
||||
This will result in a help list like so::
|
||||
|
||||
--alpha, --zeta # 'alpha' wins
|
||||
--beta
|
||||
-a, --query # short flag wins
|
||||
-b, --argh
|
||||
-c
|
||||
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
# TODO: argument/flag API must change :(
|
||||
# having to call to_flag on 1st name of an Argument is just dumb.
|
||||
# To pass in an Argument object to help_for may require moderate
|
||||
# changes?
|
||||
return list(
|
||||
map(
|
||||
lambda x: self.help_for(to_flag(x.name)),
|
||||
sorted(self.flags.values(), key=flag_key),
|
||||
)
|
||||
)
|
||||
|
||||
def flag_names(self) -> Tuple[str, ...]:
|
||||
"""
|
||||
Similar to `help_tuples` but returns flag names only, no helpstrs.
|
||||
|
||||
Specifically, all flag names, flattened, in rough order.
|
||||
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
# Regular flag names
|
||||
flags = sorted(self.flags.values(), key=flag_key)
|
||||
names = [self.names_for(to_flag(x.name)) for x in flags]
|
||||
# Inverse flag names sold separately
|
||||
names.append(list(self.inverse_flags.keys()))
|
||||
return tuple(itertools.chain.from_iterable(names))
|
||||
@@ -0,0 +1,455 @@
|
||||
import copy
|
||||
from typing import TYPE_CHECKING, Any, Iterable, List, Optional
|
||||
|
||||
try:
|
||||
from ..vendor.lexicon import Lexicon
|
||||
from ..vendor.fluidity import StateMachine, state, transition
|
||||
except ImportError:
|
||||
from lexicon import Lexicon # type: ignore[no-redef]
|
||||
from fluidity import ( # type: ignore[no-redef]
|
||||
StateMachine,
|
||||
state,
|
||||
transition,
|
||||
)
|
||||
|
||||
from ..exceptions import ParseError
|
||||
from ..util import debug
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .context import ParserContext
|
||||
|
||||
|
||||
def is_flag(value: str) -> bool:
|
||||
return value.startswith("-")
|
||||
|
||||
|
||||
def is_long_flag(value: str) -> bool:
|
||||
return value.startswith("--")
|
||||
|
||||
|
||||
class ParseResult(List["ParserContext"]):
|
||||
"""
|
||||
List-like object with some extra parse-related attributes.
|
||||
|
||||
Specifically, a ``.remainder`` attribute, which is the string found after a
|
||||
``--`` in any parsed argv list; and an ``.unparsed`` attribute, a list of
|
||||
tokens that were unable to be parsed.
|
||||
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.remainder = ""
|
||||
self.unparsed: List[str] = []
|
||||
|
||||
|
||||
class Parser:
|
||||
"""
|
||||
Create parser conscious of ``contexts`` and optional ``initial`` context.
|
||||
|
||||
``contexts`` should be an iterable of ``Context`` instances which will be
|
||||
searched when new context names are encountered during a parse. These
|
||||
Contexts determine what flags may follow them, as well as whether given
|
||||
flags take values.
|
||||
|
||||
``initial`` is optional and will be used to determine validity of "core"
|
||||
options/flags at the start of the parse run, if any are encountered.
|
||||
|
||||
``ignore_unknown`` determines what to do when contexts are found which do
|
||||
not map to any members of ``contexts``. By default it is ``False``, meaning
|
||||
any unknown contexts result in a parse error exception. If ``True``,
|
||||
encountering an unknown context halts parsing and populates the return
|
||||
value's ``.unparsed`` attribute with the remaining parse tokens.
|
||||
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
contexts: Iterable["ParserContext"] = (),
|
||||
initial: Optional["ParserContext"] = None,
|
||||
ignore_unknown: bool = False,
|
||||
) -> None:
|
||||
self.initial = initial
|
||||
self.contexts = Lexicon()
|
||||
self.ignore_unknown = ignore_unknown
|
||||
for context in contexts:
|
||||
debug("Adding {}".format(context))
|
||||
if not context.name:
|
||||
raise ValueError("Non-initial contexts must have names.")
|
||||
exists = "A context named/aliased {!r} is already in this parser!"
|
||||
if context.name in self.contexts:
|
||||
raise ValueError(exists.format(context.name))
|
||||
self.contexts[context.name] = context
|
||||
for alias in context.aliases:
|
||||
if alias in self.contexts:
|
||||
raise ValueError(exists.format(alias))
|
||||
self.contexts.alias(alias, to=context.name)
|
||||
|
||||
def parse_argv(self, argv: List[str]) -> ParseResult:
|
||||
"""
|
||||
Parse an argv-style token list ``argv``.
|
||||
|
||||
Returns a list (actually a subclass, `.ParseResult`) of
|
||||
`.ParserContext` objects matching the order they were found in the
|
||||
``argv`` and containing `.Argument` objects with updated values based
|
||||
on any flags given.
|
||||
|
||||
Assumes any program name has already been stripped out. Good::
|
||||
|
||||
Parser(...).parse_argv(['--core-opt', 'task', '--task-opt'])
|
||||
|
||||
Bad::
|
||||
|
||||
Parser(...).parse_argv(['invoke', '--core-opt', ...])
|
||||
|
||||
:param argv: List of argument string tokens.
|
||||
:returns:
|
||||
A `.ParseResult` (a ``list`` subclass containing some number of
|
||||
`.ParserContext` objects).
|
||||
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
machine = ParseMachine(
|
||||
# FIXME: initial should not be none
|
||||
initial=self.initial, # type: ignore[arg-type]
|
||||
contexts=self.contexts,
|
||||
ignore_unknown=self.ignore_unknown,
|
||||
)
|
||||
# FIXME: Why isn't there str.partition for lists? There must be a
|
||||
# better way to do this. Split argv around the double-dash remainder
|
||||
# sentinel.
|
||||
debug("Starting argv: {!r}".format(argv))
|
||||
try:
|
||||
ddash = argv.index("--")
|
||||
except ValueError:
|
||||
ddash = len(argv) # No remainder == body gets all
|
||||
body = argv[:ddash]
|
||||
remainder = argv[ddash:][1:] # [1:] to strip off remainder itself
|
||||
if remainder:
|
||||
debug(
|
||||
"Remainder: argv[{!r}:][1:] => {!r}".format(ddash, remainder)
|
||||
)
|
||||
for index, token in enumerate(body):
|
||||
# Handle non-space-delimited forms, if not currently expecting a
|
||||
# flag value and still in valid parsing territory (i.e. not in
|
||||
# "unknown" state which implies store-only)
|
||||
# NOTE: we do this in a few steps so we can
|
||||
# split-then-check-validity; necessary for things like when the
|
||||
# previously seen flag optionally takes a value.
|
||||
mutations = []
|
||||
orig = token
|
||||
if is_flag(token) and not machine.result.unparsed:
|
||||
# Equals-sign-delimited flags, eg --foo=bar or -f=bar
|
||||
if "=" in token:
|
||||
token, _, value = token.partition("=")
|
||||
msg = "Splitting x=y expr {!r} into tokens {!r} and {!r}"
|
||||
debug(msg.format(orig, token, value))
|
||||
mutations.append((index + 1, value))
|
||||
# Contiguous boolean short flags, e.g. -qv
|
||||
elif not is_long_flag(token) and len(token) > 2:
|
||||
full_token = token[:]
|
||||
rest, token = token[2:], token[:2]
|
||||
err = "Splitting {!r} into token {!r} and rest {!r}"
|
||||
debug(err.format(full_token, token, rest))
|
||||
# Handle boolean flag block vs short-flag + value. Make
|
||||
# sure not to test the token as a context flag if we've
|
||||
# passed into 'storing unknown stuff' territory (e.g. on a
|
||||
# core-args pass, handling what are going to be task args)
|
||||
have_flag = (
|
||||
token in machine.context.flags
|
||||
and machine.current_state != "unknown"
|
||||
)
|
||||
if have_flag and machine.context.flags[token].takes_value:
|
||||
msg = "{!r} is a flag for current context & it takes a value, giving it {!r}" # noqa
|
||||
debug(msg.format(token, rest))
|
||||
mutations.append((index + 1, rest))
|
||||
else:
|
||||
_rest = ["-{}".format(x) for x in rest]
|
||||
msg = "Splitting multi-flag glob {!r} into {!r} and {!r}" # noqa
|
||||
debug(msg.format(orig, token, _rest))
|
||||
for item in reversed(_rest):
|
||||
mutations.append((index + 1, item))
|
||||
# Here, we've got some possible mutations queued up, and 'token'
|
||||
# may have been overwritten as well. Whether we apply those and
|
||||
# continue as-is, or roll it back, depends:
|
||||
# - If the parser wasn't waiting for a flag value, we're already on
|
||||
# the right track, so apply mutations and move along to the
|
||||
# handle() step.
|
||||
# - If we ARE waiting for a value, and the flag expecting it ALWAYS
|
||||
# wants a value (it's not optional), we go back to using the
|
||||
# original token. (TODO: could reorganize this to avoid the
|
||||
# sub-parsing in this case, but optimizing for human-facing
|
||||
# execution isn't critical.)
|
||||
# - Finally, if we are waiting for a value AND it's optional, we
|
||||
# inspect the first sub-token/mutation to see if it would otherwise
|
||||
# have been a valid flag, and let that determine what we do (if
|
||||
# valid, we apply the mutations; if invalid, we reinstate the
|
||||
# original token.)
|
||||
if machine.waiting_for_flag_value:
|
||||
optional = machine.flag and machine.flag.optional
|
||||
subtoken_is_valid_flag = token in machine.context.flags
|
||||
if not (optional and subtoken_is_valid_flag):
|
||||
token = orig
|
||||
mutations = []
|
||||
for index, value in mutations:
|
||||
body.insert(index, value)
|
||||
machine.handle(token)
|
||||
machine.finish()
|
||||
result = machine.result
|
||||
result.remainder = " ".join(remainder)
|
||||
return result
|
||||
|
||||
|
||||
class ParseMachine(StateMachine):
|
||||
initial_state = "context"
|
||||
|
||||
state("context", enter=["complete_flag", "complete_context"])
|
||||
state("unknown", enter=["complete_flag", "complete_context"])
|
||||
state("end", enter=["complete_flag", "complete_context"])
|
||||
|
||||
transition(from_=("context", "unknown"), event="finish", to="end")
|
||||
transition(
|
||||
from_="context",
|
||||
event="see_context",
|
||||
action="switch_to_context",
|
||||
to="context",
|
||||
)
|
||||
transition(
|
||||
from_=("context", "unknown"),
|
||||
event="see_unknown",
|
||||
action="store_only",
|
||||
to="unknown",
|
||||
)
|
||||
|
||||
def changing_state(self, from_: str, to: str) -> None:
|
||||
debug("ParseMachine: {!r} => {!r}".format(from_, to))
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
initial: "ParserContext",
|
||||
contexts: Lexicon,
|
||||
ignore_unknown: bool,
|
||||
) -> None:
|
||||
# Initialize
|
||||
self.ignore_unknown = ignore_unknown
|
||||
self.initial = self.context = copy.deepcopy(initial)
|
||||
debug("Initialized with context: {!r}".format(self.context))
|
||||
self.flag = None
|
||||
self.flag_got_value = False
|
||||
self.result = ParseResult()
|
||||
self.contexts = copy.deepcopy(contexts)
|
||||
debug("Available contexts: {!r}".format(self.contexts))
|
||||
# In case StateMachine does anything in __init__
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
def waiting_for_flag_value(self) -> bool:
|
||||
# Do we have a current flag, and does it expect a value (vs being a
|
||||
# bool/toggle)?
|
||||
takes_value = self.flag and self.flag.takes_value
|
||||
if not takes_value:
|
||||
return False
|
||||
# OK, this flag is one that takes values.
|
||||
# Is it a list type (which has only just been switched to)? Then it'll
|
||||
# always accept more values.
|
||||
# TODO: how to handle somebody wanting it to be some other iterable
|
||||
# like tuple or custom class? Or do we just say unsupported?
|
||||
if self.flag.kind is list and not self.flag_got_value:
|
||||
return True
|
||||
# Not a list, okay. Does it already have a value?
|
||||
has_value = self.flag.raw_value is not None
|
||||
# If it doesn't have one, we're waiting for one (which tells the parser
|
||||
# how to proceed and typically to store the next token.)
|
||||
# TODO: in the negative case here, we should do something else instead:
|
||||
# - Except, "hey you screwed up, you already gave that flag!"
|
||||
# - Overwrite, "oh you changed your mind?" - which requires more work
|
||||
# elsewhere too, unfortunately. (Perhaps additional properties on
|
||||
# Argument that can be queried, e.g. "arg.is_iterable"?)
|
||||
return not has_value
|
||||
|
||||
def handle(self, token: str) -> None:
|
||||
debug("Handling token: {!r}".format(token))
|
||||
# Handle unknown state at the top: we don't care about even
|
||||
# possibly-valid input if we've encountered unknown input.
|
||||
if self.current_state == "unknown":
|
||||
debug("Top-of-handle() see_unknown({!r})".format(token))
|
||||
self.see_unknown(token)
|
||||
return
|
||||
# Flag
|
||||
if self.context and token in self.context.flags:
|
||||
debug("Saw flag {!r}".format(token))
|
||||
self.switch_to_flag(token)
|
||||
elif self.context and token in self.context.inverse_flags:
|
||||
debug("Saw inverse flag {!r}".format(token))
|
||||
self.switch_to_flag(token, inverse=True)
|
||||
# Value for current flag
|
||||
elif self.waiting_for_flag_value:
|
||||
debug(
|
||||
"We're waiting for a flag value so {!r} must be it?".format(
|
||||
token
|
||||
)
|
||||
) # noqa
|
||||
self.see_value(token)
|
||||
# Positional args (must come above context-name check in case we still
|
||||
# need a posarg and the user legitimately wants to give it a value that
|
||||
# just happens to be a valid context name.)
|
||||
elif self.context and self.context.missing_positional_args:
|
||||
msg = "Context {!r} requires positional args, eating {!r}"
|
||||
debug(msg.format(self.context, token))
|
||||
self.see_positional_arg(token)
|
||||
# New context
|
||||
elif token in self.contexts:
|
||||
self.see_context(token)
|
||||
# Initial-context flag being given as per-task flag (e.g. --help)
|
||||
elif self.initial and token in self.initial.flags:
|
||||
debug("Saw (initial-context) flag {!r}".format(token))
|
||||
flag = self.initial.flags[token]
|
||||
# Special-case for core --help flag: context name is used as value.
|
||||
if flag.name == "help":
|
||||
flag.value = self.context.name
|
||||
msg = "Saw --help in a per-task context, setting task name ({!r}) as its value" # noqa
|
||||
debug(msg.format(flag.value))
|
||||
# All others: just enter the 'switch to flag' parser state
|
||||
else:
|
||||
# TODO: handle inverse core flags too? There are none at the
|
||||
# moment (e.g. --no-dedupe is actually 'no_dedupe', not a
|
||||
# default-False 'dedupe') and it's up to us whether we actually
|
||||
# put any in place.
|
||||
self.switch_to_flag(token)
|
||||
# Unknown
|
||||
else:
|
||||
if not self.ignore_unknown:
|
||||
debug("Can't find context named {!r}, erroring".format(token))
|
||||
self.error("No idea what {!r} is!".format(token))
|
||||
else:
|
||||
debug("Bottom-of-handle() see_unknown({!r})".format(token))
|
||||
self.see_unknown(token)
|
||||
|
||||
def store_only(self, token: str) -> None:
|
||||
# Start off the unparsed list
|
||||
debug("Storing unknown token {!r}".format(token))
|
||||
self.result.unparsed.append(token)
|
||||
|
||||
def complete_context(self) -> None:
|
||||
debug(
|
||||
"Wrapping up context {!r}".format(
|
||||
self.context.name if self.context else self.context
|
||||
)
|
||||
)
|
||||
# Ensure all of context's positional args have been given.
|
||||
if self.context and self.context.missing_positional_args:
|
||||
err = "'{}' did not receive required positional arguments: {}"
|
||||
names = ", ".join(
|
||||
"'{}'".format(x.name)
|
||||
for x in self.context.missing_positional_args
|
||||
)
|
||||
self.error(err.format(self.context.name, names))
|
||||
if self.context and self.context not in self.result:
|
||||
self.result.append(self.context)
|
||||
|
||||
def switch_to_context(self, name: str) -> None:
|
||||
self.context = copy.deepcopy(self.contexts[name])
|
||||
debug("Moving to context {!r}".format(name))
|
||||
debug("Context args: {!r}".format(self.context.args))
|
||||
debug("Context flags: {!r}".format(self.context.flags))
|
||||
debug("Context inverse_flags: {!r}".format(self.context.inverse_flags))
|
||||
|
||||
def complete_flag(self) -> None:
|
||||
if self.flag:
|
||||
msg = "Completing current flag {} before moving on"
|
||||
debug(msg.format(self.flag))
|
||||
# Barf if we needed a value and didn't get one
|
||||
if (
|
||||
self.flag
|
||||
and self.flag.takes_value
|
||||
and self.flag.raw_value is None
|
||||
and not self.flag.optional
|
||||
):
|
||||
err = "Flag {!r} needed value and was not given one!"
|
||||
self.error(err.format(self.flag))
|
||||
# Handle optional-value flags; at this point they were not given an
|
||||
# explicit value, but they were seen, ergo they should get treated like
|
||||
# bools.
|
||||
if self.flag and self.flag.raw_value is None and self.flag.optional:
|
||||
msg = "Saw optional flag {!r} go by w/ no value; setting to True"
|
||||
debug(msg.format(self.flag.name))
|
||||
# Skip casting so the bool gets preserved
|
||||
self.flag.set_value(True, cast=False)
|
||||
|
||||
def check_ambiguity(self, value: Any) -> bool:
|
||||
"""
|
||||
Guard against ambiguity when current flag takes an optional value.
|
||||
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
# No flag is currently being examined, or one is but it doesn't take an
|
||||
# optional value? Ambiguity isn't possible.
|
||||
if not (self.flag and self.flag.optional):
|
||||
return False
|
||||
# We *are* dealing with an optional-value flag, but it's already
|
||||
# received a value? There can't be ambiguity here either.
|
||||
if self.flag.raw_value is not None:
|
||||
return False
|
||||
# Otherwise, there *may* be ambiguity if 1 or more of the below tests
|
||||
# fail.
|
||||
tests = []
|
||||
# Unfilled posargs still exist?
|
||||
tests.append(self.context and self.context.missing_positional_args)
|
||||
# Value matches another valid task/context name?
|
||||
tests.append(value in self.contexts)
|
||||
if any(tests):
|
||||
msg = "{!r} is ambiguous when given after an optional-value flag"
|
||||
raise ParseError(msg.format(value))
|
||||
|
||||
def switch_to_flag(self, flag: str, inverse: bool = False) -> None:
|
||||
# Sanity check for ambiguity w/ prior optional-value flag
|
||||
self.check_ambiguity(flag)
|
||||
# Also tie it off, in case prior had optional value or etc. Seems to be
|
||||
# harmless for other kinds of flags. (TODO: this is a serious indicator
|
||||
# that we need to move some of this flag-by-flag bookkeeping into the
|
||||
# state machine bits, if possible - as-is it was REAL confusing re: why
|
||||
# this was manually required!)
|
||||
self.complete_flag()
|
||||
# Set flag/arg obj
|
||||
flag = self.context.inverse_flags[flag] if inverse else flag
|
||||
# Update state
|
||||
try:
|
||||
self.flag = self.context.flags[flag]
|
||||
except KeyError as e:
|
||||
# Try fallback to initial/core flag
|
||||
try:
|
||||
self.flag = self.initial.flags[flag]
|
||||
except KeyError:
|
||||
# If it wasn't in either, raise the original context's
|
||||
# exception, as that's more useful / correct.
|
||||
raise e
|
||||
debug("Moving to flag {!r}".format(self.flag))
|
||||
# Bookkeeping for iterable-type flags (where the typical 'value
|
||||
# non-empty/nondefault -> clearly it got its value already' test is
|
||||
# insufficient)
|
||||
self.flag_got_value = False
|
||||
# Handle boolean flags (which can immediately be updated)
|
||||
if self.flag and not self.flag.takes_value:
|
||||
val = not inverse
|
||||
debug("Marking seen flag {!r} as {}".format(self.flag, val))
|
||||
self.flag.value = val
|
||||
|
||||
def see_value(self, value: Any) -> None:
|
||||
self.check_ambiguity(value)
|
||||
if self.flag and self.flag.takes_value:
|
||||
debug("Setting flag {!r} to value {!r}".format(self.flag, value))
|
||||
self.flag.value = value
|
||||
self.flag_got_value = True
|
||||
else:
|
||||
self.error("Flag {!r} doesn't take any value!".format(self.flag))
|
||||
|
||||
def see_positional_arg(self, value: Any) -> None:
|
||||
for arg in self.context.positional_args:
|
||||
if arg.value is None:
|
||||
arg.value = value
|
||||
break
|
||||
|
||||
def error(self, msg: str) -> None:
|
||||
raise ParseError(msg, self.context)
|
||||
Reference in New Issue
Block a user