Skip to content

Commit 4c92584

Browse files
authored
Merge pull request pytest-dev#7631 from bluetech/capture-1
capture: add type annotations to CaptureFixture
2 parents bee72e1 + acc9310 commit 4c92584

File tree

2 files changed

+119
-24
lines changed

2 files changed

+119
-24
lines changed

src/_pytest/capture.py

+79-21
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
"""Per-test stdout/stderr capturing mechanism."""
2-
import collections
32
import contextlib
3+
import functools
44
import io
55
import os
66
import sys
77
from io import UnsupportedOperation
88
from tempfile import TemporaryFile
9+
from typing import Any
10+
from typing import AnyStr
911
from typing import Generator
12+
from typing import Generic
13+
from typing import Iterator
1014
from typing import Optional
1115
from typing import TextIO
1216
from typing import Tuple
@@ -488,10 +492,64 @@ def writeorg(self, data):
488492

489493
# MultiCapture
490494

491-
CaptureResult = collections.namedtuple("CaptureResult", ["out", "err"])
492495

496+
# This class was a namedtuple, but due to mypy limitation[0] it could not be
497+
# made generic, so was replaced by a regular class which tries to emulate the
498+
# pertinent parts of a namedtuple. If the mypy limitation is ever lifted, can
499+
# make it a namedtuple again.
500+
# [0]: https://github.com/python/mypy/issues/685
501+
@functools.total_ordering
502+
class CaptureResult(Generic[AnyStr]):
503+
"""The result of :method:`CaptureFixture.readouterr`."""
493504

494-
class MultiCapture:
505+
# Can't use slots in Python<3.5.3 due to https://bugs.python.org/issue31272
506+
if sys.version_info >= (3, 5, 3):
507+
__slots__ = ("out", "err")
508+
509+
def __init__(self, out: AnyStr, err: AnyStr) -> None:
510+
self.out = out # type: AnyStr
511+
self.err = err # type: AnyStr
512+
513+
def __len__(self) -> int:
514+
return 2
515+
516+
def __iter__(self) -> Iterator[AnyStr]:
517+
return iter((self.out, self.err))
518+
519+
def __getitem__(self, item: int) -> AnyStr:
520+
return tuple(self)[item]
521+
522+
def _replace(
523+
self, *, out: Optional[AnyStr] = None, err: Optional[AnyStr] = None
524+
) -> "CaptureResult[AnyStr]":
525+
return CaptureResult(
526+
out=self.out if out is None else out, err=self.err if err is None else err
527+
)
528+
529+
def count(self, value: AnyStr) -> int:
530+
return tuple(self).count(value)
531+
532+
def index(self, value) -> int:
533+
return tuple(self).index(value)
534+
535+
def __eq__(self, other: object) -> bool:
536+
if not isinstance(other, (CaptureResult, tuple)):
537+
return NotImplemented
538+
return tuple(self) == tuple(other)
539+
540+
def __hash__(self) -> int:
541+
return hash(tuple(self))
542+
543+
def __lt__(self, other: object) -> bool:
544+
if not isinstance(other, (CaptureResult, tuple)):
545+
return NotImplemented
546+
return tuple(self) < tuple(other)
547+
548+
def __repr__(self) -> str:
549+
return "CaptureResult(out={!r}, err={!r})".format(self.out, self.err)
550+
551+
552+
class MultiCapture(Generic[AnyStr]):
495553
_state = None
496554
_in_suspended = False
497555

@@ -514,7 +572,7 @@ def start_capturing(self) -> None:
514572
if self.err:
515573
self.err.start()
516574

517-
def pop_outerr_to_orig(self):
575+
def pop_outerr_to_orig(self) -> Tuple[AnyStr, AnyStr]:
518576
"""Pop current snapshot out/err capture and flush to orig streams."""
519577
out, err = self.readouterr()
520578
if out:
@@ -555,7 +613,7 @@ def stop_capturing(self) -> None:
555613
if self.in_:
556614
self.in_.done()
557615

558-
def readouterr(self) -> CaptureResult:
616+
def readouterr(self) -> CaptureResult[AnyStr]:
559617
if self.out:
560618
out = self.out.snap()
561619
else:
@@ -567,7 +625,7 @@ def readouterr(self) -> CaptureResult:
567625
return CaptureResult(out, err)
568626

569627

570-
def _get_multicapture(method: "_CaptureMethod") -> MultiCapture:
628+
def _get_multicapture(method: "_CaptureMethod") -> MultiCapture[str]:
571629
if method == "fd":
572630
return MultiCapture(in_=FDCapture(0), out=FDCapture(1), err=FDCapture(2))
573631
elif method == "sys":
@@ -605,8 +663,8 @@ class CaptureManager:
605663

