Add xfailing tests for extended version output information
Marco Ricci

Marco Ricci commited on 2025-02-13 14:03:41
Zeige 3 geänderte Dateien mit 289 Einfügungen und 1 Löschungen.


Assert that the `--version` output contains further information about
the build and configuration state of `derivepassphrase`.  Specifically,
it should contain the list of supported passphrase derivation schemes,
supported foreign configuration formats, and available PEP 508 extras.

As of this commit, only provide the necessary testing machinery to parse
such version output, and test that it works.  The actual version output
tests still xfail.
... ...
@@ -37,13 +37,17 @@ from typing import TYPE_CHECKING, Any
37 37
 from derivepassphrase import _types, exporter
38 38
 from derivepassphrase._internals import cli_messages as _msg
39 39
 
40
+if TYPE_CHECKING:
41
+    from typing_extensions import Buffer
42
+
40 43
 if TYPE_CHECKING:
41 44
     from collections.abc import Iterator
42 45
 
43 46
     from cryptography.hazmat.primitives import ciphers, hashes, hmac, padding
44 47
     from cryptography.hazmat.primitives.ciphers import algorithms, modes
45 48
     from cryptography.hazmat.primitives.kdf import pbkdf2
46
-    from typing_extensions import Buffer
49
+
50
+    STUBBED = False
47 51
 else:
48 52
     try:
49 53
         importlib.import_module('cryptography')
... ...
@@ -49,6 +49,8 @@ if TYPE_CHECKING:
49 49
     from cryptography.hazmat.primitives import ciphers, hashes, hmac, padding
50 50
     from cryptography.hazmat.primitives.ciphers import algorithms, modes
51 51
     from cryptography.hazmat.primitives.kdf import pbkdf2
52
+
53
+    STUBBED = False
52 54
 else:
53 55
     try:
54 56
         importlib.import_module('cryptography')
... ...
@@ -13,6 +13,7 @@ import json
13 13
 import logging
14 14
 import os
15 15
 import pathlib
16
+import re
16 17
 import shlex
17 18
 import shutil
18 19
 import socket
... ...
@@ -80,6 +81,12 @@ class OptionCombination(NamedTuple):
80 81
     check_success: bool
81 82
 
82 83
 
