Support aliases in `--version` output (item feature lists)
Marco Ricci

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