• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

domdfcoding / consolekit / 20520117960

26 Dec 2025 09:33AM UTC coverage: 94.189% (+0.007%) from 94.182%
20520117960

push

github

domdfcoding
Ignore some blocks from coverage.

778 of 826 relevant lines covered (94.19%)

0.94 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.51
/consolekit/testing.py
1
#!/usr/bin/env python3
2
#
3
#  testing.py
4
"""
5
Test helpers.
6

7
.. versionadded:: 0.9.0
8

9
.. extras-require:: testing
10
        :pyproject:
11
"""
12
#
13
#  Copyright © 2020-2021 Dominic Davis-Foster <dominic@davis-foster.co.uk>
14
#
15
#  Permission is hereby granted, free of charge, to any person obtaining a copy
16
#  of this software and associated documentation files (the "Software"), to deal
17
#  in the Software without restriction, including without limitation the rights
18
#  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
19
#  copies of the Software, and to permit persons to whom the Software is
20
#  furnished to do so, subject to the following conditions:
21
#
22
#  The above copyright notice and this permission notice shall be included in all
23
#  copies or substantial portions of the Software.
24
#
25
#  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
26
#  EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
27
#  MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
28
#  IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
29
#  DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
30
#  OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
31
#  OR OTHER DEALINGS IN THE SOFTWARE.
32
#
33
#  Result and CliRunner based on https://github.com/pallets/click
34
#  Copyright 2014 Pallets
35
#  |  Redistribution and use in source and binary forms, with or without modification,
36
#  |  are permitted provided that the following conditions are met:
37
#  |
38
#  |      * Redistributions of source code must retain the above copyright notice,
39
#  |        this list of conditions and the following disclaimer.
40
#  |      * Redistributions in binary form must reproduce the above copyright notice,
41
#  |        this list of conditions and the following disclaimer in the documentation
42
#  |        and/or other materials provided with the distribution.
43
#  |      * Neither the name of the copyright holder nor the names of its contributors
44
#  |        may be used to endorse or promote products derived from this software without
45
#  |        specific prior written permission.
46
#  |
47
#  |  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
48
#  |  "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
49
#  |  LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
50
#  |  A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER
51
#  |  OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
52
#  |  EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
53
#  |  PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
54
#  |  PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
55
#  |  LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
56
#  |  NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
57
#  |  SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
58
#
59

60
# stdlib
61
from types import TracebackType
1✔
62
from typing import IO, Any, Iterable, Mapping, Optional, Tuple, Type, Union
1✔
63

64
# 3rd party
65
import click.testing
1✔
66
import pytest  # nodep
1✔
67
from coincidence.regressions import check_file_regression  # nodep
1✔
68
from domdf_python_tools.compat import importlib_metadata
1✔
69
from pytest_regressions.file_regression import FileRegressionFixture  # nodep
1✔
70
from typing_extensions import Literal
1✔
71

72
__all__ = ("CliRunner", "Result", "cli_runner", "click_version", "click_major")
1✔
73

74
_click_version = tuple(map(int, importlib_metadata.version("click").split('.')))
1✔
75
_click_major = _click_version[0]
1✔
76

77
click_version: Tuple[int, ...] = _click_version
1✔
78
"""
79
The version number of the currently installed click package.
80

81
.. versionadded:: 1.9.0
82
"""
83

84
click_major: int = _click_major
1✔
85
"""
86
The first part of the version number of the currently installed click package.
87

88
.. versionadded:: 1.9.0
89
"""
90

91

92
class Result(click.testing.Result):
1✔
93
        """
94
        Holds the captured result of an invoked CLI script.
95

96
        :param runner: The runner that created the result.
97
        :param stdout_bytes: The standard output as bytes.
98
        :param stderr_bytes: The standard error as bytes, or :py:obj:`None` if not available.
99
        :param exit_code: The command's exit code.
100
        :param exception: The exception that occurred, if any.
101
        :param exc_info: The traceback, if an exception occurred.
102
        :param output_bytes:
103
        """
104