84
+class VersionOutputData(NamedTuple):
85
+    derivation_schemes: dict[str, bool]
86
+    foreign_configuration_formats: dict[str, bool]
87
+    extras: frozenset[str]
88
+
89
+
83 90
 PASSPHRASE_GENERATION_OPTIONS: list[tuple[str, ...]] = [
84 91
     ('--phrase',),
85 92
     ('--key',),
... ...
@@ -330,6 +337,97 @@ def vault_config_exporter_shell_interpreter(  # noqa: C901
330 337
         )
331 338
 
332 339
 
340
+def parse_version_output(  # noqa: C901
341
+    version_output: str,
342
+    /,
343
+    *,
344
+    prog_name: str | None = cli_messages.PROG_NAME,
345
+    version: str | None = cli_messages.VERSION,
346
+) -> VersionOutputData:
347
+    r"""Parse the output of the `--version` option.
348
+
349
+    The version output contains two paragraphs.  The first paragraph
350
+    details the version number, and the version number of any major
351
+    libraries in use.  The second paragraph details known and supported
352
+    passphrase derivation schemes, foreign configuration formats, and
353
+    PEP 508 package extras.  For the schemes and formats, there is
354
+    a "supported" line for supported items, and a "known" line for known
355
+    but currently unsupported items (usually because of missing
356
+    dependencies), either of which may be empty and thus omitted.  For
357
+    extras, only active items are shown, and there is a separate message
358
+    for the "no extras active" case.  Item lists may be spilled across
359
+    multiple lines, but only at item boundaries, and the continuation
360
+    lines are then indented.
361
+
362
+    Args:
363
+        text:
364
+            The version output text to parse.
365
+        prog_name:
366
+            The program name to assert, defaulting to the true program
367
+            name, `derivepassphrase`.  Set to `None` to disable this
368
+            check.
369
+        version:
370
+            The program version to assert, defaulting to the true
371
+            current version of `derivepassphrase`.  Set to `None` to
372
+            disable this check.
373
+
374
+    Examples:
375
+        See [`Parametrize.VERSION_OUTPUT_DATA`][].
376
+
377
+    """
378
+    paragraphs: list[list[str]] = []
379
+    paragraph: list[str] = []
380
+    for line in version_output.splitlines(keepends=False):
381
+        if not line.strip():
382
+            if paragraph:
383
+                paragraphs.append(paragraph.copy())
384
+            paragraph.clear()
385
+        elif paragraph and line.lstrip() != line:
386
+            paragraph[-1] = f'{paragraph[-1]} {line.lstrip()}'
387
+        else:
388
+            paragraph.append(line)
389
+    if paragraph:  # pragma: no branch
390
+        paragraphs.append(paragraph.copy())
391
+        paragraph.clear()
392
+    assert len(paragraphs) == 2, (
393
+        f'expected exactly two lines of version output: {paragraphs!r}'
394
+    )
395
+    assert prog_name is None or prog_name in paragraphs[0][0], (
396
+        f'first version output line should mention '
397
+        f'{prog_name}: {paragraphs[0][0]!r}'
398
+    )
399
+    assert version is None or version in paragraphs[0][0], (
400
+        f'first version output line should mention the version number '
401
+        f'{version}: {paragraphs[0][0]!r}'
402
+    )
403
+    schemas: dict[str, bool] = {}
404
+    formats: dict[str, bool] = {}
405
+    extras: set[str] = set()
406
+    for line in paragraphs[1]:
407
+        line_type, _, value = line.partition(':')
408
+        if line_type == line:
409
+            continue
410
+        for item_ in re.split(r'(?:, *|.$)', value):
411
+            item = item_.strip()
412
+            if not item:
413
+                continue
414
+            if line_type == 'Supported foreign configuration formats':
415
+                formats[item] = True
416
+            elif line_type == 'Known foreign configuration formats':
417
+                formats[item] = False
418
+            elif line_type == 'Supported derivation schemes':
419
+                schemas[item] = True
420
+            elif line_type == 'Known derivation schemes':
421
+                schemas[item] = False
422
+            elif line_type == 'PEP 508 extras':
423
+                extras.add(item)
424
+            else:
425
+                raise AssertionError(  # noqa: TRY003
426
+                    f'Unknown version info line type: {line_type!r}'  # noqa: EM102
427
+                )
428
+    return VersionOutputData(schemas, formats, frozenset(extras))
429
+
430
+
333 431
 def bash_format(item: click.shell_completion.CompletionItem) -> str:
334 432
     """A formatter for `bash`-style shell completion items.
335 433
 
... ...
@@ -382,6 +480,8 @@ def zsh_format(item: click.shell_completion.CompletionItem) -> str:
382 480
 
383 481
 
384 482
 class Parametrize(types.SimpleNamespace):
483
+    """Common test parametrizations."""
484
+
385 485
     EAGER_ARGUMENTS = pytest.mark.parametrize(
386 486
         'arguments',
387 487
         [['--help'], ['--version']],
... ...
@@ -1275,6 +1375,8 @@ class Parametrize(types.SimpleNamespace):
1275 1375
             ),
1276 1376
         ],
1277 1377
     )
1378
+    MASK_PROG_NAME = pytest.mark.parametrize('mask_prog_name', [False, True])
1379
+    MASK_VERSION = pytest.mark.parametrize('mask_version', [False, True])
1278 1380
     CONFIG_SETTING_MODE = pytest.mark.parametrize('mode', ['config', 'import'])
1279 1381
     NO_COLOR = pytest.mark.parametrize(
1280 1382
         'no_color',
... ...
@@ -1341,6 +1443,102 @@ class Parametrize(types.SimpleNamespace):
1341 1443
     TRY_RACE_FREE_IMPLEMENTATION = pytest.mark.parametrize(
1342 1444
         'try_race_free_implementation', [True, False]
1343 1445
     )
1446
+    VERSION_OUTPUT_DATA = pytest.mark.parametrize(
1447
+        ['version_output', 'prog_name', 'version', 'expected_parse'],
1448
+        [
1449
+            pytest.param(
1450
+                """\
1451
+derivepassphrase 0.4.0
1452
+Using cryptography 44.0.0
1453
+
1454
+Supported derivation schemes: vault.
1455
+Supported foreign configuration formats: vault storeroom, vault v0.2,
1456
+    vault v0.3.
1457
+PEP 508 extras: export.
1458
+""",
1459
+                'derivepassphrase',
1460
+                '0.4.0',
1461
+                VersionOutputData(
1462
+                    derivation_schemes={'vault': True},
1463
+                    foreign_configuration_formats={
1464
+                        'vault storeroom': True,
1465
+                        'vault v0.2': True,
1466
+                        'vault v0.3': True,
1467
+                    },
1468
+                    extras=frozenset({'export'}),
1469
+                ),
1470
+                id='derivepassphrase-0.4.0-export',
1471
+            ),
1472
+            pytest.param(
1473
+                """\
1474
+derivepassphrase 0.5
1475
+
1476
+Supported derivation schemes: vault.
1477
+Known foreign configuration formats: vault storeroom, vault v0.2, vault v0.3.
1478
+No PEP 508 extras are active.
1479
+""",
1480
+                'derivepassphrase',
1481
+                '0.5',
1482
+                VersionOutputData(
1483
+                    derivation_schemes={'vault': True},
1484
+                    foreign_configuration_formats={
1485
+                        'vault storeroom': False,
1486
+                        'vault v0.2': False,
1487
+                        'vault v0.3': False,
1488
+                    },
1489
+                    extras=frozenset({}),
1490
+                ),
1491
+                id='derivepassphrase-0.5-plain',
1492
+            ),
1493
+            pytest.param(
1494
+                """\
1495
+
1496
+
1497
+
1498
+inventpassphrase -1.3
1499
+Using not-a-library 7.12
1500
+Copyright 2025 Nobody.  All rights reserved.
1501
+
1502
+Supported derivation schemes: nonsense.
1503
+Known derivation schemes: divination, /dev/random,
1504
+    geiger counter,
1505
+    crossword solver.
1506
+Supported foreign configuration formats: derivepassphrase, nonsense.
1507
+Known foreign configuration formats: divination v3.141592,
1508
+    /dev/random.
1509
+PEP 508 extras: annoying-popups, delete-all-files,
1510
+    dump-core-depending-on-the-phase-of-the-moon.
1511
+
1512
+
1513
+
1514
+""",
1515
+                'inventpassphrase',
1516
+                '-1.3',
1517
+                VersionOutputData(
1518
+                    derivation_schemes={
1519
+                        'nonsense': True,
1520
+                        'divination': False,
1521
+                        '/dev/random': False,
1522
+                        'geiger counter': False,
1523
+                        'crossword solver': False,
1524
+                    },
1525
+                    foreign_configuration_formats={
1526
+                        'derivepassphrase': True,
1527
+                        'nonsense': True,
1528
+                        'divination v3.141592': False,
1529
+                        '/dev/random': False,
1530
+                    },
1531
+                    extras=frozenset({
1532
+                        'annoying-popups',
1533
+                        'delete-all-files',
1534
+                        'dump-core-depending-on-the-phase-of-the-moon',
1535
+                    }),
1536
+                ),
1537
+                id='inventpassphrase',
1538
+            ),
1539
+        ],
1540
+    )
1541
+    """Sample data for [`parse_version_output`][]."""
1344 1542
     VALIDATION_FUNCTION_INPUT = pytest.mark.parametrize(
1345 1543
         ['vfunc', 'input'],
1346 1544
         [
... ...
@@ -1353,6 +1551,28 @@ class Parametrize(types.SimpleNamespace):
1353 1551
 class TestAllCLI:
1354 1552
     """Tests uniformly for all command-line interfaces."""
1355 1553
 
1554
+    @Parametrize.MASK_PROG_NAME
1555
+    @Parametrize.MASK_VERSION
1556
+    @Parametrize.VERSION_OUTPUT_DATA
1557
+    def test_001_parse_version_output(
1558
+        self,
1559
+        version_output: str,
1560
+        prog_name: str | None,
1561
+        version: str | None,
1562
+        mask_prog_name: bool,
1563
+        mask_version: bool,
1564
+        expected_parse: VersionOutputData,
1565
+    ) -> None:
1566
+        """The parsing machinery for expected version output data works."""
1567
+        prog_name = None if mask_prog_name else prog_name
1568
+        version = None if mask_version else version
1569
+        assert (
1570
+            parse_version_output(
1571
+                version_output, prog_name=prog_name, version=version
1572
+            )
1573
+            == expected_parse
1574
+        )
1575
+
1356 1576
     # TODO(the-13th-letter): Do we actually need this?  What should we
1357 1577
     # check for?
1358 1578
     def test_100_help_output(self) -> None:
... ...
@@ -1558,6 +1778,66 @@ class TestAllCLI:
1558 1778
             'Expected no color, but found an ANSI control sequence'
1559 1779
         )
1560 1780
 
1781
+    @pytest.mark.xfail(
1782
+        reason='extras output not implemented yet',
1783
+        raises=AssertionError,
1784
+        strict=True,
1785
+    )
1786
+    @Parametrize.COMMAND_NON_EAGER_ARGUMENTS
1787
+    def test_202_version_option_output(
1788
+        self,
1789
+        command: list[str],
1790
+        non_eager_arguments: list[str],
1791
+    ) -> None:
1792
+        """The version output states supported features.
1793
+
1794
+        The version output is parsed using [`parse_version_output`][].
1795
+        Format examples can be found in
1796
+        [`Parametrize.VERSION_OUTPUT_DATA`][].
1797
+
1798
+        """
1799
+        del non_eager_arguments
1800
+        runner = click.testing.CliRunner(mix_stderr=False)
1801
+        # TODO(the-13th-letter): Rewrite using parenthesized
1802
+        # with-statements.
1803
+        # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
1804
+        with contextlib.ExitStack() as stack:
1805
+            monkeypatch = stack.enter_context(pytest.MonkeyPatch.context())
1806
+            stack.enter_context(
1807
+                tests.isolated_config(
1808
+                    monkeypatch=monkeypatch,
1809
+                    runner=runner,
1810
+                )
1811
+            )
1812
+            result_ = runner.invoke(
1813
+                cli.derivepassphrase,
1814
+                [*command, '--version'],
1815
+                catch_exceptions=False,
1816
+            )
1817
+            result = tests.ReadableResult.parse(result_)
1818
+        assert result.clean_exit(empty_stderr=True), 'expected clean exit'
1819
+        assert result.output.strip(), 'expected version output'
1820
+        version_data = parse_version_output(result.output)
1821
+        actually_known_formats: dict[str, bool] = {}
1822
+        actually_known_schemes = {'vault': True}
1823
+        actually_enabled_extras: set[str] = set()
1824
+        with contextlib.suppress(ModuleNotFoundError):
1825
+            from derivepassphrase.exporter import storeroom, vault_native  # noqa: I001,PLC0415
1826
+
1827
+            actually_known_formats.update({
1828
+                'vault storeroom': not storeroom.STUBBED,
1829
+                'vault v0.2': not vault_native.STUBBED,
1830
+                'vault v0.3': not vault_native.STUBBED,
1831
+            })
1832
+            if not storeroom.STUBBED and not vault_native.STUBBED:
1833
+                actually_enabled_extras.add('export')
1834
+        assert (
1835
+            version_data.foreign_configuration_formats
1836
+            == actually_known_formats
1837
+        )
1838
+        assert version_data.derivation_schemes == actually_known_schemes
1839
+        assert version_data.extras == actually_enabled_extras
1840
+
1561 1841
 
1562 1842
 class TestCLI:
1563 1843
     """Tests for the `derivepassphrase vault` command-line interface."""
... ...
@@ -1591,6 +1871,8 @@ class TestCLI:
1591 1871
             empty_stderr=True, output='Use $VISUAL or $EDITOR to configure'
1592 1872
         ), 'expected clean exit, and option group epilog in help text'
1593 1873
 
1874
+    # TODO(the-13th-letter): Remove this test once
1875
+    # TestAllCLI.test_202_version_option_output no longer xfails.
1594 1876
     def test_200a_version_output(
1595 1877
         self,
1596 1878
     ) -> None:
1597 1879