Skip to content

Commit

Permalink
Support -x/--exitfirst
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
nicoddemus committed Jul 7, 2024
1 parent 9175242 commit ab98685
Show file tree
Hide file tree
Showing 3 changed files with 84 additions and 56 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ CHANGELOG
UNRELEASED
----------

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

.. _#131: https://github.com/pytest-dev/pytest-subtests/pull/131
.. _#134: https://github.com/pytest-dev/pytest-subtests/pull/134

0.12.1 (2024-03-07)
-------------------
Expand Down
80 changes: 60 additions & 20 deletions src/pytest_subtests/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import sys
import time
from contextlib import contextmanager
from contextlib import ExitStack
from contextlib import nullcontext
from typing import Any
from typing import Callable
Expand Down Expand Up @@ -218,49 +219,88 @@ def _capturing_logs(self) -> Generator[CapturedLogs | NullCapturedLogs, None, No
with catching_logs(handler):
yield captured_logs

@contextmanager
def test(
self,
msg: str | None = None,
**kwargs: Any,
) -> Generator[None, None, None]:
) -> _SubTestContextManager:
return _SubTestContextManager(
self.ihook,
msg,
kwargs,
capturing_output_ctx=self._capturing_output,
capturing_logs_ctx=self._capturing_logs,
request=self.request,
suspend_capture_ctx=self.suspend_capture_ctx,
)


@attr.s(auto_attribs=True)
class _SubTestContextManager:
ihook: pluggy.HookRelay
msg: str | None
kwargs: dict[str, Any]
capturing_output_ctx: Callable[[], ContextManager]
capturing_logs_ctx: Callable[[], ContextManager]
suspend_capture_ctx: Callable[[], ContextManager]
request: SubRequest

def __enter__(self) -> None:
# Hide from tracebacks.
__tracebackhide__ = True
# __tracebackhide__ = True

self._start = time.time()
self._precise_start = time.perf_counter()
self._exc_info = None

start = time.time()
precise_start = time.perf_counter()
exc_info = None
self._exit_stack = ExitStack()
self._captured_output = self._exit_stack.enter_context(
self.capturing_output_ctx()
)
self._captured_logs = self._exit_stack.enter_context(self.capturing_logs_ctx())

def __exit__(
self,
exc_type: type[Exception] | None,
exc_val: Exception | None,
exc_tb: TracebackType | None,
) -> bool:
try:
if exc_val is not None:
if self.request.session.shouldfail:
return False

with self._capturing_output() as captured_output, self._capturing_logs() as captured_logs:
try:
yield
except (Exception, OutcomeException):
exc_info = ExceptionInfo.from_current()
exc_info = ExceptionInfo.from_exception(exc_val)
else:
exc_info = None
finally:
self._exit_stack.close()

precise_stop = time.perf_counter()
duration = precise_stop - precise_start
duration = precise_stop - self._precise_start
stop = time.time()

call_info = make_call_info(
exc_info, start=start, stop=stop, duration=duration, when="call"
exc_info, start=self._start, stop=stop, duration=duration, when="call"
)
report = self.ihook.pytest_runtest_makereport(
item=self.request.node, call=call_info
)
report = self.ihook.pytest_runtest_makereport(item=self.item, call=call_info)
sub_report = SubTestReport._from_test_report(report)
sub_report.context = SubTestContext(msg, kwargs.copy())
sub_report.context = SubTestContext(self.msg, self.kwargs.copy())

captured_output.update_report(sub_report)
captured_logs.update_report(sub_report)
self._captured_output.update_report(sub_report)
self._captured_logs.update_report(sub_report)

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

if check_interactive_exception(call_info, sub_report):
self.ihook.pytest_exception_interact(
node=self.item, call=call_info, report=sub_report
node=self.request.node, call=call_info, report=sub_report
)

if self.request.session.shouldfail:
raise self.request.session.Failed(self.request.session.shouldfail) from None
return True


def make_call_info(
Expand Down
58 changes: 22 additions & 36 deletions tests/test_subtests.py
Original file line number Diff line number Diff line change
Expand Up @@ -582,41 +582,27 @@ def runpytest_and_check_pdb(
assert self._FakePdb.calls == ["init", "reset", "interaction"]


class TestExitFirst:
def test_exitfirst(self, pytester: pytest.Pytester) -> None:
"""
Validate that when passing --exitfirst the test exits after the first failed subtest
def test_exitfirst(pytester: pytest.Pytester) -> None:
"""
Validate that when passing --exitfirst the test exits after the first failed subtest.
"""
pytester.makepyfile(
"""
pytester.makepyfile(
"""
def testfoo(subtests):
with subtests.test("sub1"):
assert False
with subtests.test("sub2"):
pass
"""
)
result = pytester.runpytest("--exitfirst")
def test_foo(subtests):
with subtests.test("sub1"):
assert False
assert result.parseoutcomes()["failed"] == 2
result.stdout.fnmatch_lines(
[
"*[[]sub1[]] SUBFAIL test_exitfirst.py::testfoo - assert False*", # sub1 failed, this seems desiable
"*FAILED test_exitfirst.py::testfoo*", # testfoo failed, this may or may not be desirable
]
)
result.stdout.no_fnmatch_line("*sub2*") # sub2 not executed, this seems good

# This seems really wrong. The reason the testfoo is considered a failure
# is b/c of the exception raised during teardown of the subtests.test

result.stdout.fnmatch_lines(
[
"> self.gen.throw(typ, value, traceback)",
"E _pytest.main.Failed: stopping after 1 failures",
"",
"/usr/lib/python3.10/contextlib.py:153: Failed",
],
consecutive=True,
)
with subtests.test("sub2"):
pass
"""
)
result = pytester.runpytest("--exitfirst")
assert result.parseoutcomes()["failed"] == 1
result.stdout.fnmatch_lines(
[
"*[[]sub1[]] SUBFAIL test_exitfirst.py::test_foo - assert False*",
"* stopping after 1 failures*",
],
consecutive=True,
)
result.stdout.no_fnmatch_line("*sub2*") # sub2 not executed.

0 comments on commit ab98685

Please sign in to comment.