diff --git a/CHANGES.rst b/CHANGES.rst index 39ad6ee8d..b9ced76ff 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -7,6 +7,11 @@ Unreleased - Use modern packaging metadata with ``pyproject.toml`` instead of ``setup.cfg``. :pr:`326` +- Keep `` and `` stremas in `CliRunner` independent. Always + collect `` output and never raise an exception. Add a new + `` stream to simulate what the user sees in its terminal. Only mix + `` and `` in `` when ``mix_stderr=True``. + :issue:`2522` :pr:`2523` Version 8.1.4 diff --git a/src/click/testing.py b/src/click/testing.py index 7b6dd7f1c..a3a70f970 100644 --- a/src/click/testing.py +++ b/src/click/testing.py @@ -61,6 +61,48 @@ def _pause_echo(stream: t.Optional[EchoingStdin]) -> t.Iterator[None]: stream._paused = False +class BytesIOCopy(io.BytesIO): + """Patch ``io.BytesIO`` to let the written stream be copied to another. + + .. versionadded:: 8.2 + """ + + def __init__(self, copy_to: io.BytesIO) -> None: + super().__init__() + self.copy_to = copy_to + + def flush(self) -> None: + super().flush() + self.copy_to.flush() + + def write(self, b) -> int: # type: ignore[no-untyped-def] + self.copy_to.write(b) + return super().write(b) + + +class StreamMixer: + """Mixes `` and `` streams if ``mix_stderr=True``. + + The result is available in the ``output`` attribute. + + If ``mix_stderr=False``, the `` and `` streams are kept + independent and the ``output`` is the same as the `` stream. + + .. versionadded:: 8.2 + """ + + def __init__(self, mix_stderr: bool) -> None: + if not mix_stderr: + self.stdout = io.BytesIO() + self.stderr = io.BytesIO() + self.output = self.stdout + + else: + self.output = io.BytesIO() + self.stdout = BytesIOCopy(copy_to=self.output) + self.stderr = BytesIOCopy(copy_to=self.output) + + class _NamedTextIOWrapper(io.TextIOWrapper): def __init__( self, buffer: t.BinaryIO, name: str, mode: str, **kwargs: t.Any @@ -105,7 +147,8 @@ def __init__( self, runner: "CliRunner", stdout_bytes: bytes, - stderr_bytes: t.Optional[bytes], + stderr_bytes: bytes, + output_bytes: bytes, return_value: t.Any, exit_code: int, exception: t.Optional[BaseException], @@ -117,8 +160,16 @@ def __init__( self.runner = runner #: The standard output as bytes. self.stdout_bytes = stdout_bytes - #: The standard error as bytes, or None if not available + #: The standard error as bytes. + #: + #: .. versionchanged:: 8.2 + #: No longer optional. self.stderr_bytes = stderr_bytes + #: A mix of `stdout_bytes` and `stderr_bytes``, as the user would see + # it in its terminal. + #: + #: .. versionadded:: 8.2 + self.output_bytes = output_bytes #: The value returned from the invoked command. #: #: .. versionadded:: 8.0 @@ -132,8 +183,15 @@ def __init__( @property def output(self) -> str: - """The (standard) output as unicode string.""" - return self.stdout + """The terminal output as unicode string, as the user would see it. + + .. versionchanged:: 8.2 + No longer a proxy for ``self.stdout``. Now has its own stream to mix + `` and `` depending on ``mix_stderr`` value. + """ + return self.output_bytes.decode(self.runner.charset, "replace").replace( + "\r\n", "\n" + ) @property def stdout(self) -> str: @@ -144,9 +202,11 @@ def stdout(self) -> str: @property def stderr(self) -> str: - """The standard error as unicode string.""" - if self.stderr_bytes is None: - raise ValueError("stderr not separately captured") + """The standard error as unicode string. + + .. versionchanged:: 8.2 + No longer raise an exception, always returns the `` string. + """ return self.stderr_bytes.decode(self.runner.charset, "replace").replace( "\r\n", "\n" ) @@ -164,15 +224,16 @@ class CliRunner: :param charset: the character set for the input and output data. :param env: a dictionary with environment variables for overriding. - :param echo_stdin: if this is set to `True`, then reading from stdin writes - to stdout. This is useful for showing examples in + :param echo_stdin: if this is set to `True`, then reading from `` writes + to ``. This is useful for showing examples in some circumstances. Note that regular prompts will automatically echo the input. - :param mix_stderr: if this is set to `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 + :param mix_stderr: if this is set to `False`, then the output will be the + same as the `` stream. If set to `True` (the + default), then the output will feature both `` + and ``, in the order they were written. This is + useful for testing the output of a command as the user + would see it in its terminal. """ def __init__( @@ -209,22 +270,29 @@ def isolation( input: t.Optional[t.Union[str, bytes, t.IO[t.Any]]] = None, env: t.Optional[t.Mapping[str, t.Optional[str]]] = None, color: bool = False, - ) -> t.Iterator[t.Tuple[io.BytesIO, t.Optional[io.BytesIO]]]: + ) -> t.Iterator[t.Tuple[io.BytesIO, io.BytesIO, io.BytesIO]]: """A context manager that sets up the isolation for invoking of a - command line tool. This sets up stdin with the given input data + command line tool. This sets up `` with the given input data and `os.environ` with the overrides from the given dictionary. This also rebinds some internals in Click to be mocked (like the prompt functionality). This is automatically done in the :meth:`invoke` method. - :param input: the input stream to put into sys.stdin. + :param input: the input stream to put into `sys.stdin`. :param env: the environment overrides as dictionary. :param color: whether the output should contain color codes. The application can still override this explicitly. + .. versionadded:: 8.2 + An additional output stream is returned, which is a mix of + `` and `` streams if ``mix_stderr=True``. + + .. versionchanged:: 8.2 + Always returns the `` stream. + .. versionchanged:: 8.0 - ``stderr`` is opened with ``errors="backslashreplace"`` + `` is opened with ``errors="backslashreplace"`` instead of the default ``"strict"``. .. versionchanged:: 4.0 @@ -241,11 +309,11 @@ def isolation( env = self.make_env(env) - bytes_output = io.BytesIO() + stream_mixer = StreamMixer(mix_stderr=self.mix_stderr) if self.echo_stdin: bytes_input = echo_input = t.cast( - t.BinaryIO, EchoingStdin(bytes_input, bytes_output) + t.BinaryIO, EchoingStdin(bytes_input, stream_mixer.stdout) ) sys.stdin = text_input = _NamedTextIOWrapper( @@ -258,21 +326,16 @@ def isolation( text_input._CHUNK_SIZE = 1 # type: ignore sys.stdout = _NamedTextIOWrapper( - bytes_output, encoding=self.charset, name="", mode="w" + stream_mixer.stdout, encoding=self.charset, name="", mode="w" ) - bytes_error = None - if self.mix_stderr: - sys.stderr = sys.stdout - else: - bytes_error = io.BytesIO() - sys.stderr = _NamedTextIOWrapper( - bytes_error, - encoding=self.charset, - name="", - mode="w", - errors="backslashreplace", - ) + sys.stderr = _NamedTextIOWrapper( + stream_mixer.stderr, + encoding=self.charset, + name="", + mode="w", + errors="backslashreplace", + ) @_pause_echo(echo_input) # type: ignore def visible_input(prompt: t.Optional[str] = None) -> str: @@ -327,7 +390,7 @@ def should_strip_ansi( pass else: os.environ[key] = value - yield (bytes_output, bytes_error) + yield (stream_mixer.stdout, stream_mixer.stderr, stream_mixer.output) finally: for key, value in old_env.items(): if value is None: @@ -376,6 +439,14 @@ def invoke( :param color: whether the output should contain color codes. The application can still override this explicitly. + .. versionadded:: 8.2 + The result object has the ``output_bytes`` attribute with + the mix of ``stdout_bytes`` and ``stderr_bytes``, as the user would + see it in its terminal. + + .. versionchanged:: 8.2 + The result object always returns the ``stderr_bytes`` stream. + .. versionchanged:: 8.0 The result object has the ``return_value`` attribute with the value returned from the invoked command. @@ -432,15 +503,14 @@ def invoke( finally: sys.stdout.flush() stdout = outstreams[0].getvalue() - if self.mix_stderr: - stderr = None - else: - stderr = outstreams[1].getvalue() # type: ignore + stderr = outstreams[1].getvalue() + output = outstreams[2].getvalue() return Result( runner=self, stdout_bytes=stdout, stderr_bytes=stderr, + output_bytes=output, return_value=return_value, exit_code=exit_code, exception=exception, diff --git a/tests/test_termui.py b/tests/test_termui.py index 312325278..c8a9ab29b 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -245,7 +245,7 @@ def test_secho(runner): ("value", "expect"), [(123, b"\x1b[45m123\x1b[0m"), (b"test", b"test")] ) def test_secho_non_text(runner, value, expect): - with runner.isolation() as (out, _): + with runner.isolation() as (out, _, _): click.secho(value, nl=False, color=True, bg="magenta") result = out.getvalue() assert result == expect diff --git a/tests/test_testing.py b/tests/test_testing.py index 9f294b3a1..7d40d64f7 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -303,25 +303,25 @@ def cli_env(): def test_stderr(): @click.command() def cli_stderr(): - click.echo("stdout") - click.echo("stderr", err=True) + click.echo("1 - stdout") + click.echo("2 - stderr", err=True) + click.echo("3 - stdout") + click.echo("4 - stderr", err=True) runner = CliRunner(mix_stderr=False) result = runner.invoke(cli_stderr) - assert result.output == "stdout\n" - assert result.stdout == "stdout\n" - assert result.stderr == "stderr\n" + assert result.output == "1 - stdout\n3 - stdout\n" + assert result.stdout == "1 - stdout\n3 - stdout\n" + assert result.stderr == "2 - stderr\n4 - stderr\n" runner_mix = CliRunner(mix_stderr=True) result_mix = runner_mix.invoke(cli_stderr) - assert result_mix.output == "stdout\nstderr\n" - assert result_mix.stdout == "stdout\nstderr\n" - - with pytest.raises(ValueError): - result_mix.stderr + assert result_mix.output == "1 - stdout\n2 - stderr\n3 - stdout\n4 - stderr\n" + assert result_mix.stdout == "1 - stdout\n3 - stdout\n" + assert result_mix.stderr == "2 - stderr\n4 - stderr\n" @click.command() def cli_empty_stderr(): @@ -414,7 +414,7 @@ def test_isolation_stderr_errors(): """ runner = CliRunner(mix_stderr=False) - with runner.isolation() as (_, err): + with runner.isolation() as (_, err, _): click.echo("\udce2", err=True, nl=False) assert err.getvalue() == b"\\udce2"