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 |