Skip to content

Commit 5ff5be6

Browse files
rhoban13nicoddemus
authored andcommitted
Support -x/--exitfirst
In order to support that and provide a good error message, we needed to stop using @contextmanager so we could have full control over the error stack. Fix #23
1 parent 89e0434 commit 5ff5be6

File tree

3 files changed

+109
-18
lines changed

3 files changed

+109
-18
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: 81 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
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
@@ -218,47 +219,109 @@ def _capturing_logs(self) -> Generator[CapturedLogs | NullCapturedLogs, None, No
218219
with catching_logs(handler):
219220
yield captured_logs
220221

221-
@contextmanager
222222
def test(
223223
self,
224224
msg: str | None = None,
225225
**kwargs: Any,
226-
) -> Generator[None, None, None]:
227-
# Hide from tracebacks.
226+
) -> _SubTestContextManager:
227+
"""
228+
Context manager for subtests, capturing exceptions raised inside the subtest scope and handling
229+
them through the pytest machinery.
230+
231+
Usage:
232+
233+
.. code-block:: python
234+
235+
with subtests.test(msg="subtest"):
236+
assert 1 == 1
237+
"""
238+
return _SubTestContextManager(
239+
self.ihook,
240+
msg,
241+
kwargs,
242+
capturing_output_ctx=self._capturing_output,
243+
capturing_logs_ctx=self._capturing_logs,
244+
request=self.request,
245+
suspend_capture_ctx=self.suspend_capture_ctx,
246+
)
247+
248+
249+
@attr.s(auto_attribs=True)
250+
class _SubTestContextManager:
251+
"""
252+
Context manager for subtests, capturing exceptions raised inside the subtest scope and handling
253+
them through the pytest machinery.
254+
255+
Note: initially this logic was implemented directly in SubTests.test() as a @contextmanager, however
256+
it is not possible to control the output fully when exiting from it due to an exception when
257+
in --exitfirst mode, so this was refactored into an explicit context manager class (#134).
258+
"""
259+
260+
ihook: pluggy.HookRelay
261+
msg: str | None
262+
kwargs: dict[str, Any]
263+
capturing_output_ctx: Callable[[], ContextManager]
264+
capturing_logs_ctx: Callable[[], ContextManager]
265+
suspend_capture_ctx: Callable[[], ContextManager]
266+
request: SubRequest
267+
268+
def __enter__(self) -> None:
228269
__tracebackhide__ = True
229270

230-
start = time.time()
231-
precise_start = time.perf_counter()
232-
exc_info = None
271+
self._start = time.time()
272+
self._precise_start = time.perf_counter()
273+
self._exc_info = None
274+
275+
self._exit_stack = ExitStack()
276+
self._captured_output = self._exit_stack.enter_context(
277+
self.capturing_output_ctx()
278+
)
279+
self._captured_logs = self._exit_stack.enter_context(self.capturing_logs_ctx())
280+
281+
def __exit__(
282+
self,
283+
exc_type: type[Exception] | None,
284+
exc_val: Exception | None,
285+
exc_tb: TracebackType | None,
286+
) -> bool:
287+
__tracebackhide__ = True
288+
try:
289+
if exc_val is not None:
290+
if self.request.session.shouldfail:
291+
return False
233292

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()
293+
exc_info = ExceptionInfo.from_exception(exc_val)
294+
else:
295+
exc_info = None
296+
finally:
297+
self._exit_stack.close()
239298

240299
precise_stop = time.perf_counter()
241-
duration = precise_stop - precise_start
300+
duration = precise_stop - self._precise_start
242301
stop = time.time()
243302

244303
call_info = make_call_info(
245-
exc_info, start=start, stop=stop, duration=duration, when="call"
304+
exc_info, start=self._start, stop=stop, duration=duration, when="call"
305+
)
306+
report = self.ihook.pytest_runtest_makereport(
307+
item=self.request.node, call=call_info
246308
)
247-
report = self.ihook.pytest_runtest_makereport(item=self.item, call=call_info)
248309
sub_report = SubTestReport._from_test_report(report)
249-
sub_report.context = SubTestContext(msg, kwargs.copy())
310+
sub_report.context = SubTestContext(self.msg, self.kwargs.copy())
250311

251-
captured_output.update_report(sub_report)
252-
captured_logs.update_report(sub_report)
312+
self._captured_output.update_report(sub_report)
313+
self._captured_logs.update_report(sub_report)
253314

254315
with self.suspend_capture_ctx():
255316
self.ihook.pytest_runtest_logreport(report=sub_report)
256317

257318
if check_interactive_exception(call_info, sub_report):
258319
self.ihook.pytest_exception_interact(
259-
node=self.item, call=call_info, report=sub_report
320+
node=self.request.node, call=call_info, report=sub_report
260321
)
261322

323+
return True
324+
262325

263326
def make_call_info(
264327
exc_info: ExceptionInfo[BaseException] | None,

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)