Marco Ricci commited on 2026-01-24 23:47:54
Zeige 2 geänderte Dateien mit 155 Einfügungen und 11 Löschungen.
Whenever a list of feature items is given during `--version`, such as "Supported subcommands", support adding a list of aliases to that item (provided there is no line break in between, the aliases are marked as such, and neither contain nested aliases nor parentheses). Document this format somewhat more explicitly in `tests.test_derivepassphrase_cli.test_all_cli.parse_version_output`, and provide a more explicit reference parser/tokenizer as well. That said, the format is restricted enough to allow other parsers to somewhat easily be written manually, or via a parser generator. Though this could principally be used for the subcommands case (as also used in the test cases for this functionality), the real beneficiary is a piece of code I intend to commit next.
| ... | ... |
@@ -1301,6 +1301,17 @@ class Label(enum.Enum): |
| 1301 | 1301 |
"PEP 508 extras:", |
| 1302 | 1302 |
) |
| 1303 | 1303 |
"""""" |
| 1304 |
+ FEATURE_ITEM_ALIASES = commented( |
|
| 1305 |
+ "This is part of the version output, designating a list of names " |
|
| 1306 |
+ "as aliases to the previous entry. " |
|
| 1307 |
+ "A comma-separated English list of items follows. " |
|
| 1308 |
+ "This label, and the list of names, is included in parentheses: " |
|
| 1309 |
+ "`entry_name (aliases: alias1, alias2, ...)`.", |
|
| 1310 |
+ )( |
|
| 1311 |
+ "Label :: Info Message:: Table row header", |
|
| 1312 |
+ "aliases:", |
|
| 1313 |
+ ) |
|
| 1314 |
+ """""" |
|
| 1304 | 1315 |
SUPPORTED_DERIVATION_SCHEMES = commented( |
| 1305 | 1316 |
"This is part of the version output, emitting lists of supported " |
| 1306 | 1317 |
"derivation schemes. " |
| ... | ... |
@@ -15,6 +15,7 @@ import contextlib |
| 15 | 15 |
import enum |
| 16 | 16 |
import re |
| 17 | 17 |
import types |
| 18 |
+from typing import TYPE_CHECKING |
|
| 18 | 19 |
|
| 19 | 20 |
import exceptiongroup |
| 20 | 21 |
import pytest |
| ... | ... |
@@ -25,6 +26,9 @@ from derivepassphrase._internals import cli_messages |
| 25 | 26 |
from tests import machinery |
| 26 | 27 |
from tests.machinery import pytest as pytest_machinery |
| 27 | 28 |
|
| 29 |
+if TYPE_CHECKING: |
|
| 30 |
+ from collections.abc import Iterator |
|
| 31 |
+ |
|
| 28 | 32 |
|
| 29 | 33 |
class VersionOutputData(NamedTuple): |
| 30 | 34 |
derivation_schemes: dict[str, bool] |
| ... | ... |
@@ -268,11 +272,130 @@ PEP 508 extras: annoying-popups, delete-all-files, |
| 268 | 272 |
), |
| 269 | 273 |
id="inventpassphrase", |
| 270 | 274 |
), |
| 275 |
+ pytest.param( |
|
| 276 |
+ """\ |
|
| 277 |
+derivepassphrase 1.0 |
|
| 278 |
+Using wishful-thinking 2.0. |
|
| 279 |
+ |
|
| 280 |
+Supported derivation schemes: spectre ({aliases!s} master-password, mpw),
|
|
| 281 |
+ vault. |
|
| 282 |
+Supported subcommands: export, spectre ({aliases!s} master-password, mpw),
|
|
| 283 |
+ vault. |
|
| 284 |
+""".format( |
|
| 285 |
+ aliases=cli_messages.TranslatedString( |
|
| 286 |
+ cli_messages.Label.FEATURE_ITEM_ALIASES |
|
| 287 |
+ ) |
|
| 288 |
+ ), |
|
| 289 |
+ "derivepassphrase", |
|
| 290 |
+ "1.0", |
|
| 291 |
+ VersionOutputData( |
|
| 292 |
+ derivation_schemes={
|
|
| 293 |
+ "master-password": True, |
|
| 294 |
+ "mpw": True, |
|
| 295 |
+ "spectre": True, |
|
| 296 |
+ "vault": True, |
|
| 297 |
+ }, |
|
| 298 |
+ foreign_configuration_formats={},
|
|
| 299 |
+ subcommands=frozenset({
|
|
| 300 |
+ "export", |
|
| 301 |
+ "master-password", |
|
| 302 |
+ "mpw", |
|
| 303 |
+ "spectre", |
|
| 304 |
+ "vault", |
|
| 305 |
+ }), |
|
| 306 |
+ features={},
|
|
| 307 |
+ extras=frozenset(), |
|
| 308 |
+ ), |
|
| 309 |
+ id="aliases", |
|
| 310 |
+ ), |
|
| 271 | 311 |
], |
| 272 | 312 |
) |
| 273 | 313 |
"""Sample data for [`parse_version_output`][].""" |
| 274 | 314 |
|
| 275 | 315 |
|
| 316 |
+def tokenize_version_output_item_listing( |
|
| 317 |
+ line: str, |
|
| 318 |
+ /, |
|
| 319 |
+ *, |
|
| 320 |
+ is_alias_annotation: bool = False, |
|
| 321 |
+) -> Iterator[str]: |
|
| 322 |
+ """Yield the next feature in a `--version` feature listing. |
|
| 323 |
+ |
|
| 324 |
+ This is a regular expression-based parser (alluded to in |
|
| 325 |
+ [`parse_version_output`][]) that yields the next item name or alias |
|
| 326 |
+ it encounters. (The output is indistinguishable for those two |
|
| 327 |
+ types.) |
|
| 328 |
+ |
|
| 329 |
+ We assume that continuation lines have already been reversed, i.e., |
|
| 330 |
+ that the whole input is on a single line. We further assume that |
|
| 331 |
+ the listing header has been removed, i.e., we are only processing |
|
| 332 |
+ the raw list items. |
|
| 333 |
+ |
|
| 334 |
+ Args: |
|
| 335 |
+ line: |
|
| 336 |
+ The input line, normalized as explained above. |
|
| 337 |
+ is_alias_annotation: |
|
| 338 |
+ If true, then the input line is contents of an alias |
|
| 339 |
+ listing, and itself does not support aliases. Otherwise, |
|
| 340 |
+ aliases are supported. |
|
| 341 |
+ |
|
| 342 |
+ Yields: |
|
| 343 |
+ The next item name or alias. There is no way to distinguish |
|
| 344 |
+ these cases based on output alone. |
|
| 345 |
+ |
|
| 346 |
+ """ |
|
| 347 |
+ chunk_re = re.compile( |
|
| 348 |
+ r""" |
|
| 349 |
+ # the item name |
|
| 350 |
+ (?P<name>[^,()]+) |
|
| 351 |
+ # whitespace |
|
| 352 |
+ (?:[ ]*) |
|
| 353 |
+ # the terminator |
|
| 354 |
+ (?:,[ ]*|$) |
|
| 355 |
+ """ |
|
| 356 |
+ if is_alias_annotation |
|
| 357 |
+ else r""" |
|
| 358 |
+ # the item name |
|
| 359 |
+ (?P<name>[^,()]+) |
|
| 360 |
+ # alias list |
|
| 361 |
+ (?:[ ]+ |
|
| 362 |
+ # alias marker |
|
| 363 |
+ \( |
|
| 364 |
+ {aliases_marker!s}
|
|
| 365 |
+ [ ]+ |
|
| 366 |
+ # the alias entries |
|
| 367 |
+ (?P<alias_list>[^()]+) |
|
| 368 |
+ \) |
|
| 369 |
+ )? |
|
| 370 |
+ # the terminator |
|
| 371 |
+ (?:,[ ]*|\.$) |
|
| 372 |
+ """.format( |
|
| 373 |
+ aliases_marker=cli_messages.TranslatedString( |
|
| 374 |
+ cli_messages.Label.FEATURE_ITEM_ALIASES |
|
| 375 |
+ ) |
|
| 376 |
+ ), |
|
| 377 |
+ re.VERBOSE, |
|
| 378 |
+ ) |
|
| 379 |
+ rest = line.strip() |
|
| 380 |
+ while (match := chunk_re.match(rest)) is not None: |
|
| 381 |
+ name = match.group("name")
|
|
| 382 |
+ assert name, "item listing tokenizer is inconsistent" |
|
| 383 |
+ assert name.strip() == name, "item listing tokenizer is inconsistent" |
|
| 384 |
+ yield name |
|
| 385 |
+ if not is_alias_annotation and match.group("alias_list"):
|
|
| 386 |
+ alias_list = match.group("alias_list")
|
|
| 387 |
+ assert alias_list, "item listing tokenizer is inconsistent" |
|
| 388 |
+ assert alias_list.strip(), "item listing tokenizer is inconsistent" |
|
| 389 |
+ assert alias_list.lstrip() == alias_list, "item listing tokenizer is inconsistent" |
|
| 390 |
+ yield from tokenize_version_output_item_listing( |
|
| 391 |
+ alias_list.strip(), is_alias_annotation=True |
|
| 392 |
+ ) |
|
| 393 |
+ rest = rest.removeprefix(match.group(0)) |
|
| 394 |
+ if rest: # pragma: no cover [defensive] |
|
| 395 |
+ msg = f"Trailing unparsable junk on {line!r}: {rest!r}"
|
|
| 396 |
+ raise ValueError(msg) |
|
| 397 |
+ |
|
| 398 |
+ |
|
| 276 | 399 |
def parse_version_output( # noqa: C901 |
| 277 | 400 |
version_output: str, |
| 278 | 401 |
/, |
| ... | ... |
@@ -286,14 +409,23 @@ def parse_version_output( # noqa: C901 |
| 286 | 409 |
details the version number, and the version number of any major |
| 287 | 410 |
libraries in use. The second paragraph details known and supported |
| 288 | 411 |
passphrase derivation schemes, foreign configuration formats, |
| 289 |
- subcommands and PEP 508 package extras. For the schemes and |
|
| 290 |
- formats, there is a "supported" line for supported items, and |
|
| 291 |
- a "known" line for known but currently unsupported items (usually |
|
| 292 |
- because of missing dependencies), either of which may be empty and |
|
| 293 |
- thus omitted. For extras, only active items are shown, and there is |
|
| 294 |
- a separate message for the "no extras active" case. Item lists may |
|
| 295 |
- be spilled across multiple lines, but only at item boundaries, and |
|
| 296 |
- the continuation lines are then indented. |
|
| 412 |
+ subcommands, SSH agent socket providers and PEP 508 package extras. |
|
| 413 |
+ |
|
| 414 |
+ For the schemes, formats and socket providers, there is |
|
| 415 |
+ a "supported" line for supported items, and a "known" line for known |
|
| 416 |
+ but currently unsupported items (usually because of missing |
|
| 417 |
+ dependencies), either of which may be empty and thus omitted. For |
|
| 418 |
+ extras, only active items are shown, and there is a separate message |
|
| 419 |
+ for the "no extras active" case. Items may be followed by a list of |
|
| 420 |
+ aliases, explicitly marked as such. Item lists may be spilled |
|
| 421 |
+ across multiple lines, but only at item boundaries. (The alias list |
|
| 422 |
+ counts as part of the same item.) The continuation lines are then |
|
| 423 |
+ indented. |
|
| 424 |
+ |
|
| 425 |
+ The list of aliases is formatted as `<name> (aliases: <alias1>, |
|
| 426 |
+ <alias2>)`. Only one level of aliases is supported, and neither |
|
| 427 |
+ `<name>` nor `<alias>` must contain parentheses. (Brackets and |
|
| 428 |
+ braces are discouraged, but not expressly forbidden.) |
|
| 297 | 429 |
|
| 298 | 430 |
Args: |
| 299 | 431 |
version_output: |
| ... | ... |
@@ -310,6 +442,9 @@ def parse_version_output( # noqa: C901 |
| 310 | 442 |
Examples: |
| 311 | 443 |
See [`Parametrize.VERSION_OUTPUT_DATA`][]. |
| 312 | 444 |
|
| 445 |
+ See also: |
|
| 446 |
+ * [`tokenize_version_output_item_listing`][] |
|
| 447 |
+ |
|
| 313 | 448 |
""" |
| 314 | 449 |
paragraphs: list[list[str]] = [] |
| 315 | 450 |
paragraph: list[str] = [] |
| ... | ... |
@@ -353,10 +488,8 @@ def parse_version_output( # noqa: C901 |
| 353 | 488 |
line_type, _, value = line.partition(":")
|
| 354 | 489 |
if line_type == line: |
| 355 | 490 |
continue |
| 356 |
- for item_ in re.split(r"(?:, *|.$)", value): |
|
| 491 |
+ for item_ in tokenize_version_output_item_listing(value): |
|
| 357 | 492 |
item = item_.strip() |
| 358 |
- if not item: |
|
| 359 |
- continue |
|
| 360 | 493 |
if line_type == KnownLineType.SUPPORTED_FOREIGN_CONFS: |
| 361 | 494 |
formats[item] = True |
| 362 | 495 |
elif line_type == KnownLineType.UNAVAILABLE_FOREIGN_CONFS: |
| 363 | 496 |