Initial commit (Clean history)
This commit is contained in:
@@ -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))
|
||||
Reference in New Issue
Block a user