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 |