105
        runner: click.testing.CliRunner
1✔
106
        exit_code: int
1✔
107
        exception: Optional[BaseException]
1✔
108
        exc_info: Optional[Any]
1✔
109
        stdout_bytes: bytes
1✔
110
        stderr_bytes: Optional[bytes]
1✔
111
        return_value: Optional[Tuple[Type[BaseException], BaseException, TracebackType]]
1✔
112

113
        def __init__(
1✔
114
                        self,
115
                        runner: click.testing.CliRunner,
116
                        stdout_bytes: bytes,
117
                        stderr_bytes: Optional[bytes],
118
                        exit_code: int,
119
                        exception: Optional[BaseException],
120
                        exc_info: Optional[Tuple[Type[BaseException], BaseException, TracebackType]] = None,
121
                        output_bytes: Optional[bytes] = None,
122
                        ) -> None:
123

124
                if _click_version >= (8, 2):
1✔
125
                        super().__init__(
1✔
126
                                        runner=runner,
127
                                        stdout_bytes=stdout_bytes,
128
                                        stderr_bytes=stderr_bytes,
129
                                        output_bytes=output_bytes,  # type: ignore[call-arg]
130
                                        exit_code=exit_code,
131
                                        exception=exception,
132
                                        exc_info=exc_info,
133
                                        return_value=None,
134
                                        )
135
                elif _click_version[0] >= 8:
1✔
136
                        super().__init__(
1✔
137
                                        runner=runner,
138
                                        stdout_bytes=stdout_bytes,
139
                                        stderr_bytes=stderr_bytes,
140
                                        exit_code=exit_code,
141
                                        exception=exception,
142
                                        exc_info=exc_info,
143
                                        return_value=None,
144
                                        )
145
                else:
146
                        super().__init__(  # type: ignore[call-arg]
×
147
                                runner=runner,
148
                                stdout_bytes=stdout_bytes,
149
                                stderr_bytes=stderr_bytes,
150
                                exit_code=exit_code,
151
                                exception=exception,
152
                                exc_info=exc_info,
153
                                )
154

155
        @property
1✔
156
        def output(self) -> str:
1✔
157
                """
158
                The (standard) output as a string.
159
                """
160

161
                if _click_version >= (8, 2):
1✔
162
                        ob = self.output_bytes  # type: ignore[attr-defined]
1✔
163
                        return ob.decode(self.runner.charset, "replace").replace("\r\n", '\n')
1✔
164

165
                return super().output
1✔
166

167
        @property
1✔
168
        def stdout(self) -> str:
1✔
169
                """
170
                The standard output as a string.
171
                """
172

173
                if _click_version >= (8, 2) and self.runner.mix_stderr:
1✔
174
                        return self.output
1✔
175
                else:
176
                        return super().stdout
1✔
177

178
        @property
1✔
179
        def stderr(self) -> str:
1✔
180
                """
181
                The standard error as a string.
182
                """
183

184
                return super().stderr
1✔
185

186
        @classmethod
1✔
187
        def _from_click_result(cls, result: click.testing.Result) -> "Result":
1✔
188
                if _click_version >= (8, 2):
1✔
189
                        output_bytes = result.output_bytes  # type: ignore[attr-defined]
1✔
190
                else:
191
                        output_bytes = None
1✔
192

193
                return cls(
1✔
194
                                runner=result.runner,
195
                                stdout_bytes=result.stdout_bytes,
196
                                stderr_bytes=result.stderr_bytes,
197
                                exit_code=result.exit_code,
198
                                exception=result.exception,
199
                                exc_info=result.exc_info,
200
                                output_bytes=output_bytes,
201
                                )
202

203
        def check_stdout(
1✔
204
                        self,
205
                        file_regression: FileRegressionFixture,
206
                        extension: str = ".txt",
207
                        **kwargs,
208
                        ) -> Literal[True]:
209
                r"""
210
                Perform a regression check on the standard output from the command.
211

212
                :param file_regression:
213
                :param extension: The extension of the reference file.
214
                :param \*\*kwargs: Additional keyword arguments passed to :meth:`.FileRegressionFixture.check`.
215
                """
