Skip to content

Commit 96c8408

Browse files
authored
Merge pull request #134 from rhoban13/exitfirst_working
Support -x to fail fast
2 parents 89e0434 + f03ed7a commit 96c8408

File tree

3 files changed

+154
-61
lines changed

3 files changed

+154
-61
lines changed

CHANGELOG.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ CHANGELOG
44
UNRELEASED
55
----------
66

7+
* Support ``-x/--exitfirst`` (`#134`_).
78
* Hide the traceback inside the ``SubTests.test()`` method (`#131`_).
89

910
.. _#131: https://github.com/pytest-dev/pytest-subtests/pull/131
11+
.. _#134: https://github.com/pytest-dev/pytest-subtests/pull/134
1012

1113
0.12.1 (2024-03-07)
1214
-------------------

src/pytest_subtests/plugin.py

Lines changed: 126 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
import sys
44
import time
55
from contextlib import contextmanager
6+
from contextlib import ExitStack
67
from contextlib import nullcontext
78
from typing import Any
89
from typing import Callable
910
from typing import ContextManager
1011
from typing import Generator
12+
from typing import Iterator
1113
from typing import Mapping
1214
from typing import TYPE_CHECKING
1315
from unittest import TestCase
@@ -174,91 +176,107 @@ class SubTests:
174176
def item(self) -> pytest.Item:
175177
return self.request.node
176178

177-
@contextmanager
178-
def _capturing_output(self) -> Generator[Captured, None, None]:
179-
option = self.request.config.getoption("capture", None)
179+
def test(
180+
self,
181+
msg: str | None = None,
182+
**kwargs: Any,
183+
) -> _SubTestContextManager:
184+
"""
185+
Context manager for subtests, capturing exceptions raised inside the subtest scope and handling
186+
them through the pytest machinery.
187+
188+
Usage:
189+
190+
.. code-block:: python
191+
192+
with subtests.test(msg="subtest"):
193+
assert 1 == 1
194+
"""
195+
return _SubTestContextManager(
196+
self.ihook,
197+
msg,
198+
kwargs,
199+
request=self.request,
200+
suspend_capture_ctx=self.suspend_capture_ctx,
201+
)
180202

181-
# capsys or capfd are active, subtest should not capture
182203

183-
capman = self.request.config.pluginmanager.getplugin("capturemanager")
184-
capture_fixture_active = getattr(capman, "_capture_fixture", None)
204+
@attr.s(auto_attribs=True)
205+
class _SubTestContextManager:
206+
"""
207+
Context manager for subtests, capturing exceptions raised inside the subtest scope and handling
208+
them through the pytest machinery.
185209
186-
if option == "sys" and not capture_fixture_active:
187-
with ignore_pytest_private_warning():
188-
fixture = CaptureFixture(SysCapture, self.request)
189-
elif option == "fd" and not capture_fixture_active:
190-
with ignore_pytest_private_warning():
191-
fixture = CaptureFixture(FDCapture, self.request)
192-
else:
193-
fixture = None
210+
Note: initially this logic was implemented directly in SubTests.test() as a @contextmanager, however
211+
it is not possible to control the output fully when exiting from it due to an exception when
212+
in --exitfirst mode, so this was refactored into an explicit context manager class (#134).
213+
"""
194214

195-
if fixture is not None:
196-
fixture._start()
215+
ihook: pluggy.HookRelay
216+
msg: str | None
217+
kwargs: dict[str, Any]
218+
suspend_capture_ctx: Callable[[], ContextManager]
219+
request: SubRequest
197220

198-
captured = Captured()
199-
try:
200-
yield captured
201-
finally:
202-
if fixture is not None:
203-
out, err = fixture.readouterr()
204-
fixture.close()
205-
captured.out = out
206-
captured.err = err
207-
208-
@contextmanager
209-
def _capturing_logs(self) -> Generator[CapturedLogs | NullCapturedLogs, None, None]:
210-
logging_plugin = self.request.config.pluginmanager.getplugin("logging-plugin")
211-
if logging_plugin is None:
212-
yield NullCapturedLogs()
213-
else:
214-
handler = LogCaptureHandler()
215-
handler.setFormatter(logging_plugin.formatter)
216-
217-
captured_logs = CapturedLogs(handler)
218-
with catching_logs(handler):
219-
yield captured_logs
220-
221-
@contextmanager
222-
def test(
223-
self,
224-
msg: str | None = None,
225-
**kwargs: Any,
226-
) -> Generator[None, None, None]:
227-
# Hide from tracebacks.
221+
def __enter__(self) -> None:
228222
__tracebackhide__ = True
229223

230-
start = time.time()
231-
precise_start = time.perf_counter()
232-
exc_info = None
224+
self._start = time.time()
225+
self._precise_start = time.perf_counter()
226+
self._exc_info = None
227+
228+
self._exit_stack = ExitStack()
229+
self._captured_output = self._exit_stack.enter_context(
230+
capturing_output(self.request)
231+
)
232+
self._captured_logs = self._exit_stack.enter_context(
233+
capturing_logs(self.request)
234+
)
235+
236+
def __exit__(
237+
self,
238+
exc_type: type[Exception] | None,
239+
exc_val: Exception | None,
240+
exc_tb: TracebackType | None,
241+
) -> bool:
242+
__tracebackhide__ = True
243+
try:
244+
if exc_val is not None:
245+
if self.request.session.shouldfail:
246+
return False
233247