606664
def __init__(self, method: "_CaptureMethod") -> None:
607665
self._method = method
608-
self._global_capturing = None # type: Optional[MultiCapture]
609-
self._capture_fixture = None # type: Optional[CaptureFixture]
666+
self._global_capturing = None # type: Optional[MultiCapture[str]]
667+
self._capture_fixture = None # type: Optional[CaptureFixture[Any]]
610668

611669
def __repr__(self) -> str:
612670
return "<CaptureManager _method={!r} _global_capturing={!r} _capture_fixture={!r}>".format(
@@ -655,13 +713,13 @@ def resume(self) -> None:
655713
self.resume_global_capture()
656714
self.resume_fixture()
657715

658-
def read_global_capture(self):
716+
def read_global_capture(self) -> CaptureResult[str]:
659717
assert self._global_capturing is not None
660718
return self._global_capturing.readouterr()
661719

662720
# Fixture Control
663721

664-
def set_fixture(self, capture_fixture: "CaptureFixture") -> None:
722+
def set_fixture(self, capture_fixture: "CaptureFixture[Any]") -> None:
665723
if self._capture_fixture:
666724
current_fixture = self._capture_fixture.request.fixturename
667725
requested_fixture = capture_fixture.request.fixturename
@@ -760,14 +818,14 @@ def pytest_internalerror(self) -> None:
760818
self.stop_global_capturing()
761819

762820

763-
class CaptureFixture:
821+
class CaptureFixture(Generic[AnyStr]):
764822
"""Object returned by the :py:func:`capsys`, :py:func:`capsysbinary`,
765823
:py:func:`capfd` and :py:func:`capfdbinary` fixtures."""
766824

767825
def __init__(self, captureclass, request: SubRequest) -> None:
768826
self.captureclass = captureclass
769827
self.request = request
770-
self._capture = None # type: Optional[MultiCapture]
828+
self._capture = None # type: Optional[MultiCapture[AnyStr]]
771829
self._captured_out = self.captureclass.EMPTY_BUFFER
772830
self._captured_err = self.captureclass.EMPTY_BUFFER
773831

@@ -786,7 +844,7 @@ def close(self) -> None:
786844
self._capture.stop_capturing()
787845
self._capture = None
788846

789-
def readouterr(self):
847+
def readouterr(self) -> CaptureResult[AnyStr]:
790848
"""Read and return the captured output so far, resetting the internal
791849
buffer.
792850
@@ -825,15 +883,15 @@ def disabled(self) -> Generator[None, None, None]:
825883

826884

827885
@pytest.fixture
828-
def capsys(request: SubRequest) -> Generator[CaptureFixture, None, None]:
886+
def capsys(request: SubRequest) -> Generator[CaptureFixture[str], None, None]:
829887
"""Enable text capturing of writes to ``sys.stdout`` and ``sys.stderr``.
830888
831889
The captured output is made available via ``capsys.readouterr()`` method
832890
calls, which return a ``(out, err)`` namedtuple.
833891
``out`` and ``err`` will be ``text`` objects.
834892
"""
835893
capman = request.config.pluginmanager.getplugin("capturemanager")
836-
capture_fixture = CaptureFixture(SysCapture, request)
894+
capture_fixture = CaptureFixture[str](SysCapture, request)
837895
capman.set_fixture(capture_fixture)
838896
capture_fixture._start()
839897
yield capture_fixture
@@ -842,15 +900,15 @@ def capsys(request: SubRequest) -> Generator[CaptureFixture, None, None]:
842900

843901

844902
@pytest.fixture
845-
def capsysbinary(request: SubRequest) -> Generator[CaptureFixture, None, None]:
903+
def capsysbinary(request: SubRequest) -> Generator[CaptureFixture[bytes], None, None]:
846904
"""Enable bytes capturing of writes to ``sys.stdout`` and ``sys.stderr``.
847905
848906
The captured output is made available via ``capsysbinary.readouterr()``
849907
method calls, which return a ``(out, err)`` namedtuple.
850908
``out`` and ``err`` will be ``bytes`` objects.
851909
"""
852910
capman = request.config.pluginmanager.getplugin("capturemanager")
853-
capture_fixture = CaptureFixture(SysCaptureBinary, request)
911+
capture_fixture = CaptureFixture[bytes](SysCaptureBinary, request)
854912
capman.set_fixture(capture_fixture)
855913
capture_fixture._start()
856914
yield capture_fixture
@@ -859,15 +917,15 @@ def capsysbinary(request: SubRequest) -> Generator[CaptureFixture, None, None]:
859917

860918

861919
@pytest.fixture
862-
def capfd(request: SubRequest) -> Generator[CaptureFixture, None, None]:
920+
def capfd(request: SubRequest) -> Generator[CaptureFixture[str], None, None]:
863921
"""Enable text capturing of writes to file descriptors ``1`` and ``2``.
864922
865923
The captured output is made available via ``capfd.readouterr()`` method
866924
calls, which return a ``(out, err)`` namedtuple.
867925
``out`` and ``err`` will be ``text`` objects.
868926
"""
869927
capman = request.config.pluginmanager.getplugin("capturemanager")
870-
capture_fixture = CaptureFixture(FDCapture, request)
928+
capture_fixture = CaptureFixture[str](FDCapture, request)
871929
capman.set_fixture(capture_fixture)
872930
capture_fixture._start()
873931
yield capture_fixture
@@ -876,15 +934,15 @@ def capfd(request: SubRequest) -> Generator[CaptureFixture, None, None]:
876934

877935

878936
@pytest.fixture
879-
def capfdbinary(request: SubRequest) -> Generator[CaptureFixture, None, None]:
937+
def capfdbinary(request: SubRequest) -> Generator[CaptureFixture[bytes], None, None]:
880938
"""Enable bytes capturing of writes to file descriptors ``1`` and ``2``.
881939
882940
The captured output is made available via ``capfd.readouterr()`` method
883941
calls, which return a ``(out, err)`` namedtuple.
884942
``out`` and ``err`` will be ``byte`` objects.
885943
"""
886944
capman = request.config.pluginmanager.getplugin("capturemanager")
887-
capture_fixture = CaptureFixture(FDCaptureBinary, request)
945+
capture_fixture = CaptureFixture[bytes](FDCaptureBinary, request)
888946
capman.set_fixture(capture_fixture)
889947
capture_fixture._start()
890948
yield capture_fixture

testing/test_capture.py

+40-3
Original file line numberDiff line numberDiff line change
@@ -14,30 +14,37 @@
1414
from _pytest import capture
1515
from _pytest.capture import _get_multicapture
1616
from _pytest.capture import CaptureManager
17+
from _pytest.capture import CaptureResult
1718
from _pytest.capture import MultiCapture
1819
from _pytest.config import ExitCode
1920

2021
# note: py.io capture tests where copied from
2122
# pylib 1.4.20.dev2 (rev 13d9af95547e)
2223

2324

24-
def StdCaptureFD(out: bool = True, err: bool = True, in_: bool = True) -> MultiCapture:
25+
def StdCaptureFD(
26+
out: bool = True, err: bool = True, in_: bool = True
27+
) -> MultiCapture[str]:
2528
return capture.MultiCapture(
2629
in_=capture.FDCapture(0) if in_ else None,
2730
out=capture.FDCapture(1) if out else None,
2831
err=capture.FDCapture(2) if err else None,
2932
)
3033

3134

32-
def StdCapture(out: bool = True, err: bool = True, in_: bool = True) -> MultiCapture:
35+
def StdCapture(
36+
out: bool = True, err: bool = True, in_: bool = True
37+
) -> MultiCapture[str]:
3338
return capture.MultiCapture(
3439
in_=capture.SysCapture(0) if in_ else None,
3540
out=capture.SysCapture(1) if out else None,
3641
err=capture.SysCapture(2) if err else None,
3742
)
3843

3944

40-
def TeeStdCapture(out: bool = True, err: bool = True, in_: bool = True) -> MultiCapture:
45+
def TeeStdCapture(
46+
out: bool = True, err: bool = True, in_: bool = True
47+
) -> MultiCapture[str]:
4148
return capture.MultiCapture(
4249
in_=capture.SysCapture(0, tee=True) if in_ else None,
4350
out=capture.SysCapture(1, tee=True) if out else None,
@@ -856,6 +863,36 @@ def test_dontreadfrominput():
856863
f.close() # just for completeness
857864

858865

866+
def test_captureresult() -> None:
867+
cr = CaptureResult("out", "err")
868+
assert len(cr) == 2
869+
assert cr.out == "out"
870+
assert cr.err == "err"
871+
out, err = cr
872+
assert out == "out"
873+
assert err == "err"
874+
assert cr[0] == "out"
875+
assert cr[1] == "err"
876+
assert cr == cr
877+
assert cr == CaptureResult("out", "err")
878+
assert cr != CaptureResult("wrong", "err")
879+
assert cr == ("out", "err")
880+
assert cr != ("out", "wrong")
881+
assert hash(cr) == hash(CaptureResult("out", "err"))
882+
assert hash(cr) == hash(("out", "err"))
883+
assert hash(cr) != hash(("out", "wrong"))
884+
assert cr < ("z",)
885+
assert cr < ("z", "b")
886+
assert cr < ("z", "b", "c")
887+
assert cr.count("err") == 1
888+
assert cr.count("wrong") == 0
889+
assert cr.index("err") == 1
890+
with pytest.raises(ValueError):
891+
assert cr.index("wrong") == 0
892+
assert next(iter(cr)) == "out"
893+
assert cr._replace(err="replaced") == ("out", "replaced")
894+
895+
859896
@pytest.fixture
860897
def tmpfile(testdir) -> Generator[BinaryIO, None, None]:
861898
f = testdir.makepyfile("").open("wb+")

0 commit comments

Comments
 (0)