Marco Ricci commited on 2026-08-16 15:37:05
Zeige 1 geänderte Dateien mit 164 Einfügungen und 1 Löschungen.
The canonical map of option or subcommand names maps such a name to the respective option or subcommand object. It is a dependent attribute (and thus should be excluded from `__init__`, `__eq__` and `__lt__`, etc.) and somewhat expensive to compute, so we cache the result in a global weak key dictionary, because the subcommand objects themselves are immutable.
| ... | ... |
@@ -20,7 +20,9 @@ import dataclasses |
| 20 | 20 |
import importlib.metadata |
| 21 | 21 |
import inspect |
| 22 | 22 |
import logging |
| 23 |
+import types |
|
| 23 | 24 |
import warnings |
| 25 |
+import weakref |
|
| 24 | 26 |
from typing import TYPE_CHECKING, Generic, NamedTuple, TypeVar, Union, cast |
| 25 | 27 |
|
| 26 | 28 |
import click |
| ... | ... |
@@ -31,10 +33,12 @@ from derivepassphrase import _internals, _types |
| 31 | 33 |
from derivepassphrase._internals import cli_messages as _msg |
| 32 | 34 |
|
| 33 | 35 |
if TYPE_CHECKING: |
| 34 |
- import types |
|
| 35 | 36 |
from collections.abc import ( |
| 36 | 37 |
Callable, |
| 38 |
+ Mapping, |
|
| 39 |
+ MutableMapping, |
|
| 37 | 40 |
MutableSequence, |
| 41 |
+ Sequence, |
|
| 38 | 42 |
) |
| 39 | 43 |
from typing import TextIO |
| 40 | 44 |
|
| ... | ... |
@@ -107,6 +111,132 @@ class CLIArgument: |
| 107 | 111 |
"""The list of possible values, if applicable.""" |
| 108 | 112 |
|
| 109 | 113 |
|
| 114 |
+_canon_map_cache: MutableMapping[ |
|
| 115 |
+ CLISubcommand, Mapping[str | None, CLIOption | CLISubcommand] |
|
| 116 |
+] = weakref.WeakKeyDictionary() |
|
| 117 |
+""" |
|
| 118 |
+A weak key mapping from subcommand to canonical name map. Because the |
|
| 119 |
+`canon_map` property is defined on frozen dataclasses (the subcommand |
|
| 120 |
+objects), we cannot store the canonical name map on the subcommand |
|
| 121 |
+object itself, but must maintain our own cache, without interfering with |
|
| 122 |
+garbage collection. |
|
| 123 |
+""" |
|
| 124 |
+ |
|
| 125 |
+ |
|
| 126 |
+def _check_nonunique_alias( |
|
| 127 |
+ name: str, |
|
| 128 |
+ cname1: CLIOption | CLISubcommand, |
|
| 129 |
+ mapping: Mapping[str | None, CLIOption | CLISubcommand], |
|
| 130 |
+ /, |
|
| 131 |
+) -> None: |
|
| 132 |
+ if name in mapping: # pragma: no cover [failsafe] |
|
| 133 |
+ cname2 = mapping[name] |
|
| 134 |
+ cname1str = cname1.names[0] |
|
| 135 |
+ cname2str = cname2.names[0] |
|
| 136 |
+ msg = ( |
|
| 137 |
+ f"Non-unique option or subcommand alias {name!r} "
|
|
| 138 |
+ f"for both {cname1str!r} and {cname2str!r}"
|
|
| 139 |
+ ) |
|
| 140 |
+ raise ValueError(msg) |
|
| 141 |
+ |
|
| 142 |
+ |
|
| 143 |
+def _check_unique_argument_names( |
|
| 144 |
+ arguments: Sequence[CLIArgument], |
|
| 145 |
+ /, |
|
| 146 |
+) -> None: |
|
| 147 |
+ names_seen: set[str] = set() |
|
| 148 |
+ for arg in arguments: |
|
| 149 |
+ name = arg.name |
|
| 150 |
+ if name in names_seen: # pragma: no cover [failsafe] |
|
| 151 |
+ msg = f"Non-unique positional argument name {name!r}"
|
|
| 152 |
+ raise ValueError(msg) |
|
| 153 |
+ names_seen.add(name) |
|
| 154 |
+ |
|
| 155 |
+ |
|
| 156 |
+def _check_is_valid_option_name( |
|
| 157 |
+ name: str, |
|
| 158 |
+ /, |
|
| 159 |
+) -> None: |
|
| 160 |
+ parts = name.split("-")
|
|
| 161 |
+ is_short_option = ( |
|
| 162 |
+ len(parts) == 2 # noqa: PLR2004 |
|
| 163 |
+ and not any(parts[:1]) |
|
| 164 |
+ and all(len(p) == 1 for p in parts[1:]) |
|
| 165 |
+ ) |
|
| 166 |
+ is_long_option = ( |
|
| 167 |
+ len(parts) > 2 # noqa: PLR2004 |
|
| 168 |
+ and not any(parts[:2]) |
|
| 169 |
+ and all(len(p) >= 1 for p in parts[2:]) |
|
| 170 |
+ ) |
|
| 171 |
+ if not (is_short_option or is_long_option): # pragma: no cover [failsafe] |
|
| 172 |
+ msg = f"Invalid option name {name!r}"
|
|
| 173 |
+ raise ValueError(msg) |
|
| 174 |
+ |
|
| 175 |
+ |
|
| 176 |
+def _check_is_valid_subcommand_name( |
|
| 177 |
+ name: str, |
|
| 178 |
+ /, |
|
| 179 |
+) -> None: |
|
| 180 |
+ if not name: # pragma: no cover [failsafe] |
|
| 181 |
+ msg = f"Invalid argument/subcommand name {name!r}"
|
|
| 182 |
+ raise ValueError(msg) |
|
| 183 |
+ |
|
| 184 |
+ |
|
| 185 |
+def calculate_canon_map( |
|
| 186 |
+ command: CLISubcommand, |
|
| 187 |
+ /, |
|
| 188 |
+) -> Mapping[str | None, CLIOption | CLISubcommand]: |
|
| 189 |
+ """Return a mapping of options and subcommands to canonical names. |
|
| 190 |
+ |
|
| 191 |
+ Options, positional arguments and further subcommands are mapped |
|
| 192 |
+ only up to the next subcommand boundary. The canonical name of the |
|
| 193 |
+ default subcommand (if any) is registered as the name `""`. |
|
| 194 |
+ |
|
| 195 |
+ Args: |
|
| 196 |
+ command: |
|
| 197 |
+ A subcommand whose options, positional arguments and |
|
| 198 |
+ further subcommands to map. |
|
| 199 |
+ |
|
| 200 |
+ Returns: |
|
| 201 |
+ A mapping of option/subcommand names to their respective |
|
| 202 |
+ canonical option/subcommand name, for this subcommand, and |
|
| 203 |
+ not including nested subcommands. |
|
| 204 |
+ |
|
| 205 |
+ Raises: |
|
| 206 |
+ ValueError: |
|
| 207 |
+ The command contains invalid or duplicate |
|
| 208 |
+ option/subcommand names. |
|
| 209 |
+ |
|
| 210 |
+ """ |
|
| 211 |
+ mapping: dict[str | None, CLIOption | CLISubcommand] = {}
|
|
| 212 |
+ for option_group in command.contents[0]: |
|
| 213 |
+ for option in option_group.options: |
|
| 214 |
+ for name in option.names: |
|
| 215 |
+ _check_is_valid_option_name(name) |
|
| 216 |
+ _check_nonunique_alias(name, option, mapping) |
|
| 217 |
+ mapping[name] = option |
|
| 218 |
+ if isinstance(command, CLITerminalSubcommand): |
|
| 219 |
+ for argument in command.contents[1]: |
|
| 220 |
+ _check_is_valid_subcommand_name(argument.name) |
|
| 221 |
+ _check_unique_argument_names(command.contents[1]) |
|
| 222 |
+ else: |
|
| 223 |
+ for subcommand in command.contents[1]: |
|
| 224 |
+ for name in subcommand.names: |
|
| 225 |
+ _check_is_valid_subcommand_name(name) |
|
| 226 |
+ _check_nonunique_alias(name, subcommand, mapping) |
|
| 227 |
+ mapping[name] = subcommand |
|
| 228 |
+ if command.contents[2] is not None: |
|
| 229 |
+ name = command.contents[2] |
|
| 230 |
+ _check_is_valid_subcommand_name(name) |
|
| 231 |
+ try: |
|
| 232 |
+ canonical_option_or_subcommand = mapping[name] |
|
| 233 |
+ except KeyError as exc: # pragma: no cover [failsafe] |
|
| 234 |
+ msg = f"Invalid default subcommand name {name!r}"
|
|
| 235 |
+ raise ValueError(msg) from exc |
|
| 236 |
+ mapping[None] = canonical_option_or_subcommand |
|
| 237 |
+ return types.MappingProxyType(mapping) |
|
| 238 |
+ |
|
| 239 |
+ |
|
| 110 | 240 |
@dataclasses.dataclass(frozen=True) |
| 111 | 241 |
class CLITerminalSubcommand: |
| 112 | 242 |
"""A CLI subcommand without own subcommands.""" |
| ... | ... |
@@ -126,6 +256,22 @@ class CLITerminalSubcommand: |
| 126 | 256 |
epilog: tuple[TrStr, ...] |
| 127 | 257 |
"""The help text epilog.""" |
| 128 | 258 |
|
| 259 |
+ @property |
|
| 260 |
+ def canon_map(self) -> Mapping[str | None, CLISubcommand | CLIOption]: |
|
| 261 |
+ """A mapping of names to the option or subcommand object. |
|
| 262 |
+ |
|
| 263 |
+ In the general case, the mapping only tables top-level option |
|
| 264 |
+ and subcommand names from this subcommand (i.e., nested |
|
| 265 |
+ subcommands are excluded), and it may also contain a `None` key, |
|
| 266 |
+ pointing to the default subcommand object. |
|
| 267 |
+ |
|
| 268 |
+ This property accesses and writes to a shared cache, in |
|
| 269 |
+ a non-threadsafe manner. |
|
| 270 |
+ |
|
| 271 |
+ """ |
|
| 272 |
+ if self not in _canon_map_cache: |
|
| 273 |
+ _canon_map_cache[self] = calculate_canon_map(self) |
|
| 274 |
+ return _canon_map_cache[self] |
|
| 129 | 275 |
|
| 130 | 276 |
|
| 131 | 277 |
@dataclasses.dataclass(frozen=True) |
| ... | ... |
@@ -149,6 +295,23 @@ class CLICompositeSubcommand: |
| 149 | 295 |
epilog: tuple[TrStr, ...] |
| 150 | 296 |
"""The help text epilog.""" |
| 151 | 297 |
|
| 298 |
+ @property |
|
| 299 |
+ def canon_map(self) -> Mapping[str | None, CLISubcommand | CLIOption]: |
|
| 300 |
+ """A mapping of names to the option or subcommand object. |
|
| 301 |
+ |
|
| 302 |
+ The mapping is for this subcommand only (i.e., excluding nested |
|
| 303 |
+ subcommands), and it optionally includes a `None` entry pointing |
|
| 304 |
+ to the default subcommand object. |
|
| 305 |
+ |
|
| 306 |
+ This property accesses and writes to a shared cache, in |
|
| 307 |
+ a non-threadsafe manner. |
|
| 308 |
+ |
|
| 309 |
+ """ |
|
| 310 |
+ if self not in _canon_map_cache: |
|
| 311 |
+ _canon_map_cache[self] = calculate_canon_map(self) |
|
| 312 |
+ return _canon_map_cache[self] |
|
| 313 |
+ |
|
| 314 |
+ |
|
| 152 | 315 |
class ParseState(NamedTuple): |
| 153 | 316 |
"""The internal state of a command-line parser. |
| 154 | 317 |
|
| 155 | 318 |