Wrap click.testing.CliRunner to directly return a ReadableResult
Marco Ricci

Marco Ricci commited on 2025-04-09 19:16:36
Zeige 5 geänderte Dateien mit 348 Einfügungen und 368 Löschungen.


Instead of using `click.testing.CliRunner` directly and transforming the
result, wrap the runner in a new class that does the necessary
transformations on the result automatically.

This indirection of the CLI runner also allows the wrapper to cope with
(some) incompatible changes in `click` across versions, or even
eventually swap out the underlying implementation completely.

As a first incompatibility to bridge, `click` 8.2.0 (currently in beta)
merges many bad or incomplete fixes for long-standing issues, but with
no regard to backward compatibility.  Of particular relevance,
`click.testing.CliRunner` no longer accepts the `mix_stderr`
argument,[^1] and the meanings of the `output` and the `stdout`
attribute of `click.testing.Result` have changed.[^2]  These two
incompatibilities are handled by the wrapper.

  [^1]: This breaks practically every call to the runner: `mix_stderr`
  defaults to `True`, thus the default behavior is to mangle the output
  streams.  Why would you want that, in your testing machinery no less,
  where automatically mangling or discarding information behind the
  scenes is detrimental to debugging?!  As a consequence, `mix_stderr`
  *has* to be specified if you want output streams not to be mangled,
  and if you want them to be mangled, you *should* specify `mix_stderr`
  explicitly.  So basically, you always specify `mix_stderr`.  And thus
  removing the (definition of the) `mix_stderr` argument breaks
  practically every call to the runner.

  [^2]: This is complementary to the removal of the `mix_stderr`
  argument: the mixed output is now always stored in the `output`
  attribute, and the `stdout` is the true standard output.  Of course,
  this contradicts the previous use of `output` and `stdout`, where
  `output` was the true standard output, and `stdout` was a convenience
  alias via an extra layer of redirection.
... ...
@@ -19,12 +19,13 @@ import stat
19 19
 import tempfile
20 20
 import types
21 21
 import zipfile
22
-from typing import TYPE_CHECKING
22
+from typing import TYPE_CHECKING, TypedDict
23 23
 
24
+import click.testing
24 25
 import hypothesis
25 26
 import pytest
26 27
 from hypothesis import strategies
27
-from typing_extensions import NamedTuple, Self, assert_never
28
+from typing_extensions import NamedTuple, assert_never
28 29
 
29 30
 from derivepassphrase import _types, cli, ssh_agent, vault
30 31
 from derivepassphrase._internals import cli_helpers, cli_machinery
... ...
@@ -35,8 +36,8 @@ if TYPE_CHECKING:
35 36
     import socket
36 37
     from collections.abc import Callable, Iterator, Mapping, Sequence
37 38
     from contextlib import AbstractContextManager
39
+    from typing import IO, NotRequired
38 40
 
39
-    import click.testing
40 41
     from typing_extensions import Any
41 42
 
42 43
 
... ...
@@ -1699,7 +1700,7 @@ def phrase_from_key(
1699 1700
 @contextlib.contextmanager
1700 1701
 def isolated_config(
1701 1702
     monkeypatch: pytest.MonkeyPatch,
1702
-    runner: click.testing.CliRunner,
1703
+    runner: CliRunner,
1703 1704
     main_config_str: str | None = None,
1704 1705
 ) -> Iterator[None]:
1705 1706
     """Provide an isolated configuration setup, as a context.
... ...
@@ -1752,7 +1753,7 @@ def isolated_config(
1752 1753
 @contextlib.contextmanager
1753 1754
 def isolated_vault_config(
1754 1755
     monkeypatch: pytest.MonkeyPatch,
1755
-    runner: click.testing.CliRunner,
1756
+    runner: CliRunner,
1756 1757
     vault_config: Any,
1757 1758
     main_config_str: str | None = None,
1758 1759
 ) -> Iterator[None]:
... ...
@@ -1790,7 +1791,7 @@ def isolated_vault_config(
1790 1791
 @contextlib.contextmanager
1791 1792
 def isolated_vault_exporter_config(
1792 1793
     monkeypatch: pytest.MonkeyPatch,
1793
-    runner: click.testing.CliRunner,
1794
+    runner: CliRunner,
1794 1795
     vault_config: str | bytes | None = None,
1795 1796
     vault_key: str | None = None,
1796 1797
 ) -> Iterator[None]:
... ...
@@ -1949,18 +1950,9 @@ class ReadableResult(NamedTuple):
1949 1950
 
1950 1951
     exception: BaseException | None
1951 1952
     exit_code: int
1952
-    output: str
1953
+    stdout: str
1953 1954
     stderr: str
1954 1955
 
1955
-    @classmethod
1956
-    def parse(cls, r: click.testing.Result, /) -> Self:
1957
-        """Return a readable result object, given a result."""
1958
-        try:
1959
-            stderr = r.stderr
1960
-        except ValueError:
1961
-            stderr = r.output
1962
-        return cls(r.exception, r.exit_code, r.output or '', stderr or '')
1963
-
1964 1956
     def clean_exit(
1965 1957
         self, *, output: str = '', empty_stderr: bool = False
1966 1958
     ) -> bool:
... ...
@@ -1979,7 +1971,7 @@ class ReadableResult(NamedTuple):
1979 1971
                     and self.exit_code == 0
1980 1972
                 )
1981 1973
             )
1982
-            and (not output or output in self.output)
1974
+            and (not output or output in self.stdout)
1983 1975
             and (not empty_stderr or not self.stderr)
1984 1976
         )
1985 1977
 
... ...
@@ -2025,6 +2017,92 @@ class ReadableResult(NamedTuple):
2025 2017
             )
2026 2018
 
2027 2019
 
2020
+class CliRunner:
2021
+    """An abstracted CLI runner class.
2022
+
2023
+    Intended to provide similar functionality and scope as the
2024
+    [`click.testing.CliRunner`][] class, though not necessarily
2025
+    `click`-specific.  Also allows for seamless migration away from
2026
+    `click`, if/when we decide this.
2027
+
2028
+    """
2029
+
2030
+    _SUPPORTS_MIX_STDERR_ATTRIBUTE = not hasattr(click.testing, 'StreamMixer')
2031
+    """
2032
+    True if and only if [`click.testing.CliRunner`][] supports the
2033
+    `mix_stderr` attribute.  It was removed in 8.2.0 in favor of the
2034
+    [`click.testing.StreamMixer`][] class.
2035
+
2036
+    See also
2037
+    [`pallets/click#2523`](https://github.com/pallets/click/pull/2523).
2038
+    """
2039
+
2040
+    def __init__(
2041
+        self,
2042
+        *,
2043
+        mix_stderr: bool = False,
2044
+        color: bool | None = None,
2045
+    ) -> None:
2046
+        self.color = color
2047
+        self.mix_stderr = mix_stderr
2048
+
2049
+        class MixStderrAttribute(TypedDict):
2050
+            mix_stderr: NotRequired[bool]
2051
+
2052
+        mix_stderr_args: MixStderrAttribute = (
2053
+            {'mix_stderr': mix_stderr}
2054
+            if self._SUPPORTS_MIX_STDERR_ATTRIBUTE
2055
+            else {}
2056
+        )
2057
+        self.click_testing_clirunner = click.testing.CliRunner(
2058
+            **mix_stderr_args
2059
+        )
2060
+
2061
+    def invoke(
2062
+        self,
2063
+        cli: click.BaseCommand,
2064
+        args: Sequence[str] | str | None = None,
2065
+        input: str | bytes | IO[Any] | None = None,
2066
+        env: Mapping[str, str | None] | None = None,
2067
+        catch_exceptions: bool = True,
2068
+        color: bool | None = None,
2069
+        **extra: Any,
2070
+    ) -> ReadableResult:
2071
+        if color is None:  # pragma: no cover
2072
+            color = self.color if self.color is not None else False
2073
+        raw_result = self.click_testing_clirunner.invoke(
2074
+            cli,
2075
+            args=args,
2076
+            input=input,
2077
+            env=env,
2078
+            catch_exceptions=catch_exceptions,
2079
+            color=color,
2080
+            **extra,
2081
+        )
2082
+        # In 8.2.0, r.stdout is no longer a property aliasing the
2083
+        # `output` attribute, but rather the raw stdout value.
2084
+        try:
2085
+            stderr = raw_result.stderr
2086
+        except ValueError:
2087
+            stderr = raw_result.stdout
2088
+        return ReadableResult(
2089
+            raw_result.exception,
2090
+            raw_result.exit_code,
2091
+            (raw_result.stdout if not self.mix_stderr else raw_result.output)
2092
+            or '',
2093
+            stderr or '',
2094
+        )
2095
+        return ReadableResult.parse(raw_result)
2096
+
2097
+    def isolated_filesystem(
2098
+        self,
2099
+        temp_dir: str | os.PathLike[str] | None = None,
2100
+    ) -> AbstractContextManager[str]:
2101
+        return self.click_testing_clirunner.isolated_filesystem(
2102
+            temp_dir=temp_dir
2103
+        )
2104
+
2105
+
2028 2106
 def parse_sh_export_line(line: str, *, env_name: str) -> str:
2029 2107
     """Parse the output of typical SSH agents' SSH_AUTH_SOCK lines.
2030 2108
 
... ...
@@ -317,8 +317,8 @@ def vault_config_exporter_shell_interpreter(  # noqa: C901
317 317
     *,
318 318
     prog_name_list: list[str] | None = None,
319 319
     command: click.BaseCommand | None = None,
320
-    runner: click.testing.CliRunner | None = None,
321
-) -> Iterator[click.testing.Result]:
320
+    runner: tests.CliRunner | None = None,
321
+) -> Iterator[tests.ReadableResult]:
322 322
     """A rudimentary sh(1) interpreter for `--export-as=sh` output.
323 323
 
324 324
     Assumes a script as emitted by `derivepassphrase vault
... ...
@@ -335,7 +335,7 @@ def vault_config_exporter_shell_interpreter(  # noqa: C901
335 335
     if command is None:  # pragma: no cover
336 336
         command = cli.derivepassphrase_vault
337 337
     if runner is None:  # pragma: no cover
338
-        runner = click.testing.CliRunner(mix_stderr=False)
338
+        runner = tests.CliRunner(mix_stderr=False)
339 339
     n = len(prog_name_list)
340 340
     it = iter(script)
341 341
     while True:
... ...
@@ -1666,7 +1666,7 @@ class TestAllCLI:
1666 1666
         TODO: Do we actually need this?  What should we check for?
1667 1667
 
1668 1668
         """
1669
-        runner = click.testing.CliRunner(mix_stderr=False)
1669
+        runner = tests.CliRunner(mix_stderr=False)
1670 1670
         # TODO(the-13th-letter): Rewrite using parenthesized
1671 1671
         # with-statements.
1672 1672
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -1678,10 +1678,9 @@ class TestAllCLI:
1678 1678
                     runner=runner,
1679 1679
                 )
1680 1680
             )
1681
-            result_ = runner.invoke(
1681
+            result = runner.invoke(
1682 1682
                 cli.derivepassphrase, ['--help'], catch_exceptions=False
1683 1683
             )
1684
-            result = tests.ReadableResult.parse(result_)
1685 1684
         assert result.clean_exit(
1686 1685
             empty_stderr=True, output='currently implemented subcommands'
1687 1686
         ), 'expected clean exit, and known help text'
... ...
@@ -1696,7 +1695,7 @@ class TestAllCLI:
1696 1695
         TODO: Do we actually need this?  What should we check for?
1697 1696
 
1698 1697
         """
1699
-        runner = click.testing.CliRunner(mix_stderr=False)
1698
+        runner = tests.CliRunner(mix_stderr=False)
1700 1699
         # TODO(the-13th-letter): Rewrite using parenthesized
1701 1700
         # with-statements.
1702 1701
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -1708,12 +1707,11 @@ class TestAllCLI:
1708 1707
                     runner=runner,
1709 1708
                 )
1710 1709
             )
1711
-            result_ = runner.invoke(
1710
+            result = runner.invoke(
1712 1711
                 cli.derivepassphrase,
1713 1712
                 ['export', '--help'],
1714 1713
                 catch_exceptions=False,
1715 1714
             )
1716
-            result = tests.ReadableResult.parse(result_)
1717 1715
         assert result.clean_exit(
1718 1716
             empty_stderr=True, output='only available subcommand'
1719 1717
         ), 'expected clean exit, and known help text'
... ...
@@ -1728,7 +1726,7 @@ class TestAllCLI:
1728 1726
         TODO: Do we actually need this?  What should we check for?
1729 1727
 
1730 1728
         """
1731
-        runner = click.testing.CliRunner(mix_stderr=False)
1729
+        runner = tests.CliRunner(mix_stderr=False)
1732 1730
         # TODO(the-13th-letter): Rewrite using parenthesized
1733 1731
         # with-statements.
1734 1732
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -1740,12 +1738,11 @@ class TestAllCLI:
1740 1738
                     runner=runner,
1741 1739
                 )
1742 1740
             )
1743
-            result_ = runner.invoke(
1741
+            result = runner.invoke(
1744 1742
                 cli.derivepassphrase,
1745 1743
                 ['export', 'vault', '--help'],
1746 1744
                 catch_exceptions=False,
1747 1745
             )
1748
-            result = tests.ReadableResult.parse(result_)
1749 1746
         assert result.clean_exit(
1750 1747
             empty_stderr=True, output='Export a vault-native configuration'
1751 1748
         ), 'expected clean exit, and known help text'
... ...
@@ -1760,7 +1757,7 @@ class TestAllCLI:
1760 1757
         TODO: Do we actually need this?  What should we check for?
1761 1758
 
1762 1759
         """
1763
-        runner = click.testing.CliRunner(mix_stderr=False)
1760
+        runner = tests.CliRunner(mix_stderr=False)
1764 1761
         # TODO(the-13th-letter): Rewrite using parenthesized
1765 1762
         # with-statements.
1766 1763
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -1772,12 +1769,11 @@ class TestAllCLI:
1772 1769
                     runner=runner,
1773 1770
                 )
1774 1771
             )
1775
-            result_ = runner.invoke(
1772
+            result = runner.invoke(
1776 1773
                 cli.derivepassphrase,
1777 1774
                 ['vault', '--help'],
1778 1775
                 catch_exceptions=False,
1779 1776
             )
1780
-            result = tests.ReadableResult.parse(result_)
1781 1777
         assert result.clean_exit(
1782 1778
             empty_stderr=True, output='Passphrase generation:\n'
1783 1779
         ), 'expected clean exit, and option groups in help text'
... ...
@@ -1794,7 +1790,7 @@ class TestAllCLI:
1794 1790
         non_eager_arguments: list[str],
1795 1791
     ) -> None:
1796 1792
         """Eager options terminate option and argument processing."""
1797
-        runner = click.testing.CliRunner(mix_stderr=False)
1793
+        runner = tests.CliRunner(mix_stderr=False)
1798 1794
         # TODO(the-13th-letter): Rewrite using parenthesized
1799 1795
         # with-statements.
1800 1796
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -1806,12 +1802,11 @@ class TestAllCLI:
1806 1802
                     runner=runner,
1807 1803
                 )
1808 1804
             )
1809
-            result_ = runner.invoke(
1805
+            result = runner.invoke(
1810 1806
                 cli.derivepassphrase,
1811 1807
                 [*command, *arguments, *non_eager_arguments],
1812 1808
                 catch_exceptions=False,
1813 1809
             )
1814
-            result = tests.ReadableResult.parse(result_)
1815 1810
         assert result.clean_exit(empty_stderr=True), 'expected clean exit'
1816 1811
 
1817 1812
     @Parametrize.NO_COLOR
... ...
@@ -1830,7 +1825,7 @@ class TestAllCLI:
1830 1825
         # Force color on if force_color.  Otherwise force color off if
1831 1826
         # no_color.  Otherwise set color if and only if we have a TTY.
1832 1827
         color = force_color or not no_color if isatty else force_color
1833
-        runner = click.testing.CliRunner(mix_stderr=False)
1828
+        runner = tests.CliRunner(mix_stderr=False)
1834 1829
         # TODO(the-13th-letter): Rewrite using parenthesized
1835 1830
         # with-statements.
1836 1831
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -1846,14 +1841,13 @@ class TestAllCLI:
1846 1841
                 monkeypatch.setenv('NO_COLOR', 'yes')
1847 1842
             if force_color:
1848 1843
                 monkeypatch.setenv('FORCE_COLOR', 'yes')
1849
-            result_ = runner.invoke(
1844
+            result = runner.invoke(
1850 1845
                 cli.derivepassphrase,
1851 1846
                 command_line,
1852 1847
                 input=input,
1853 1848
                 catch_exceptions=False,
1854 1849
                 color=isatty,
1855 1850
             )
1856
-            result = tests.ReadableResult.parse(result_)
1857 1851
         assert (
1858 1852
             not color
1859 1853
             or '\x1b[0m' in result.stderr
... ...
@@ -1876,7 +1870,7 @@ class TestAllCLI:
1876 1870
         subcommands.
1877 1871
 
1878 1872
         """
1879
-        runner = click.testing.CliRunner(mix_stderr=False)
1873
+        runner = tests.CliRunner(mix_stderr=False)
1880 1874
         # TODO(the-13th-letter): Rewrite using parenthesized
1881 1875
         # with-statements.
1882 1876
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -1888,15 +1882,14 @@ class TestAllCLI:
1888 1882
                     runner=runner,
1889 1883
                 )
