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 "".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))