Skip to content

Commit 58358ed

Browse files
authored
Various changes. (#40)
1 parent 79cb0cd commit 58358ed

16 files changed

+247
-139
lines changed

.conda/meta.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ test:
4545
- pytask --version
4646
- pytask clean
4747
- pytask markers
48+
- pytask collect
4849

4950
- pytest tests
5051

docs/changes.rst

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ all releases are available on `Anaconda.org <https://anaconda.org/pytask/pytask>
1919
- :gh:`38` allows to pass dictionaries as dependencies and products and inside the
2020
function ``depends_on`` and ``produces`` become dictionaries.
2121
- :gh:`39` releases v0.0.9.
22+
- :gh:`40` cleans up the capture manager and other parts of pytask.
2223
- :gh:`41` shortens the task ids in the error reports for better readability.
2324

2425

docs/tutorials/how_to_capture.rst

+14-4
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,29 @@
11
How to capture
22
==============
33

4+
What is capturing? Some of your tasks may use ``print`` statements, have progress bars,
5+
require user input or the libraries you are using show information during execution.
6+
7+
Since the output would pollute the terminal and the information shown by pytask, it
8+
captures all the output during execution and attaches it to the report of this task by
9+
default.
10+
11+
If the task fails, the output is shown along with the traceback to help you track down
12+
the error.
13+
14+
415
Default stdout/stderr/stdin capturing behavior
516
----------------------------------------------
617

7-
During task execution any output sent to ``stdout`` and ``stderr`` is captured. If a
8-
task its according captured output will usually be shown along with the failure
9-
traceback.
18+
During task execution any output sent to ``stdout`` and ``stderr`` is captured. If a
19+
task fails its captured output will usually be shown along with the failure traceback.
1020

1121
In addition, ``stdin`` is set to a "null" object which will fail on attempts to read
1222
from it because it is rarely desired to wait for interactive input when running
1323
automated tasks.
1424

1525
By default capturing is done by intercepting writes to low level file descriptors. This
16-
allows to capture output from simple print statements as well as output from a
26+
allows to capture output from simple ``print`` statements as well as output from a
1727
subprocess started by a task.
1828

1929

src/_pytask/capture.py

+94-56
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,26 @@
11
"""Capture stdout and stderr during collection and execution.
22
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+
315
References
416
----------
517
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>`_.
1024
1125
"""
1226
import contextlib
@@ -108,9 +122,9 @@ def pytask_post_parse(config):
108122
pluginmanager = config["pm"]
109123
capman = CaptureManager(config["capture"])
110124
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()
114128

115129

116130
def _capture_callback(x):
@@ -139,7 +153,7 @@ def _show_capture_callback(x):
139153
return x
140154

141155

142-
# Copied from pytest.
156+
# Copied from pytest with slightly modified docstrings.
143157

144158

145159
def _colorama_workaround() -> None:
@@ -149,6 +163,7 @@ def _colorama_workaround() -> None:
149163
colorama uses the terminal on import time. So if something does the
150164
first import of colorama while I/O capture is active, colorama will
151165
fail in various ways.
166+
152167
"""
153168
if sys.platform.startswith("win32"):
154169
try:
@@ -161,10 +176,10 @@ def _readline_workaround() -> None:
161176
"""Ensure readline is imported so that it attaches to the correct stdio handles on
162177
Windows.
163178
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.
168183
169184
This is a problem for pyreadline, which is often used to implement readline support
170185
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:
198213
different handle by replicating the logic in
199214
"Py_lifecycle.c:initstdio/create_stdio".
200215
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.
204221
205222
See https://github.com/pytest-dev/py/issues/103.
223+
206224
"""
207225
if not sys.platform.startswith("win32") or hasattr(sys, "pypy_version_info"):
208226
return
@@ -244,14 +262,13 @@ class EncodedFile(io.TextIOWrapper):
244262

245263
@property
246264
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
249267
return repr(self.buffer)
250268

251269
@property
252270
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.
255272
return self.buffer.mode.replace("b", "")
256273

257274

@@ -275,11 +292,13 @@ def write(self, s: str) -> int:
275292

276293

277294
class DontReadFromInput:
295+
"""Class to disable reading from stdin while capturing is activated."""
296+
278297
encoding = None
279298

280299
def read(self, *_args): # noqa: U101
281300
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`."
283302
)
284303

285304
readline = read
@@ -307,14 +326,22 @@ def buffer(self):
307326

308327

309328
patchsysdict = {0: "stdin", 1: "stdout", 2: "stderr"}
329+
"""Dict[int, str]: Map file descriptors to their names."""
310330

311331

312332
class NoCapture:
333+
"""Dummy class when capturing is disabled."""
334+
313335
EMPTY_BUFFER = None
314336
__init__ = start = done = suspend = resume = lambda *_args: None # noqa: U101
315337

316338

317339
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+
"""
318345

319346
EMPTY_BUFFER = b""
320347

@@ -397,6 +424,12 @@ def writeorg(self, data) -> None:
397424

398425

399426
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+
400433
EMPTY_BUFFER = "" # type: ignore[assignment]
401434

402435
def snap(self):
@@ -428,7 +461,7 @@ def __init__(self, targetfd: int) -> None:
428461
except OSError:
429462
# FD capturing is conceptually simple -- create a temporary file, redirect
430463
# 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.
432465

433466
# Tests themselves shouldn't care if the FD is valid, FD capturing should
434467
# work regardless of external circumstances. So falling back to just sys
@@ -556,14 +589,19 @@ def writeorg(self, data):
556589
# MultiCapture
557590

558591

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
563592
@final
564593
@functools.total_ordering
565594
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+
"""
567605

568606
# Can't use slots in Python<3.5.3 due to https://bugs.python.org/issue31272
569607
if sys.version_info >= (3, 5, 3):
@@ -613,6 +651,14 @@ def __repr__(self) -> str:
613651

614652

615653
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+
616662
_state = None
617663
_in_suspended = False
618664

@@ -700,6 +746,12 @@ def readouterr(self) -> CaptureResult[AnyStr]:
700746

701747

702748
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+
"""
703755
if method == "fd":
704756
return MultiCapture(in_=FDCapture(0), out=FDCapture(1), err=FDCapture(2))
705757
elif method == "sys":
@@ -719,14 +771,13 @@ def _get_multicapture(method: "_CaptureMethod") -> MultiCapture[str]:
719771
class CaptureManager:
720772
"""The capture plugin.
721773
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.
727777
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.
730781
731782
"""
732783

@@ -735,49 +786,35 @@ def __init__(self, method: "_CaptureMethod") -> None:
735786
self._global_capturing = None # type: Optional[MultiCapture[str]]
736787

737788
def __repr__(self) -> str:
738-
return ("<CaptureManager _method={!r} _global_capturing={!r} ").format(
789+
return ("<CaptureManager _method={!r} _global_capturing={!r}>").format(
739790
self._method, self._global_capturing
740791
)
741792

742793
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:
750794
return self._method != "no"
751795

752-
def start_global_capturing(self) -> None:
796+
def start_capturing(self) -> None:
753797
assert self._global_capturing is None
754798
self._global_capturing = _get_multicapture(self._method)
755799
self._global_capturing.start_capturing()
756800

757-
def stop_global_capturing(self) -> None:
801+
def stop_capturing(self) -> None:
758802
if self._global_capturing is not None:
759803
self._global_capturing.pop_outerr_to_orig()
760804
self._global_capturing.stop_capturing()
761805
self._global_capturing = None
762806

763-
def resume_global_capture(self) -> None:
807+
def resume(self) -> None:
764808
# During teardown of the python process, and on rare occasions, capture
765809
# attributes can be `None` while trying to resume global capture.
766810
if self._global_capturing is not None:
767811
self._global_capturing.resume_capturing()
768812

769-
def suspend_global_capture(self, in_: bool = False) -> None:
813+
def suspend(self, in_: bool = False) -> None:
770814
if self._global_capturing is not None:
771815
self._global_capturing.suspend_capturing(in_=in_)
772816

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]:
781818
assert self._global_capturing is not None
782819
return self._global_capturing.readouterr()
783820

@@ -786,13 +823,14 @@ def read_global_capture(self) -> CaptureResult[str]:
786823
@contextlib.contextmanager
787824
def task_capture(self, when: str, task: MetaTask) -> Generator[None, None, None]:
788825
"""Pipe captured stdout and stderr into report sections."""
789-
self.resume_global_capture()
826+
self.resume()
827+
790828
try:
791829
yield
792830
finally:
793-
self.suspend_global_capture(in_=False)
831+
self.suspend(in_=False)
794832

795-
out, err = self.read_global_capture()
833+
out, err = self.read()
796834
task.add_report_section(when, "stdout", out)
797835
task.add_report_section(when, "stderr", err)
798836

src/_pytask/collect.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ def _collect_from_paths(session):
7272
@hookimpl
7373
def pytask_ignore_collect(path, config):
7474
"""Ignore a path during the collection."""
75-
ignored = any(path.match(pattern) for pattern in config["ignore"])
76-
return ignored
75+
is_ignored = any(path.match(pattern) for pattern in config["ignore"])
76+
return is_ignored
7777

7878

7979
@hookimpl

0 commit comments

Comments
 (0)