1890 1884
             )
1891
-            result_ = runner.invoke(
1885
+            result = runner.invoke(
1892 1886
                 cli.derivepassphrase,
1893 1887
                 ['--version'],
1894 1888
                 catch_exceptions=False,
1895 1889
             )
1896
-            result = tests.ReadableResult.parse(result_)
1897 1890
         assert result.clean_exit(empty_stderr=True), 'expected clean exit'
1898
-        assert result.output.strip(), 'expected version output'
1899
-        version_data = parse_version_output(result.output)
1891
+        assert result.stdout.strip(), 'expected version output'
1892
+        version_data = parse_version_output(result.stdout)
1900 1893
         actually_known_schemes = dict.fromkeys(_types.DerivationScheme, True)
1901 1894
         subcommands = set(_types.Subcommand)
1902 1895
         assert version_data.derivation_schemes == actually_known_schemes
... ...
@@ -1918,7 +1911,7 @@ class TestAllCLI:
1918 1911
         of subcommands.
1919 1912
 
1920 1913
         """
1921
-        runner = click.testing.CliRunner(mix_stderr=False)
1914
+        runner = tests.CliRunner(mix_stderr=False)
1922 1915
         # TODO(the-13th-letter): Rewrite using parenthesized
1923 1916
         # with-statements.
1924 1917
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -1930,15 +1923,14 @@ class TestAllCLI:
1930 1923
                     runner=runner,
1931 1924
                 )
1932 1925
             )
1933
-            result_ = runner.invoke(
1926
+            result = runner.invoke(
1934 1927
                 cli.derivepassphrase,
1935 1928
                 ['export', '--version'],
1936 1929
                 catch_exceptions=False,
1937 1930
             )
1938
-            result = tests.ReadableResult.parse(result_)
1939 1931
         assert result.clean_exit(empty_stderr=True), 'expected clean exit'
1940
-        assert result.output.strip(), 'expected version output'
1941
-        version_data = parse_version_output(result.output)
1932
+        assert result.stdout.strip(), 'expected version output'
1933
+        version_data = parse_version_output(result.stdout)
1942 1934
         actually_known_formats: dict[str, bool] = {
1943 1935
             _types.ForeignConfigurationFormat.VAULT_STOREROOM: False,
1944 1936
             _types.ForeignConfigurationFormat.VAULT_V02: False,
... ...
@@ -1967,7 +1959,7 @@ class TestAllCLI:
1967 1959
         configuration formats, and a list of available PEP 508 extras.
1968 1960
 
1969 1961
         """
1970
-        runner = click.testing.CliRunner(mix_stderr=False)
1962
+        runner = tests.CliRunner(mix_stderr=False)
1971 1963
         # TODO(the-13th-letter): Rewrite using parenthesized
1972 1964
         # with-statements.
1973 1965
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -1979,15 +1971,14 @@ class TestAllCLI:
1979 1971
                     runner=runner,
1980 1972
                 )
1981 1973
             )
