This commit is contained in:
Iliyan Angelov
2025-12-06 03:27:35 +02:00
parent 7667eb5eda
commit 5a8ca3c475
2211 changed files with 28086 additions and 37066 deletions

View File

@@ -1,36 +1,30 @@
# mypy: allow-untyped-defs
"""Per-test stdout/stderr capturing mechanism."""
from __future__ import annotations
import abc
import collections
from collections.abc import Generator
from collections.abc import Iterable
from collections.abc import Iterator
import contextlib
import io
from io import UnsupportedOperation
import os
import sys
from io import UnsupportedOperation
from tempfile import TemporaryFile
from types import TracebackType
from typing import Any
from typing import AnyStr
from typing import BinaryIO
from typing import cast
from typing import Final
from typing import final
from typing import Generator
from typing import Generic
from typing import Literal
from typing import Iterable
from typing import Iterator
from typing import List
from typing import NamedTuple
from typing import Optional
from typing import TextIO
from typing import Tuple
from typing import Type
from typing import TYPE_CHECKING
from typing import Union
if TYPE_CHECKING:
from typing_extensions import Self
from _pytest.compat import final
from _pytest.config import Config
from _pytest.config import hookimpl
from _pytest.config.argparsing import Parser
@@ -40,15 +34,17 @@ from _pytest.fixtures import SubRequest
from _pytest.nodes import Collector
from _pytest.nodes import File
from _pytest.nodes import Item
from _pytest.reports import CollectReport
if TYPE_CHECKING:
from typing_extensions import Final
from typing_extensions import Literal
_CaptureMethod = Literal["fd", "sys", "no", "tee-sys"]
_CaptureMethod = Literal["fd", "sys", "no", "tee-sys"]
def pytest_addoption(parser: Parser) -> None:
group = parser.getgroup("general")
group.addoption(
group._addoption(
"--capture",
action="store",
default="fd",
@@ -56,7 +52,7 @@ def pytest_addoption(parser: Parser) -> None:
choices=["fd", "sys", "no", "tee-sys"],
help="Per-test capturing method: one of fd|sys|no|tee-sys",
)
group._addoption( # private to use reserved lower-case short option
group._addoption(
"-s",
action="store_const",
const="no",
@@ -80,23 +76,6 @@ def _colorama_workaround() -> None:
pass
def _readline_workaround() -> None:
"""Ensure readline is imported early so it attaches to the correct stdio handles.
This isn't a problem with the default GNU readline implementation, but in
some configurations, Python uses libedit instead (on macOS, and for prebuilt
binaries such as used by uv).
In theory this is only needed if readline.backend == "libedit", but the
workaround consists of importing readline here, so we already worked around
the issue by the time we could check if we need to.
"""
try:
import readline # noqa: F401
except ImportError:
pass
def _windowsconsoleio_workaround(stream: TextIO) -> None:
"""Workaround for Windows Unicode console handling.
@@ -125,16 +104,17 @@ def _windowsconsoleio_workaround(stream: TextIO) -> None:
return
# Bail out if ``stream`` doesn't seem like a proper ``io`` stream (#2666).
if not hasattr(stream, "buffer"): # type: ignore[unreachable,unused-ignore]
if not hasattr(stream, "buffer"): # type: ignore[unreachable]
return
raw_stdout = stream.buffer.raw if hasattr(stream.buffer, "raw") else stream.buffer
buffered = hasattr(stream.buffer, "raw")
raw_stdout = stream.buffer.raw if buffered else stream.buffer # type: ignore[attr-defined]
if not isinstance(raw_stdout, io._WindowsConsoleIO): # type: ignore[attr-defined,unused-ignore]
if not isinstance(raw_stdout, io._WindowsConsoleIO): # type: ignore[attr-defined]
return
def _reopen_stdio(f, mode):
if not hasattr(stream.buffer, "raw") and mode[0] == "w":
if not buffered and mode[0] == "w":
buffering = 0
else:
buffering = -1
@@ -152,13 +132,12 @@ def _windowsconsoleio_workaround(stream: TextIO) -> None:
sys.stderr = _reopen_stdio(sys.stderr, "wb")
@hookimpl(wrapper=True)
def pytest_load_initial_conftests(early_config: Config) -> Generator[None]:
@hookimpl(hookwrapper=True)
def pytest_load_initial_conftests(early_config: Config):
ns = early_config.known_args_namespace
if ns.capture == "fd":
_windowsconsoleio_workaround(sys.stdout)
_colorama_workaround()
_readline_workaround()
pluginmanager = early_config.pluginmanager
capman = CaptureManager(ns.capture)
pluginmanager.register(capman, "capturemanager")
@@ -168,16 +147,12 @@ def pytest_load_initial_conftests(early_config: Config) -> Generator[None]:
# Finally trigger conftest loading but while capturing (issue #93).
capman.start_global_capturing()
try:
try:
yield
finally:
capman.suspend_global_capture()
except BaseException:
outcome = yield
capman.suspend_global_capture()
if outcome.excinfo is not None:
out, err = capman.read_global_capture()
sys.stdout.write(out)
sys.stderr.write(err)
raise
# IO Helpers.
@@ -196,8 +171,7 @@ class EncodedFile(io.TextIOWrapper):
def mode(self) -> str:
# TextIOWrapper doesn't expose a mode, but at least some of our
# tests check it.
assert hasattr(self.buffer, "mode")
return cast(str, self.buffer.mode.replace("b", ""))
return self.buffer.mode.replace("b", "")
class CaptureIO(io.TextIOWrapper):
@@ -222,7 +196,6 @@ class TeeCaptureIO(CaptureIO):
class DontReadFromInput(TextIO):
@property
def encoding(self) -> str:
assert sys.__stdin__ is not None
return sys.__stdin__.encoding
def read(self, size: int = -1) -> str:
@@ -235,7 +208,7 @@ class DontReadFromInput(TextIO):
def __next__(self) -> str:
return self.readline()
def readlines(self, hint: int | None = -1) -> list[str]:
def readlines(self, hint: Optional[int] = -1) -> List[str]:
raise OSError(
"pytest: reading from stdin while output is captured! Consider using `-s`."
)
@@ -267,7 +240,7 @@ class DontReadFromInput(TextIO):
def tell(self) -> int:
raise UnsupportedOperation("redirected stdin is pseudofile, has no tell()")
def truncate(self, size: int | None = None) -> int:
def truncate(self, size: Optional[int] = None) -> int:
raise UnsupportedOperation("cannot truncate stdin")
def write(self, data: str) -> int:
@@ -279,14 +252,14 @@ class DontReadFromInput(TextIO):
def writable(self) -> bool:
return False
def __enter__(self) -> Self:
def __enter__(self) -> "DontReadFromInput":
return self
def __exit__(
self,
type: type[BaseException] | None,
value: BaseException | None,
traceback: TracebackType | None,
type: Optional[Type[BaseException]],
value: Optional[BaseException],
traceback: Optional[TracebackType],
) -> None:
pass
@@ -361,7 +334,7 @@ class NoCapture(CaptureBase[str]):
class SysCaptureBase(CaptureBase[AnyStr]):
def __init__(
self, fd: int, tmpfile: TextIO | None = None, *, tee: bool = False
self, fd: int, tmpfile: Optional[TextIO] = None, *, tee: bool = False
) -> None:
name = patchsysdict[fd]
self._old: TextIO = getattr(sys, name)
@@ -378,7 +351,7 @@ class SysCaptureBase(CaptureBase[AnyStr]):
return "<{} {} _old={} _state={!r} tmpfile={!r}>".format(
class_name,
self.name,
(hasattr(self, "_old") and repr(self._old)) or "<UNSET>",
hasattr(self, "_old") and repr(self._old) or "<UNSET>",
self._state,
self.tmpfile,
)
@@ -387,16 +360,16 @@ class SysCaptureBase(CaptureBase[AnyStr]):
return "<{} {} _old={} _state={!r} tmpfile={!r}>".format(
self.__class__.__name__,
self.name,
(hasattr(self, "_old") and repr(self._old)) or "<UNSET>",
hasattr(self, "_old") and repr(self._old) or "<UNSET>",
self._state,
self.tmpfile,
)
def _assert_state(self, op: str, states: tuple[str, ...]) -> None:
assert self._state in states, (
"cannot {} in state {!r}: expected one of {}".format(
op, self._state, ", ".join(states)
)
def _assert_state(self, op: str, states: Tuple[str, ...]) -> None:
assert (
self._state in states
), "cannot {} in state {!r}: expected one of {}".format(
op, self._state, ", ".join(states)
)
def start(self) -> None:
@@ -479,7 +452,7 @@ class FDCaptureBase(CaptureBase[AnyStr]):
# Further complications are the need to support suspend() and the
# possibility of FD reuse (e.g. the tmpfile getting the very same
# target FD). The following approach is robust, I believe.
self.targetfd_invalid: int | None = os.open(os.devnull, os.O_RDWR)
self.targetfd_invalid: Optional[int] = os.open(os.devnull, os.O_RDWR)
os.dup2(self.targetfd_invalid, targetfd)
else:
self.targetfd_invalid = None
@@ -504,16 +477,19 @@ class FDCaptureBase(CaptureBase[AnyStr]):
self._state = "initialized"
def __repr__(self) -> str:
return (
f"<{self.__class__.__name__} {self.targetfd} oldfd={self.targetfd_save} "
f"_state={self._state!r} tmpfile={self.tmpfile!r}>"
return "<{} {} oldfd={} _state={!r} tmpfile={!r}>".format(
self.__class__.__name__,
self.targetfd,
self.targetfd_save,
self._state,
self.tmpfile,
)
def _assert_state(self, op: str, states: tuple[str, ...]) -> None:
assert self._state in states, (
"cannot {} in state {!r}: expected one of {}".format(
op, self._state, ", ".join(states)
)
def _assert_state(self, op: str, states: Tuple[str, ...]) -> None:
assert (
self._state in states
), "cannot {} in state {!r}: expected one of {}".format(
op, self._state, ", ".join(states)
)
def start(self) -> None:
@@ -570,7 +546,7 @@ class FDCaptureBinary(FDCaptureBase[bytes]):
res = self.tmpfile.buffer.read()
self.tmpfile.seek(0)
self.tmpfile.truncate()
return res # type: ignore[return-value]
return res
def writeorg(self, data: bytes) -> None:
"""Write to original file descriptor."""
@@ -609,7 +585,7 @@ if sys.version_info >= (3, 11) or TYPE_CHECKING:
@final
class CaptureResult(NamedTuple, Generic[AnyStr]):
"""The result of :method:`caplog.readouterr() <pytest.CaptureFixture.readouterr>`."""
"""The result of :method:`CaptureFixture.readouterr`."""
out: AnyStr
err: AnyStr
@@ -617,10 +593,9 @@ if sys.version_info >= (3, 11) or TYPE_CHECKING:
else:
class CaptureResult(
collections.namedtuple("CaptureResult", ["out", "err"]), # noqa: PYI024
Generic[AnyStr],
collections.namedtuple("CaptureResult", ["out", "err"]), Generic[AnyStr]
):
"""The result of :method:`caplog.readouterr() <pytest.CaptureFixture.readouterr>`."""
"""The result of :method:`CaptureFixture.readouterr`."""
__slots__ = ()
@@ -631,18 +606,21 @@ class MultiCapture(Generic[AnyStr]):
def __init__(
self,
in_: CaptureBase[AnyStr] | None,
out: CaptureBase[AnyStr] | None,
err: CaptureBase[AnyStr] | None,
in_: Optional[CaptureBase[AnyStr]],
out: Optional[CaptureBase[AnyStr]],
err: Optional[CaptureBase[AnyStr]],
) -> None:
self.in_: CaptureBase[AnyStr] | None = in_
self.out: CaptureBase[AnyStr] | None = out
self.err: CaptureBase[AnyStr] | None = err
self.in_: Optional[CaptureBase[AnyStr]] = in_
self.out: Optional[CaptureBase[AnyStr]] = out
self.err: Optional[CaptureBase[AnyStr]] = err
def __repr__(self) -> str:
return (
f"<MultiCapture out={self.out!r} err={self.err!r} in_={self.in_!r} "
f"_state={self._state!r} _in_suspended={self._in_suspended!r}>"
return "<MultiCapture out={!r} err={!r} in_={!r} _state={!r} _in_suspended={!r}>".format(
self.out,
self.err,
self.in_,
self._state,
self._in_suspended,
)
def start_capturing(self) -> None:
@@ -654,7 +632,7 @@ class MultiCapture(Generic[AnyStr]):
if self.err:
self.err.start()
def pop_outerr_to_orig(self) -> tuple[AnyStr, AnyStr]:
def pop_outerr_to_orig(self) -> Tuple[AnyStr, AnyStr]:
"""Pop current snapshot out/err capture and flush to orig streams."""
out, err = self.readouterr()
if out:
@@ -709,7 +687,7 @@ class MultiCapture(Generic[AnyStr]):
return CaptureResult(out, err) # type: ignore[arg-type]
def _get_multicapture(method: _CaptureMethod) -> MultiCapture[str]:
def _get_multicapture(method: "_CaptureMethod") -> MultiCapture[str]:
if method == "fd":
return MultiCapture(in_=FDCapture(0), out=FDCapture(1), err=FDCapture(2))
elif method == "sys":
@@ -745,22 +723,21 @@ class CaptureManager:
needed to ensure the fixtures take precedence over the global capture.
"""
def __init__(self, method: _CaptureMethod) -> None:
def __init__(self, method: "_CaptureMethod") -> None:
self._method: Final = method
self._global_capturing: MultiCapture[str] | None = None
self._capture_fixture: CaptureFixture[Any] | None = None
self._global_capturing: Optional[MultiCapture[str]] = None
self._capture_fixture: Optional[CaptureFixture[Any]] = None
def __repr__(self) -> str:
return (
f"<CaptureManager _method={self._method!r} _global_capturing={self._global_capturing!r} "
f"_capture_fixture={self._capture_fixture!r}>"
return "<CaptureManager _method={!r} _global_capturing={!r} _capture_fixture={!r}>".format(
self._method, self._global_capturing, self._capture_fixture
)
def is_capturing(self) -> str | bool:
def is_capturing(self) -> Union[str, bool]:
if self.is_globally_capturing():
return "global"
if self._capture_fixture:
return f"fixture {self._capture_fixture.request.fixturename}"
return "fixture %s" % self._capture_fixture.request.fixturename
return False
# Global capturing control
@@ -804,12 +781,14 @@ class CaptureManager:
# Fixture Control
def set_fixture(self, capture_fixture: CaptureFixture[Any]) -> None:
def set_fixture(self, capture_fixture: "CaptureFixture[Any]") -> None:
if self._capture_fixture:
current_fixture = self._capture_fixture.request.fixturename
requested_fixture = capture_fixture.request.fixturename
capture_fixture.request.raiseerror(
f"cannot use {requested_fixture} and {current_fixture} at the same time"
"cannot use {} and {} at the same time".format(
requested_fixture, current_fixture
)
)
self._capture_fixture = capture_fixture
@@ -838,7 +817,7 @@ class CaptureManager:
# Helper context managers
@contextlib.contextmanager
def global_and_fixture_disabled(self) -> Generator[None]:
def global_and_fixture_disabled(self) -> Generator[None, None, None]:
"""Context manager to temporarily disable global and current fixture capturing."""
do_fixture = self._capture_fixture and self._capture_fixture._is_started()
if do_fixture:
@@ -855,7 +834,7 @@ class CaptureManager:
self.resume_fixture()
@contextlib.contextmanager
def item_capture(self, when: str, item: Item) -> Generator[None]:
def item_capture(self, when: str, item: Item) -> Generator[None, None, None]:
self.resume_global_capture()
self.activate_fixture()
try:
@@ -864,45 +843,41 @@ class CaptureManager:
self.deactivate_fixture()
self.suspend_global_capture(in_=False)
out, err = self.read_global_capture()
item.add_report_section(when, "stdout", out)
item.add_report_section(when, "stderr", err)
out, err = self.read_global_capture()
item.add_report_section(when, "stdout", out)
item.add_report_section(when, "stderr", err)
# Hooks
@hookimpl(wrapper=True)
def pytest_make_collect_report(
self, collector: Collector
) -> Generator[None, CollectReport, CollectReport]:
@hookimpl(hookwrapper=True)
def pytest_make_collect_report(self, collector: Collector):
if isinstance(collector, File):
self.resume_global_capture()
try:
rep = yield
finally:
self.suspend_global_capture()
outcome = yield
self.suspend_global_capture()
out, err = self.read_global_capture()
rep = outcome.get_result()
if out:
rep.sections.append(("Captured stdout", out))
if err:
rep.sections.append(("Captured stderr", err))
else:
rep = yield
return rep
yield
@hookimpl(wrapper=True)
def pytest_runtest_setup(self, item: Item) -> Generator[None]:
@hookimpl(hookwrapper=True)
def pytest_runtest_setup(self, item: Item) -> Generator[None, None, None]:
with self.item_capture("setup", item):
return (yield)
yield
@hookimpl(wrapper=True)
def pytest_runtest_call(self, item: Item) -> Generator[None]:
@hookimpl(hookwrapper=True)
def pytest_runtest_call(self, item: Item) -> Generator[None, None, None]:
with self.item_capture("call", item):
return (yield)
yield
@hookimpl(wrapper=True)
def pytest_runtest_teardown(self, item: Item) -> Generator[None]:
@hookimpl(hookwrapper=True)
def pytest_runtest_teardown(self, item: Item) -> Generator[None, None, None]:
with self.item_capture("teardown", item):
return (yield)
yield
@hookimpl(tryfirst=True)
def pytest_keyboard_interrupt(self) -> None:
@@ -919,17 +894,15 @@ class CaptureFixture(Generic[AnyStr]):
def __init__(
self,
captureclass: type[CaptureBase[AnyStr]],
captureclass: Type[CaptureBase[AnyStr]],
request: SubRequest,
*,
config: dict[str, Any] | None = None,
_ispytest: bool = False,
) -> None:
check_ispytest(_ispytest)
self.captureclass: type[CaptureBase[AnyStr]] = captureclass
self.captureclass: Type[CaptureBase[AnyStr]] = captureclass
self.request = request
self._config = config if config else {}
self._capture: MultiCapture[AnyStr] | None = None
self._capture: Optional[MultiCapture[AnyStr]] = None
self._captured_out: AnyStr = self.captureclass.EMPTY_BUFFER
self._captured_err: AnyStr = self.captureclass.EMPTY_BUFFER
@@ -937,8 +910,8 @@ class CaptureFixture(Generic[AnyStr]):
if self._capture is None:
self._capture = MultiCapture(
in_=None,
out=self.captureclass(1, **self._config),
err=self.captureclass(2, **self._config),
out=self.captureclass(1),
err=self.captureclass(2),
)
self._capture.start_capturing()
@@ -984,7 +957,7 @@ class CaptureFixture(Generic[AnyStr]):
return False
@contextlib.contextmanager
def disabled(self) -> Generator[None]:
def disabled(self) -> Generator[None, None, None]:
"""Temporarily disable capturing while inside the ``with`` block."""
capmanager: CaptureManager = self.request.config.pluginmanager.getplugin(
"capturemanager"
@@ -997,7 +970,7 @@ class CaptureFixture(Generic[AnyStr]):
@fixture
def capsys(request: SubRequest) -> Generator[CaptureFixture[str]]:
def capsys(request: SubRequest) -> Generator[CaptureFixture[str], None, None]:
r"""Enable text capturing of writes to ``sys.stdout`` and ``sys.stderr``.
The captured output is made available via ``capsys.readouterr()`` method
@@ -1025,42 +998,7 @@ def capsys(request: SubRequest) -> Generator[CaptureFixture[str]]:
@fixture
def capteesys(request: SubRequest) -> Generator[CaptureFixture[str]]:
r"""Enable simultaneous text capturing and pass-through of writes
to ``sys.stdout`` and ``sys.stderr`` as defined by ``--capture=``.
The captured output is made available via ``capteesys.readouterr()`` method
calls, which return a ``(out, err)`` namedtuple.
``out`` and ``err`` will be ``text`` objects.
The output is also passed-through, allowing it to be "live-printed",
reported, or both as defined by ``--capture=``.
Returns an instance of :class:`CaptureFixture[str] <pytest.CaptureFixture>`.
Example:
.. code-block:: python
def test_output(capteesys):
print("hello")
captured = capteesys.readouterr()
assert captured.out == "hello\n"
"""
capman: CaptureManager = request.config.pluginmanager.getplugin("capturemanager")
capture_fixture = CaptureFixture(
SysCapture, request, config=dict(tee=True), _ispytest=True
)
capman.set_fixture(capture_fixture)
capture_fixture._start()
yield capture_fixture
capture_fixture.close()
capman.unset_fixture()
@fixture
def capsysbinary(request: SubRequest) -> Generator[CaptureFixture[bytes]]:
def capsysbinary(request: SubRequest) -> Generator[CaptureFixture[bytes], None, None]:
r"""Enable bytes capturing of writes to ``sys.stdout`` and ``sys.stderr``.
The captured output is made available via ``capsysbinary.readouterr()``
@@ -1088,7 +1026,7 @@ def capsysbinary(request: SubRequest) -> Generator[CaptureFixture[bytes]]:
@fixture
def capfd(request: SubRequest) -> Generator[CaptureFixture[str]]:
def capfd(request: SubRequest) -> Generator[CaptureFixture[str], None, None]:
r"""Enable text capturing of writes to file descriptors ``1`` and ``2``.
The captured output is made available via ``capfd.readouterr()`` method
@@ -1116,7 +1054,7 @@ def capfd(request: SubRequest) -> Generator[CaptureFixture[str]]:
@fixture
def capfdbinary(request: SubRequest) -> Generator[CaptureFixture[bytes]]:
def capfdbinary(request: SubRequest) -> Generator[CaptureFixture[bytes], None, None]:
r"""Enable bytes capturing of writes to file descriptors ``1`` and ``2``.
The captured output is made available via ``capfd.readouterr()`` method