Add a canon_map property to the CLISubcommand classes
Marco Ricci

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