1982
-            result_ = runner.invoke(
1974
+            result = runner.invoke(
1983 1975
                 cli.derivepassphrase,
1984 1976
                 ['export', 'vault', '--version'],
1985 1977
                 catch_exceptions=False,
1986 1978
             )
1987
-            result = tests.ReadableResult.parse(result_)
1988 1979
         assert result.clean_exit(empty_stderr=True), 'expected clean exit'
1989
-        assert result.output.strip(), 'expected version output'
1990
-        version_data = parse_version_output(result.output)
1980
+        assert result.stdout.strip(), 'expected version output'
1981
+        version_data = parse_version_output(result.stdout)
1991 1982
         actually_known_formats: dict[str, bool] = {}
1992 1983
         actually_enabled_extras: set[str] = set()
1993 1984
         with contextlib.suppress(ModuleNotFoundError):
... ...
@@ -2021,7 +2012,7 @@ class TestAllCLI:
2021 2012
         first paragraph.
2022 2013
 
2023 2014
         """
2024
-        runner = click.testing.CliRunner(mix_stderr=False)
2015
+        runner = tests.CliRunner(mix_stderr=False)
2025 2016
         # TODO(the-13th-letter): Rewrite using parenthesized
2026 2017
         # with-statements.
2027 2018
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2033,15 +2024,14 @@ class TestAllCLI:
2033 2024
                     runner=runner,
2034 2025
                 )
2035 2026
             )
2036
-            result_ = runner.invoke(
2027
+            result = runner.invoke(
2037 2028
                 cli.derivepassphrase,
2038 2029
                 ['vault', '--version'],
2039 2030
                 catch_exceptions=False,
2040 2031
             )
2041
-            result = tests.ReadableResult.parse(result_)
2042 2032
         assert result.clean_exit(empty_stderr=True), 'expected clean exit'
2043
-        assert result.output.strip(), 'expected version output'
2044
-        version_data = parse_version_output(result.output)
2033
+        assert result.stdout.strip(), 'expected version output'
2034
+        version_data = parse_version_output(result.stdout)
2045 2035
         features: dict[str, bool] = {
2046 2036
             _types.Feature.SSH_KEY: hasattr(socket, 'AF_UNIX'),
2047 2037
         }
... ...
@@ -2059,7 +2049,7 @@ class TestCLI:
2059 2049
         self,
2060 2050
     ) -> None:
2061 2051
         """The `--help` option emits help text."""
2062
-        runner = click.testing.CliRunner(mix_stderr=False)
2052
+        runner = tests.CliRunner(mix_stderr=False)
2063 2053
         # TODO(the-13th-letter): Rewrite using parenthesized
2064 2054
         # with-statements.
2065 2055
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2071,12 +2061,11 @@ class TestCLI:
2071 2061
                     runner=runner,
2072 2062
                 )
2073 2063
             )
2074
-            result_ = runner.invoke(
2064
+            result = runner.invoke(
2075 2065
                 cli.derivepassphrase_vault,
2076 2066
                 ['--help'],
2077 2067
                 catch_exceptions=False,
2078 2068
             )
2079
-            result = tests.ReadableResult.parse(result_)
2080 2069
         assert result.clean_exit(
2081 2070
             empty_stderr=True, output='Passphrase generation:\n'
2082 2071
         ), 'expected clean exit, and option groups in help text'
... ...
@@ -2090,7 +2079,7 @@ class TestCLI:
2090 2079
         self,
2091 2080
     ) -> None:
2092 2081
         """The `--version` option emits version information."""
2093
-        runner = click.testing.CliRunner(mix_stderr=False)
2082
+        runner = tests.CliRunner(mix_stderr=False)
2094 2083
         # TODO(the-13th-letter): Rewrite using parenthesized
2095 2084
         # with-statements.
2096 2085
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2102,12 +2091,11 @@ class TestCLI:
2102 2091
                     runner=runner,
2103 2092
                 )
2104 2093
             )
2105
-            result_ = runner.invoke(
2094
+            result = runner.invoke(
2106 2095
                 cli.derivepassphrase_vault,
2107 2096
                 ['--version'],
2108 2097
                 catch_exceptions=False,
2109 2098
             )
2110
-            result = tests.ReadableResult.parse(result_)
2111 2099
         assert result.clean_exit(empty_stderr=True, output=cli.PROG_NAME), (
2112 2100
             'expected clean exit, and program name in version text'
2113 2101
         )
... ...
@@ -2123,7 +2111,7 @@ class TestCLI:
2123 2111
         """Named character classes can be disabled on the command-line."""
2124 2112
         option = f'--{charset_name}'
2125 2113
         charset = vault.Vault.CHARSETS[charset_name].decode('ascii')
2126
-        runner = click.testing.CliRunner(mix_stderr=False)
2114
+        runner = tests.CliRunner(mix_stderr=False)
2127 2115
         # TODO(the-13th-letter): Rewrite using parenthesized
2128 2116
         # with-statements.
2129 2117
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2138,16 +2126,15 @@ class TestCLI:
2138 2126
             monkeypatch.setattr(
2139 2127
                 cli_helpers, 'prompt_for_passphrase', tests.auto_prompt
2140 2128
             )
2141
-            result_ = runner.invoke(
2129
+            result = runner.invoke(
2142 2130
                 cli.derivepassphrase_vault,
2143 2131
                 [option, '0', '-p', '--', DUMMY_SERVICE],
2144 2132
                 input=DUMMY_PASSPHRASE,
2145 2133
                 catch_exceptions=False,
2146 2134
             )
2147
-            result = tests.ReadableResult.parse(result_)
2148 2135
         assert result.clean_exit(empty_stderr=True), 'expected clean exit:'
2149 2136
         for c in charset:
2150
-            assert c not in result.output, (
2137
+            assert c not in result.stdout, (
2151 2138
                 f'derived password contains forbidden character {c!r}'
2152 2139
             )
2153 2140
 
... ...
@@ -2155,7 +2142,7 @@ class TestCLI:
2155 2142
         self,
2156 2143
     ) -> None:
2157 2144
         """Character repetition can be disabled on the command-line."""
2158
-        runner = click.testing.CliRunner(mix_stderr=False)
2145
+        runner = tests.CliRunner(mix_stderr=False)
2159 2146
         # TODO(the-13th-letter): Rewrite using parenthesized
2160 2147
         # with-statements.
2161 2148
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2170,21 +2157,20 @@ class TestCLI:
2170 2157
             monkeypatch.setattr(
2171 2158
                 cli_helpers, 'prompt_for_passphrase', tests.auto_prompt
2172 2159
             )
2173
-            result_ = runner.invoke(
2160
+            result = runner.invoke(
2174 2161
                 cli.derivepassphrase_vault,
2175 2162
                 ['--repeat', '0', '-p', '--', DUMMY_SERVICE],
2176 2163
                 input=DUMMY_PASSPHRASE,
2177 2164
                 catch_exceptions=False,
2178 2165
             )
2179
-            result = tests.ReadableResult.parse(result_)
2180 2166
         assert result.clean_exit(empty_stderr=True), (
2181 2167
             'expected clean exit and empty stderr'
2182 2168
         )
2183
-        passphrase = result.output.rstrip('\r\n')
2169
+        passphrase = result.stdout.rstrip('\r\n')
2184 2170
         for i in range(len(passphrase) - 1):
2185 2171
             assert passphrase[i : i + 1] != passphrase[i + 1 : i + 2], (
2186 2172
                 f'derived password contains repeated character '
2187
-                f'at position {i}: {result.output!r}'
2173
+                f'at position {i}: {result.stdout!r}'
2188 2174
             )
2189 2175
 
2190 2176
     @Parametrize.CONFIG_WITH_KEY
... ...
@@ -2193,7 +2179,7 @@ class TestCLI:
2193 2179
         config: _types.VaultConfig,
2194 2180
     ) -> None:
2195 2181
         """A stored configured SSH key will be used."""
2196
-        runner = click.testing.CliRunner(mix_stderr=False)
2182
+        runner = tests.CliRunner(mix_stderr=False)
2197 2183
         # TODO(the-13th-letter): Rewrite using parenthesized
2198 2184
         # with-statements.
2199 2185
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2209,28 +2195,28 @@ class TestCLI:
2209 2195
             monkeypatch.setattr(
2210 2196
                 vault.Vault, 'phrase_from_key', tests.phrase_from_key
2211 2197
             )
2212
-            result_ = runner.invoke(
2198
+            result = runner.invoke(
2213 2199
                 cli.derivepassphrase_vault,
2214 2200
                 ['--', DUMMY_SERVICE],
2215 2201
                 catch_exceptions=False,
2216 2202
             )
2217
-        result = tests.ReadableResult.parse(result_)
2218 2203
         assert result.clean_exit(empty_stderr=True), (
2219 2204
             'expected clean exit and empty stderr'
2220 2205
         )
2221
-        assert result_.stdout_bytes
2222
-        assert result_.stdout_bytes.rstrip(b'\n') != DUMMY_RESULT_PASSPHRASE, (
2223
-            'known false output: phrase-based instead of key-based'
2224
-        )
2225
-        assert result_.stdout_bytes.rstrip(b'\n') == DUMMY_RESULT_KEY1, (
2226
-            'expected known output'
2227
-        )
2206
+        assert result.stdout
2207
+        assert (
2208
+            result.stdout.rstrip('\n').encode('UTF-8')
2209
+            != DUMMY_RESULT_PASSPHRASE
2210
+        ), 'known false output: phrase-based instead of key-based'
2211
+        assert (
2212
+            result.stdout.rstrip('\n').encode('UTF-8') == DUMMY_RESULT_KEY1
2213
+        ), 'expected known output'
2228 2214
 
2229 2215
     def test_204b_key_from_command_line(
2230 2216
         self,
2231 2217
     ) -> None:
2232 2218
         """An SSH key requested on the command-line will be used."""
2233
-        runner = click.testing.CliRunner(mix_stderr=False)
2219
+        runner = tests.CliRunner(mix_stderr=False)
2234 2220
         # TODO(the-13th-letter): Rewrite using parenthesized
2235 2221
         # with-statements.
2236 2222
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2251,20 +2237,19 @@ class TestCLI:
2251 2237
             monkeypatch.setattr(
2252 2238
                 vault.Vault, 'phrase_from_key', tests.phrase_from_key
2253 2239
             )
2254
-            result_ = runner.invoke(
2240
+            result = runner.invoke(
2255 2241
                 cli.derivepassphrase_vault,
2256 2242
                 ['-k', '--', DUMMY_SERVICE],
2257 2243
                 input='1\n',
2258 2244
                 catch_exceptions=False,
2259 2245
             )
2260
-        result = tests.ReadableResult.parse(result_)
2261 2246
         assert result.clean_exit(), 'expected clean exit'
2262
-        assert result_.stdout_bytes, 'expected program output'
2263
-        last_line = result_.stdout_bytes.splitlines(True)[-1]
2264
-        assert last_line.rstrip(b'\n') != DUMMY_RESULT_PASSPHRASE, (
2265
-            'known false output: phrase-based instead of key-based'
2266
-        )
2267
-        assert last_line.rstrip(b'\n') == DUMMY_RESULT_KEY1, (
2247
+        assert result.stdout, 'expected program output'
2248
+        last_line = result.stdout.splitlines(True)[-1]
2249
+        assert (
2250
+            last_line.rstrip('\n').encode('UTF-8') != DUMMY_RESULT_PASSPHRASE
2251
+        ), 'known false output: phrase-based instead of key-based'
2252
+        assert last_line.rstrip('\n').encode('UTF-8') == DUMMY_RESULT_KEY1, (
2268 2253
             'expected known output'
2269 2254
         )
2270 2255
 
... ...
@@ -2277,7 +2262,7 @@ class TestCLI:
2277 2262
         key_index: int,
2278 2263
     ) -> None:
2279 2264
         """A command-line SSH key will override the configured key."""
2280
-        runner = click.testing.CliRunner(mix_stderr=False)
2265
+        runner = tests.CliRunner(mix_stderr=False)
2281 2266
         # TODO(the-13th-letter): Rewrite using parenthesized
2282 2267
         # with-statements.
2283 2268
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2295,14 +2280,13 @@ class TestCLI:
2295 2280
                 ssh_agent.SSHAgentClient, 'list_keys', tests.list_keys
2296 2281
             )
2297 2282
             monkeypatch.setattr(ssh_agent.SSHAgentClient, 'sign', tests.sign)
2298
-            result_ = runner.invoke(
2283
+            result = runner.invoke(
2299 2284
                 cli.derivepassphrase_vault,
2300 2285
                 ['-k', '--', DUMMY_SERVICE],
2301 2286
                 input=f'{key_index}\n',
2302 2287
             )
2303
-        result = tests.ReadableResult.parse(result_)
2304 2288
         assert result.clean_exit(), 'expected clean exit'
2305
-        assert result.output, 'expected program output'
2289
+        assert result.stdout, 'expected program output'
2306 2290
         assert result.stderr, 'expected stderr'
2307 2291
         assert 'Error:' not in result.stderr, (
2308 2292
             'expected no error messages on stderr'
... ...
@@ -2313,7 +2297,7 @@ class TestCLI:
2313 2297
         running_ssh_agent: tests.RunningSSHAgentInfo,
2314 2298
     ) -> None:
2315 2299
         """A command-line passphrase will override the configured key."""
2316
-        runner = click.testing.CliRunner(mix_stderr=False)
2300
+        runner = tests.CliRunner(mix_stderr=False)
2317 2301
         # TODO(the-13th-letter): Rewrite using parenthesized
2318 2302
         # with-statements.
2319 2303
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2339,19 +2323,18 @@ class TestCLI:
2339 2323
                 ssh_agent.SSHAgentClient, 'list_keys', tests.list_keys
2340 2324
             )
2341 2325
             monkeypatch.setattr(ssh_agent.SSHAgentClient, 'sign', tests.sign)
2342
-            result_ = runner.invoke(
2326
+            result = runner.invoke(
2343 2327
                 cli.derivepassphrase_vault,
2344 2328
                 ['--', DUMMY_SERVICE],
2345 2329
                 catch_exceptions=False,
2346 2330
             )
2347
-        result = tests.ReadableResult.parse(result_)
2348 2331
         assert result.clean_exit(), 'expected clean exit'
2349
-        assert result_.stdout_bytes, 'expected program output'
2350
-        last_line = result_.stdout_bytes.splitlines(True)[-1]
2351
-        assert last_line.rstrip(b'\n') != DUMMY_RESULT_PASSPHRASE, (
2352
-            'known false output: phrase-based instead of key-based'
2353
-        )
2354
-        assert last_line.rstrip(b'\n') == DUMMY_RESULT_KEY1, (
2332
+        assert result.stdout, 'expected program output'
2333
+        last_line = result.stdout.splitlines(True)[-1]
2334
+        assert (
2335
+            last_line.rstrip('\n').encode('UTF-8') != DUMMY_RESULT_PASSPHRASE
2336
+        ), 'known false output: phrase-based instead of key-based'
2337
+        assert last_line.rstrip('\n').encode('UTF-8') == DUMMY_RESULT_KEY1, (
2355 2338
             'expected known output'
2356 2339
         )
2357 2340
 
... ...
@@ -2364,7 +2347,7 @@ class TestCLI:
2364 2347
         command_line: list[str],
2365 2348
     ) -> None:
2366 2349
         """Configuring a passphrase atop an SSH key works, but warns."""
2367
-        runner = click.testing.CliRunner(mix_stderr=False)
2350
+        runner = tests.CliRunner(mix_stderr=False)
2368 2351
         # TODO(the-13th-letter): Rewrite using parenthesized
2369 2352
         # with-statements.
2370 2353
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2382,15 +2365,14 @@ class TestCLI:
2382 2365
                 ssh_agent.SSHAgentClient, 'list_keys', tests.list_keys
2383 2366
             )
2384 2367
             monkeypatch.setattr(ssh_agent.SSHAgentClient, 'sign', tests.sign)
2385
-            result_ = runner.invoke(
2368
+            result = runner.invoke(
2386 2369
                 cli.derivepassphrase_vault,
2387 2370
                 command_line,
2388 2371
                 input=DUMMY_PASSPHRASE,
2389 2372
                 catch_exceptions=False,
2390 2373
             )
2391
-        result = tests.ReadableResult.parse(result_)
2392 2374
         assert result.clean_exit(), 'expected clean exit'
2393
-        assert not result.output.strip(), 'expected no program output'
2375
+        assert not result.stdout.strip(), 'expected no program output'
2394 2376
         assert result.stderr, 'expected known error output'
2395 2377
         err_lines = result.stderr.splitlines(False)
2396 2378
         assert err_lines[0].startswith('Passphrase:')
... ...
@@ -2422,7 +2404,7 @@ class TestCLI:
2422 2404
     ) -> None:
2423 2405
         """Service notes are printed, if they exist."""
2424 2406
         hypothesis.assume('Error:' not in notes)
2425
-        runner = click.testing.CliRunner(mix_stderr=False)
2407
+        runner = tests.CliRunner(mix_stderr=False)
2426 2408
         # TODO(the-13th-letter): Rewrite using parenthesized
2427 2409
         # with-statements.
2428 2410
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2445,14 +2427,13 @@ class TestCLI:
2445 2427
                     },
2446 2428
                 )
2447 2429
             )
2448
-            result_ = runner.invoke(
2430
+            result = runner.invoke(
2449 2431
                 cli.derivepassphrase_vault,
2450 2432
                 ['--', DUMMY_SERVICE],
2451 2433
             )
2452
-        result = tests.ReadableResult.parse(result_)
2453 2434
         assert result.clean_exit(), 'expected clean exit'
2454
-        assert result.output, 'expected program output'
2455
-        assert result.output.strip() == DUMMY_RESULT_PASSPHRASE.decode(
2435
+        assert result.stdout, 'expected program output'
2436
+        assert result.stdout.strip() == DUMMY_RESULT_PASSPHRASE.decode(
2456 2437
             'ascii'
2457 2438
         ), 'expected known program output'
2458 2439
         assert result.stderr or not notes.strip(), 'expected stderr'
... ...
@@ -2469,7 +2450,7 @@ class TestCLI:
2469 2450
         option: str,
2470 2451
     ) -> None:
2471 2452
         """Requesting invalidly many characters from a class fails."""
2472
-        runner = click.testing.CliRunner(mix_stderr=False)
2453
+        runner = tests.CliRunner(mix_stderr=False)
2473 2454
         # TODO(the-13th-letter): Rewrite using parenthesized
2474 2455
         # with-statements.
2475 2456
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2482,13 +2463,12 @@ class TestCLI:
2482 2463
                 )
2483 2464
             )
2484 2465
             for value in '-42', 'invalid':
2485
-                result_ = runner.invoke(
2466
+                result = runner.invoke(
2486 2467
                     cli.derivepassphrase_vault,
2487 2468
                     [option, value, '-p', '--', DUMMY_SERVICE],
2488 2469
                     input=DUMMY_PASSPHRASE,
2489 2470
                     catch_exceptions=False,
2490 2471
                 )
2491
-                result = tests.ReadableResult.parse(result_)
2492 2472
                 assert result.error_exit(error='Invalid value'), (
2493 2473
                     'expected error exit and known error message'
2494 2474
                 )
... ...
@@ -2502,7 +2482,7 @@ class TestCLI:
2502 2482
         check_success: bool,
2503 2483
     ) -> None:
2504 2484
         """We require or forbid a service argument, depending on options."""
2505
-        runner = click.testing.CliRunner(mix_stderr=False)
2485
+        runner = tests.CliRunner(mix_stderr=False)
2506 2486
         # TODO(the-13th-letter): Rewrite using parenthesized
2507 2487
         # with-statements.
2508 2488
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2518,13 +2498,12 @@ class TestCLI:
2518 2498
             monkeypatch.setattr(
2519 2499
                 cli_helpers, 'prompt_for_passphrase', tests.auto_prompt
2520 2500
             )
2521
-            result_ = runner.invoke(
2501
+            result = runner.invoke(
2522 2502
                 cli.derivepassphrase_vault,
2523 2503
                 options if service else [*options, '--', DUMMY_SERVICE],
2524 2504
                 input=input,
2525 2505
                 catch_exceptions=False,
2526 2506
             )
2527
-            result = tests.ReadableResult.parse(result_)
2528 2507
             if service is not None:
2529 2508
                 err_msg = (
2530 2509
                     ' requires a SERVICE'
... ...
@@ -2557,13 +2536,12 @@ class TestCLI:
2557 2536
                 monkeypatch.setattr(
2558 2537
                     cli_helpers, 'prompt_for_passphrase', tests.auto_prompt
2559 2538
                 )
2560
-                result_ = runner.invoke(
2539
+                result = runner.invoke(
2561 2540
                     cli.derivepassphrase_vault,
2562 2541
                     [*options, '--', DUMMY_SERVICE] if service else options,
2563 2542
                     input=input,
2564 2543
                     catch_exceptions=False,
2565 2544
                 )
2566
-                result = tests.ReadableResult.parse(result_)
2567 2545
             assert result.clean_exit(empty_stderr=True), 'expected clean exit'
2568 2546
 
2569 2547
     def test_211a_empty_service_name_causes_warning(
... ...
@@ -2583,7 +2561,7 @@ class TestCLI:
2583 2561
                 'An empty SERVICE is not supported by vault(1)', [record]
2584 2562
             )
2585 2563
 
2586
-        runner = click.testing.CliRunner(mix_stderr=False)
2564
+        runner = tests.CliRunner(mix_stderr=False)
2587 2565
         # TODO(the-13th-letter): Rewrite using parenthesized
2588 2566
         # with-statements.
2589 2567
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2599,12 +2577,11 @@ class TestCLI:
2599 2577
             monkeypatch.setattr(
2600 2578
                 cli_helpers, 'prompt_for_passphrase', tests.auto_prompt
2601 2579
             )
2602
-            result_ = runner.invoke(
2580
+            result = runner.invoke(
2603 2581
                 cli.derivepassphrase_vault,
2604 2582
                 ['--config', '--length=30', '--', ''],
2605 2583
                 catch_exceptions=False,
2606 2584
             )
2607
-            result = tests.ReadableResult.parse(result_)
2608 2585
             assert result.clean_exit(empty_stderr=False), 'expected clean exit'
2609 2586
             assert result.stderr is not None, 'expected known error output'
2610 2587
             assert all(map(is_expected_warning, caplog.record_tuples)), (
... ...
@@ -2615,13 +2592,12 @@ class TestCLI:
2615 2592
                 'services': {},
2616 2593
             }, 'requested configuration change was not applied'
2617 2594
             caplog.clear()
2618
-            result_ = runner.invoke(
2595
+            result = runner.invoke(
2619 2596
                 cli.derivepassphrase_vault,
2620 2597
                 ['--import', '-'],
2621 2598
                 input=json.dumps({'services': {'': {'length': 40}}}),
2622 2599
                 catch_exceptions=False,
2623 2600
             )
2624
-            result = tests.ReadableResult.parse(result_)
2625 2601
             assert result.clean_exit(empty_stderr=False), 'expected clean exit'
2626 2602
             assert result.stderr is not None, 'expected known error output'
2627 2603
             assert all(map(is_expected_warning, caplog.record_tuples)), (
... ...
@@ -2639,7 +2615,7 @@ class TestCLI:
2639 2615
         service: bool | None,
2640 2616
     ) -> None:
2641 2617
         """Incompatible options are detected."""
2642
-        runner = click.testing.CliRunner(mix_stderr=False)
2618
+        runner = tests.CliRunner(mix_stderr=False)
2643 2619
         # TODO(the-13th-letter): Rewrite using parenthesized
2644 2620
         # with-statements.
2645 2621
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2651,13 +2627,12 @@ class TestCLI:
2651 2627
                     runner=runner,
2652 2628
                 )
2653 2629
             )
2654
-            result_ = runner.invoke(
2630
+            result = runner.invoke(
2655 2631
                 cli.derivepassphrase_vault,
2656 2632
                 [*options, '--', DUMMY_SERVICE] if service else options,
2657 2633
                 input=DUMMY_PASSPHRASE,
2658 2634
                 catch_exceptions=False,
2659 2635
             )
2660
-        result = tests.ReadableResult.parse(result_)
2661 2636
         assert result.error_exit(error='mutually exclusive with '), (
2662 2637
             'expected error exit and known error message'
2663 2638
         )
... ...
@@ -2669,7 +2644,7 @@ class TestCLI:
2669 2644
         config: Any,
2670 2645
     ) -> None:
2671 2646
         """Importing a configuration works."""
2672
-        runner = click.testing.CliRunner(mix_stderr=False)
2647
+        runner = tests.CliRunner(mix_stderr=False)
2673 2648
         # TODO(the-13th-letter): Rewrite using parenthesized
2674 2649
         # with-statements.
2675 2650
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2682,7 +2657,7 @@ class TestCLI:
2682 2657
                     vault_config={'services': {}},
2683 2658
                 )
2684 2659
             )
2685
-            result_ = runner.invoke(
2660
+            result = runner.invoke(
2686 2661
                 cli.derivepassphrase_vault,
2687 2662
                 ['--import', '-'],
2688 2663
                 input=json.dumps(config),
... ...
@@ -2692,7 +2667,6 @@ class TestCLI:
2692 2667
                 subsystem='vault'
2693 2668
             ).read_text(encoding='UTF-8')
2694 2669
             config2 = json.loads(config_txt)
2695
-        result = tests.ReadableResult.parse(result_)
2696 2670
         assert result.clean_exit(empty_stderr=False), 'expected clean exit'
2697 2671
         assert config2 == config, 'config not imported correctly'
2698 2672
         assert not result.stderr or all(  # pragma: no branch
... ...
@@ -2730,7 +2704,7 @@ class TestCLI:
2730 2704
         _types.clean_up_falsy_vault_config_values(config2)
2731 2705
         # Reset caplog between hypothesis runs.
2732 2706
         caplog.clear()
2733
-        runner = click.testing.CliRunner(mix_stderr=False)
2707
+        runner = tests.CliRunner(mix_stderr=False)
2734 2708
         # TODO(the-13th-letter): Rewrite using parenthesized
2735 2709
         # with-statements.
2736 2710
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2743,7 +2717,7 @@ class TestCLI:
2743 2717
                     vault_config={'services': {}},
2744 2718
                 )
2745 2719
             )
2746
-            result_ = runner.invoke(
2720
+            result = runner.invoke(
2747 2721
                 cli.derivepassphrase_vault,
2748 2722
                 ['--import', '-'],
2749 2723
                 input=json.dumps(config),
... ...
@@ -2753,7 +2727,6 @@ class TestCLI:
2753 2727
                 subsystem='vault'
2754 2728
             ).read_text(encoding='UTF-8')
2755 2729
             config3 = json.loads(config_txt)
2756
-        result = tests.ReadableResult.parse(result_)
2757 2730
         assert result.clean_exit(empty_stderr=False), 'expected clean exit'
2758 2731
         assert config3 == config2, 'config not imported correctly'
2759 2732
         assert not result.stderr or all(
... ...
@@ -2765,7 +2738,7 @@ class TestCLI:
2765 2738
         self,
2766 2739
     ) -> None:
2767 2740
         """Importing an invalid config fails."""
2768
-        runner = click.testing.CliRunner(mix_stderr=False)
2741
+        runner = tests.CliRunner(mix_stderr=False)
2769 2742
         # TODO(the-13th-letter): Rewrite using parenthesized
2770 2743
         # with-statements.
2771 2744
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2777,13 +2750,12 @@ class TestCLI:
2777 2750
                     runner=runner,
2778 2751
                 )
2779 2752
             )
2780
-            result_ = runner.invoke(
2753
+            result = runner.invoke(
2781 2754
                 cli.derivepassphrase_vault,
2782 2755
                 ['--import', '-'],
2783 2756
                 input='null',
2784 2757
                 catch_exceptions=False,
2785 2758
             )
2786
-        result = tests.ReadableResult.parse(result_)
2787 2759
         assert result.error_exit(error='Invalid vault config'), (
2788 2760
             'expected error exit and known error message'
2789 2761
         )
... ...
@@ -2792,7 +2764,7 @@ class TestCLI:
2792 2764
         self,
2793 2765
     ) -> None:
2794 2766
         """Importing an invalid config fails."""
2795
-        runner = click.testing.CliRunner(mix_stderr=False)
2767
+        runner = tests.CliRunner(mix_stderr=False)
2796 2768
         # TODO(the-13th-letter): Rewrite using parenthesized
2797 2769
         # with-statements.
2798 2770
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2804,13 +2776,12 @@ class TestCLI:
2804 2776
                     runner=runner,
2805 2777
                 )
2806 2778
             )
2807
-            result_ = runner.invoke(
2779
+            result = runner.invoke(
2808 2780
                 cli.derivepassphrase_vault,
2809 2781
                 ['--import', '-'],
2810 2782
                 input='This string is not valid JSON.',
2811 2783
                 catch_exceptions=False,
2812 2784
             )
2813
-        result = tests.ReadableResult.parse(result_)
2814 2785
         assert result.error_exit(error='cannot decode JSON'), (
2815 2786
             'expected error exit and known error message'
2816 2787
         )
... ...
@@ -2819,7 +2790,7 @@ class TestCLI:
2819 2790
         self,
2820 2791
     ) -> None:
2821 2792
         """Importing an invalid config fails."""
2822
-        runner = click.testing.CliRunner(mix_stderr=False)
2793
+        runner = tests.CliRunner(mix_stderr=False)
2823 2794
         # `isolated_vault_config` ensures the configuration is valid
2824 2795
         # JSON.  So, to pass an actual broken configuration, we must
2825 2796
         # open the configuration file ourselves afterwards, inside the
... ...
@@ -2841,12 +2812,11 @@ class TestCLI:
2841 2812
                 'This string is not valid JSON.\n', encoding='UTF-8'
2842 2813
             )
2843 2814
             dname = cli_helpers.config_filename(subsystem=None)
2844
-            result_ = runner.invoke(
2815
+            result = runner.invoke(
2845 2816
                 cli.derivepassphrase_vault,
2846 2817
                 ['--import', os.fsdecode(dname)],
2847 2818
                 catch_exceptions=False,
2848 2819
             )
2849
-        result = tests.ReadableResult.parse(result_)
2850 2820
         assert result.error_exit(error=os.strerror(errno.EISDIR)), (
2851 2821
             'expected error exit and known error message'
2852 2822
         )
... ...
@@ -2858,7 +2828,7 @@ class TestCLI:
2858 2828
         config: Any,
2859 2829
     ) -> None:
2860 2830
         """Exporting a configuration works."""
2861
-        runner = click.testing.CliRunner(mix_stderr=False)
2831
+        runner = tests.CliRunner(mix_stderr=False)
2862 2832
         # TODO(the-13th-letter): Rewrite using parenthesized
2863 2833
         # with-statements.
2864 2834
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2876,7 +2846,7 @@ class TestCLI:
2876 2846
             ) as outfile:
2877 2847
                 # Ensure the config is written on one line.
2878 2848
                 json.dump(config, outfile, indent=None)
2879
-            result_ = runner.invoke(
2849
+            result = runner.invoke(
2880 2850
                 cli.derivepassphrase_vault,
2881 2851
                 ['--export', '-'],
2882 2852
                 catch_exceptions=False,
... ...
@@ -2885,13 +2855,12 @@ class TestCLI:
2885 2855
                 encoding='UTF-8'
2886 2856
             ) as infile:
2887 2857
                 config2 = json.load(infile)
2888
-        result = tests.ReadableResult.parse(result_)
2889 2858
         assert result.clean_exit(empty_stderr=False), 'expected clean exit'
2890 2859
         assert config2 == config, 'config not imported correctly'
2891 2860
         assert not result.stderr or all(  # pragma: no branch
2892 2861
             map(is_harmless_config_import_warning, caplog.record_tuples)
2893 2862
         ), 'unexpected error output'
2894
-        assert_vault_config_is_indented_and_line_broken(result.output)
2863
+        assert_vault_config_is_indented_and_line_broken(result.stdout)
2895 2864
 
2896 2865
     @Parametrize.EXPORT_FORMAT_OPTIONS
2897 2866
     def test_214a_export_settings_no_stored_settings(
... ...
@@ -2899,7 +2868,7 @@ class TestCLI:
2899 2868
         export_options: list[str],
2900 2869
     ) -> None:
2901 2870
         """Exporting the default, empty config works."""
2902
-        runner = click.testing.CliRunner(mix_stderr=False)
2871
+        runner = tests.CliRunner(mix_stderr=False)
2903 2872
         # TODO(the-13th-letter): Rewrite using parenthesized
2904 2873
         # with-statements.
2905 2874
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2914,7 +2883,7 @@ class TestCLI:
2914 2883
             cli_helpers.config_filename(subsystem='vault').unlink(
2915 2884
                 missing_ok=True
2916 2885
             )
2917
-            result_ = runner.invoke(
2886
+            result = runner.invoke(
2918 2887
                 # Test parent context navigation by not calling
2919 2888
                 # `cli.derivepassphrase_vault` directly.  Used e.g. in
2920 2889
                 # the `--export-as=sh` section to autoconstruct the
... ...
@@ -2923,7 +2892,6 @@ class TestCLI:
2923 2892
                 ['vault', '--export', '-', *export_options],
2924 2893
                 catch_exceptions=False,
2925 2894
             )
2926
-        result = tests.ReadableResult.parse(result_)
2927 2895
         assert result.clean_exit(empty_stderr=True), 'expected clean exit'
2928 2896
 
2929 2897
     @Parametrize.EXPORT_FORMAT_OPTIONS
... ...
@@ -2932,7 +2900,7 @@ class TestCLI:
2932 2900
         export_options: list[str],
2933 2901
     ) -> None:
2934 2902
         """Exporting an invalid config fails."""
2935
-        runner = click.testing.CliRunner(mix_stderr=False)
2903
+        runner = tests.CliRunner(mix_stderr=False)
2936 2904
         # TODO(the-13th-letter): Rewrite using parenthesized
2937 2905
         # with-statements.
2938 2906
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2945,13 +2913,12 @@ class TestCLI:
2945 2913
                     vault_config={},
2946 2914
                 )
2947 2915
             )
2948
-            result_ = runner.invoke(
2916
+            result = runner.invoke(
2949 2917
                 cli.derivepassphrase_vault,
2950 2918
                 ['--export', '-', *export_options],
2951 2919
                 input='null',
2952 2920
                 catch_exceptions=False,
2953 2921
             )
2954
-        result = tests.ReadableResult.parse(result_)
2955 2922
         assert result.error_exit(error='Cannot load vault settings:'), (
2956 2923
             'expected error exit and known error message'
2957 2924
         )
... ...
@@ -2962,7 +2929,7 @@ class TestCLI:
2962 2929
         export_options: list[str],
2963 2930
     ) -> None:
2964 2931
         """Exporting an invalid config fails."""
2965
-        runner = click.testing.CliRunner(mix_stderr=False)
2932
+        runner = tests.CliRunner(mix_stderr=False)
2966 2933
         # TODO(the-13th-letter): Rewrite using parenthesized
2967 2934
         # with-statements.
2968 2935
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -2977,13 +2944,12 @@ class TestCLI:
2977 2944
             config_file = cli_helpers.config_filename(subsystem='vault')
2978 2945
             config_file.unlink(missing_ok=True)
2979 2946
             config_file.mkdir(parents=True, exist_ok=True)
2980
-            result_ = runner.invoke(
2947
+            result = runner.invoke(
2981 2948
                 cli.derivepassphrase_vault,
2982 2949
                 ['--export', '-', *export_options],
2983 2950
                 input='null',
2984 2951
                 catch_exceptions=False,
2985 2952
             )
2986
-        result = tests.ReadableResult.parse(result_)
2987 2953
         assert result.error_exit(error='Cannot load vault settings:'), (
2988 2954
             'expected error exit and known error message'
2989 2955
         )
... ...
@@ -2994,7 +2960,7 @@ class TestCLI:
2994 2960
         export_options: list[str],
2995 2961
     ) -> None:
2996 2962
         """Exporting an invalid config fails."""
2997
-        runner = click.testing.CliRunner(mix_stderr=False)
2963
+        runner = tests.CliRunner(mix_stderr=False)
2998 2964
         # TODO(the-13th-letter): Rewrite using parenthesized
2999 2965
         # with-statements.
3000 2966
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -3007,13 +2973,12 @@ class TestCLI:
3007 2973
                 )
3008 2974
             )
3009 2975
             dname = cli_helpers.config_filename(subsystem=None)
3010
-            result_ = runner.invoke(
2976
+            result = runner.invoke(
3011 2977
                 cli.derivepassphrase_vault,
3012 2978
                 ['--export', os.fsdecode(dname), *export_options],
3013 2979
                 input='null',
3014 2980
                 catch_exceptions=False,
3015 2981
             )
3016
-        result = tests.ReadableResult.parse(result_)
3017 2982
         assert result.error_exit(error='Cannot export vault settings:'), (
3018 2983
             'expected error exit and known error message'
3019 2984
         )
... ...
@@ -3024,7 +2989,7 @@ class TestCLI:
3024 2989
         export_options: list[str],
3025 2990
     ) -> None:
3026 2991
         """Exporting an invalid config fails."""
3027
-        runner = click.testing.CliRunner(mix_stderr=False)
2992
+        runner = tests.CliRunner(mix_stderr=False)
3028 2993
         # TODO(the-13th-letter): Rewrite using parenthesized
3029 2994
         # with-statements.
3030 2995
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -3040,13 +3005,12 @@ class TestCLI:
3040 3005
             with contextlib.suppress(FileNotFoundError):
3041 3006
                 shutil.rmtree(config_dir)
3042 3007
             config_dir.write_text('Obstruction!!\n')
3043
-            result_ = runner.invoke(
3008
+            result = runner.invoke(
3044 3009
                 cli.derivepassphrase_vault,
3045 3010
                 ['--export', '-', *export_options],
3046 3011
                 input='null',
3047 3012
                 catch_exceptions=False,
3048 3013
             )
3049
-        result = tests.ReadableResult.parse(result_)
3050 3014
         assert result.error_exit(
3051 3015
             error='Cannot load vault settings:'
3052 3016
         ) or result.error_exit(error='Cannot load user config:'), (
... ...
@@ -3083,7 +3047,7 @@ class TestCLI:
3083 3047
             if notes_placement == 'before'
3084 3048
             else f'{result_phrase}\n\n{notes}\n\n'
3085 3049
         )
3086
-        runner = click.testing.CliRunner(mix_stderr=True)
3050
+        runner = tests.CliRunner(mix_stderr=True)
3087 3051
         # TODO(the-13th-letter): Rewrite using parenthesized
3088 3052
         # with-statements.
3089 3053
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -3096,12 +3060,11 @@ class TestCLI:
3096 3060
                     vault_config=vault_config,
3097 3061
                 )
3098 3062
             )
3099
-            result_ = runner.invoke(
3063
+            result = runner.invoke(
3100 3064
                 cli.derivepassphrase_vault,
3101 3065
                 [*placement_args, '--', DUMMY_SERVICE],
3102 3066
                 catch_exceptions=False,
3103 3067
             )
3104
-            result = tests.ReadableResult.parse(result_)
3105 3068
             assert result.clean_exit(output=expected), 'expected clean exit'
3106 3069
 
3107 3070
     @Parametrize.MODERN_EDITOR_INTERFACE
... ...
@@ -3137,7 +3100,7 @@ class TestCLI:
3137 3100
 """
3138 3101
         # Reset caplog between hypothesis runs.
3139 3102
         caplog.clear()
3140
-        runner = click.testing.CliRunner(mix_stderr=False)
3103
+        runner = tests.CliRunner(mix_stderr=False)
3141 3104
         # TODO(the-13th-letter): Rewrite using parenthesized
3142 3105
         # with-statements.
3143 3106
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -3161,7 +3124,7 @@ class TestCLI:
3161 3124
                 encoding='UTF-8',
3162 3125
             )
