Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/4389.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed captured ``sys.stdout`` and ``sys.stderr`` reporting ``UTF-8`` instead of the original encoding.
48 changes: 44 additions & 4 deletions src/_pytest/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -201,18 +214,26 @@ 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)
return self.buffer.getvalue().decode("UTF-8")


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)
Expand Down Expand Up @@ -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
Expand All @@ -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"

Expand Down Expand Up @@ -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)
Expand Down
40 changes: 40 additions & 0 deletions testing/test_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"""
Expand Down
Loading