|
3 | 3 | import sys
|
4 | 4 | import time
|
5 | 5 | from contextlib import contextmanager
|
| 6 | +from contextlib import ExitStack |
6 | 7 | from contextlib import nullcontext
|
7 | 8 | from typing import Any
|
8 | 9 | from typing import Callable
|
@@ -218,47 +219,109 @@ def _capturing_logs(self) -> Generator[CapturedLogs | NullCapturedLogs, None, No
|
218 | 219 | with catching_logs(handler):
|
219 | 220 | yield captured_logs
|
220 | 221 |
|
221 |
| - @contextmanager |
222 | 222 | def test(
|
223 | 223 | self,
|
224 | 224 | msg: str | None = None,
|
225 | 225 | **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: |
228 | 269 | __tracebackhide__ = True
|
229 | 270 |
|
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 |
233 | 292 |
|
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() |
239 | 298 |
|
240 | 299 | precise_stop = time.perf_counter()
|
241 |
| - duration = precise_stop - precise_start |
| 300 | + duration = precise_stop - self._precise_start |
242 | 301 | stop = time.time()
|
243 | 302 |
|
244 | 303 | 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 |
246 | 308 | )
|
247 |
| - report = self.ihook.pytest_runtest_makereport(item=self.item, call=call_info) |
248 | 309 | 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()) |
250 | 311 |
|
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) |
253 | 314 |
|
254 | 315 | with self.suspend_capture_ctx():
|
255 | 316 | self.ihook.pytest_runtest_logreport(report=sub_report)
|
256 | 317 |
|
257 | 318 | if check_interactive_exception(call_info, sub_report):
|
258 | 319 | 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 |
260 | 321 | )
|
261 | 322 |
|
| 323 | + return True |
| 324 | + |
262 | 325 |
|
263 | 326 | def make_call_info(
|
264 | 327 | exc_info: ExceptionInfo[BaseException] | None,
|
|
0 commit comments