3163 3126
             monkeypatch.setattr(click, 'edit', lambda *_a, **_kw: edit_result)
3164
-            result_ = runner.invoke(
3127
+            result = runner.invoke(
3165 3128
                 cli.derivepassphrase_vault,
3166 3129
                 [
3167 3130
                     '--config',
... ...
@@ -3174,7 +3137,6 @@ class TestCLI:
3174 3137
                 ],
3175 3138
                 catch_exceptions=False,
3176 3139
             )
3177
-            result = tests.ReadableResult.parse(result_)
3178 3140
             assert result.clean_exit(), 'expected clean exit'
3179 3141
             assert all(map(is_warning_line, result.stderr.splitlines(True)))
3180 3142
             assert modern_editor_interface or tests.warning_emitted(
... ...
@@ -3228,7 +3190,7 @@ class TestCLI:
3228 3190
             return '       ' + notes.strip() + '\n\n\n\n\n\n'
3229 3191
 
3230 3192
         edit_funcs = {'empty': empty, 'space': space}
3231
-        runner = click.testing.CliRunner(mix_stderr=False)
3193
+        runner = tests.CliRunner(mix_stderr=False)
3232 3194
         # TODO(the-13th-letter): Rewrite using parenthesized
3233 3195
         # with-statements.
3234 3196
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -3252,7 +3214,7 @@ class TestCLI:
3252 3214
                 encoding='UTF-8',
3253 3215
             )
3254 3216
             monkeypatch.setattr(click, 'edit', edit_funcs[edit_func_name])
3255
-            result_ = runner.invoke(
3217
+            result = runner.invoke(
3256 3218
                 cli.derivepassphrase_vault,
3257 3219
                 [
3258 3220
                     '--config',
... ...
@@ -3265,7 +3227,6 @@ class TestCLI:
3265 3227
                 ],
3266 3228
                 catch_exceptions=False,
3267 3229
             )
3268
-            result = tests.ReadableResult.parse(result_)
3269 3230
             assert result.clean_exit(empty_stderr=True) or result.error_exit(
3270 3231
                 error='the user aborted the request'
3271 3232
             ), 'expected clean exit'
... ...
@@ -3318,7 +3279,7 @@ class TestCLI:
3318 3279
         hypothesis.assume(str(notes_marker) not in notes.strip())
3319 3280
         # Reset caplog between hypothesis runs.
3320 3281
         caplog.clear()
3321
-        runner = click.testing.CliRunner(mix_stderr=False)
3282
+        runner = tests.CliRunner(mix_stderr=False)
3322 3283
         # TODO(the-13th-letter): Rewrite using parenthesized
3323 3284
         # with-statements.
3324 3285
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -3342,7 +3303,7 @@ class TestCLI:
3342 3303
                 encoding='UTF-8',
3343 3304
             )
3344 3305
             monkeypatch.setattr(click, 'edit', lambda *_a, **_kw: notes)
3345
-            result_ = runner.invoke(
3306
+            result = runner.invoke(
3346 3307
                 cli.derivepassphrase_vault,
3347 3308
                 [
3348 3309
                     '--config',
... ...
@@ -3355,7 +3316,6 @@ class TestCLI:
3355 3316
                 ],
3356 3317
                 catch_exceptions=False,
3357 3318
             )
3358
-            result = tests.ReadableResult.parse(result_)
3359 3319
             assert result.clean_exit(), 'expected clean exit'
3360 3320
             assert not result.stderr or all(
3361 3321
                 map(is_warning_line, result.stderr.splitlines(True))
... ...
@@ -3396,7 +3356,7 @@ class TestCLI:
3396 3356
         Aborting is only supported with the modern editor interface.
3397 3357
 
3398 3358
         """
3399
-        runner = click.testing.CliRunner(mix_stderr=False)
3359
+        runner = tests.CliRunner(mix_stderr=False)
3400 3360
         # TODO(the-13th-letter): Rewrite using parenthesized
3401 3361
         # with-statements.
3402 3362
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -3413,7 +3373,7 @@ class TestCLI:
3413 3373
                 )
3414 3374
             )
3415 3375
             monkeypatch.setattr(click, 'edit', lambda *_a, **_kw: '')
3416
-            result_ = runner.invoke(
3376
+            result = runner.invoke(
3417 3377
                 cli.derivepassphrase_vault,
3418 3378
                 [
3419 3379
                     '--config',
... ...
@@ -3424,7 +3384,6 @@ class TestCLI:
3424 3384
                 ],
3425 3385
                 catch_exceptions=False,
3426 3386
             )
3427
-            result = tests.ReadableResult.parse(result_)
3428 3387
             assert result.error_exit(error='the user aborted the request'), (
3429 3388
                 'expected known error message'
3430 3389
             )
... ...
@@ -3445,7 +3404,7 @@ class TestCLI:
3445 3404
         Aborting is only supported with the modern editor interface.
3446 3405
 
3447 3406
         """
3448
-        runner = click.testing.CliRunner(mix_stderr=False)
3407
+        runner = tests.CliRunner(mix_stderr=False)
3449 3408
         # TODO(the-13th-letter): Rewrite using parenthesized
3450 3409
         # with-statements.
3451 3410
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -3462,7 +3421,7 @@ class TestCLI:
3462 3421
                 )
3463 3422
             )
3464 3423
             monkeypatch.setattr(click, 'edit', lambda *_a, **_kw: '')
3465
-            result_ = runner.invoke(
3424
+            result = runner.invoke(
3466 3425
                 cli.derivepassphrase_vault,
3467 3426
                 [
3468 3427
                     '--config',
... ...
@@ -3473,7 +3432,6 @@ class TestCLI:
3473 3432
                 ],
3474 3433
                 catch_exceptions=False,
3475 3434
             )
3476
-            result = tests.ReadableResult.parse(result_)
3477 3435
             assert result.error_exit(error='the user aborted the request'), (
3478 3436
                 'expected known error message'
3479 3437
             )
... ...
@@ -3517,7 +3475,7 @@ class TestCLI:
3517 3475
         }
3518 3476
         # Reset caplog between hypothesis runs.
3519 3477
         caplog.clear()
3520
-        runner = click.testing.CliRunner(mix_stderr=False)
3478
+        runner = tests.CliRunner(mix_stderr=False)
3521 3479
         # TODO(the-13th-letter): Rewrite using parenthesized
3522 3480
         # with-statements.
3523 3481
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -3543,7 +3501,7 @@ class TestCLI:
3543 3501
                 encoding='UTF-8',
3544 3502
             )
3545 3503
             monkeypatch.setattr(click, 'edit', raiser)
