267 lines
9.6 KiB
Python
267 lines
9.6 KiB
Python
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))
|