Add data structures for the planned new command-line argument parser
Marco Ricci

Marco Ricci commited on 2026-08-16 15:36:09
Zeige 1 geänderte Dateien mit 266 Einfügungen und 3 Löschungen.


The command-line interface is organized as a hierarchy of "subcommand"
objects, which contain "option group" objects, which contain "option"
objects.  The parser is multi-stage, and has an explicit "state" object
containing the top-level subcommand specification and the command-line
to parse.  The parser steps are monadic, similar to the "Maybe" monad:
each step depends only on the current state, and returns a ParseFailure
or ParseEarlyExit state (which skips any further computation), or
a ParseSuccess state (which does not).

This commit only contains the data structures, not the actual parser
steps.
... ...
@@ -14,16 +14,18 @@ Warning:
14 14
 
15 15
 from __future__ import annotations
16 16
 
17
+import abc
17 18
 import collections
19
+import dataclasses
18 20
 import importlib.metadata
19 21
 import inspect
20 22
 import logging
21 23
 import warnings
22
-from typing import TYPE_CHECKING, Callable, Literal, TextIO, TypeVar
24
+from typing import TYPE_CHECKING, Generic, NamedTuple, TypeVar, Union, cast
23 25
 
24 26
 import click
25 27
 import click.shell_completion
26
-from typing_extensions import Any, ParamSpec, override
28
+from typing_extensions import ParamSpec, final, override
27 29
 
28 30
 from derivepassphrase import _internals, _types
29 31
 from derivepassphrase._internals import cli_messages as _msg
... ...
@@ -31,10 +33,12 @@ from derivepassphrase._internals import cli_messages as _msg
31 33
 if TYPE_CHECKING:
32 34
     import types
33 35
     from collections.abc import (
36
+        Callable,
34 37
         MutableSequence,
35 38
     )
39
+    from typing import TextIO
36 40
 
37
-    from typing_extensions import Self
41
+    from typing_extensions import Any, Literal, Self, TypeAlias
38 42
 
39 43
 PROG_NAME = _internals.PROG_NAME
40 44
 VERSION = _internals.VERSION
... ...
@@ -46,6 +50,265 @@ NOT_A_NONNEGATIVE_INTEGER = "not a non-negative integer"
46 50
 NOT_A_POSITIVE_INTEGER = "not a positive integer"
47 51
 
48 52
 
