#!/usr/bin/env python3
#
# testing.py
"""
Test helpers.
.. versionadded:: 0.9.0
.. extras-require:: testing
:pyproject:
"""
#
# Copyright © 2020-2021 Dominic Davis-Foster <dominic@davis-foster.co.uk>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
# OR OTHER DEALINGS IN THE SOFTWARE.
#
# Result and CliRunner based on https://github.com/pallets/click
# Copyright 2014 Pallets
# | Redistribution and use in source and binary forms, with or without modification,
# | are permitted provided that the following conditions are met:
# |
# | * Redistributions of source code must retain the above copyright notice,
# | this list of conditions and the following disclaimer.
# | * Redistributions in binary form must reproduce the above copyright notice,
# | this list of conditions and the following disclaimer in the documentation
# | and/or other materials provided with the distribution.
# | * Neither the name of the copyright holder nor the names of its contributors
# | may be used to endorse or promote products derived from this software without
# | specific prior written permission.
# |
# | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
# | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER
# | OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
# | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
# | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
# | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
# stdlib
from types import TracebackType
from typing import IO, Any, Iterable, Mapping, Optional, Tuple, Type, Union
# 3rd party
import click.testing
import pytest # nodep
from coincidence.regressions import check_file_regression # nodep
from pytest_regressions.file_regression import FileRegressionFixture # nodep
from typing_extensions import Literal
__all__ = ("CliRunner", "Result", "cli_runner")
_click_major = int(click.__version__.split('.')[0])
[docs]class Result(click.testing.Result):
"""
Holds the captured result of an invoked CLI script.
:param runner: The runner that created the result.
:param stdout_bytes: The standard output as bytes.
:param stderr_bytes: The standard error as bytes, or :py:obj:`None` if not available.
:param exit_code: The command's exit code.
:param exception: The exception that occurred, if any.
:param exc_info: The traceback, if an exception occurred.
"""
runner: click.testing.CliRunner
exit_code: int
exception: Optional[BaseException]
exc_info: Optional[Any]
stdout_bytes: bytes
stderr_bytes: Optional[bytes]
return_value: Optional[Tuple[Type[BaseException], BaseException, TracebackType]]
def __init__(
self,
runner: click.testing.CliRunner,
stdout_bytes: bytes,
stderr_bytes: Optional[bytes],
exit_code: int,
exception: Optional[BaseException],
exc_info: Optional[Tuple[Type[BaseException], BaseException, TracebackType]] = None,
) -> None:
if _click_major >= 8:
super().__init__(
runner=runner,
stdout_bytes=stdout_bytes,
stderr_bytes=stderr_bytes,
exit_code=exit_code,
exception=exception,
exc_info=exc_info,
return_value=None,
)
else:
super().__init__( # type: ignore[call-arg]
runner=runner,
stdout_bytes=stdout_bytes,
stderr_bytes=stderr_bytes,
exit_code=exit_code,
exception=exception,
exc_info=exc_info,
)
@property
def output(self) -> str:
"""
The (standard) output as a string.
"""
return super().output
@property
def stdout(self) -> str:
"""
The standard output as a string.
"""
return super().stdout
@property
def stderr(self) -> str:
"""
The standard error as a string.
"""
return super().stderr
@classmethod
def _from_click_result(cls, result: click.testing.Result) -> "Result":
return cls(
runner=result.runner,
stdout_bytes=result.stdout_bytes,
stderr_bytes=result.stderr_bytes,
exit_code=result.exit_code,
exception=result.exception,
exc_info=result.exc_info,
)
[docs] def check_stdout(
self,
file_regression: FileRegressionFixture,
extension: str = ".txt",
**kwargs,
) -> Literal[True]:
r"""
Perform a regression check on the standard output from the command.
:param file_regression:
:param extension: The extension of the reference file.
:param \*\*kwargs: Additional keyword arguments passed to :meth:`.FileRegressionFixture.check`.
"""
__tracebackhide__ = True
check_file_regression(self.stdout.rstrip(), file_regression, extension=extension, **kwargs)
return True
[docs]class CliRunner(click.testing.CliRunner):
"""
Provides functionality to invoke and test a Click script in an isolated environment.
This only works in single-threaded systems without any concurrency as it changes the global interpreter state.
:param charset: The character set for the input and output data.
:param env: A dictionary with environment variables to override.
:param echo_stdin: If :py:obj:`True`, then reading from stdin writes to stdout.
This is useful for showing examples in some circumstances.
Note that regular prompts will automatically echo the input.
:param mix_stderr: If :py:obj:`False`, then stdout and stderr are preserved as independent streams.
This is useful for Unix-philosophy apps that have predictable stdout and noisy stderr,
such that each may be measured independently.
.. autoclasssumm:: CliRunner
:autosummary-sections: ;;
"""
def __init__(
self,
charset: str = "UTF-8",
env: Optional[Mapping[str, str]] = None,
*,
echo_stdin: bool = False,
mix_stderr: bool = True,
) -> None:
super().__init__(charset, env, echo_stdin, mix_stderr)
[docs] def invoke( # type: ignore[override]
self,
cli: click.BaseCommand,
args: Optional[Union[str, Iterable[str]]] = None,
input: Optional[Union[bytes, str, IO]] = None, # noqa: A002 # pylint: disable=redefined-builtin
env: Optional[Mapping[str, str]] = None,
*,
catch_exceptions: bool = False,
color: bool = False,
**extra,
) -> Result:
r"""
Invokes a command in an isolated environment.
The arguments are forwarded directly to the command line script,
the ``extra`` keyword arguments are passed to the :meth:`~click.Command.main`
function of the command.
:param cli: The command to invoke.
:param args: The arguments to invoke. It may be given as an iterable or a string.
When given as string it will be interpreted as a Unix shell command.
More details at :func:`shlex.split`.
:param input: The input data for ``sys.stdin``.
:param env: The environment overrides.
:param catch_exceptions: Whether to catch any other exceptions than :exc:`SystemExit`.
:param color: whether the output should contain color codes.
The application can still override this explicitly.
:param \*\*extra: The keyword arguments to pass to :meth:`click.Command.main`.
"""
if args is not None and not isinstance(args, str):
args = list(args)
result = super().invoke(
cli,
args=args,
input=input,
env=env,
catch_exceptions=catch_exceptions,
color=color,
**extra,
)
return Result._from_click_result(result)
[docs]@pytest.fixture()
def cli_runner() -> CliRunner:
"""
Returns a click runner for this test function.
"""
return CliRunner()