234-
with self._capturing_output() as captured_output, self._capturing_logs() as captured_logs:
235-
try:
236-
yield
237-
except (Exception, OutcomeException):
238-
exc_info = ExceptionInfo.from_current()
248+
exc_info = ExceptionInfo.from_exception(exc_val)
249+
else:
250+
exc_info = None
251+
finally:
252+
self._exit_stack.close()
239253

240254
precise_stop = time.perf_counter()
241-
duration = precise_stop - precise_start
255+
duration = precise_stop - self._precise_start
242256
stop = time.time()
243257

244258
call_info = make_call_info(
245-
exc_info, start=start, stop=stop, duration=duration, when="call"
259+
exc_info, start=self._start, stop=stop, duration=duration, when="call"
260+
)
261+
report = self.ihook.pytest_runtest_makereport(
262+
item=self.request.node, call=call_info
246263
)
247-
report = self.ihook.pytest_runtest_makereport(item=self.item, call=call_info)
248264
sub_report = SubTestReport._from_test_report(report)
249-
sub_report.context = SubTestContext(msg, kwargs.copy())
265+
sub_report.context = SubTestContext(self.msg, self.kwargs.copy())
250266

251-
captured_output.update_report(sub_report)
252-
captured_logs.update_report(sub_report)
267+
self._captured_output.update_report(sub_report)
268+
self._captured_logs.update_report(sub_report)
253269

254270
with self.suspend_capture_ctx():
255271
self.ihook.pytest_runtest_logreport(report=sub_report)
256272

257273
if check_interactive_exception(call_info, sub_report):
258274
self.ihook.pytest_exception_interact(
259-
node=self.item, call=call_info, report=sub_report
275+
node=self.request.node, call=call_info, report=sub_report
260276
)
261277

278+
return True
279+
262280

263281
def make_call_info(
264282
exc_info: ExceptionInfo[BaseException] | None,
@@ -279,6 +297,53 @@ def make_call_info(
279297
)
280298

281299

300+
@contextmanager
301+
def capturing_output(request: SubRequest) -> Iterator[Captured]:
302+
option = request.config.getoption("capture", None)
303+
304+
# capsys or capfd are active, subtest should not capture.
305+
capman = request.config.pluginmanager.getplugin("capturemanager")
306+
capture_fixture_active = getattr(capman, "_capture_fixture", None)
307+
308+
if option == "sys" and not capture_fixture_active:
309+
with ignore_pytest_private_warning():
310+
fixture = CaptureFixture(SysCapture, request)
311+
elif option == "fd" and not capture_fixture_active:
312+
with ignore_pytest_private_warning():
313+
fixture = CaptureFixture(FDCapture, request)
314+
else:
315+
fixture = None
316+
317+
if fixture is not None:
318+
fixture._start()
319+
320+
captured = Captured()
321+
try:
322+
yield captured
323+
finally:
324+
if fixture is not None:
325+
out, err = fixture.readouterr()
326+
fixture.close()
327+
captured.out = out
328+
captured.err = err
329+
330+
331+
@contextmanager
332+
def capturing_logs(
333+
request: SubRequest,
334+
) -> Iterator[CapturedLogs | NullCapturedLogs]:
335+
logging_plugin = request.config.pluginmanager.getplugin("logging-plugin")
336+
if logging_plugin is None:
337+
yield NullCapturedLogs()
338+
else:
339+
handler = LogCaptureHandler()
340+
handler.setFormatter(logging_plugin.formatter)
341+
342+
captured_logs = CapturedLogs(handler)
343+
with catching_logs(handler):
344+
yield captured_logs
345+
346+
282347
@contextmanager
283348
def ignore_pytest_private_warning() -> Generator[None, None, None]:
284349
import warnings

tests/test_subtests.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,3 +580,29 @@ def runpytest_and_check_pdb(
580580
# assert.
581581
result.stdout.fnmatch_lines("*entering PDB*")
582582
assert self._FakePdb.calls == ["init", "reset", "interaction"]
583+
584+
585+
def test_exitfirst(pytester: pytest.Pytester) -> None:
586+
"""
587+
Validate that when passing --exitfirst the test exits after the first failed subtest.
588+
"""
589+
pytester.makepyfile(
590+
"""
591+
def test_foo(subtests):
592+
with subtests.test("sub1"):
593+
assert False
594+
595+
with subtests.test("sub2"):
596+
pass
597+
"""
598+
)
599+
result = pytester.runpytest("--exitfirst")
600+
assert result.parseoutcomes()["failed"] == 1
601+
result.stdout.fnmatch_lines(
602+
[
603+
"*[[]sub1[]] SUBFAIL test_exitfirst.py::test_foo - assert False*",
604+
"* stopping after 1 failures*",
605+
],
606+
consecutive=True,
607+
)
608+
result.stdout.no_fnmatch_line("*sub2*") # sub2 not executed.

0 commit comments

Comments
 (0)