diff --git a/changelog/4389.bugfix.rst b/changelog/4389.bugfix.rst new file mode 100644 index 00000000000..b20871b10e2 --- /dev/null +++ b/changelog/4389.bugfix.rst @@ -0,0 +1 @@ +Fixed captured ``sys.stdout`` and ``sys.stderr`` reporting ``UTF-8`` instead of the original encoding. diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index b914bc2831c..dfc443002fd 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -186,6 +186,19 @@ def pytest_load_initial_conftests(early_config: Config) -> Generator[None]: class EncodedFile(io.TextIOWrapper): __slots__ = () + def __init__( + self, *args: Any, reported_encoding: str | None = None, **kwargs: Any + ) -> None: + super().__init__(*args, **kwargs) + self._reported_encoding = reported_encoding + + @property + def encoding(self) -> str: # type: ignore[override] + # Report original encoding, keep construction codec for readouterr() (#4389). + if self._reported_encoding is not None: + return self._reported_encoding + return super().encoding + @property def name(self) -> str: # Ensure that file.name is a string. Workaround for a Python bug @@ -201,8 +214,16 @@ def mode(self) -> str: class CaptureIO(io.TextIOWrapper): - def __init__(self) -> None: + def __init__(self, reported_encoding: str | None = None) -> None: super().__init__(io.BytesIO(), encoding="UTF-8", newline="", write_through=True) + self._reported_encoding = reported_encoding + + @property + def encoding(self) -> str: # type: ignore[override] + # Report original encoding, keep UTF-8 buffer for readouterr() (#4389). + if self._reported_encoding is not None: + return self._reported_encoding + return super().encoding def getvalue(self) -> str: assert isinstance(self.buffer, io.BytesIO) @@ -210,9 +231,9 @@ def getvalue(self) -> str: class TeeCaptureIO(CaptureIO): - def __init__(self, other: TextIO) -> None: + def __init__(self, other: TextIO, reported_encoding: str | None = None) -> None: self._other = other - super().__init__() + super().__init__(reported_encoding=reported_encoding) def write(self, s: str) -> int: super().write(s) @@ -359,6 +380,16 @@ def writeorg(self, data: str) -> None: pass +def _original_encoding(stream: object) -> str | None: + """Best-effort original encoding of the stream capture replaces. + + Nested layers chain through reported encodings, so the + process-original propagates inward (#4389). + """ + encoding = getattr(stream, "encoding", None) + return encoding if isinstance(encoding, str) else None + + class SysCaptureBase(CaptureBase[AnyStr]): def __init__( self, fd: int, tmpfile: TextIO | None = None, *, tee: bool = False @@ -370,7 +401,13 @@ def __init__( if name == "stdin": tmpfile = DontReadFromInput() else: - tmpfile = CaptureIO() if not tee else TeeCaptureIO(self._old) + reported_encoding = _original_encoding(self._old) + if tee: + tmpfile = TeeCaptureIO( + self._old, reported_encoding=reported_encoding + ) + else: + tmpfile = CaptureIO(reported_encoding=reported_encoding) self.tmpfile = tmpfile self._state = "initialized" @@ -489,12 +526,15 @@ def __init__(self, targetfd: int) -> None: self.tmpfile = open(os.devnull, encoding="utf-8") self.syscapture: CaptureBase[str] = SysCapture(targetfd) else: + # Only fds 0/1/2 map to sys streams; others keep the real codec (#4389). + orig_stream = getattr(sys, patchsysdict.get(targetfd, ""), None) self.tmpfile = EncodedFile( TemporaryFile(buffering=0), encoding="utf-8", errors="replace", newline="", write_through=True, + reported_encoding=_original_encoding(orig_stream), ) if targetfd in patchsysdict: self.syscapture = SysCapture(targetfd, self.tmpfile) diff --git a/testing/test_capture.py b/testing/test_capture.py index a0a4f044d62..33f7f6f74b6 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -131,6 +131,46 @@ def test_unicode(): result.stdout.fnmatch_lines(["*1 passed*"]) +@pytest.mark.parametrize("method", ["fd", "sys"]) +def test_capture_replacement_reports_original_encoding( + pytester: Pytester, monkeypatch: MonkeyPatch, method: str +) -> None: + """Replacement streams report the original encoding, buffer stays UTF-8 (#4389).""" + pytester.makepyfile( + """\ + def test_encoding(capsys): + import sys + + assert sys.stdout.encoding == sys.__stdout__.encoding + assert sys.stderr.encoding == sys.__stderr__.encoding + print("hx\\u0107 calf\\u00e9 \\u65e5\\u672c\\u8a9e") + out, _ = capsys.readouterr() + assert out == "hx\\u0107 calf\\u00e9 \\u65e5\\u672c\\u8a9e\\n" + """ + ) + monkeypatch.setenv("PYTHONIOENCODING", "latin-1") + result = pytester.runpytest_subprocess(f"--capture={method}") + result.stdout.fnmatch_lines(["*1 passed*"]) + + +def test_capture_replacement_reports_original_encoding_tee_sys( + pytester: Pytester, monkeypatch: MonkeyPatch +) -> None: + """Tee replacements report the original encoding as well (#4389).""" + pytester.makepyfile( + """\ + def test_encoding(): + import sys + + assert sys.stdout.encoding == sys.__stdout__.encoding + print("plain ascii") + """ + ) + monkeypatch.setenv("PYTHONIOENCODING", "latin-1") + result = pytester.runpytest_subprocess("--capture=tee-sys") + result.stdout.fnmatch_lines(["*1 passed*"]) + + def test_collect_capturing(pytester: Pytester) -> None: p = pytester.makepyfile( """