53
+# CLI parsing machinery
54
+# =====================
55
+
56
+# Data types and helper functions
57
+# -------------------------------
58
+
59
+TrStr: TypeAlias = Union["_msg.TranslatedString", str]
60
+CLISubcommand: TypeAlias = Union[
61
+    "CLITerminalSubcommand", "CLICompositeSubcommand"
62
+]
63
+
64
+
65
+@dataclasses.dataclass(frozen=True)
66
+class CLIOption:
67
+    """A CLI option."""
68
+
69
+    names: tuple[str, ...]
70
+    """
71
+    The names for this option, beginning with the canonical long option
72
+    name, then the aliases.
73
+    """
74
+    help: TrStr
75
+    """The help string for this option, including metavars, if any."""
76
+    has_argument: tuple[str, ...] | bool = False
77
+    """
78
+    Whether the option takes an argument or not.  If only certain
79
+    values are permitted, then this attribute lists those values.
80
+    """
81
+    eager: bool = False
82
+    """
83
+    Whether the option is eager, overriding all other arguments on the
84
+    command-line.
85
+    """
86
+
87
+
88
+@dataclasses.dataclass(frozen=True)
89
+class CLIOptionGroup:
90
+    """A named group of CLI options, with epilog."""
91
+
92
+    options: tuple[CLIOption, ...]
93
+    """The group contents."""
94
+    title: TrStr
95
+    """The group title."""
96
+    epilog: TrStr = ""
97
+    """The epilog."""
98
+
99
+
100
+@dataclasses.dataclass(frozen=True)
101
+class CLIArgument:
102
+    """A CLI positional argument."""
103
+
104
+    name: str
105
+    """The argument name, usually equal to its metavar."""
106
+    choices: tuple[str, ...] | None = None
107
+    """The list of possible values, if applicable."""
108
+
109
+
110
+@dataclasses.dataclass(frozen=True)
111
+class CLITerminalSubcommand:
112
+    """A CLI subcommand without own subcommands."""
113
+
114
+    names: tuple[str, ...]
115
+    """
116
+    The names for this subcommand, beginning with the canonical subcommand
117
+    name, then the aliases.
118
+    """
119
+    contents: tuple[tuple[CLIOptionGroup, ...], tuple[CLIArgument, ...]]
120
+    """
121
+    The contents of this subcommand: a list of option groups and positional
122
+    arguments.
123
+    """
124
+    prolog: tuple[TrStr, ...]
125
+    """The help text prolog."""
126
+    epilog: tuple[TrStr, ...]
127
+    """The help text epilog."""
128
+
129
+
130
+
131
+@dataclasses.dataclass(frozen=True)
132
+class CLICompositeSubcommand:
133
+    """A CLI subcommand with own subcommands."""
134
+
135
+    names: tuple[str, ...]
136
+    """
137
+    The names for this subcommand, beginning with the canonical subcommand
138
+    name, then the aliases.
139
+    """
140
+    contents: tuple[
141
+        tuple[CLIOptionGroup, ...], tuple[CLISubcommand, ...], str | None
142
+    ]
143
+    """
144
+    The contents of this subcommand: a list of option groups, a list of
145
+    subcommands, and an optional default subcommand name.
146
+    """
147
+    prolog: tuple[TrStr, ...]
148
+    """The help text prolog."""
149
+    epilog: tuple[TrStr, ...]
150
+    """The help text epilog."""
151
+
152
+class ParseState(NamedTuple):
153
+    """The internal state of a command-line parser.
154
+
155
+    Typically used in a monadic context within the [`ParseResult`][]
156
+    class.
157
+
158
+    Attributes:
159
+        command_line:
160
+            The unparsed/remaining command-line.
161
+        subcommand:
162
+            A subcommand structure that governs the options, positional
163
+            arguments and subcommands recognized in this section of the
164
+            command line.
165
+
166
+    """
167
+
168
+    command_line: tuple[str, ...]
169
+    subcommand: CLISubcommand
170
+
171
+
172
+TResult = TypeVar("TResult", bound="ParseResult")
173
+TSuccess = TypeVar("TSuccess")
174
+
175
+
176
+@dataclasses.dataclass(frozen=True)
177
+class ParseResult(abc.ABC):
178
+    """A monadic CLI parser parse result.
179
+
180
+    A parse result has three manifestations: "success", "failure", and
181
+    "early exit".  A "success" result can be transformed further by a
182
+    [bound operation][ParseResult.bind] into any of the three
183
+    manifestations, a "failure" or "early exit" result will remain the
184
+    same.  Each manifestation also carries additional information
185
+    specific to this manifestation.
186
+
187
+    A plain parser state can be wrapped via the [`unit`
188
+    operation][ParseResult.unit].
189
+
190
+    Attributes:
191
+        state:
192
+            The underlying parser state.
193
+
194
+    """
195
+
196
+    state: ParseState
197
+
198
+    @classmethod
199
+    def unit(cls, state: ParseState) -> ParseSuccess[None]:
200
+        """Return a wrapped parser state, in the context of the monad."""
201
+        return ParseSuccess(state=state, result=None)
202
+
203
+    @abc.abstractmethod
204
+    def bind(
205
+        self,
206
+        f: Callable[[ParseState], ParseResult],
207
+        /,
208
+    ) -> ParseResult:
209
+        """Operate on the parser state, in the context of the monad.
210
+
211
+        Args:
212
+            f:
213
+                An operator to be bound to this parse result.  If the
214
+                parse result is successful, then `f` will be called on
215
+                the embedded parse state, and the return value will be
216
+                returned as the new parse result.  (The new parse result
217
+                is not necessarily successful.)  Otherwise, the old
218
+                parse result will be returned unchanged, and `f` will
219
+                not be called.
220
+
221
+        Returns:
222
+            A new parse result, obtained by transforming the old parse
223
+            result with `f` if applicable, else by aliasing the old
224
+            parse result.
225
+
226
+        """
227
+
228
+
229
+@final
230
+@dataclasses.dataclass(frozen=True)
231
+class ParseSuccess(ParseResult, Generic[TSuccess]):
232
+    result: TSuccess
233
+
234
+    @override
235
+    def bind(
236
+        self,
237
+        f: Callable[[ParseState], TResult],
238
+        /,
239
+    ) -> TResult:
240
+        """Operate on the parser state, in the context of the monad.
241
+
242
+        Args:
243
+            f:
244
+                An operator to be bound to this parse result.  Because
245
+                this parse result is successful, `f` will be called on
246
+                the embedded parse state, and the return value will be
247
+                returned as the new parse result.  The new parse result
248
+                is not necessarily successful.
249
+
250
+        Returns:
251
+            A new parse result, obtained by transforming the old parse
252
+            result with `f`.
253
+
254
+        """
255
+        return f(self.state)
256
+
257
+
258
+@final
259
+@dataclasses.dataclass(frozen=True)
260
+class ParseEarlyExit(ParseResult):
261
+    result: str
262
+
263
+    @override
264
+    def bind(
265
+        self,
266
+        f: Callable[[ParseState], ParseResult],
267
+        /,
268
+    ) -> Self:
269
+        """Operate on the parser state, in the context of the monad.
270
+
271
+        Args:
272
+            f:
273
+                Ignored, because this parse result is not successful.
274
+
275
+        Returns:
276
+            The old parse result, unchanged.
277
+
278
+        """
279
+        del f
280
+        return self
281
+
282
+
283
+@final
284
+@dataclasses.dataclass(frozen=True)
285
+class ParseFailure(ParseResult):
286
+    error: str
287
+
288
+    @override
289
+    def bind(
290
+        self,
291
+        f: Callable[[ParseState], ParseResult],
292
+        /,
293
+    ) -> Self:
294
+        """Operate on the parser state, in the context of the monad.
295
+
296
+        Args:
297
+            f:
298
+                Ignored, because this parse result is not successful.
299
+
300
+        Returns:
301
+            The old parse result, unchanged.
302
+
303
+        """
304
+        del f
305
+        return self
306
+
307
+
308
+# Parsing stages
309
+# --------------
310
+
311
+
49 312
 # Logging
50 313
 # =======
51 314
 
52 315