3546
-            result_ = runner.invoke(
3504
+            result = runner.invoke(
3547 3505
                 cli.derivepassphrase_vault,
3548 3506
                 [
3549 3507
                     '--notes',
... ...
@@ -3555,7 +3513,6 @@ class TestCLI:
3555 3513
                 ],
3556 3514
                 catch_exceptions=False,
3557 3515
             )
3558
-            result = tests.ReadableResult.parse(result_)
3559 3516
             assert result.clean_exit(
3560 3517
                 output=DUMMY_RESULT_PASSPHRASE.decode('ascii')
3561 3518
             ), 'expected clean exit'
... ...
@@ -3595,7 +3552,7 @@ class TestCLI:
3595 3552
         the config more readable.
3596 3553
 
3597 3554
         """
3598
-        runner = click.testing.CliRunner(mix_stderr=False)
3555
+        runner = tests.CliRunner(mix_stderr=False)
3599 3556
         # TODO(the-13th-letter): Rewrite using parenthesized
3600 3557
         # with-statements.
3601 3558
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -3611,13 +3568,12 @@ class TestCLI:
3611 3568
             monkeypatch.setattr(
3612 3569
                 cli_helpers, 'get_suitable_ssh_keys', tests.suitable_ssh_keys
3613 3570
             )
3614
-            result_ = runner.invoke(
3571
+            result = runner.invoke(
3615 3572
                 cli.derivepassphrase_vault,
3616 3573
                 ['--config', *command_line],
3617 3574
                 catch_exceptions=False,
3618 3575
                 input=input,
3619 3576
             )
3620
-            result = tests.ReadableResult.parse(result_)
3621 3577
             assert result.clean_exit(), 'expected clean exit'
3622 3578
             config_txt = cli_helpers.config_filename(
3623 3579
                 subsystem='vault'
... ...
@@ -3636,7 +3592,7 @@ class TestCLI:
3636 3592
         err_text: str,
3637 3593
     ) -> None:
3638 3594
         """Storing invalid settings via `--config` fails."""
3639
-        runner = click.testing.CliRunner(mix_stderr=False)
3595
+        runner = tests.CliRunner(mix_stderr=False)
3640 3596
         # TODO(the-13th-letter): Rewrite using parenthesized
3641 3597
         # with-statements.
3642 3598
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -3652,13 +3608,12 @@ class TestCLI:
3652 3608
             monkeypatch.setattr(
3653 3609
                 cli_helpers, 'get_suitable_ssh_keys', tests.suitable_ssh_keys
3654 3610
             )
3655
-            result_ = runner.invoke(
3611
+            result = runner.invoke(
3656 3612
                 cli.derivepassphrase_vault,
3657 3613
                 ['--config', *command_line],
3658 3614
                 catch_exceptions=False,
3659 3615
                 input=input,
3660 3616
             )
3661
-        result = tests.ReadableResult.parse(result_)
3662 3617
         assert result.error_exit(error=err_text), (
3663 3618
             'expected error exit and known error message'
3664 3619
         )
... ...
@@ -3667,7 +3622,7 @@ class TestCLI:
3667 3622
         self,
3668 3623
     ) -> None:
3669 3624
         """Not selecting an SSH key during `--config --key` fails."""
3670
-        runner = click.testing.CliRunner(mix_stderr=False)
3625
+        runner = tests.CliRunner(mix_stderr=False)
3671 3626
         # TODO(the-13th-letter): Rewrite using parenthesized
3672 3627
         # with-statements.
3673 3628
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -3686,12 +3641,11 @@ class TestCLI:
3686 3641
                 raise RuntimeError(custom_error)
3687 3642
 
3688 3643
             monkeypatch.setattr(cli_helpers, 'select_ssh_key', raiser)
3689
-            result_ = runner.invoke(
3644
+            result = runner.invoke(
3690 3645
                 cli.derivepassphrase_vault,
3691 3646
                 ['--key', '--config'],
3692 3647
                 catch_exceptions=False,
3693 3648
             )
3694
-        result = tests.ReadableResult.parse(result_)
3695 3649
         assert result.error_exit(error=custom_error), (
3696 3650
             'expected error exit and known error message'
3697 3651
         )
... ...
@@ -3702,7 +3656,7 @@ class TestCLI:
3702 3656
     ) -> None:
3703 3657
         """Not running an SSH agent during `--config --key` fails."""
3704 3658
         del skip_if_no_af_unix_support
3705
-        runner = click.testing.CliRunner(mix_stderr=False)
3659
+        runner = tests.CliRunner(mix_stderr=False)
3706 3660
         # TODO(the-13th-letter): Rewrite using parenthesized
3707 3661
         # with-statements.
3708 3662
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -3716,12 +3670,11 @@ class TestCLI:
3716 3670
                 )
3717 3671
             )
3718 3672
             monkeypatch.delenv('SSH_AUTH_SOCK', raising=False)
3719
-            result_ = runner.invoke(
3673
+            result = runner.invoke(
3720 3674
                 cli.derivepassphrase_vault,
3721 3675
                 ['--key', '--config'],
3722 3676
                 catch_exceptions=False,
3723 3677
             )
3724
-        result = tests.ReadableResult.parse(result_)
3725 3678
         assert result.error_exit(error='Cannot find any running SSH agent'), (
3726 3679
             'expected error exit and known error message'
3727 3680
         )
... ...
@@ -3730,7 +3683,7 @@ class TestCLI:
3730 3683
         self,
3731 3684
     ) -> None:
3732 3685
         """Not running a reachable SSH agent during `--config --key` fails."""
3733
-        runner = click.testing.CliRunner(mix_stderr=False)
3686
+        runner = tests.CliRunner(mix_stderr=False)
3734 3687
         # TODO(the-13th-letter): Rewrite using parenthesized
3735 3688
         # with-statements.
3736 3689
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -3745,12 +3698,11 @@ class TestCLI:
3745 3698
             )
3746 3699
             cwd = pathlib.Path.cwd().resolve()
3747 3700
             monkeypatch.setenv('SSH_AUTH_SOCK', str(cwd))
3748
-            result_ = runner.invoke(
3701
+            result = runner.invoke(
3749 3702
                 cli.derivepassphrase_vault,
3750 3703
                 ['--key', '--config'],
3751 3704
                 catch_exceptions=False,
3752 3705
             )
3753
-        result = tests.ReadableResult.parse(result_)
3754 3706
         assert result.error_exit(error='Cannot connect to the SSH agent'), (
3755 3707
             'expected error exit and known error message'
3756 3708
         )
... ...
@@ -3761,7 +3713,7 @@ class TestCLI:
3761 3713
         try_race_free_implementation: bool,
3762 3714
     ) -> None:
3763 3715
         """Using a read-only configuration file with `--config` fails."""
3764
-        runner = click.testing.CliRunner(mix_stderr=False)
3716
+        runner = tests.CliRunner(mix_stderr=False)
3765 3717
         # TODO(the-13th-letter): Rewrite using parenthesized
3766 3718
         # with-statements.
3767 3719
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -3778,12 +3730,11 @@ class TestCLI:
3778 3730
                 cli_helpers.config_filename(subsystem='vault'),
3779 3731
                 try_race_free_implementation=try_race_free_implementation,
3780 3732
             )
3781
-            result_ = runner.invoke(
3733
+            result = runner.invoke(
3782 3734
                 cli.derivepassphrase_vault,
3783 3735
                 ['--config', '--length=15', '--', DUMMY_SERVICE],
3784 3736
                 catch_exceptions=False,
3785 3737
             )
3786
-        result = tests.ReadableResult.parse(result_)
3787 3738
         assert result.error_exit(error='Cannot store vault settings:'), (
3788 3739
             'expected error exit and known error message'
3789 3740
         )
... ...
@@ -3792,7 +3743,7 @@ class TestCLI:
3792 3743
         self,
3793 3744
     ) -> None:
3794 3745
         """OS-erroring with `--config` fails."""
3795
-        runner = click.testing.CliRunner(mix_stderr=False)
3746
+        runner = tests.CliRunner(mix_stderr=False)
3796 3747
         # TODO(the-13th-letter): Rewrite using parenthesized
3797 3748
         # with-statements.
3798 3749
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -3812,12 +3763,11 @@ class TestCLI:
3812 3763
                 raise RuntimeError(custom_error)
3813 3764
 
3814 3765
             monkeypatch.setattr(cli_helpers, 'save_config', raiser)
3815
-            result_ = runner.invoke(
3766
+            result = runner.invoke(
3816 3767
                 cli.derivepassphrase_vault,
3817 3768
                 ['--config', '--length=15', '--', DUMMY_SERVICE],
3818 3769
                 catch_exceptions=False,
3819 3770
             )
3820
-        result = tests.ReadableResult.parse(result_)
3821 3771
         assert result.error_exit(error=custom_error), (
3822 3772
             'expected error exit and known error message'
3823 3773
         )
... ...
@@ -3826,7 +3776,7 @@ class TestCLI:
3826 3776
         self,
3827 3777
     ) -> None:
3828 3778
         """Issuing conflicting settings to `--config` fails."""
3829
-        runner = click.testing.CliRunner(mix_stderr=False)
3779
+        runner = tests.CliRunner(mix_stderr=False)
3830 3780
         # TODO(the-13th-letter): Rewrite using parenthesized
3831 3781
         # with-statements.
3832 3782
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -3839,7 +3789,7 @@ class TestCLI:
3839 3789
                     vault_config={'global': {'phrase': 'abc'}, 'services': {}},
3840 3790
                 )
3841 3791
             )
3842
-            result_ = runner.invoke(
3792
+            result = runner.invoke(
3843 3793
                 cli.derivepassphrase_vault,
3844 3794
                 [
3845 3795
                     '--config',
... ...
@@ -3850,7 +3800,6 @@ class TestCLI:
3850 3800
                 ],
3851 3801
                 catch_exceptions=False,
3852 3802
             )
3853
-        result = tests.ReadableResult.parse(result_)
3854 3803
         assert result.error_exit(
3855 3804
             error='Attempted to unset and set --length at the same time.'
3856 3805
         ), 'expected error exit and known error message'
... ...
@@ -3861,7 +3810,7 @@ class TestCLI:
3861 3810
     ) -> None:
3862 3811
         """Not holding any SSH keys during `--config --key` fails."""
3863 3812
         del running_ssh_agent
3864
-        runner = click.testing.CliRunner(mix_stderr=False)
3813
+        runner = tests.CliRunner(mix_stderr=False)
3865 3814
         # TODO(the-13th-letter): Rewrite using parenthesized
3866 3815
         # with-statements.
3867 3816
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -3882,12 +3831,11 @@ class TestCLI:
3882 3831
                 return []
3883 3832
 
3884 3833
             monkeypatch.setattr(ssh_agent.SSHAgentClient, 'list_keys', func)
3885
-            result_ = runner.invoke(
3834
+            result = runner.invoke(
3886 3835
                 cli.derivepassphrase_vault,
3887 3836
                 ['--key', '--config'],
3888 3837
                 catch_exceptions=False,
3889 3838
             )
3890
-        result = tests.ReadableResult.parse(result_)
3891 3839
         assert result.error_exit(error='no keys suitable'), (
3892 3840
             'expected error exit and known error message'
3893 3841
         )
... ...
@@ -3898,7 +3846,7 @@ class TestCLI:
3898 3846
     ) -> None:
3899 3847
         """The SSH agent erroring during `--config --key` fails."""
3900 3848
         del running_ssh_agent
3901
-        runner = click.testing.CliRunner(mix_stderr=False)
3849
+        runner = tests.CliRunner(mix_stderr=False)
3902 3850
         # TODO(the-13th-letter): Rewrite using parenthesized
3903 3851
         # with-statements.
3904 3852
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -3916,12 +3864,11 @@ class TestCLI:
3916 3864
                 raise ssh_agent.TrailingDataError()
3917 3865
 
3918 3866
             monkeypatch.setattr(ssh_agent.SSHAgentClient, 'list_keys', raiser)
3919
-            result_ = runner.invoke(
3867
+            result = runner.invoke(
3920 3868
                 cli.derivepassphrase_vault,
3921 3869
                 ['--key', '--config'],
3922 3870
                 catch_exceptions=False,
3923 3871
             )
3924
-        result = tests.ReadableResult.parse(result_)
3925 3872
         assert result.error_exit(
3926 3873
             error='violates the communication protocol.'
3927 3874
         ), 'expected error exit and known error message'
... ...
@@ -3932,7 +3879,7 @@ class TestCLI:
3932 3879
     ) -> None:
3933 3880
         """The SSH agent refusing during `--config --key` fails."""
3934 3881
         del running_ssh_agent
3935
-        runner = click.testing.CliRunner(mix_stderr=False)
3882
+        runner = tests.CliRunner(mix_stderr=False)
3936 3883
         # TODO(the-13th-letter): Rewrite using parenthesized
3937 3884
         # with-statements.
3938 3885
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -3952,19 +3899,18 @@ class TestCLI:
3952 3899
                 )
3953 3900
 
3954 3901
             monkeypatch.setattr(ssh_agent.SSHAgentClient, 'list_keys', func)
3955
-            result_ = runner.invoke(
3902
+            result = runner.invoke(
3956 3903
                 cli.derivepassphrase_vault,
3957 3904
                 ['--key', '--config'],
3958 3905
                 catch_exceptions=False,
3959 3906
             )
3960
-        result = tests.ReadableResult.parse(result_)
3961 3907
         assert result.error_exit(error='refused to'), (
3962 3908
             'expected error exit and known error message'
3963 3909
         )
3964 3910
 
3965 3911
     def test_226_no_arguments(self) -> None:
3966 3912
         """Calling `derivepassphrase vault` without any arguments fails."""
3967
-        runner = click.testing.CliRunner(mix_stderr=False)
3913
+        runner = tests.CliRunner(mix_stderr=False)
3968 3914
         # TODO(the-13th-letter): Rewrite using parenthesized
3969 3915
         # with-statements.
3970 3916
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -3976,10 +3922,9 @@ class TestCLI:
3976 3922
                     runner=runner,
3977 3923
                 )
3978 3924
             )
3979
-            result_ = runner.invoke(
3925
+            result = runner.invoke(
3980 3926
                 cli.derivepassphrase_vault, [], catch_exceptions=False
3981 3927
             )
3982
-        result = tests.ReadableResult.parse(result_)
3983 3928
         assert result.error_exit(
3984 3929
             error='Deriving a passphrase requires a SERVICE'
3985 3930
         ), 'expected error exit and known error message'
... ...
@@ -3988,7 +3933,7 @@ class TestCLI:
3988 3933
         self,
3989 3934
     ) -> None:
3990 3935
         """Deriving a passphrase without a passphrase or key fails."""
3991
-        runner = click.testing.CliRunner(mix_stderr=False)
3936
+        runner = tests.CliRunner(mix_stderr=False)
3992 3937
         # TODO(the-13th-letter): Rewrite using parenthesized
3993 3938
         # with-statements.
3994 3939
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -4000,12 +3945,11 @@ class TestCLI:
4000 3945
                     runner=runner,
4001 3946
                 )
4002 3947
             )
4003
-            result_ = runner.invoke(
3948
+            result = runner.invoke(
4004 3949
                 cli.derivepassphrase_vault,
4005 3950
                 ['--', DUMMY_SERVICE],
4006 3951
                 catch_exceptions=False,
4007 3952
             )
4008
-        result = tests.ReadableResult.parse(result_)
4009 3953
         assert result.error_exit(error='No passphrase or key was given'), (
4010 3954
             'expected error exit and known error message'
4011 3955
         )
... ...
@@ -4020,7 +3964,7 @@ class TestCLI:
4020 3964
         [issue #6]: https://github.com/the-13th-letter/derivepassphrase/issues/6
4021 3965
 
4022 3966
         """
4023
-        runner = click.testing.CliRunner(mix_stderr=False)
3967
+        runner = tests.CliRunner(mix_stderr=False)
4024 3968
         # TODO(the-13th-letter): Rewrite using parenthesized
4025 3969
         # with-statements.
4026 3970
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -4034,13 +3978,12 @@ class TestCLI:
4034 3978
             )
4035 3979
             with contextlib.suppress(FileNotFoundError):
4036 3980
                 shutil.rmtree(cli_helpers.config_filename(subsystem=None))
