22
33from __future__ import annotations
44
5+ from collections import defaultdict
56from collections .abc import Callable
67from collections .abc import Iterator
78from collections .abc import Mapping
1314import time
1415from types import TracebackType
1516from typing import Any
17+ from typing import ClassVar
1618from typing import TYPE_CHECKING
1719
1820import pluggy
3335from _pytest .reports import TestReport
3436from _pytest .runner import CallInfo
3537from _pytest .runner import check_interactive_exception
38+ import pytest
3639
3740
3841if TYPE_CHECKING :
@@ -172,6 +175,9 @@ def test(subtests):
172175 kwargs ,
173176 request = self ._request ,
174177 suspend_capture_ctx = self ._suspend_capture_ctx ,
178+ reporter = self ._request .config .pluginmanager .getplugin (
179+ SubtestsReporterPlugin .NAME
180+ ),
175181 )
176182
177183
@@ -192,6 +198,7 @@ class _SubTestContextManager:
192198 kwargs : dict [str , Any ]
193199 suspend_capture_ctx : Callable [[], AbstractContextManager [None ]]
194200 request : SubRequest
201+ reporter : SubtestsReporterPlugin
195202
196203 def __enter__ (self ) -> None :
197204 __tracebackhide__ = True
@@ -243,6 +250,9 @@ def __exit__(
243250 report , SubtestContext (msg = self .msg , kwargs = self .kwargs .copy ())
244251 )
245252
253+ if sub_report .failed :
254+ self .reporter .contains_failed_subtests [self .request .node .nodeid ] += 1
255+
246256 self ._captured_output .update_report (sub_report )
247257 self ._captured_logs .update_report (sub_report )
248258
@@ -322,7 +332,9 @@ class CapturedLogs:
322332 handler : LogCaptureHandler
323333
324334 def update_report (self , report : TestReport ) -> None :
325- report .sections .append (("Captured log call" , self .handler .stream .getvalue ()))
335+ captured_log = self .handler .stream .getvalue ()
336+ if captured_log :
337+ report .sections .append (("Captured log call" , captured_log ))
326338
327339
328340class NullCapturedLogs :
@@ -342,47 +354,78 @@ def pytest_report_from_serializable(data: dict[str, Any]) -> SubtestReport | Non
342354 return None
343355
344356
345- @hookimpl (tryfirst = True )
346- def pytest_report_teststatus (
347- report : TestReport ,
348- config : Config ,
349- ) -> tuple [str , str , str | Mapping [str , bool ]] | None :
350- if report .when != "call" or not isinstance (report , SubtestReport ):
351- return None
357+ def pytest_configure (config : pytest .Config ) -> None :
358+ config .pluginmanager .register (SubtestsReporterPlugin (), SubtestsReporterPlugin .NAME )
352359
353- outcome = report . outcome
354- description = report . _sub_test_description ()
355- no_output = ( "" , "" , "" )
356-
357- if hasattr ( report , "wasxfail" ):
358- if config . option . no_subtests_reports and outcome != "skipped" :
359- return no_output
360- elif outcome == "skipped" :
361- category = "xfailed"
362- short = "y" # x letter is used for regular xfail, y for subtest xfail
363- status = "SUBXFAIL"
364- elif outcome == "passed" :
365- category = "xpassed"
366- short = "Y" # X letter is used for regular xpass, Y for subtest xpass
367- status = "SUBXPASS"
368- else :
369- # This should not normally happen, unless some plugin is setting wasxfail without
370- # the correct outcome. Pytest expects the call outcome to be either skipped or passed in case of xfail.
371- # Let's pass this report to the next hook.
360+
361+ @ dataclasses . dataclass ()
362+ class SubtestsReporterPlugin :
363+ NAME : ClassVar [ str ] = "subtests-reporter"
364+ # Tracks node-ids -> number of failed subtests.
365+ contains_failed_subtests : defaultdict [ str , int ] = dataclasses . field (
366+ default_factory = lambda : defaultdict ( lambda : 0 )
367+ )
368+
369+ def __hash__ ( self ) -> int :
370+ return id ( self )
371+
372+ @ hookimpl ( tryfirst = True )
373+ def pytest_report_teststatus (
374+ self ,
375+ report : TestReport ,
376+ config : Config ,
377+ ) -> tuple [ str , str , str | Mapping [ str , bool ]] | None :
378+ if report . when != "call" :
372379 return None
373- short = "" if config .option .no_subtests_shortletter else short
374- return f"subtests { category } " , short , f"{ description } { status } "
375-
376- if config .option .no_subtests_reports and outcome != "failed" :
377- return no_output
378- elif report .passed :
379- short = "" if config .option .no_subtests_shortletter else ","
380- return f"subtests { outcome } " , short , f"{ description } SUBPASS"
381- elif report .skipped :
382- short = "" if config .option .no_subtests_shortletter else "-"
383- return outcome , short , f"{ description } SUBSKIP"
384- elif outcome == "failed" :
385- short = "" if config .option .no_subtests_shortletter else "u"
386- return outcome , short , f"{ description } SUBFAIL"
387380
388- return None
381+ if isinstance (report , SubtestReport ):
382+ outcome = report .outcome
383+ description = report ._sub_test_description ()
384+ no_output = ("" , "" , "" )
385+
386+ if hasattr (report , "wasxfail" ):
387+ if config .option .no_subtests_reports and outcome != "skipped" :
388+ return no_output
389+ elif outcome == "skipped" :
390+ category = "xfailed"
391+ short = (
392+ "y" # x letter is used for regular xfail, y for subtest xfail
393+ )
394+ status = "SUBXFAIL"
395+ elif outcome == "passed" :
396+ category = "xpassed"
397+ short = (
398+ "Y" # X letter is used for regular xpass, Y for subtest xpass
399+ )
400+ status = "SUBXPASS"
401+ else :
402+ # This should not normally happen, unless some plugin is setting wasxfail without
403+ # the correct outcome. Pytest expects the call outcome to be either skipped or
404+ # passed in case of xfail.
405+ # Let's pass this report to the next hook.
406+ return None
407+ short = "" if config .option .no_subtests_shortletter else short
408+ return f"subtests { category } " , short , f"{ description } { status } "
409+
410+ if config .option .no_subtests_reports and outcome != "failed" :
411+ return no_output
412+ elif report .passed :
413+ short = "" if config .option .no_subtests_shortletter else ","
414+ return f"subtests { outcome } " , short , f"{ description } SUBPASSED"
415+ elif report .skipped :
416+ short = "" if config .option .no_subtests_shortletter else "-"
417+ return outcome , short , f"{ description } SUBSKIPPED"
418+ elif outcome == "failed" :
419+ short = "" if config .option .no_subtests_shortletter else "u"
420+ return outcome , short , f"{ description } SUBFAILED"
421+ else :
422+ # Top-level test, fail it it contains failed subtests and it has passed.
423+ if (
424+ report .passed
425+ and (count := self .contains_failed_subtests .get (report .nodeid , 0 )) > 0
426+ ):
427+ report .outcome = "failed"
428+ suffix = "s" if count > 1 else ""
429+ report .longrepr = f"Contains { count } failed subtest{ suffix } "
430+
431+ return None
0 commit comments