1import itertools
2from typing import Any, Dict, List, Iterable, Optional, Tuple, Union
3
4try:
5 from ..vendor.lexicon import Lexicon
6except ImportError:
7 from lexicon import Lexicon # type: ignore[no-redef]
8
9from .argument import Argument
10
11
12def translate_underscores(name: str) -> str:
13 return name.lstrip("_").rstrip("_").replace("_", "-")
14
15
16def to_flag(name: str) -> str:
17 name = translate_underscores(name)
18 if len(name) == 1:
19 return "-" + name
20 return "--" + name
21
22
23def sort_candidate(arg: Argument) -> str:
24 names = arg.names
25 # TODO: is there no "split into two buckets on predicate" builtin?
26 shorts = {x for x in names if len(x.strip("-")) == 1}
27 longs = {x for x in names if x not in shorts}
28 return str(sorted(shorts if shorts else longs)[0])
29
30
31def flag_key(arg: Argument) -> List[Union[int, str]]:
32 """
33 Obtain useful key list-of-ints for sorting CLI flags.
34
35 .. versionadded:: 1.0
36 """
37 # Setup
38 ret: List[Union[int, str]] = []
39 x = sort_candidate(arg)
40 # Long-style flags win over short-style ones, so the first item of
41 # comparison is simply whether the flag is a single character long (with
42 # non-length-1 flags coming "first" [lower number])
43 ret.append(1 if len(x) == 1 else 0)
44 # Next item of comparison is simply the strings themselves,
45 # case-insensitive. They will compare alphabetically if compared at this
46 # stage.
47 ret.append(x.lower())
48 # Finally, if the case-insensitive test also matched, compare
49 # case-sensitive, but inverse (with lowercase letters coming first)
50 inversed = ""
51 for char in x:
52 inversed += char.lower() if char.isupper() else char.upper()
53 ret.append(inversed)
54 return ret
55
56
57# Named slightly more verbose so Sphinx references can be unambiguous.
58# Got real sick of fully qualified paths.
59class ParserContext:
60 """
61 Parsing context with knowledge of flags & their format.
62
63 Generally associated with the core program or a task.
64
65 When run through a parser, will also hold runtime values filled in by the
66 parser.
67
68 .. versionadded:: 1.0
69 """
70
71 def __init__(
72 self,
73 name: Optional[str] = None,
74 aliases: Iterable[str] = (),
75 args: Iterable[Argument] = (),
76 ) -> None:
77 """
78 Create a new ``ParserContext`` named ``name``, with ``aliases``.
79
80 ``name`` is optional, and should be a string if given. It's used to
81 tell ParserContext objects apart, and for use in a Parser when
82 determining what chunk of input might belong to a given ParserContext.
83
84 ``aliases`` is also optional and should be an iterable containing
85 strings. Parsing will honor any aliases when trying to "find" a given
86 context in its input.
87
88 May give one or more ``args``, which is a quick alternative to calling
89 ``for arg in args: self.add_arg(arg)`` after initialization.
90 """
91 self.args = Lexicon()
92 self.positional_args: List[Argument] = []
93 self.flags = Lexicon()
94 self.inverse_flags: Dict[str, str] = {} # No need for Lexicon here
95 self.name = name
96 self.aliases = aliases
97 for arg in args:
98 self.add_arg(arg)
99
100 def __repr__(self) -> str:
101 aliases = ""
102 if self.aliases:
103 aliases = " ({})".format(", ".join(self.aliases))
104 name = (" {!r}{}".format(self.name, aliases)) if self.name else ""
105 args = (": {!r}".format(self.args)) if self.args else ""
106 return "<parser/Context{}{}>".format(name, args)
107
108 def add_arg(self, *args: Any, **kwargs: Any) -> None:
109 """
110 Adds given ``Argument`` (or constructor args for one) to this context.
111
112 The Argument in question is added to the following dict attributes:
113
114 * ``args``: "normal" access, i.e. the given names are directly exposed
115 as keys.
116 * ``flags``: "flaglike" access, i.e. the given names are translated
117 into CLI flags, e.g. ``"foo"`` is accessible via ``flags['--foo']``.
118 * ``inverse_flags``: similar to ``flags`` but containing only the
119 "inverse" versions of boolean flags which default to True. This
120 allows the parser to track e.g. ``--no-myflag`` and turn it into a
121 False value for the ``myflag`` Argument.
122
123 .. versionadded:: 1.0
124 """
125 # Normalize
126 if len(args) == 1 and isinstance(args[0], Argument):
127 arg = args[0]
128 else:
129 arg = Argument(*args, **kwargs)
130 # Uniqueness constraint: no name collisions
131 for name in arg.names:
132 if name in self.args:
133 msg = "Tried to add an argument named {!r} but one already exists!" # noqa
134 raise ValueError(msg.format(name))
135 # First name used as "main" name for purposes of aliasing
136 main = arg.names[0] # NOT arg.name
137 self.args[main] = arg
138 # Note positionals in distinct, ordered list attribute
139 if arg.positional:
140 self.positional_args.append(arg)
141 # Add names & nicknames to flags, args
142 self.flags[to_flag(main)] = arg
143 for name in arg.nicknames:
144 self.args.alias(name, to=main)
145 self.flags.alias(to_flag(name), to=to_flag(main))
146 # Add attr_name to args, but not flags
147 if arg.attr_name:
148 self.args.alias(arg.attr_name, to=main)
149 # Add to inverse_flags if required
150 if arg.kind == bool and arg.default is True:
151 # Invert the 'main' flag name here, which will be a dashed version
152 # of the primary argument name if underscore-to-dash transformation
153 # occurred.
154 inverse_name = to_flag("no-{}".format(main))
155 self.inverse_flags[inverse_name] = to_flag(main)
156
157 @property
158 def missing_positional_args(self) -> List[Argument]:
159 return [x for x in self.positional_args if x.value is None]
160
161 @property
162 def as_kwargs(self) -> Dict[str, Any]:
163 """
164 This context's arguments' values keyed by their ``.name`` attribute.
165
166 Results in a dict suitable for use in Python contexts, where e.g. an
167 arg named ``foo-bar`` becomes accessible as ``foo_bar``.
168
169 .. versionadded:: 1.0
170 """
171 ret = {}
172 for arg in self.args.values():
173 ret[arg.name] = arg.value
174 return ret
175
176 def names_for(self, flag: str) -> List[str]:
177 # TODO: should probably be a method on Lexicon/AliasDict
178 return list(set([flag] + self.flags.aliases_of(flag)))
179
180 def help_for(self, flag: str) -> Tuple[str, str]:
181 """
182 Return 2-tuple of ``(flag-spec, help-string)`` for given ``flag``.
183
184 .. versionadded:: 1.0
185 """
186 # Obtain arg obj
187 if flag not in self.flags:
188 err = "{!r} is not a valid flag for this context! Valid flags are: {!r}" # noqa
189 raise ValueError(err.format(flag, self.flags.keys()))
190 arg = self.flags[flag]
191 # Determine expected value type, if any
192 value = {str: "STRING", int: "INT"}.get(arg.kind)
193 # Format & go
194 full_names = []
195 for name in self.names_for(flag):
196 if value:
197 # Short flags are -f VAL, long are --foo=VAL
198 # When optional, also, -f [VAL] and --foo[=VAL]
199 if len(name.strip("-")) == 1:
200 value_ = ("[{}]".format(value)) if arg.optional else value
201 valuestr = " {}".format(value_)
202 else:
203 valuestr = "={}".format(value)
204 if arg.optional:
205 valuestr = "[{}]".format(valuestr)
206 else:
207 # no value => boolean
208 # check for inverse
209 if name in self.inverse_flags.values():
210 name = "--[no-]{}".format(name[2:])
211
212 valuestr = ""
213 # Tack together
214 full_names.append(name + valuestr)
215 namestr = ", ".join(sorted(full_names, key=len))
216 helpstr = arg.help or ""
217 return namestr, helpstr
218
219 def help_tuples(self) -> List[Tuple[str, Optional[str]]]:
220 """
221 Return sorted iterable of help tuples for all member Arguments.
222
223 Sorts like so:
224
225 * General sort is alphanumerically
226 * Short flags win over long flags
227 * Arguments with *only* long flags and *no* short flags will come
228 first.
229 * When an Argument has multiple long or short flags, it will sort using
230 the most favorable (lowest alphabetically) candidate.
231
232 This will result in a help list like so::
233
234 --alpha, --zeta # 'alpha' wins
235 --beta
236 -a, --query # short flag wins
237 -b, --argh
238 -c
239
240 .. versionadded:: 1.0
241 """
242 # TODO: argument/flag API must change :(
243 # having to call to_flag on 1st name of an Argument is just dumb.
244 # To pass in an Argument object to help_for may require moderate
245 # changes?
246 return list(
247 map(
248 lambda x: self.help_for(to_flag(x.name)),
249 sorted(self.flags.values(), key=flag_key),
250 )
251 )
252
253 def flag_names(self) -> Tuple[str, ...]:
254 """
255 Similar to `help_tuples` but returns flag names only, no helpstrs.
256
257 Specifically, all flag names, flattened, in rough order.
258
259 .. versionadded:: 1.0
260 """
261 # Regular flag names
262 flags = sorted(self.flags.values(), key=flag_key)
263 names = [self.names_for(to_flag(x.name)) for x in flags]
264 # Inverse flag names sold separately
265 names.append(list(self.inverse_flags.keys()))
266 return tuple(itertools.chain.from_iterable(names))