4037
-            result_ = runner.invoke(
3981
+            result = runner.invoke(
4038 3982
                 cli.derivepassphrase_vault,
4039 3983
                 ['--config', '-p'],
4040 3984
                 catch_exceptions=False,
4041 3985
                 input='abc\n',
4042 3986
             )
4043
-            result = tests.ReadableResult.parse(result_)
4044 3987
             assert result.clean_exit(), 'expected clean exit'
4045 3988
             assert result.stderr == 'Passphrase:', (
4046 3989
                 'program unexpectedly failed?!'
... ...
@@ -4067,7 +4010,7 @@ class TestCLI:
4067 4010
         [issue #6]: https://github.com/the-13th-letter/derivepassphrase/issues/6
4068 4011
 
4069 4012
         """
4070
-        runner = click.testing.CliRunner(mix_stderr=False)
4013
+        runner = tests.CliRunner(mix_stderr=False)
4071 4014
         # TODO(the-13th-letter): Rewrite using parenthesized
4072 4015
         # with-statements.
4073 4016
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -4092,13 +4035,12 @@ class TestCLI:
4092 4035
             monkeypatch.setattr(
4093 4036
                 cli_helpers, 'save_config', obstruct_config_saving
4094 4037
             )
4095
-            result_ = runner.invoke(
4038
+            result = runner.invoke(
4096 4039
                 cli.derivepassphrase_vault,
4097 4040
                 ['--config', '-p'],
4098 4041
                 catch_exceptions=False,
4099 4042
                 input='abc\n',
4100 4043
             )
4101
-            result = tests.ReadableResult.parse(result_)
4102 4044
             assert result.error_exit(error='Cannot store vault settings:'), (
4103 4045
                 'expected error exit and known error message'
4104 4046
             )
... ...
@@ -4107,7 +4049,7 @@ class TestCLI:
4107 4049
         self,
4108 4050
     ) -> None:
4109 4051
         """Storing the configuration reacts even to weird errors."""
4110
-        runner = click.testing.CliRunner(mix_stderr=False)
4052
+        runner = tests.CliRunner(mix_stderr=False)
4111 4053
         # TODO(the-13th-letter): Rewrite using parenthesized
4112 4054
         # with-statements.
4113 4055
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -4126,13 +4068,12 @@ class TestCLI:
4126 4068
                 raise RuntimeError(custom_error)
4127 4069
 
4128 4070
             monkeypatch.setattr(cli_helpers, 'save_config', raiser)
4129
-            result_ = runner.invoke(
4071
+            result = runner.invoke(
4130 4072
                 cli.derivepassphrase_vault,
4131 4073
                 ['--config', '-p'],
4132 4074
                 catch_exceptions=False,
4133 4075
                 input='abc\n',
4134 4076
             )
4135
-            result = tests.ReadableResult.parse(result_)
4136 4077
             assert result.error_exit(error=custom_error), (
4137 4078
                 'expected error exit and known error message'
4138 4079
             )
... ...
@@ -4147,7 +4088,7 @@ class TestCLI:
4147 4088
         warning_message: str,
4148 4089
     ) -> None:
4149 4090
         """Using unnormalized Unicode passphrases warns."""
4150
-        runner = click.testing.CliRunner(mix_stderr=False)
4091
+        runner = tests.CliRunner(mix_stderr=False)
4151 4092
         # TODO(the-13th-letter): Rewrite using parenthesized
4152 4093
         # with-statements.
4153 4094
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -4165,13 +4106,12 @@ class TestCLI:
4165 4106
                     main_config_str=main_config,
4166 4107
                 )
4167 4108
             )
4168
-            result_ = runner.invoke(
4109
+            result = runner.invoke(
4169 4110
                 cli.derivepassphrase_vault,
4170 4111
                 ['--debug', *command_line],
4171 4112
                 catch_exceptions=False,
4172 4113
                 input=input,
4173 4114
             )
4174
-        result = tests.ReadableResult.parse(result_)
4175 4115
         assert result.clean_exit(), 'expected clean exit'
4176 4116
         assert tests.warning_emitted(warning_message, caplog.record_tuples), (
4177 4117
             'expected known warning message in stderr'
... ...
@@ -4186,7 +4126,7 @@ class TestCLI:
4186 4126
         error_message: str,
4187 4127
     ) -> None:
4188 4128
         """Using unknown Unicode normalization forms fails."""
4189
-        runner = click.testing.CliRunner(mix_stderr=False)
4129
+        runner = tests.CliRunner(mix_stderr=False)
4190 4130
         # TODO(the-13th-letter): Rewrite using parenthesized
4191 4131
         # with-statements.
4192 4132
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -4204,13 +4144,12 @@ class TestCLI:
4204 4144
                     main_config_str=main_config,
4205 4145
                 )
4206 4146
             )
4207
-            result_ = runner.invoke(
4147
+            result = runner.invoke(
4208 4148
                 cli.derivepassphrase_vault,
4209 4149
                 command_line,
4210 4150
                 catch_exceptions=False,
4211 4151
                 input=input,
4212 4152
             )
4213
-        result = tests.ReadableResult.parse(result_)
4214 4153
         assert result.error_exit(
4215 4154
             error='The user configuration file is invalid.'
4216 4155
         ), 'expected error exit and known error message'
... ...
@@ -4224,7 +4163,7 @@ class TestCLI:
4224 4163
         command_line: list[str],
4225 4164
     ) -> None:
4226 4165
         """Using unknown Unicode normalization forms in the config fails."""
4227
-        runner = click.testing.CliRunner(mix_stderr=False)
4166
+        runner = tests.CliRunner(mix_stderr=False)
4228 4167
         # TODO(the-13th-letter): Rewrite using parenthesized
4229 4168
         # with-statements.
4230 4169
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -4244,13 +4183,12 @@ class TestCLI:
4244 4183
                     ),
4245 4184
                 )
4246 4185
             )
4247
-            result_ = runner.invoke(
4186
+            result = runner.invoke(
4248 4187
                 cli.derivepassphrase_vault,
4249 4188
                 command_line,
4250 4189
                 input=DUMMY_PASSPHRASE,
4251 4190
                 catch_exceptions=False,
4252 4191
             )
4253
-            result = tests.ReadableResult.parse(result_)
4254 4192
             assert result.error_exit(
4255 4193
                 error='The user configuration file is invalid.'
4256 4194
             ), 'expected error exit and known error message'
... ...
@@ -4265,7 +4203,7 @@ class TestCLI:
4265 4203
         self,
4266 4204
     ) -> None:
4267 4205
         """Loading a user configuration file in an invalid format fails."""
4268
-        runner = click.testing.CliRunner(mix_stderr=False)
4206
+        runner = tests.CliRunner(mix_stderr=False)
4269 4207
         # TODO(the-13th-letter): Rewrite using parenthesized
4270 4208
         # with-statements.
4271 4209
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -4279,13 +4217,12 @@ class TestCLI:
4279 4217
                     main_config_str='This file is not valid TOML.\n',
4280 4218
                 )
4281 4219
             )
4282
-            result_ = runner.invoke(
4220
+            result = runner.invoke(
4283 4221
                 cli.derivepassphrase_vault,
4284 4222
                 ['--phrase', '--', DUMMY_SERVICE],
4285 4223
                 input=DUMMY_PASSPHRASE,
4286 4224
                 catch_exceptions=False,
4287 4225
             )
4288
-            result = tests.ReadableResult.parse(result_)
4289 4226
             assert result.error_exit(error='Cannot load user config:'), (
4290 4227
                 'expected error exit and known error message'
4291 4228
             )
... ...
@@ -4294,7 +4231,7 @@ class TestCLI:
4294 4231
         self,
4295 4232
     ) -> None:
4296 4233
         """Loading a user configuration file in an invalid format fails."""
4297
-        runner = click.testing.CliRunner(mix_stderr=False)
4234
+        runner = tests.CliRunner(mix_stderr=False)
4298 4235
         # TODO(the-13th-letter): Rewrite using parenthesized
4299 4236
         # with-statements.
4300 4237
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -4313,13 +4250,12 @@ class TestCLI:
4313 4250
             )
4314 4251
             user_config.unlink()
4315 4252
             user_config.mkdir(parents=True, exist_ok=True)
4316
-            result_ = runner.invoke(
4253
+            result = runner.invoke(
4317 4254
                 cli.derivepassphrase_vault,
4318 4255
                 ['--phrase', '--', DUMMY_SERVICE],
4319 4256
                 input=DUMMY_PASSPHRASE,
4320 4257
                 catch_exceptions=False,
4321 4258
             )
4322
-            result = tests.ReadableResult.parse(result_)
4323 4259
             assert result.error_exit(error='Cannot load user config:'), (
4324 4260
                 'expected error exit and known error message'
4325 4261
             )
... ...
@@ -4328,7 +4264,7 @@ class TestCLI:
4328 4264
         self,
4329 4265
     ) -> None:
4330 4266
         """Querying the SSH agent without `AF_UNIX` support fails."""
4331
-        runner = click.testing.CliRunner(mix_stderr=False)
4267
+        runner = tests.CliRunner(mix_stderr=False)
4332 4268
         # TODO(the-13th-letter): Rewrite using parenthesized
4333 4269
         # with-statements.
4334 4270
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -4345,12 +4281,11 @@ class TestCLI:
4345 4281
                 'SSH_AUTH_SOCK', "the value doesn't even matter"
4346 4282
             )
4347 4283
             monkeypatch.delattr(socket, 'AF_UNIX', raising=False)
4348
-            result_ = runner.invoke(
4284
+            result = runner.invoke(
4349 4285
                 cli.derivepassphrase_vault,
4350 4286
                 ['--key', '--config'],
4351 4287
                 catch_exceptions=False,
4352 4288
             )
4353
-        result = tests.ReadableResult.parse(result_)
4354 4289
         assert result.error_exit(
4355 4290
             error='does not support UNIX domain sockets'
4356 4291
         ), 'expected error exit and known error message'
... ...
@@ -4365,7 +4300,7 @@ class TestCLIUtils:
4365 4300
         config: Any,
4366 4301
     ) -> None:
4367 4302
         """[`cli_helpers.load_config`][] works for valid configurations."""
4368
-        runner = click.testing.CliRunner(mix_stderr=False)
4303
+        runner = tests.CliRunner(mix_stderr=False)
4369 4304
         # TODO(the-13th-letter): Rewrite using parenthesized
4370 4305
         # with-statements.
4371 4306
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -4387,7 +4322,7 @@ class TestCLIUtils:
4387 4322
         self,
4388 4323
     ) -> None:
4389 4324
         """[`cli_helpers.save_config`][] fails for bad configurations."""
4390
-        runner = click.testing.CliRunner(mix_stderr=False)
4325
+        runner = tests.CliRunner(mix_stderr=False)
4391 4326
         # TODO(the-13th-letter): Rewrite using parenthesized
4392 4327
         # with-statements.
4393 4328
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -4437,9 +4372,8 @@ class TestCLIUtils:
4437 4372
             click.echo(items[index])
4438 4373
             click.echo('(Note: Vikings strictly optional.)')
4439 4374
 
4440
-        runner = click.testing.CliRunner(mix_stderr=True)
4441
-        result_ = runner.invoke(driver, [], input='9')
4442
-        result = tests.ReadableResult.parse(result_)
4375
+        runner = tests.CliRunner(mix_stderr=True)
4376
+        result = runner.invoke(driver, [], input='9')
4443 4377
         assert result.clean_exit(
4444 4378
             output="""\
4445 4379
 Our menu:
... ...
@@ -4458,15 +4392,14 @@ A fine choice: Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam
4458 4392
 (Note: Vikings strictly optional.)
4459 4393
 """
4460 4394
         ), 'expected clean exit'
4461
-        result_ = runner.invoke(
4395
+        result = runner.invoke(
4462 4396
             driver, ['--heading='], input='', catch_exceptions=True
4463 4397
         )
4464
-        result = tests.ReadableResult.parse(result_)
4465 4398
         assert result.error_exit(error=IndexError), (
4466 4399
             'expected error exit and known error type'
4467 4400
         )
4468 4401
         assert (
4469
-            result.output
4402
+            result.stdout
4470 4403
             == """\
4471 4404
 [1] Egg and bacon
4472 4405
 [2] Egg, sausage and bacon
... ...
@@ -4499,11 +4432,10 @@ Your selection? (1-10, leave empty to abort):\x20
4499 4432
             else:
4500 4433
                 click.echo('Great!')
4501 4434
 
4502
-        runner = click.testing.CliRunner(mix_stderr=True)
4503
-        result_ = runner.invoke(
4435
+        runner = tests.CliRunner(mix_stderr=True)
4436
+        result = runner.invoke(
4504 4437
             driver, ['Will replace with spam. Confirm, y/n?'], input='y'
4505 4438
         )
4506
-        result = tests.ReadableResult.parse(result_)
4507 4439
         assert result.clean_exit(
4508 4440
             output="""\
4509 4441
 [1] baked beans
... ...
@@ -4511,17 +4443,16 @@ Will replace with spam. Confirm, y/n? y
4511 4443
 Great!
4512 4444
 """
4513 4445
         ), 'expected clean exit'
4514
-        result_ = runner.invoke(
4446
+        result = runner.invoke(
4515 4447
             driver,
4516 4448
             ['Will replace with spam, okay? (Please say "y" or "n".)'],
4517 4449
             input='',
4518 4450
         )
4519
-        result = tests.ReadableResult.parse(result_)
4520 4451
         assert result.error_exit(error=IndexError), (
4521 4452
             'expected error exit and known error type'
4522 4453
         )
4523 4454
         assert (
4524
-            result.output
4455
+            result.stdout
4525 4456
             == """\
4526 4457
 [1] baked beans
4527 4458
 Will replace with spam, okay? (Please say "y" or "n".):\x20
... ...
@@ -4693,7 +4624,7 @@ Boo.
4693 4624
                 config, outfile=outfile, prog_name_list=prog_name_list
4694 4625
             )
4695 4626
             script = outfile.getvalue()
4696
-        runner = click.testing.CliRunner(mix_stderr=False)
4627
+        runner = tests.CliRunner(mix_stderr=False)
4697 4628
         # TODO(the-13th-letter): Rewrite using parenthesized
4698 4629
         # with-statements.
4699 4630
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -4706,8 +4637,7 @@ Boo.
4706 4637
                     vault_config={'services': {}},
4707 4638
                 )
4708 4639
             )
4709
-            for result_ in vault_config_exporter_shell_interpreter(script):
4710
-                result = tests.ReadableResult.parse(result_)
4640
+            for result in vault_config_exporter_shell_interpreter(script):
4711 4641
                 assert result.clean_exit()
4712 4642
             assert cli_helpers.load_config() == config
4713 4643
 
... ...
@@ -4943,7 +4873,7 @@ Boo.
4943 4873
         `cli_helpers.get_tempdir` returned the configuration directory.
4944 4874
 
4945 4875
         """
4946
-        runner = click.testing.CliRunner(mix_stderr=False)
4876
+        runner = tests.CliRunner(mix_stderr=False)
4947 4877
         # TODO(the-13th-letter): Rewrite using parenthesized
4948 4878
         # with-statements.
4949 4879
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -4980,7 +4910,7 @@ Boo.
4980 4910
         configuration directory.
4981 4911
 
4982 4912
         """
4983
-        runner = click.testing.CliRunner(mix_stderr=False)
4913
+        runner = tests.CliRunner(mix_stderr=False)
4984 4914
         # TODO(the-13th-letter): Rewrite using parenthesized
4985 4915
         # with-statements.
4986 4916
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -5035,7 +4965,7 @@ Boo.
5035 4965
     ) -> None:
5036 4966
         """Repeatedly removing the same parts of a configuration works."""
5037 4967
         for start_config in [config, result_config]:
5038
-            runner = click.testing.CliRunner(mix_stderr=False)
4968
+            runner = tests.CliRunner(mix_stderr=False)
5039 4969
             # TODO(the-13th-letter): Rewrite using parenthesized
5040 4970
             # with-statements.
5041 4971
             # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -5048,12 +4978,11 @@ Boo.
5048 4978
                         vault_config=start_config,
5049 4979
                     )
5050 4980
                 )
5051
-                result_ = runner.invoke(
4981
+                result = runner.invoke(
5052 4982
                     cli.derivepassphrase_vault,
5053 4983
                     command_line,
5054 4984
                     catch_exceptions=False,
5055 4985
                 )
5056
-                result = tests.ReadableResult.parse(result_)
5057 4986
                 assert result.clean_exit(empty_stderr=True), (
5058 4987
                     'expected clean exit'
5059 4988
                 )
... ...
@@ -5220,7 +5149,7 @@ class TestCLITransition:
5220 5149
         config: Any,
5221 5150
     ) -> None:
5222 5151
         """Loading the old settings file works."""
5223
-        runner = click.testing.CliRunner(mix_stderr=False)
5152
+        runner = tests.CliRunner(mix_stderr=False)
5224 5153
         # TODO(the-13th-letter): Rewrite using parenthesized
5225 5154
         # with-statements.
5226 5155
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -5243,7 +5172,7 @@ class TestCLITransition:
5243 5172
         config: Any,
5244 5173
     ) -> None:
5245 5174
         """Migrating the old settings file works."""
5246
-        runner = click.testing.CliRunner(mix_stderr=False)
5175
+        runner = tests.CliRunner(mix_stderr=False)
5247 5176
         # TODO(the-13th-letter): Rewrite using parenthesized
5248 5177
         # with-statements.
5249 5178
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -5266,7 +5195,7 @@ class TestCLITransition:
5266 5195
         config: Any,
5267 5196
     ) -> None:
5268 5197
         """Migrating the old settings file atop a directory fails."""
5269
-        runner = click.testing.CliRunner(mix_stderr=False)
5198
+        runner = tests.CliRunner(mix_stderr=False)
5270 5199
         # TODO(the-13th-letter): Rewrite using parenthesized
5271 5200
         # with-statements.
5272 5201
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -5295,7 +5224,7 @@ class TestCLITransition:
5295 5224
         config: Any,
5296 5225
     ) -> None:
5297 5226
         """Migrating an invalid old settings file fails."""
5298
-        runner = click.testing.CliRunner(mix_stderr=False)
5227
+        runner = tests.CliRunner(mix_stderr=False)
5299 5228
         # TODO(the-13th-letter): Rewrite using parenthesized
5300 5229
         # with-statements.
5301 5230
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -5321,7 +5250,7 @@ class TestCLITransition:
5321 5250
     ) -> None:
5322 5251
         """Forwarding arguments from "export" to "export vault" works."""
5323 5252
         pytest.importorskip('cryptography', minversion='38.0')
5324
-        runner = click.testing.CliRunner(mix_stderr=False)
5253
+        runner = tests.CliRunner(mix_stderr=False)
5325 5254
         # TODO(the-13th-letter): Rewrite using parenthesized
5326 5255
         # with-statements.
5327 5256
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -5336,11 +5265,10 @@ class TestCLITransition:
5336 5265
                 )
5337 5266
             )
5338 5267
             monkeypatch.setenv('VAULT_KEY', tests.VAULT_MASTER_KEY)
5339
-            result_ = runner.invoke(
5268
+            result = runner.invoke(
5340 5269
                 cli.derivepassphrase,
5341 5270
                 ['export', 'VAULT_PATH'],
5342 5271
             )
5343
-        result = tests.ReadableResult.parse(result_)
5344 5272
         assert result.clean_exit(empty_stderr=False), 'expected clean exit'
5345 5273
         assert tests.deprecation_warning_emitted(
5346 5274
             'A subcommand will be required here in v1.0', caplog.record_tuples
... ...
@@ -5348,7 +5276,7 @@ class TestCLITransition:
5348 5276
         assert tests.deprecation_warning_emitted(
5349 5277
             'Defaulting to subcommand "vault"', caplog.record_tuples
5350 5278
         )
5351
-        assert json.loads(result.output) == tests.VAULT_V03_CONFIG_DATA
5279
+        assert json.loads(result.stdout) == tests.VAULT_V03_CONFIG_DATA
5352 5280
 
5353 5281
     def test_201_forward_export_vault_empty_commandline(
5354 5282
         self,
... ...
@@ -5356,7 +5284,7 @@ class TestCLITransition:
5356 5284
     ) -> None:
5357 5285
         """Deferring from "export" to "export vault" works."""
5358 5286
         pytest.importorskip('cryptography', minversion='38.0')
5359
-        runner = click.testing.CliRunner(mix_stderr=False)
5287
+        runner = tests.CliRunner(mix_stderr=False)
5360 5288
         # TODO(the-13th-letter): Rewrite using parenthesized
5361 5289
         # with-statements.
5362 5290
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -5368,11 +5296,10 @@ class TestCLITransition:
5368 5296
                     runner=runner,
5369 5297
                 )
5370 5298
             )
5371
-            result_ = runner.invoke(
5299
+            result = runner.invoke(
5372 5300
                 cli.derivepassphrase,
5373 5301
                 ['export'],
5374 5302
             )
5375
-        result = tests.ReadableResult.parse(result_)
5376 5303
         assert tests.deprecation_warning_emitted(
5377 5304
             'A subcommand will be required here in v1.0', caplog.record_tuples
5378 5305
         )
... ...
@@ -5392,7 +5319,7 @@ class TestCLITransition:
5392 5319
         """Forwarding arguments from top-level to "vault" works."""
5393 5320
         option = f'--{charset_name}'
5394 5321
         charset = vault.Vault.CHARSETS[charset_name].decode('ascii')
5395
-        runner = click.testing.CliRunner(mix_stderr=False)
5322
+        runner = tests.CliRunner(mix_stderr=False)
5396 5323
         # TODO(the-13th-letter): Rewrite using parenthesized
5397 5324
         # with-statements.
5398 5325
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -5407,13 +5334,12 @@ class TestCLITransition:
5407 5334
             monkeypatch.setattr(
5408 5335
                 cli_helpers, 'prompt_for_passphrase', tests.auto_prompt
5409 5336
             )
5410
-            result_ = runner.invoke(
5337
+            result = runner.invoke(
5411 5338
                 cli.derivepassphrase,
5412 5339
                 [option, '0', '-p', '--', DUMMY_SERVICE],
5413 5340
                 input=DUMMY_PASSPHRASE,
5414 5341
                 catch_exceptions=False,
5415 5342
             )
5416
-            result = tests.ReadableResult.parse(result_)
5417 5343
         assert result.clean_exit(empty_stderr=False), 'expected clean exit'
5418 5344
         assert tests.deprecation_warning_emitted(
5419 5345
             'A subcommand will be required here in v1.0', caplog.record_tuples
... ...
@@ -5422,7 +5348,7 @@ class TestCLITransition:
5422 5348
             'Defaulting to subcommand "vault"', caplog.record_tuples
5423 5349
         )
5424 5350
         for c in charset:
5425
-            assert c not in result.output, (
5351
+            assert c not in result.stdout, (
5426 5352
                 f'derived password contains forbidden character {c!r}'
5427 5353
             )
5428 5354
 
... ...
@@ -5431,7 +5357,7 @@ class TestCLITransition:
5431 5357
         caplog: pytest.LogCaptureFixture,
5432 5358
     ) -> None:
5433 5359
         """Deferring from top-level to "vault" works."""
5434
-        runner = click.testing.CliRunner(mix_stderr=False)
5360
+        runner = tests.CliRunner(mix_stderr=False)
5435 5361
         # TODO(the-13th-letter): Rewrite using parenthesized
5436 5362
         # with-statements.
5437 5363
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -5443,13 +5369,12 @@ class TestCLITransition:
5443 5369
                     runner=runner,
5444 5370
                 )
5445 5371
             )
5446
-            result_ = runner.invoke(
5372
+            result = runner.invoke(
5447 5373
                 cli.derivepassphrase,
5448 5374
                 [],
5449 5375
                 input=DUMMY_PASSPHRASE,
5450 5376
                 catch_exceptions=False,
5451 5377
             )
5452
-            result = tests.ReadableResult.parse(result_)
5453 5378
         assert tests.deprecation_warning_emitted(
5454 5379
             'A subcommand will be required here in v1.0', caplog.record_tuples
5455 5380
         )
... ...
@@ -5466,7 +5391,7 @@ class TestCLITransition:
5466 5391
     ) -> None:
5467 5392
         """Exporting from (and migrating) the old settings file works."""
5468 5393
         caplog.set_level(logging.INFO)
5469
-        runner = click.testing.CliRunner(mix_stderr=False)
5394
+        runner = tests.CliRunner(mix_stderr=False)
5470 5395
         # TODO(the-13th-letter): Rewrite using parenthesized
5471 5396
         # with-statements.
5472 5397
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -5488,12 +5413,11 @@ class TestCLITransition:
5488 5413
                 + '\n',
5489 5414
                 encoding='UTF-8',
5490 5415
             )
5491
-            result_ = runner.invoke(
5416
+            result = runner.invoke(
5492 5417
                 cli.derivepassphrase_vault,
5493 5418
                 ['--export', '-'],
5494 5419
                 catch_exceptions=False,
5495 5420
             )
5496
-        result = tests.ReadableResult.parse(result_)
5497 5421
         assert result.clean_exit(), 'expected clean exit'
5498 5422
         assert tests.deprecation_warning_emitted(
5499 5423
             'v0.1-style config file', caplog.record_tuples
... ...
@@ -5507,7 +5431,7 @@ class TestCLITransition:
5507 5431
         caplog: pytest.LogCaptureFixture,
5508 5432
     ) -> None:
5509 5433
         """Exporting from (and not migrating) the old settings file fails."""
5510
-        runner = click.testing.CliRunner(mix_stderr=False)
5434
+        runner = tests.CliRunner(mix_stderr=False)
5511 5435
         # TODO(the-13th-letter): Rewrite using parenthesized
5512 5436
         # with-statements.
5513 5437
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -5539,12 +5463,11 @@ class TestCLITransition:
5539 5463
 
5540 5464
             monkeypatch.setattr(os, 'replace', raiser)
5541 5465
             monkeypatch.setattr(pathlib.Path, 'rename', raiser)
5542
-            result_ = runner.invoke(
5466
+            result = runner.invoke(
5543 5467
                 cli.derivepassphrase_vault,
5544 5468
                 ['--export', '-'],
5545 5469
                 catch_exceptions=False,
5546 5470
             )
5547
-        result = tests.ReadableResult.parse(result_)
5548 5471
         assert result.clean_exit(), 'expected clean exit'
5549 5472
         assert tests.deprecation_warning_emitted(
5550 5473
             'v0.1-style config file', caplog.record_tuples
... ...
@@ -5558,7 +5481,7 @@ class TestCLITransition:
5558 5481
     ) -> None:
5559 5482
         """Completing service names from the old settings file works."""
5560 5483
         config = {'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}}
5561
-        runner = click.testing.CliRunner(mix_stderr=False)
5484
+        runner = tests.CliRunner(mix_stderr=False)
5562 5485
         # TODO(the-13th-letter): Rewrite using parenthesized
5563 5486
         # with-statements.
5564 5487
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -5714,7 +5637,7 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
5714 5637
     def __init__(self) -> None:
5715 5638
         """Initialize self, set up context managers and enter them."""
5716 5639
         super().__init__()
5717
-        self.runner = click.testing.CliRunner(mix_stderr=False)
5640
+        self.runner = tests.CliRunner(mix_stderr=False)
5718 5641
         self.exit_stack = contextlib.ExitStack().__enter__()
5719 5642
         self.monkeypatch = self.exit_stack.enter_context(
5720 5643
             pytest.MonkeyPatch().context()
... ...
@@ -5834,7 +5757,7 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
5834 5757
         # NOTE: This relies on settings_obj containing only the keys
5835 5758
         # "length", "repeat", "upper", "lower", "number", "space",
5836 5759
         # "dash" and "symbol".
5837
-        result_ = self.runner.invoke(
5760
+        result = self.runner.invoke(
5838 5761
             cli.derivepassphrase_vault,
5839 5762
             [
5840 5763
                 '--config',
... ...
@@ -5848,7 +5771,6 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
5848 5771
             ],
5849 5772
             catch_exceptions=False,
5850 5773
         )
5851
-        result = tests.ReadableResult.parse(result_)
5852 5774
         assert result.clean_exit(empty_stderr=False)
5853 5775
         assert cli_helpers.load_config() == config
5854 5776
         return config
... ...
@@ -5907,7 +5829,7 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
5907 5829
         # NOTE: This relies on settings_obj containing only the keys
5908 5830
         # "length", "repeat", "upper", "lower", "number", "space",
5909 5831
         # "dash" and "symbol".
5910
-        result_ = self.runner.invoke(
5832
+        result = self.runner.invoke(
5911 5833
             cli.derivepassphrase_vault,
5912 5834
             [
5913 5835
                 '--config',
... ...
@@ -5922,7 +5844,6 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
5922 5844
             + ['--', service],
5923 5845
             catch_exceptions=False,
5924 5846
         )
5925
-        result = tests.ReadableResult.parse(result_)
5926 5847
         assert result.clean_exit(empty_stderr=False)
5927 5848
         assert cli_helpers.load_config() == config
5928 5849
         return config
... ...
@@ -5947,13 +5868,12 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
5947 5868
         """
5948 5869
         cli_helpers.save_config(config)
5949 5870
         config.pop('global', None)
5950
-        result_ = self.runner.invoke(
5871
+        result = self.runner.invoke(
5951 5872
             cli.derivepassphrase_vault,
5952 5873
             ['--delete-globals'],
5953 5874
             input='y',
5954 5875
             catch_exceptions=False,
5955 5876
         )
5956
-        result = tests.ReadableResult.parse(result_)
5957 5877
         assert result.clean_exit(empty_stderr=False)
5958 5878
         assert cli_helpers.load_config() == config
5959 5879
         return config
... ...
@@ -5987,13 +5907,12 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
5987 5907
         config, service = config_and_service
5988 5908
         cli_helpers.save_config(config)
5989 5909
         config['services'].pop(service, None)
5990
-        result_ = self.runner.invoke(
5910
+        result = self.runner.invoke(
5991 5911
             cli.derivepassphrase_vault,
5992 5912
             ['--delete', '--', service],
5993 5913
             input='y',
5994 5914
             catch_exceptions=False,
5995 5915
         )
5996
-        result = tests.ReadableResult.parse(result_)
5997 5916
         assert result.clean_exit(empty_stderr=False)
5998 5917
         assert cli_helpers.load_config() == config
5999 5918
         return config
... ...
@@ -6018,13 +5937,12 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
6018 5937
         """
6019 5938
         cli_helpers.save_config(config)
6020 5939
         config = {'services': {}}
6021
-        result_ = self.runner.invoke(
5940
+        result = self.runner.invoke(
6022 5941
             cli.derivepassphrase_vault,
6023 5942
             ['--clear'],
6024 5943
             input='y',
6025 5944
             catch_exceptions=False,
6026 5945
         )
6027
-        result = tests.ReadableResult.parse(result_)
6028 5946
         assert result.clean_exit(empty_stderr=False)
6029 5947
         assert cli_helpers.load_config() == config
6030 5948
         return config
... ...
@@ -6064,16 +5982,14 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
6064 5982
             else config_to_import
6065 5983
         )
6066 5984
         assert _types.is_vault_config(config)
6067
-        result_ = self.runner.invoke(
5985
+        result = self.runner.invoke(
6068 5986
             cli.derivepassphrase_vault,
6069 5987
             ['--import', '-']
6070 5988
             + (['--overwrite-existing'] if overwrite else []),
6071 5989
             input=json.dumps(config_to_import),
6072 5990
             catch_exceptions=False,
6073 5991
         )
6074
-        assert tests.ReadableResult.parse(result_).clean_exit(
6075
-            empty_stderr=False
6076
-        )
5992
+        assert result.clean_exit(empty_stderr=False)
6077 5993
         assert cli_helpers.load_config() == config
6078 5994
         return config
6079 5995
 
... ...
@@ -6202,7 +6118,7 @@ class TestShellCompletion:
6202 6118
         completions: AbstractSet[str],
6203 6119
     ) -> None:
6204 6120
         """Our completion machinery works for vault service names."""
6205
-        runner = click.testing.CliRunner(mix_stderr=False)
6121
+        runner = tests.CliRunner(mix_stderr=False)
6206 6122
         # TODO(the-13th-letter): Rewrite using parenthesized
6207 6123
         # with-statements.
6208 6124
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -6234,7 +6150,7 @@ class TestShellCompletion:
6234 6150
         results: list[str | click.shell_completion.CompletionItem],
6235 6151
     ) -> None:
6236 6152
         """Custom completion functions work for all shells."""
6237
-        runner = click.testing.CliRunner(mix_stderr=False)
6153
+        runner = tests.CliRunner(mix_stderr=False)
6238 6154
         # TODO(the-13th-letter): Rewrite using parenthesized
6239 6155
         # with-statements.
6240 6156
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -6296,7 +6212,7 @@ class TestShellCompletion:
6296 6212
     ) -> None:
6297 6213
         """Completion skips incompletable items."""
6298 6214
         vault_config = config if mode == 'config' else {'services': {}}
6299
-        runner = click.testing.CliRunner(mix_stderr=False)
6215
+        runner = tests.CliRunner(mix_stderr=False)
6300 6216
         # TODO(the-13th-letter): Rewrite using parenthesized
6301 6217
         # with-statements.
6302 6218
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -6310,19 +6226,18 @@ class TestShellCompletion:
6310 6226
                 )
6311 6227
             )
6312 6228
             if mode == 'config':
6313
-                result_ = runner.invoke(
6229
+                result = runner.invoke(
6314 6230
                     cli.derivepassphrase_vault,
6315 6231
                     ['--config', '--length=10', '--', key],
6316 6232
                     catch_exceptions=False,
6317 6233
                 )
6318 6234
             else:
6319
-                result_ = runner.invoke(
6235
+                result = runner.invoke(
6320 6236
                     cli.derivepassphrase_vault,
6321 6237
                     ['--import', '-'],
6322 6238
                     catch_exceptions=False,
6323 6239
                     input=json.dumps(config),
6324 6240
                 )
6325
-            result = tests.ReadableResult.parse(result_)
6326 6241
             assert result.clean_exit(), 'expected clean exit'
6327 6242
             assert tests.warning_emitted(
6328 6243
                 'contains an ASCII control character', caplog.record_tuples
... ...
@@ -6338,7 +6253,7 @@ class TestShellCompletion:
6338 6253
         self,
6339 6254
     ) -> None:
6340 6255
         """Service name completion quietly fails on missing configuration."""
6341
-        runner = click.testing.CliRunner(mix_stderr=False)
6256
+        runner = tests.CliRunner(mix_stderr=False)
6342 6257
         # TODO(the-13th-letter): Rewrite using parenthesized
6343 6258
         # with-statements.
6344 6259
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -6368,7 +6283,7 @@ class TestShellCompletion:
6368 6283
         exc_type: type[Exception],
6369 6284
     ) -> None:
6370 6285
         """Service name completion quietly fails on configuration errors."""
6371
-        runner = click.testing.CliRunner(mix_stderr=False)
6286
+        runner = tests.CliRunner(mix_stderr=False)
6372 6287
         # TODO(the-13th-letter): Rewrite using parenthesized
6373 6288
         # with-statements.
6374 6289
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -11,7 +11,6 @@ import pathlib
11 11
 import types
12 12
 from typing import TYPE_CHECKING
13 13
 
14
-import click.testing
15 14
 import hypothesis
16 15
 import pytest
17 16
 from hypothesis import strategies
... ...
@@ -186,7 +185,7 @@ class TestCLI:
186 185
         [`exporter.get_vault_path`][] for details.
187 186
 
188 187
         """
189
-        runner = click.testing.CliRunner(mix_stderr=False)
188
+        runner = tests.CliRunner(mix_stderr=False)
190 189
         # TODO(the-13th-letter): Rewrite using parenthesized
191 190
         # with-statements.
192 191
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -201,17 +200,16 @@ class TestCLI:
201 200
                 )
202 201
             )
203 202
             monkeypatch.setenv('VAULT_KEY', tests.VAULT_MASTER_KEY)
204
-            result_ = runner.invoke(
203
+            result = runner.invoke(
205 204
                 cli.derivepassphrase_export_vault,
206 205
                 ['VAULT_PATH'],
207 206
             )
208
-        result = tests.ReadableResult.parse(result_)
209 207
         assert result.clean_exit(empty_stderr=True), 'expected clean exit'
210
-        assert json.loads(result.output) == tests.VAULT_V03_CONFIG_DATA
208
+        assert json.loads(result.stdout) == tests.VAULT_V03_CONFIG_DATA
211 209
 
212 210
     def test_201_key_parameter(self) -> None:
213 211
         """The `--key` option is supported."""
214
-        runner = click.testing.CliRunner(mix_stderr=False)
212
+        runner = tests.CliRunner(mix_stderr=False)
215 213
         # TODO(the-13th-letter): Rewrite using parenthesized
216 214
         # with-statements.
217 215
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -224,13 +222,12 @@ class TestCLI:
224 222
                     vault_config=tests.VAULT_V03_CONFIG,
225 223
                 )
226 224
             )
227
-            result_ = runner.invoke(
225
+            result = runner.invoke(
228 226
                 cli.derivepassphrase_export_vault,
229 227
                 ['-k', tests.VAULT_MASTER_KEY, '.vault'],
230 228
             )
231
-        result = tests.ReadableResult.parse(result_)
232 229
         assert result.clean_exit(empty_stderr=True), 'expected clean exit'
233
-        assert json.loads(result.output) == tests.VAULT_V03_CONFIG_DATA
230
+        assert json.loads(result.stdout) == tests.VAULT_V03_CONFIG_DATA
234 231
 
235 232
     @tests.Parametrize.VAULT_CONFIG_FORMATS_DATA
236 233
     def test_210_load_vault_v02_v03_storeroom(
... ...
@@ -245,7 +242,7 @@ class TestCLI:
245 242
         vault` to only attempt decoding in that named format.
246 243
 
247 244
         """
248
-        runner = click.testing.CliRunner(mix_stderr=False)
245
+        runner = tests.CliRunner(mix_stderr=False)
249 246
         # TODO(the-13th-letter): Rewrite using parenthesized
250 247
         # with-statements.
251 248
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -258,13 +255,12 @@ class TestCLI:
258 255
                     vault_config=config,
259 256
                 )
260 257
             )
261
-            result_ = runner.invoke(
258
+            result = runner.invoke(
262 259
                 cli.derivepassphrase_export_vault,
263 260
                 ['-f', format, '-k', tests.VAULT_MASTER_KEY, 'VAULT_PATH'],
264 261
             )
265
-        result = tests.ReadableResult.parse(result_)
266 262
         assert result.clean_exit(empty_stderr=True), 'expected clean exit'
267
-        assert json.loads(result.output) == config_data
263
+        assert json.loads(result.stdout) == config_data
268 264
 
269 265
     # test_300_invalid_format is found in
270 266
     # tests.test_derivepassphrase_export::Test002CLI
... ...
@@ -274,7 +270,7 @@ class TestCLI:
274 270
         caplog: pytest.LogCaptureFixture,
275 271
     ) -> None:
276 272
         """Fail when trying to decode non-existant files/directories."""
277
-        runner = click.testing.CliRunner(mix_stderr=False)
273
+        runner = tests.CliRunner(mix_stderr=False)
278 274
         # TODO(the-13th-letter): Rewrite using parenthesized
279 275
         # with-statements.
280 276
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -288,11 +284,10 @@ class TestCLI:
288 284
                     vault_key=tests.VAULT_MASTER_KEY,
289 285
                 )
290 286
             )
291
-            result_ = runner.invoke(
287
+            result = runner.invoke(
292 288
                 cli.derivepassphrase_export_vault,
293 289
                 ['does-not-exist.txt'],
294 290
             )
295
-        result = tests.ReadableResult.parse(result_)
296 291
         assert result.error_exit(
297 292
             error=(
298 293
                 "Cannot parse 'does-not-exist.txt' "
... ...
@@ -307,7 +302,7 @@ class TestCLI:
307 302
         caplog: pytest.LogCaptureFixture,
308 303
     ) -> None:
309 304
         """Fail to parse invalid vault configurations (files)."""
310
-        runner = click.testing.CliRunner(mix_stderr=False)
305
+        runner = tests.CliRunner(mix_stderr=False)
311 306
         # TODO(the-13th-letter): Rewrite using parenthesized
312 307
         # with-statements.
313 308
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -321,11 +316,10 @@ class TestCLI:
321 316
                     vault_key=tests.VAULT_MASTER_KEY,
322 317
                 )
323 318
             )
324
-            result_ = runner.invoke(
319
+            result = runner.invoke(
325 320
                 cli.derivepassphrase_export_vault,
326 321
                 ['.vault'],
327 322
             )
328
-        result = tests.ReadableResult.parse(result_)
329 323
         assert result.error_exit(
330 324
             error="Cannot parse '.vault' as a valid vault-native config",
331 325
             record_tuples=caplog.record_tuples,
... ...
@@ -337,7 +331,7 @@ class TestCLI:
337 331
         caplog: pytest.LogCaptureFixture,
338 332
     ) -> None:
339 333
         """Fail to parse invalid vault configurations (directories)."""
340
-        runner = click.testing.CliRunner(mix_stderr=False)
334
+        runner = tests.CliRunner(mix_stderr=False)
341 335
         # TODO(the-13th-letter): Rewrite using parenthesized
342 336
         # with-statements.
343 337
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -354,11 +348,10 @@ class TestCLI:
354 348
             p = pathlib.Path('.vault')
355 349
             p.unlink()
356 350
             p.mkdir()
357
-            result_ = runner.invoke(
351
+            result = runner.invoke(
358 352
                 cli.derivepassphrase_export_vault,
359 353
                 [str(p)],
360 354
             )
361
-        result = tests.ReadableResult.parse(result_)
362 355
         assert result.error_exit(
363 356
             error="Cannot parse '.vault' as a valid vault-native config",
364 357
             record_tuples=caplog.record_tuples,
... ...
@@ -370,7 +363,7 @@ class TestCLI:
370 363
         caplog: pytest.LogCaptureFixture,
371 364
     ) -> None:
372 365
         """Fail to parse vault configurations with invalid integrity checks."""
373
-        runner = click.testing.CliRunner(mix_stderr=False)
366
+        runner = tests.CliRunner(mix_stderr=False)
374 367
         # TODO(the-13th-letter): Rewrite using parenthesized
375 368
         # with-statements.
376 369
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -384,11 +377,10 @@ class TestCLI:
384 377
                     vault_key=tests.VAULT_MASTER_KEY,
385 378
                 )
386 379
             )
387
-            result_ = runner.invoke(
380
+            result = runner.invoke(
388 381
                 cli.derivepassphrase_export_vault,
389 382
                 ['-f', 'v0.3', '.vault'],
390 383
             )
391
-        result = tests.ReadableResult.parse(result_)
392 384
         assert result.error_exit(
393 385
             error="Cannot parse '.vault' as a valid vault-native config",
394 386
             record_tuples=caplog.record_tuples,
... ...
@@ -400,7 +392,7 @@ class TestCLI:
400 392
         caplog: pytest.LogCaptureFixture,
401 393
     ) -> None:
402 394
         """The decoded vault configuration data is valid."""
403
-        runner = click.testing.CliRunner(mix_stderr=False)
395
+        runner = tests.CliRunner(mix_stderr=False)
404 396
         # TODO(the-13th-letter): Rewrite using parenthesized
405 397
         # with-statements.
406 398
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -423,11 +415,10 @@ class TestCLI:
423 415
                 'export_vault_config_data',
424 416
                 export_vault_config_data,
425 417
             )
426
-            result_ = runner.invoke(
418
+            result = runner.invoke(
427 419
                 cli.derivepassphrase_export_vault,
428 420
                 ['.vault'],
429 421
             )
430
-        result = tests.ReadableResult.parse(result_)
431 422
         assert result.error_exit(
432 423
             error='Invalid vault config: ',
433 424
             record_tuples=caplog.record_tuples,
... ...
@@ -453,7 +444,7 @@ class TestStoreroom:
453 444
         them as well.
454 445
 
455 446
         """
456
-        runner = click.testing.CliRunner(mix_stderr=False)
447
+        runner = tests.CliRunner(mix_stderr=False)
457 448
         # TODO(the-13th-letter): Rewrite using parenthesized
458 449
         # with-statements.
459 450
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -496,7 +487,7 @@ class TestStoreroom:
496 487
         wrong shape.
497 488
 
498 489
         """
499
-        runner = click.testing.CliRunner(mix_stderr=False)
490
+        runner = tests.CliRunner(mix_stderr=False)
500 491
         master_keys = _types.StoreroomMasterKeys(
501 492
             encryption_key=bytes(storeroom.KEY_SIZE),
502 493
             signing_key=bytes(storeroom.KEY_SIZE),
... ...
@@ -533,7 +524,7 @@ class TestStoreroom:
533 524
         These include unknown versions, and data of the wrong shape.
534 525
 
535 526
         """
536
-        runner = click.testing.CliRunner(mix_stderr=False)
527
+        runner = tests.CliRunner(mix_stderr=False)
537 528
         # TODO(the-13th-letter): Rewrite using parenthesized
538 529
         # with-statements.
539 530
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -575,7 +566,7 @@ class TestStoreroom:
575 566
             subdirectories.
576 567
 
577 568
         """
578
-        runner = click.testing.CliRunner(mix_stderr=False)
569
+        runner = tests.CliRunner(mix_stderr=False)
579 570
         # TODO(the-13th-letter): Rewrite using parenthesized
580 571
         # with-statements.
581 572
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -701,7 +692,7 @@ class TestVaultNativeConfig:
701 692
             no longer does.
702 693
 
703 694
         """
704
-        runner = click.testing.CliRunner(mix_stderr=False)
695
+        runner = tests.CliRunner(mix_stderr=False)
705 696
         # TODO(the-13th-letter): Rewrite using parenthesized
706 697
         # with-statements.
707 698
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -737,7 +728,7 @@ class TestVaultNativeConfig:
737 728
         them as well.
738 729
 
739 730
         """
740
-        runner = click.testing.CliRunner(mix_stderr=False)
731
+        runner = tests.CliRunner(mix_stderr=False)
741 732
         # TODO(the-13th-letter): Rewrite using parenthesized
742 733
         # with-statements.
743 734
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -772,7 +763,7 @@ class TestVaultNativeConfig:
772 763
 
773 764
             return func
774 765
 
775
-        runner = click.testing.CliRunner(mix_stderr=False)
766
+        runner = tests.CliRunner(mix_stderr=False)
776 767
         # TODO(the-13th-letter): Rewrite using parenthesized
777 768
         # with-statements.
778 769
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -12,7 +12,6 @@ import string
12 12
 import types
13 13
 from typing import TYPE_CHECKING, Any, NamedTuple
14 14
 
15
-import click.testing
16 15
 import hypothesis
17 16
 import pytest
18 17
 from hypothesis import strategies
... ...
@@ -206,7 +205,7 @@ class Test001ExporterUtils:
206 205
             ('USER', user),
207 206
             ('USERNAME', username),
208 207
         ]
209
-        runner = click.testing.CliRunner(mix_stderr=False)
208
+        runner = tests.CliRunner(mix_stderr=False)
210 209
         # TODO(the-13th-letter): Rewrite using parenthesized
211 210
         # with-statements.
212 211
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -233,7 +232,7 @@ class Test001ExporterUtils:
233 232
         Handle relative paths, absolute paths, and missing paths.
234 233
 
235 234
         """
236
-        runner = click.testing.CliRunner(mix_stderr=False)
235
+        runner = tests.CliRunner(mix_stderr=False)
237 236
         # TODO(the-13th-letter): Rewrite using parenthesized
238 237
         # with-statements.
239 238
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -378,7 +377,7 @@ class Test002CLI:
378 377
 
379 378
     def test_300_invalid_format(self) -> None:
380 379
         """Reject invalid vault configuration format names."""
381
-        runner = click.testing.CliRunner(mix_stderr=False)
380
+        runner = tests.CliRunner(mix_stderr=False)
382 381
         # TODO(the-13th-letter): Rewrite using parenthesized
383 382
         # with-statements.
384 383
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -392,12 +391,11 @@ class Test002CLI:
392 391
                     vault_key=tests.VAULT_MASTER_KEY,
393 392
                 )
394 393
             )
395
-            result_ = runner.invoke(
394
+            result = runner.invoke(
396 395
                 cli.derivepassphrase_export_vault,
397 396
                 ['-f', 'INVALID', 'VAULT_PATH'],
398 397
                 catch_exceptions=False,
399 398
             )
400
-        result = tests.ReadableResult.parse(result_)
401 399
         for snippet in ('Invalid value for', '-f', '--format', 'INVALID'):
402 400
             assert result.error_exit(error=snippet), (
403 401
                 'expected error exit and known error message'
... ...
@@ -414,7 +412,7 @@ class Test002CLI:
414 412
     ) -> None:
415 413
         """Abort export call if no cryptography is available."""
416 414
         del config_data
417
-        runner = click.testing.CliRunner(mix_stderr=False)
415
+        runner = tests.CliRunner(mix_stderr=False)
418 416
         # TODO(the-13th-letter): Rewrite using parenthesized
419 417
         # with-statements.
420 418
         # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
... ...
@@ -428,12 +426,11 @@ class Test002CLI:
428 426
                     vault_key=tests.VAULT_MASTER_KEY,
429 427
                 )
430 428
             )
431
-            result_ = runner.invoke(
429
+            result = runner.invoke(
432 430
                 cli.derivepassphrase_export_vault,
433 431
                 ['-f', format, 'VAULT_PATH'],
434 432
                 catch_exceptions=False,
435 433
             )
436
-        result = tests.ReadableResult.parse(result_)
437 434
         assert result.error_exit(
438 435
             error=tests.CANNOT_LOAD_CRYPTOGRAPHY,
439 436
             record_tuples=caplog.record_tuples,
... ...
@@ -694,14 +694,13 @@ class TestAgentInteraction:
694 694
 
695 695
         # TODO(the-13th-letter): (Continued from above.)  Update input
696 696
         # data to use `index`/`input` directly and unconditionally.
697
-        runner = click.testing.CliRunner(mix_stderr=True)
698
-        result_ = runner.invoke(
697
+        runner = tests.CliRunner(mix_stderr=True)
698
+        result = runner.invoke(
699 699
             driver,
700 700
             [],
701 701
             input=('yes\n' if single else f'{index}\n'),
702 702
             catch_exceptions=True,
703 703
         )
704
-        result = tests.ReadableResult.parse(result_)
705 704
         for snippet in ('Suitable SSH keys:\n', text, f'\n{b64_key}\n'):
706 705
             assert result.clean_exit(output=snippet), 'expected clean exit'
707 706
 
708 707