216

217
                __tracebackhide__ = True
1✔
218

219
                check_file_regression(self.stdout.rstrip(), file_regression, extension=extension, **kwargs)
1✔
220

221
                return True
1✔
222

223

224
class CliRunner(click.testing.CliRunner):
1✔
225
        """
226
        Provides functionality to invoke and test a Click script in an isolated environment.
227

228
        This only works in single-threaded systems without any concurrency as it changes the global interpreter state.
229

230
        :param charset: The character set for the input and output data.
231
        :param env: A dictionary with environment variables to override.
232
        :param echo_stdin: If :py:obj:`True`, then reading from stdin writes to stdout.
233
                This is useful for showing examples in some circumstances.
234
                Note that regular prompts will automatically echo the input.
235
        :param mix_stderr: If :py:obj:`False`, then stdout and stderr are preserved as independent streams.
236
                This is useful for Unix-philosophy apps that have predictable stdout and noisy stderr,
237
                such that each may be measured independently.
238

239
        .. autoclasssumm:: CliRunner
240
                :autosummary-sections: ;;
241
        """
242

243
        def __init__(
1✔
244
                        self,
245
                        charset: str = "UTF-8",
246
                        env: Optional[Mapping[str, str]] = None,
247
                        *,
248
                        echo_stdin: bool = False,
249
                        mix_stderr: bool = True,
250
                        ) -> None:
251
                if _click_version >= (8, 2):
1✔
252
                        super().__init__(charset, env, echo_stdin)
1✔
253
                        self.mix_stderr = mix_stderr
1✔
254
                else:
255
                        super().__init__(charset, env, echo_stdin, mix_stderr)
1✔
256

257
        def invoke(  # type: ignore[override]
1✔
258
                self,
259
                cli: click.Command,
260
                args: Optional[Union[str, Iterable[str]]] = None,
261
                input: Optional[Union[bytes, str, IO]] = None,  # noqa: A002  # pylint: disable=redefined-builtin
262
                env: Optional[Mapping[str, str]] = None,
263
                *,
264
                catch_exceptions: bool = False,
265
                color: bool = False,
266
                **extra,
267
                ) -> Result:
268
                r"""
269
                Invokes a command in an isolated environment.
270

271
                The arguments are forwarded directly to the command line script,
272
                the ``extra`` keyword arguments are passed to the :meth:`~click.Command.main`
273
                function of the command.
274

275
                :param cli: The command to invoke.
276
                :param args: The arguments to invoke. It may be given as an iterable or a string.
277
                        When given as string it will be interpreted as a Unix shell command.
278
                        More details at :func:`shlex.split`.
279
                :param input: The input data for ``sys.stdin``.
280
                :param env: The environment overrides.
281
                :param catch_exceptions: Whether to catch any other exceptions than :exc:`SystemExit`.
282
                :param color: whether the output should contain color codes.
283
                        The application can still override this explicitly.
284
                :param \*\*extra: The keyword arguments to pass to :meth:`click.Command.main`.
285
                """
286

287
                if args is not None and not isinstance(args, str):
1✔
288
                        args = list(args)
1✔
289

290
                result = super().invoke(
1✔
291
                                cli,
292
                                args=args,
293
                                input=input,
294
                                env=env,
295
                                catch_exceptions=catch_exceptions,
296
                                color=color,
297
                                **extra,
298
                                )
299

300
                return Result._from_click_result(result)
1✔
301

302

303
@pytest.fixture()
1✔
304
def cli_runner() -> CliRunner:
1✔
305
        """
306
        Returns a click runner for this test function.
307
        """
308

309
        return CliRunner()
1✔
310

311

312
# Helpers for tests whose output depends on Click major version.
313
click_8_only = pytest.mark.skipif(_click_version[0] == 8, reason="Output differs on click 8")
1✔
314
not_click_8 = pytest.mark.skipif(_click_version[0] != 8, reason="Output differs on click 8")
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc