Adapt the test suite to use the logging system properly
Marco Ricci

Marco Ricci commited on 2024-12-07 09:30:38
Zeige 4 geänderte Dateien mit 281 Einfügungen und 69 Löschungen.


Given the previous changes to warning and error reporting, the test
suite needs new helper code to deal with them.  Again, the changes to
mosts tests are straight-forward (the location and formatting of
messages changes slightly), but the new logging machinery itself now
also needs to be tested... particularly the code that hooks into
Python's logging system, into Python's warning system, and between those
two.

`pytest` actually includes test fixtures to capture and interact with
warnings and logging calls, but these work on the library level:
warnings are captured as warnings, logging calls are captured as log
records.  To be useful to application testing, it is necessary to
inspect and assert both the low-level warnings/log records as well as
the standard error output they ultimately translate to.  The
`hypothesis`-based tests need particular (manual) care, because
`hypothesis` rejects tests using function-scoped `pytest` fixtures.
... ...
@@ -10,7 +10,9 @@ import copy
10 10
 import enum
11 11
 import importlib.util
12 12
 import json
13
+import logging
13 14
 import os
15
+import re
14 16
 import shlex
15 17
 import stat
16 18
 import sys
... ...
@@ -28,7 +30,7 @@ from derivepassphrase import _types, cli, ssh_agent, vault
28 30
 __all__ = ()
29 31
 
30 32
 if TYPE_CHECKING:
31
-    from collections.abc import Iterator, Mapping
33
+    from collections.abc import Callable, Iterator, Mapping, Sequence
32 34
 
33 35
     import click.testing
34 36
     from typing_extensions import Any, NotRequired, TypedDict
... ...
@@ -1344,6 +1346,14 @@ hypothesis_settings_coverage_compatible = (
1344 1346
     else hypothesis.settings()
1345 1347
 )
1346 1348
 
1349
+hypothesis_settings_coverage_compatible_with_caplog = hypothesis.settings(
1350
+    parent=hypothesis_settings_coverage_compatible,
1351
+    suppress_health_check={
1352
+        hypothesis.HealthCheck.function_scoped_fixture,
1353
+    }
1354
+    | set(hypothesis_settings_coverage_compatible.suppress_health_check),
1355
+)
1356
+
1347 1357
 
1348 1358
 def list_keys(self: Any = None) -> list[_types.KeyCommentPair]:
1349 1359
     del self  # Unused.
... ...
@@ -1403,7 +1413,11 @@ def isolated_config(
1403 1413
 ) -> Iterator[None]:
1404 1414
     prog_name = cli.PROG_NAME
1405 1415
     env_name = prog_name.replace(' ', '_').upper() + '_PATH'
1406
-    with runner.isolated_filesystem():
1416
+    with (
1417
+        runner.isolated_filesystem(),
1418
+        cli.StandardCLILogging.ensure_standard_logging(),
1419
+        cli.StandardCLILogging.ensure_standard_warnings_logging(),
1420
+    ):
1407 1421
         monkeypatch.setenv('HOME', os.getcwd())
1408 1422
         monkeypatch.setenv('USERPROFILE', os.getcwd())
1409 1423
         monkeypatch.delenv(env_name, raising=False)
... ...
@@ -1575,7 +1589,10 @@ class ReadableResult(NamedTuple):
1575 1589
         )
1576 1590
 
1577 1591
     def error_exit(
1578
-        self, *, error: str | type[BaseException] = BaseException
1592
+        self,
1593
+        *,
1594
+        error: str | re.Pattern[str] | type[BaseException] = BaseException,
1595
+        record_tuples: Sequence[tuple[str, int, str]] = (),
1579 1596
     ) -> bool:
1580 1597
         """Return whether the invocation exited uncleanly.
1581 1598
 
... ...
@@ -1585,15 +1602,31 @@ class ReadableResult(NamedTuple):
1585 1602
                 code, or an expected exception type.
1586 1603
 
1587 1604
         """
1605
+
1606
+        def error_match(error: str | re.Pattern[str], line: str) -> bool:
1607
+            return (
1608
+                error in line
1609
+                if isinstance(error, str)
1610
+                else error.match(line) is not None
1611
+            )
1612
+
1588 1613
         # Use match/case here once Python 3.9 becomes unsupported.
