1
1
"""Capture stdout and stderr during collection and execution.
2
2
3
+ This module implements the :class:`CaptureManager` plugin which allows for capturing in
4
+ three ways.
5
+
6
+ - fd (file descriptor) level capturing (default): All writes going to the operating
7
+ system file descriptors 1 and 2 will be captured.
8
+ - sys level capturing: Only writes to Python files ``sys.stdout`` and ``sys.stderr``
9
+ will be captured. No capturing of writes to file descriptors is performed.
10
+ - tee-sys capturing: Python writes to ``sys.stdout`` and ``sys.stderr`` will be
11
+ captured, however the writes will also be passed-through to the actual ``sys.stdout``
12
+ and ``sys.stderr``.
13
+
14
+
3
15
References
4
16
----------
5
17
6
- - <capture module in pytest
7
- <https://github.com/pytest-dev/pytest/blob/master/src/_pytest/capture.py>`
8
- - <debugging module in pytest
9
- <https://github.com/pytest-dev/pytest/blob/master/src/_pytest/debugging.py>`
18
+ - `Blog post on redirecting and file descriptors
19
+ <https://eli.thegreenplace.net/2015/redirecting-all-kinds-of-stdout-in-python>`_.
20
+ - `The capture module in pytest
21
+ <https://github.com/pytest-dev/pytest/blob/master/src/_pytest/capture.py>`_.
22
+ - `The debugging module in pytest
23
+ <https://github.com/pytest-dev/pytest/blob/master/src/_pytest/debugging.py>`_.
10
24
11
25
"""
12
26
import contextlib
@@ -108,9 +122,9 @@ def pytask_post_parse(config):
108
122
pluginmanager = config ["pm" ]
109
123
capman = CaptureManager (config ["capture" ])
110
124
pluginmanager .register (capman , "capturemanager" )
111
- capman .stop_global_capturing ()
112
- capman .start_global_capturing ()
113
- capman .suspend_global_capture ()
125
+ capman .stop_capturing ()
126
+ capman .start_capturing ()
127
+ capman .suspend ()
114
128
115
129
116
130
def _capture_callback (x ):
@@ -139,7 +153,7 @@ def _show_capture_callback(x):
139
153
return x
140
154
141
155
142
- # Copied from pytest.
156
+ # Copied from pytest with slightly modified docstrings .
143
157
144
158
145
159
def _colorama_workaround () -> None :
@@ -149,6 +163,7 @@ def _colorama_workaround() -> None:
149
163
colorama uses the terminal on import time. So if something does the
150
164
first import of colorama while I/O capture is active, colorama will
151
165
fail in various ways.
166
+
152
167
"""
153
168
if sys .platform .startswith ("win32" ):
154
169
try :
@@ -161,10 +176,10 @@ def _readline_workaround() -> None:
161
176
"""Ensure readline is imported so that it attaches to the correct stdio handles on
162
177
Windows.
163
178
164
- Pdb uses readline support where available-- when not running from the Python prompt,
165
- the readline module is not imported until running the pdb REPL. If running pytest
166
- with the --pdb option this means the readline module is not imported until after I/O
167
- capture has been started.
179
+ Pdb uses readline support where available -- when not running from the Python
180
+ prompt, the readline module is not imported until running the pdb REPL. If running
181
+ pytest with the `` --pdb`` option this means the readline module is not imported
182
+ until after I/O capture has been started.
168
183
169
184
This is a problem for pyreadline, which is often used to implement readline support
170
185
on Windows, as it does not attach to the correct handles for stdout and/or stdin if
@@ -198,11 +213,14 @@ def _py36_windowsconsoleio_workaround(stream: TextIO) -> None:
198
213
different handle by replicating the logic in
199
214
"Py_lifecycle.c:initstdio/create_stdio".
200
215
201
- :param stream:
202
- In practice ``sys.stdout`` or ``sys.stderr``, but given
203
- here as parameter for unittesting purposes.
216
+ Parameters
217
+ ---------
218
+ stream
219
+ In practice ``sys.stdout`` or ``sys.stderr``, but given here as parameter for
220
+ unit testing purposes.
204
221
205
222
See https://github.com/pytest-dev/py/issues/103.
223
+
206
224
"""
207
225
if not sys .platform .startswith ("win32" ) or hasattr (sys , "pypy_version_info" ):
208
226
return
@@ -244,14 +262,13 @@ class EncodedFile(io.TextIOWrapper):
244
262
245
263
@property
246
264
def name (self ) -> str :
247
- # Ensure that file.name is a string. Workaround for a Python bug
248
- # fixed in >=3.7.4: https://bugs.python.org/issue36015
265
+ # Ensure that file.name is a string. Workaround for a Python bug fixed in
266
+ # >=3.7.4: https://bugs.python.org/issue36015
249
267
return repr (self .buffer )
250
268
251
269
@property
252
270
def mode (self ) -> str :
253
- # TextIOWrapper doesn't expose a mode, but at least some of our
254
- # tests check it.
271
+ # TextIOWrapper doesn't expose a mode, but at least some of our tests check it.
255
272
return self .buffer .mode .replace ("b" , "" )
256
273
257
274
@@ -275,11 +292,13 @@ def write(self, s: str) -> int:
275
292
276
293
277
294
class DontReadFromInput :
295
+ """Class to disable reading from stdin while capturing is activated."""
296
+
278
297
encoding = None
279
298
280
299
def read (self , * _args ): # noqa: U101
281
300
raise OSError (
282
- "pytest: reading from stdin while output is captured! Consider using `-s`."
301
+ "pytest: reading from stdin while output is captured! Consider using `-s`."
283
302
)
284
303
285
304
readline = read
@@ -307,14 +326,22 @@ def buffer(self):
307
326
308
327
309
328
patchsysdict = {0 : "stdin" , 1 : "stdout" , 2 : "stderr" }
329
+ """Dict[int, str]: Map file descriptors to their names."""
310
330
311
331
312
332
class NoCapture :
333
+ """Dummy class when capturing is disabled."""
334
+
313
335
EMPTY_BUFFER = None
314
336
__init__ = start = done = suspend = resume = lambda * _args : None # noqa: U101
315
337
316
338
317
339
class SysCaptureBinary :
340
+ """Capture IO to/from Python's buffer for stdin, stdout, and stderr.
341
+
342
+ Instead of :class:`SysCapture`, this class produces bytes instead of text.
343
+
344
+ """
318
345
319
346
EMPTY_BUFFER = b""
320
347
@@ -397,6 +424,12 @@ def writeorg(self, data) -> None:
397
424
398
425
399
426
class SysCapture (SysCaptureBinary ):
427
+ """Capture IO to/from Python's buffer for stdin, stdout, and stderr.
428
+
429
+ Instead of :class:`SysCaptureBinary`, this class produces text instead of bytes.
430
+
431
+ """
432
+
400
433
EMPTY_BUFFER = "" # type: ignore[assignment]
401
434
402
435
def snap (self ):
@@ -428,7 +461,7 @@ def __init__(self, targetfd: int) -> None:
428
461
except OSError :
429
462
# FD capturing is conceptually simple -- create a temporary file, redirect
430
463
# the FD to it, redirect back when done. But when the target FD is invalid
431
- # it throws a wrench into this loveley scheme.
464
+ # it throws a wrench into this lovely scheme.
432
465
433
466
# Tests themselves shouldn't care if the FD is valid, FD capturing should
434
467
# work regardless of external circumstances. So falling back to just sys
@@ -556,14 +589,19 @@ def writeorg(self, data):
556
589
# MultiCapture
557
590
558
591
559
- # This class was a namedtuple, but due to mypy limitation[0] it could not be made
560
- # generic, so was replaced by a regular class which tries to emulate the pertinent parts
561
- # of a namedtuple. If the mypy limitation is ever lifted, can make it a namedtuple
562
- # again. [0]: https://github.com/python/mypy/issues/685
563
592
@final
564
593
@functools .total_ordering
565
594
class CaptureResult (Generic [AnyStr ]):
566
- """The result of ``CaptureFixture.readouterr``."""
595
+ """The result of :meth:`MultiCapture.readouterr` which wraps stdout and stderr.
596
+
597
+ This class was a namedtuple, but due to mypy limitation [0]_ it could not be made
598
+ generic, so was replaced by a regular class which tries to emulate the pertinent
599
+ parts of a namedtuple. If the mypy limitation is ever lifted, can make it a
600
+ namedtuple again.
601
+
602
+ .. [0] https://github.com/python/mypy/issues/685
603
+
604
+ """
567
605
568
606
# Can't use slots in Python<3.5.3 due to https://bugs.python.org/issue31272
569
607
if sys .version_info >= (3 , 5 , 3 ):
@@ -613,6 +651,14 @@ def __repr__(self) -> str:
613
651
614
652
615
653
class MultiCapture (Generic [AnyStr ]):
654
+ """The class which manages the buffers connected to each stream.
655
+
656
+ The class is instantiated with buffers for ``stdin``, ``stdout`` and ``stderr``.
657
+ Then, the instance provides convenient methods to control all buffers at once, like
658
+ start and stop capturing and reading the ``stdout`` and ``stderr``.
659
+
660
+ """
661
+
616
662
_state = None
617
663
_in_suspended = False
618
664
@@ -700,6 +746,12 @@ def readouterr(self) -> CaptureResult[AnyStr]:
700
746
701
747
702
748
def _get_multicapture (method : "_CaptureMethod" ) -> MultiCapture [str ]:
749
+ """Set up the MultiCapture class with the passed method.
750
+
751
+ For each valid method, the function instantiates the :class:`MultiCapture` class
752
+ with the specified buffers for ``stdin``, ``stdout``, and ``stderr``.
753
+
754
+ """
703
755
if method == "fd" :
704
756
return MultiCapture (in_ = FDCapture (0 ), out = FDCapture (1 ), err = FDCapture (2 ))
705
757
elif method == "sys" :
@@ -719,14 +771,13 @@ def _get_multicapture(method: "_CaptureMethod") -> MultiCapture[str]:
719
771
class CaptureManager :
720
772
"""The capture plugin.
721
773
722
- Manages that the appropriate capture method is enabled/disabled during collection
723
- and each test phase (setup, call, teardown). After each of those points, the
724
- captured output is obtained and attached to the collection/runtest report.
725
-
726
- There are two levels of capture:
774
+ This class is the capture plugin which implements some hooks and provides an
775
+ interface around :func:`_get_multicapture` and :class:`MultiCapture` adjusted to
776
+ pytask.
727
777
728
- * global: enabled by default and can be suppressed by the ``-s`` option. This is
729
- always enabled/disabled during collection and each test phase.
778
+ The class manages that the appropriate capture method is enabled/disabled during the
779
+ execution phase (setup, call, teardown). After each of those points, the captured
780
+ output is obtained and attached to the execution report.
730
781
731
782
"""
732
783
@@ -735,49 +786,35 @@ def __init__(self, method: "_CaptureMethod") -> None:
735
786
self ._global_capturing = None # type: Optional[MultiCapture[str]]
736
787
737
788
def __repr__ (self ) -> str :
738
- return ("<CaptureManager _method={!r} _global_capturing={!r} " ).format (
789
+ return ("<CaptureManager _method={!r} _global_capturing={!r}> " ).format (
739
790
self ._method , self ._global_capturing
740
791
)
741
792
742
793
def is_capturing (self ) -> Union [str , bool ]:
743
- if self .is_globally_capturing ():
744
- return "global"
745
- return False
746
-
747
- # Global capturing control
748
-
749
- def is_globally_capturing (self ) -> bool :
750
794
return self ._method != "no"
751
795
752
- def start_global_capturing (self ) -> None :
796
+ def start_capturing (self ) -> None :
753
797
assert self ._global_capturing is None
754
798
self ._global_capturing = _get_multicapture (self ._method )
755
799
self ._global_capturing .start_capturing ()
756
800
757
- def stop_global_capturing (self ) -> None :
801
+ def stop_capturing (self ) -> None :
758
802
if self ._global_capturing is not None :
759
803
self ._global_capturing .pop_outerr_to_orig ()
760
804
self ._global_capturing .stop_capturing ()
761
805
self ._global_capturing = None
762
806
763
- def resume_global_capture (self ) -> None :
807
+ def resume (self ) -> None :
764
808
# During teardown of the python process, and on rare occasions, capture
765
809
# attributes can be `None` while trying to resume global capture.
766
810
if self ._global_capturing is not None :
767
811
self ._global_capturing .resume_capturing ()
768
812
769
- def suspend_global_capture (self , in_ : bool = False ) -> None :
813
+ def suspend (self , in_ : bool = False ) -> None :
770
814
if self ._global_capturing is not None :
771
815
self ._global_capturing .suspend_capturing (in_ = in_ )
772
816
773
- def suspend (self , in_ : bool = False ) -> None :
774
- # Need to undo local capsys-et-al if it exists before disabling global capture.
775
- self .suspend_global_capture (in_ )
776
-
777
- def resume (self ) -> None :
778
- self .resume_global_capture ()
779
-
780
- def read_global_capture (self ) -> CaptureResult [str ]:
817
+ def read (self ) -> CaptureResult [str ]:
781
818
assert self ._global_capturing is not None
782
819
return self ._global_capturing .readouterr ()
783
820
@@ -786,13 +823,14 @@ def read_global_capture(self) -> CaptureResult[str]:
786
823
@contextlib .contextmanager
787
824
def task_capture (self , when : str , task : MetaTask ) -> Generator [None , None , None ]:
788
825
"""Pipe captured stdout and stderr into report sections."""
789
- self .resume_global_capture ()
826
+ self .resume ()
827
+
790
828
try :
791
829
yield
792
830
finally :
793
- self .suspend_global_capture (in_ = False )
831
+ self .suspend (in_ = False )
794
832
795
- out , err = self .read_global_capture ()
833
+ out , err = self .read ()
796
834
task .add_report_section (when , "stdout" , out )
797
835
task .add_report_section (when , "stderr" , err )
798
836
0 commit comments