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
9
10
from typing import ContextManager
10
11
from typing import Generator
12
+ from typing import Iterator
11
13
from typing import Mapping
12
14
from typing import TYPE_CHECKING
13
15
from unittest import TestCase
@@ -174,91 +176,107 @@ class SubTests:
174
176
def item (self ) -> pytest .Item :
175
177
return self .request .node
176
178
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
+ )
180
202
181
- # capsys or capfd are active, subtest should not capture
182
203
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.
185
209
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
+ """
194
214
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
197
220
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 :
228
222
__tracebackhide__ = True
229
223
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
233
247
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 ()
239
253
240
254
precise_stop = time .perf_counter ()
241
- duration = precise_stop - precise_start
255
+ duration = precise_stop - self . _precise_start
242
256
stop = time .time ()
243
257
244
258
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
246
263
)
247
- report = self .ihook .pytest_runtest_makereport (item = self .item , call = call_info )
248
264
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 ())
250
266
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 )
253
269
254
270
with self .suspend_capture_ctx ():
255
271
self .ihook .pytest_runtest_logreport (report = sub_report )
256
272
257
273
if check_interactive_exception (call_info , sub_report ):
258
274
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
260
276
)
261
277
278
+ return True
279
+
262
280
263
281
def make_call_info (
264
282
exc_info : ExceptionInfo [BaseException ] | None ,
@@ -279,6 +297,53 @@ def make_call_info(
279
297
)
280
298
281
299
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
+
282
347
@contextmanager
283
348
def ignore_pytest_private_warning () -> Generator [None , None , None ]:
284
349
import warnings
0 commit comments