1589
-        if isinstance(error, str):
1614
+        if isinstance(error, type):
1615
+            return isinstance(self.exception, error)
1616
+        else:  # noqa: RET505
1617
+            assert isinstance(error, (str, re.Pattern))
1590 1618
             return (
1591 1619
                 isinstance(self.exception, SystemExit)
1592 1620
                 and self.exit_code > 0
1593
-                and (not error or error in self.stderr)
1621
+                and (
1622
+                    not error
1623
+                    or any(
1624
+                        error_match(error, line)
1625
+                        for line in self.stderr.splitlines(True)
1626
+                    )
1627
+                    or error_emitted(error, record_tuples)
1628
+                )
1594 1629
             )
1595
-        else:  # noqa: RET505
1596
-            return isinstance(self.exception, error)
1597 1630
 
1598 1631
 
1599 1632
 def parse_sh_export_line(line: str, *, env_name: str) -> str:
... ...
@@ -1617,3 +1650,49 @@ def parse_sh_export_line(line: str, *, env_name: str) -> str:
1617 1650
         msg = f'Cannot parse sh line: {orig_tokens!r} -> {tokens!r}'
1618 1651
         raise ValueError(msg)
1619 1652
     return tokens[1].split('=', 1)[1]
1653
+
1654
+
1655
+def message_emitted_factory(
1656
+    level: int,
1657
+    *,
1658
+    logger_name: str = cli.PROG_NAME,
1659
+) -> Callable[[str | re.Pattern[str], Sequence[tuple[str, int, str]]], bool]:
1660
+    """Return a function to test if a matching message was emitted.
1661
+
1662
+    Args:
1663
+        level: The level to match messages at.
1664
+        logger_name: The name of the logger to match against.
1665
+
1666
+    """
1667
+
1668
+    def message_emitted(
1669
+        text: str | re.Pattern[str],
1670
+        record_tuples: Sequence[tuple[str, int, str]],
1671
+    ) -> bool:
1672
+        """Return true if a matching message was emitted.
1673
+
1674
+        Args:
1675
+            text: Substring or pattern to match against.
1676
+            record_tuples: Items to match.
1677
+
1678
+        """
1679
+
1680
+        def check_record(record: tuple[str, int, str]) -> bool:
1681
+            if record[:2] != (logger_name, level):
1682
+                return False
1683
+            if isinstance(text, str):
1684
+                return text in record[2]
1685
+            return text.match(record[2]) is not None  # pragma: no cover
1686
+
1687
+        return any(map(check_record, record_tuples))
1688
+
1689
+    return message_emitted
1690
+
1691
+
1692
+# No need to assert debug messages as of yet.
1693
+info_emitted = message_emitted_factory(logging.INFO)
1694
+warning_emitted = message_emitted_factory(logging.WARNING)
1695
+deprecation_warning_emitted = message_emitted_factory(
1696
+    logging.WARNING, logger_name=f'{cli.PROG_NAME}.deprecation'
1697
+)
1698
+error_emitted = message_emitted_factory(logging.ERROR)
... ...
@@ -8,9 +8,11 @@ import contextlib
8 8
 import copy
9 9
 import errno
10 10
 import json
11
+import logging
11 12
 import os
12 13
 import shutil
13 14
 import socket
15
+import warnings
14 16
 from typing import TYPE_CHECKING, cast
15 17
 
16 18
 import click.testing
... ...
@@ -201,8 +203,13 @@ for opt, config in SINGLES.items():
201 203
     )
202 204
 
203 205
 
204
-def is_harmless_config_import_warning_line(line: str) -> bool:
205
-    """Return true if the warning line is harmless, during config import."""
206
+def is_warning_line(line: str) -> bool:
207
+    """Return true if the line is a warning line."""
208
+    return ' Warning: ' in line or ' Deprecation warning: ' in line
209
+
210
+
211
+def is_harmless_config_import_warning(record: tuple[str, int, str]) -> bool:
212
+    """Return true if the warning is harmless, during config import."""
206 213
     possible_warnings = [
207 214
         'Replacing invalid value ',
208 215
         'Removing ineffective setting ',
... ...
@@ -215,9 +222,7 @@ def is_harmless_config_import_warning_line(line: str) -> bool:
215 222
             'because a key is also set.'
216 223
         ),
217 224
     ]
218
-    return any(  # pragma: no branch
219
-        (' Warning: ' + w) in line for w in possible_warnings
220
-    )
225
+    return any(tests.warning_emitted(w, [record]) for w in possible_warnings)
221 226
 
222 227
 
223 228
 class TestCLI:
... ...
@@ -492,6 +497,7 @@ class TestCLI:
492 497
         self,
493 498
         monkeypatch: pytest.MonkeyPatch,
494 499
         running_ssh_agent: tests.RunningSSHAgentInfo,
500
+        caplog: pytest.LogCaptureFixture,
495 501
         config: _types.VaultConfig,
496 502
     ) -> None:
497 503
         with monkeypatch.context():
... ...
@@ -518,13 +524,13 @@ class TestCLI:
518 524
         assert result.stderr, 'expected known error output'
519 525
         err_lines = result.stderr.splitlines(False)
520 526
         assert err_lines[0].startswith('Passphrase:')
521
-        assert any(  # pragma: no branch
522
-            ' Warning: Setting a service passphrase is ineffective ' in line
523
-            for line in err_lines
527
+        assert tests.warning_emitted(
528
+            'Setting a service passphrase is ineffective ',
529
+            caplog.record_tuples,
524 530
         ), 'expected known warning message'
525
-        assert all(  # pragma: no branch
526
-            is_harmless_config_import_warning_line(line)
527
-            for line in result.stderr.splitlines(True)
531
+        assert all(map(is_warning_line, result.stderr.splitlines(True)))
532
+        assert all(
533
+            map(is_harmless_config_import_warning, caplog.record_tuples)
528 534
         ), 'unexpected error output'
529 535
 
530 536
     @pytest.mark.parametrize(
... ...
@@ -557,7 +563,7 @@ class TestCLI:
557 563
                 )
558 564
                 result = tests.ReadableResult.parse(_result)
559 565
                 assert result.error_exit(
560
-                    error='Error: Invalid value'
566
+                    error='Invalid value'
561 567
                 ), 'expected error exit and known error message'
562 568
 
563 569
     @pytest.mark.parametrize(
... ...
@@ -624,11 +630,13 @@ class TestCLI:
624 630
     def test_211a_empty_service_name_causes_warning(
625 631
         self,
626 632
         monkeypatch: pytest.MonkeyPatch,
633
+        caplog: pytest.LogCaptureFixture,
627 634
     ) -> None:
628
-        def expected_warning_line(line: str) -> bool:
629
-            return is_harmless_config_import_warning_line(line) or (
630
-                ' Warning: An empty SERVICE is not supported by vault(1)'
631
-                in line
635
+        def is_expected_warning(record: tuple[str, int, str]) -> bool:
636
+            return is_harmless_config_import_warning(
637
+                record
638
+            ) or tests.warning_emitted(
639
+                'An empty SERVICE is not supported by vault(1)', [record]
632 640
             )
633 641
 
634 642
         monkeypatch.setattr(cli, '_prompt_for_passphrase', tests.auto_prompt)
... ...
@@ -647,13 +655,13 @@ class TestCLI:
647 655
             assert result.clean_exit(empty_stderr=False), 'expected clean exit'
648 656
             assert result.stderr is not None, 'expected known error output'
649 657
             assert all(
650
-                expected_warning_line(line)
651
-                for line in result.stderr.splitlines(False)
658
+                map(is_expected_warning, caplog.record_tuples)
652 659
             ), 'expected known error output'
653 660
             assert cli._load_config() == {
654 661
                 'global': {'length': 30},
655 662
                 'services': {},
656 663
             }, 'requested configuration change was not applied'
664
+            caplog.clear()
657 665
             _result = runner.invoke(
658 666
                 cli.derivepassphrase_vault,
659 667
                 ['--import', '-'],
... ...
@@ -664,8 +672,7 @@ class TestCLI:
664 672
             assert result.clean_exit(empty_stderr=False), 'expected clean exit'
665 673
             assert result.stderr is not None, 'expected known error output'
666 674
             assert all(
667
-                expected_warning_line(line)
668
-                for line in result.stderr.splitlines(False)
675
+                map(is_expected_warning, caplog.record_tuples)
669 676
             ), 'expected known error output'
670 677
             assert cli._load_config() == {
671 678
                 'global': {'length': 30},
... ...
@@ -713,6 +720,7 @@ class TestCLI:
713 720
     def test_213_import_config_success(
714 721
         self,
715 722
         monkeypatch: pytest.MonkeyPatch,
723
+        caplog: pytest.LogCaptureFixture,
716 724
         config: Any,
717 725
     ) -> None:
718 726
         runner = click.testing.CliRunner(mix_stderr=False)
... ...
@@ -735,11 +743,10 @@ class TestCLI:
735 743
         assert result.clean_exit(empty_stderr=False), 'expected clean exit'
736 744
         assert config2 == config, 'config not imported correctly'
737 745
         assert not result.stderr or all(  # pragma: no branch
738
-            is_harmless_config_import_warning_line(line)
739
-            for line in result.stderr.splitlines(True)
746
+            map(is_harmless_config_import_warning, caplog.record_tuples)
740 747
         ), 'unexpected error output'
741 748
 
742
-    @tests.hypothesis_settings_coverage_compatible
749
+    @tests.hypothesis_settings_coverage_compatible_with_caplog
743 750
     @hypothesis.given(
744 751
         conf=tests.smudged_vault_test_config(
745 752
             strategies.sampled_from(TEST_CONFIGS).filter(
... ...
@@ -749,6 +756,7 @@ class TestCLI:
749 756
     )
750 757
     def test_213a_import_config_success(
751 758
         self,
759
+        caplog: pytest.LogCaptureFixture,
752 760
         conf: tests.VaultTestConfig,
753 761
     ) -> None:
754 762
         config = conf.config
... ...
@@ -774,8 +782,7 @@ class TestCLI:
774 782
         assert result.clean_exit(empty_stderr=False), 'expected clean exit'
775 783
         assert config3 == config2, 'config not imported correctly'
776 784
         assert not result.stderr or all(
777
-            is_harmless_config_import_warning_line(line)
778
-            for line in result.stderr.splitlines(True)
785
+            map(is_harmless_config_import_warning, caplog.record_tuples)
779 786
         ), 'unexpected error output'
780 787
 
781 788
     def test_213b_import_bad_config_not_vault_config(
... ...
@@ -1479,6 +1486,7 @@ contents go here
1479 1486
     def test_300_unicode_normalization_form_warning(
1480 1487
         self,
1481 1488
         monkeypatch: pytest.MonkeyPatch,
1489
+        caplog: pytest.LogCaptureFixture,
1482 1490
         command_line: list[str],
1483 1491
         input: str | None,
1484 1492
         warning_message: str,
... ...
@@ -1497,8 +1505,8 @@ contents go here
1497 1505
             )
1498 1506
         result = tests.ReadableResult.parse(_result)
1499 1507
         assert result.clean_exit(), 'expected clean exit'
1500
-        assert (
1501
-            warning_message in result.stderr
1508
+        assert tests.warning_emitted(
1509
+            warning_message, caplog.record_tuples
1502 1510
         ), 'expected known warning message in stderr'
1503 1511
 
1504 1512
     def test_400_missing_af_unix_support(
... ...
@@ -1708,6 +1716,111 @@ Boo.
1708 1716
         assert res['kwargs'].get('err'), err_msg
1709 1717
         assert res['kwargs'].get('hide_input'), err_msg
1710 1718
 
1719
+    def test_120_standard_logging_context_manager(
1720
+        self,
1721
+        caplog: pytest.LogCaptureFixture,
1722
+        capsys: pytest.CaptureFixture[str],
1723
+    ) -> None:
1724
+        prog_name = cli.StandardCLILogging.prog_name
1725
+        package_name = cli.StandardCLILogging.package_name
1726
+        logger = logging.getLogger(package_name)
1727
+        deprecation_logger = logging.getLogger(f'{package_name}.deprecation')
1728
+        logging_cm = cli.StandardCLILogging.ensure_standard_logging()
1729
+        with logging_cm:
1730
+            assert (
1731
+                sum(
1732
+                    1
1733
+                    for h in logger.handlers
1734
+                    if h is cli.StandardCLILogging.cli_handler
1735
+                )
1736
+                == 1
1737
+            )
1738
+            logger.warning('message 1')
1739
+            with logging_cm:
1740
+                deprecation_logger.warning('message 2')
1741
+                assert (
1742
+                    sum(
1743
+                        1
1744
+                        for h in logger.handlers
1745
+                        if h is cli.StandardCLILogging.cli_handler
1746
+                    )
1747
+                    == 1
1748
+                )
1749
+                assert capsys.readouterr() == (
1750
+                    '',
1751
+                    (
1752
+                        f'{prog_name}: Warning: message 1\n'
1753
+                        f'{prog_name}: Deprecation warning: message 2\n'
1754
+                    ),
1755
+                )
1756
+            logger.warning('message 3')
1757
+            assert (
1758
+                sum(
1759
+                    1
1760
+                    for h in logger.handlers
1761
+                    if h is cli.StandardCLILogging.cli_handler
1762
+                )
1763
+                == 1
1764
+            )
1765
+            assert capsys.readouterr() == (
1766
+                '',
1767
+                f'{prog_name}: Warning: message 3\n',
1768
+            )
1769
+            assert caplog.record_tuples == [
1770
+                (package_name, logging.WARNING, 'message 1'),
1771
+                (f'{package_name}.deprecation', logging.WARNING, 'message 2'),
1772
+                (package_name, logging.WARNING, 'message 3'),
1773
+            ]
1774
+
1775
+    def test_121_standard_logging_warnings_context_manager(
1776
+        self,
1777
+        caplog: pytest.LogCaptureFixture,
1778
+        capsys: pytest.CaptureFixture[str],
1779
+    ) -> None:
1780
+        warnings_cm = cli.StandardCLILogging.ensure_standard_warnings_logging()
1781
+        THE_FUTURE = 'the future will be here sooner than you think'  # noqa: N806
1782
+        JUST_TESTING = 'just testing whether warnings work'  # noqa: N806
1783
+        with warnings_cm:
1784
+            assert (
1785
+                sum(
1786
+                    1
1787
+                    for h in logging.getLogger('py.warnings').handlers
1788
+                    if h is cli.StandardCLILogging.warnings_handler
1789
+                )
1790
+                == 1
1791
+            )
1792
+            warnings.warn(UserWarning(JUST_TESTING), stacklevel=1)
1793
+            with warnings_cm:
1794
+                warnings.warn(FutureWarning(THE_FUTURE), stacklevel=1)
1795
+                _out, err = capsys.readouterr()
1796
+                err_lines = err.splitlines(True)
1797
+                assert any(
1798
+                    f'UserWarning: {JUST_TESTING}' in line
1799
+                    for line in err_lines
1800
+                )
1801
+                assert any(
1802
+                    f'FutureWarning: {THE_FUTURE}' in line
1803
+                    for line in err_lines
1804
+                )
1805
+            warnings.warn(UserWarning(JUST_TESTING), stacklevel=1)
1806
+            _out, err = capsys.readouterr()
1807
+            err_lines = err.splitlines(True)
1808
+            assert any(
1809
+                f'UserWarning: {JUST_TESTING}' in line for line in err_lines
1810
+            )
1811
+            assert not any(
1812
+                f'FutureWarning: {THE_FUTURE}' in line for line in err_lines
1813
+            )
1814
+            record_tuples = caplog.record_tuples
1815
+            assert [tup[:2] for tup in record_tuples] == [
1816
+                ('py.warnings', logging.WARNING),
1817
+                ('py.warnings', logging.WARNING),
1818
+                ('py.warnings', logging.WARNING),
1819
+            ]
1820
+            assert f'UserWarning: {JUST_TESTING}' in record_tuples[0][2]
1821
+            assert f'FutureWarning: {THE_FUTURE}' in record_tuples[1][2]
1822
+            assert f'UserWarning: {JUST_TESTING}' in record_tuples[2][2]
1823
+
1711 1824
     @pytest.mark.parametrize(
1712 1825
         ['command_line', 'config', 'result_config'],
1713 1826
         [
... ...
@@ -2014,7 +2127,9 @@ class TestCLITransition:
2014 2127
                 cli._migrate_and_load_old_config()
2015 2128
 
2016 2129
     def test_200_forward_export_vault_path_parameter(
2017
-        self, monkeypatch: pytest.MonkeyPatch
2130
+        self,
2131
+        monkeypatch: pytest.MonkeyPatch,
2132
+        caplog: pytest.LogCaptureFixture,
2018 2133
     ) -> None:
2019 2134
         pytest.importorskip('cryptography', minversion='38.0')
2020 2135
         runner = click.testing.CliRunner(mix_stderr=False)
... ...
@@ -2031,12 +2146,11 @@ class TestCLITransition:
2031 2146
             )
2032 2147
         result = tests.ReadableResult.parse(_result)
2033 2148
         assert result.clean_exit(empty_stderr=False), 'expected clean exit'
2034
-        assert (
2035
-            result.stderr
2036
-            == f"""\
2037
-{cli.PROG_NAME}: Deprecation warning: A subcommand will be required in v1.0. See --help for available subcommands.
2038
-{cli.PROG_NAME}: Warning: Defaulting to subcommand "vault".
2039
-"""  # noqa: E501
2149
+        assert tests.deprecation_warning_emitted(
2150
+            'A subcommand will be required in v1.0', caplog.record_tuples
2151
+        )
2152
+        assert tests.warning_emitted(
2153
+            'Defaulting to subcommand "vault"', caplog.record_tuples
2040 2154
         )
2041 2155
         assert json.loads(result.output) == tests.VAULT_V03_CONFIG_DATA
2042 2156
 
... ...
@@ -2056,10 +2170,12 @@ class TestCLITransition:
2056 2170
                 ['export'],
2057 2171
             )
2058 2172
         result = tests.ReadableResult.parse(_result)
2059
-        assert result.stderr.startswith(f"""\
2060
-{cli.PROG_NAME}: Deprecation warning: A subcommand will be required in v1.0. See --help for available subcommands.
2061
-{cli.PROG_NAME}: Warning: Defaulting to subcommand "vault".
2062
-""")  # noqa: E501
2173
+        assert tests.deprecation_warning_emitted(
2174
+            'A subcommand will be required in v1.0', caplog.record_tuples
2175
+        )
2176
+        assert tests.warning_emitted(
2177
+            'Defaulting to subcommand "vault"', caplog.record_tuples
2178
+        )
2063 2179
         assert result.error_exit(
2064 2180
             error="Missing argument 'PATH'"
2065 2181
         ), 'expected error exit and known error type'
... ...
@@ -2068,7 +2184,10 @@ class TestCLITransition:
2068 2184
         'charset_name', ['lower', 'upper', 'number', 'space', 'dash', 'symbol']
2069 2185
     )
2070 2186
     def test_210_forward_vault_disable_character_set(
2071
-        self, monkeypatch: pytest.MonkeyPatch, charset_name: str
2187
+        self,
2188
+        monkeypatch: pytest.MonkeyPatch,
2189
+        caplog: pytest.LogCaptureFixture,
2190
+        charset_name: str,
2072 2191
     ) -> None:
2073 2192
         monkeypatch.setattr(cli, '_prompt_for_passphrase', tests.auto_prompt)
2074 2193
         option = f'--{charset_name}'
... ...
@@ -2086,12 +2205,11 @@ class TestCLITransition:
2086 2205
             )
2087 2206
             result = tests.ReadableResult.parse(_result)
2088 2207
         assert result.clean_exit(empty_stderr=False), 'expected clean exit'
2089
-        assert (
2090
-            result.stderr
2091
-            == f"""\
2092
-{cli.PROG_NAME}: Deprecation warning: A subcommand will be required in v1.0. See --help for available subcommands.
2093
-{cli.PROG_NAME}: Warning: Defaulting to subcommand "vault".
2094
-"""  # noqa: E501
2208
+        assert tests.deprecation_warning_emitted(
2209
+            'A subcommand will be required in v1.0', caplog.record_tuples
2210
+        )
2211
+        assert tests.warning_emitted(
2212
+            'Defaulting to subcommand "vault"', caplog.record_tuples
2095 2213
         )
2096 2214
         for c in charset:
2097 2215
             assert (
... ...
@@ -2115,10 +2233,12 @@ class TestCLITransition:
2115 2233
                 catch_exceptions=False,
2116 2234
             )
2117 2235
             result = tests.ReadableResult.parse(_result)
2118
-        assert result.stderr.startswith(f"""\
2119
-{cli.PROG_NAME}: Deprecation warning: A subcommand will be required in v1.0. See --help for available subcommands.
2120
-{cli.PROG_NAME}: Warning: Defaulting to subcommand "vault".
2121
-""")  # noqa: E501
2236
+        assert tests.deprecation_warning_emitted(
2237
+            'A subcommand will be required in v1.0', caplog.record_tuples
2238
+        )
2239
+        assert tests.warning_emitted(
2240
+            'Defaulting to subcommand "vault"', caplog.record_tuples
2241
+        )
2122 2242
         assert result.error_exit(
2123 2243
             error='SERVICE is required'
2124 2244
         ), 'expected error exit and known error type'
... ...
@@ -2126,7 +2246,9 @@ class TestCLITransition:
2126 2246
     def test_300_export_using_old_config_file(
2127 2247
         self,
2128 2248
         monkeypatch: pytest.MonkeyPatch,
2249
+        caplog: pytest.LogCaptureFixture,
2129 2250
     ) -> None:
2251
+        caplog.set_level(logging.INFO)
2130 2252
         runner = click.testing.CliRunner(mix_stderr=False)
2131 2253
         with tests.isolated_config(
2132 2254
             monkeypatch=monkeypatch,
... ...
@@ -2149,16 +2271,17 @@ class TestCLITransition:
2149 2271
             )
2150 2272
         result = tests.ReadableResult.parse(_result)
2151 2273
         assert result.clean_exit(), 'expected clean exit'
2152
-        assert (
2153
-            'v0.1-style config file' in result.stderr
2274
+        assert tests.deprecation_warning_emitted(
2275
+            'v0.1-style config file', caplog.record_tuples
2154 2276
         ), 'expected known warning message in stderr'
2155
-        assert (
2156
-            'Successfully migrated to ' in result.stderr
2277
+        assert tests.info_emitted(
2278
+            'Successfully migrated to ', caplog.record_tuples
2157 2279
         ), 'expected known warning message in stderr'
2158 2280
 
2159 2281
     def test_300a_export_using_old_config_file_migration_error(
2160 2282
         self,
2161 2283
         monkeypatch: pytest.MonkeyPatch,
2284
+        caplog: pytest.LogCaptureFixture,
2162 2285
     ) -> None:
2163 2286
         runner = click.testing.CliRunner(mix_stderr=False)
2164 2287
         with tests.isolated_config(
... ...
@@ -2191,11 +2314,11 @@ class TestCLITransition:
2191 2314
             )
2192 2315
         result = tests.ReadableResult.parse(_result)
2193 2316
         assert result.clean_exit(), 'expected clean exit'
2194
-        assert (
2195
-            'v0.1-style config file' in result.stderr
2317
+        assert tests.deprecation_warning_emitted(
2318
+            'v0.1-style config file', caplog.record_tuples
2196 2319
         ), 'expected known warning message in stderr'
2197
-        assert (
2198
-            'Warning: Failed to migrate to ' in result.stderr
2320
+        assert tests.warning_emitted(
2321
+            'Failed to migrate to ', caplog.record_tuples
2199 2322
         ), 'expected known warning message in stderr'
2200 2323
 
2201 2324
 
... ...
@@ -118,6 +118,7 @@ class TestCLI:
118 118
     def test_301_vault_config_not_found(
119 119
         self,
120 120
         monkeypatch: pytest.MonkeyPatch,
121
+        caplog: pytest.LogCaptureFixture,
121 122
     ) -> None:
122 123
         runner = click.testing.CliRunner(mix_stderr=False)
123 124
         with tests.isolated_vault_exporter_config(
... ...
@@ -132,13 +133,15 @@ class TestCLI:
132 133
             )
133 134
         result = tests.ReadableResult.parse(_result)
134 135
         assert result.error_exit(
135
-            error="Cannot parse 'does-not-exist.txt' as a valid config"
136
+            error="Cannot parse 'does-not-exist.txt' as a valid config",
137
+            record_tuples=caplog.record_tuples,
136 138
         ), 'expected error exit and known error message'
137 139
         assert tests.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr
138 140
 
139 141
     def test_302_vault_config_invalid(
140 142
         self,
141 143
         monkeypatch: pytest.MonkeyPatch,
144
+        caplog: pytest.LogCaptureFixture,
142 145
     ) -> None:
143 146
         runner = click.testing.CliRunner(mix_stderr=False)
144 147
         with tests.isolated_vault_exporter_config(
... ...
@@ -153,13 +156,15 @@ class TestCLI:
153 156
             )
154 157
         result = tests.ReadableResult.parse(_result)
155 158
         assert result.error_exit(
156
-            error="Cannot parse '.vault' as a valid config"
159
+            error="Cannot parse '.vault' as a valid config",
160
+            record_tuples=caplog.record_tuples,
157 161
         ), 'expected error exit and known error message'
158 162
         assert tests.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr
159 163
 
160 164
     def test_403_invalid_vault_config_bad_signature(
161 165
         self,
162 166
         monkeypatch: pytest.MonkeyPatch,
167
+        caplog: pytest.LogCaptureFixture,
163 168
     ) -> None:
164 169
         runner = click.testing.CliRunner(mix_stderr=False)
165 170
         with tests.isolated_vault_exporter_config(
... ...
@@ -174,13 +179,15 @@ class TestCLI:
174 179
             )
175 180
         result = tests.ReadableResult.parse(_result)
176 181
         assert result.error_exit(
177
-            error="Cannot parse '.vault' as a valid config"
182
+            error="Cannot parse '.vault' as a valid config",
183
+            record_tuples=caplog.record_tuples,
178 184
         ), 'expected error exit and known error message'
179 185
         assert tests.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr
180 186
 
181 187
     def test_500_vault_config_invalid_internal(
182 188
         self,
183 189
         monkeypatch: pytest.MonkeyPatch,
190
+        caplog: pytest.LogCaptureFixture,
184 191
     ) -> None:
185 192
         runner = click.testing.CliRunner(mix_stderr=False)
186 193
         with tests.isolated_vault_exporter_config(
... ...
@@ -200,7 +207,8 @@ class TestCLI:
200 207
             )
201 208
         result = tests.ReadableResult.parse(_result)
202 209
         assert result.error_exit(
203
-            error='Invalid vault config: '
210
+            error='Invalid vault config: ',
211
+            record_tuples=caplog.record_tuples,
204 212
         ), 'expected error exit and known error message'
205 213
         assert tests.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr
206 214
 
... ...
@@ -152,6 +152,7 @@ class Test002CLI:
152 152
     def test_999_no_cryptography_error_message(
153 153
         self,
154 154
         monkeypatch: pytest.MonkeyPatch,
155
+        caplog: pytest.LogCaptureFixture,
155 156
         format: str,
156 157
         config: str | bytes,
157 158
         key: str,
... ...
@@ -170,5 +171,6 @@ class Test002CLI:
170 171
             )
171 172
         result = tests.ReadableResult.parse(_result)
172 173
         assert result.error_exit(
173
-            error=tests.CANNOT_LOAD_CRYPTOGRAPHY
174
+            error=tests.CANNOT_LOAD_CRYPTOGRAPHY,
175
+            record_tuples=caplog.record_tuples,
174 176
         ), 'expected error exit and known error message'
175 177