55from _colorize import decolor # type: ignore[import-not-found]
66from typing import TextIO
77
8+ from .findtests import findtestdir
89from .result import TestResult
910lazy from .results import TestResults
1011from .runtests import RunTests
1112from .utils import count
1213
1314
1415class Reporter :
15- """Report test failures, for example to a CI service.
16-
17- The default reporter does nothing.
18- """
16+ """Report test failures, for example to a CI service. A no-op here."""
1917
2018 def run_started (self , runtests : RunTests ) -> None :
21- """Start a run of tests.
22-
23- Called in the main process at the start of each run (the first run
24- and the re-run), and in the process running the tests before each
25- test file.
26- """
19+ """Called at the start of each run, and in the test process."""
2720
2821 def test_case_failed (self , test_case : str , traceback : str ,
2922 stream : TextIO ) -> None :
30- """Report a failed test case.
31-
32- Called just after unittest printed the failure, or at the end of the
33- test file in quiet mode.
34- """
23+ """Called in the test process after a test case failed."""
3524
3625 def test_result (self , result : TestResult ) -> None :
37- """Report the result of a test file, in the main process ."""
26+ """Called in the main process after a test file completed ."""
3827
3928 def summary (self , results : TestResults , state : str ) -> None :
40- """Report the results of all runs, in the main process."""
29+ """Called in the main process after all runs ."""
4130
4231 @staticmethod
4332 def parse_traceback (traceback : str ) -> tuple [str | None , str | None , str ]:
44- """Parse the traceback of a failure into (filename, line, exception).
45-
46- The location is the last frame in a test file, or else the first frame
47- (the test).
48- """
33+ """Return (filename, line, exception) of a failure."""
4934 frames = re .findall (r'^ *File "(.*)", line (\d+)' , traceback ,
5035 re .MULTILINE )
5136 test_frames = [frame for frame in frames
5237 if pathlib .Path (frame [0 ]).name .startswith ('test_' )]
53- filename , line = (test_frames [- 1 ] if test_frames
54- else frames [0 ] if frames
55- else (None , None ))
38+ # Prefer the last frame in a test file, the assertion
39+ if test_frames :
40+ filename , line = test_frames [- 1 ]
41+ elif frames :
42+ filename , line = frames [0 ]
43+ else :
44+ filename = line = None
5645 exception = re .split (r'^ File .*\n(?: .*\n)*' , traceback ,
5746 flags = re .MULTILINE )[- 1 ].strip ()
5847 return filename , line , exception
@@ -65,10 +54,8 @@ def get_reporter() -> Reporter:
6554
6655
6756class GitHubActionsReporter (Reporter ):
68- """Annotate failures in the GitHub Actions job log and list them in the
69- job summary."""
57+ """Annotate failures in the job log and list them in the job summary."""
7058
71- # Escape the data and the properties of workflow commands
7259 ESCAPE_DATA = str .maketrans ({"%" : "%25" , "\r " : "%0D" , "\n " : "%0A" })
7360 ESCAPE_PROPERTY = ESCAPE_DATA | str .maketrans ({":" : "%3A" , "," : "%2C" })
7461
@@ -81,32 +68,39 @@ def run_started(self, runtests: RunTests) -> None:
8168
8269 def test_case_failed (self , test_case : str , traceback : str ,
8370 stream : TextIO ) -> None :
84- # Only report the failures which decide the result
8571 if not self .runtests .final_run :
8672 return
87- # The job log has the full traceback: only keep the exception
8873 filename , line , exception = self .parse_traceback (decolor (traceback ))
8974 self .annotate (test_case , exception , filename , line , stream )
9075
9176 def test_result (self , result : TestResult ) -> None :
92- # Failed test cases are annotated by test_case_failed()
93- if (not self .runtests .final_run
94- or not result .is_failed (self .runtests .fail_env_changed )
95- or result .failed_test_cases ):
77+ if not self .runtests .final_run :
78+ return
79+ if not result .is_failed (self .runtests .fail_env_changed ):
80+ return
81+ # Already annotated by test_case_failed()
82+ if result .failed_test_cases :
9683 return
84+ # "test.test_x.test_y" is test_x/test_y.py or test_x/test_y/__init__.py
85+ name = result .test_name .removeprefix ("test." )
86+ path = pathlib .Path (findtestdir (self .runtests .test_dir ),
87+ * name .split ("." ))
88+ filename = None
89+ for candidate in (path .with_suffix (".py" ), path / "__init__.py" ):
90+ if candidate .exists ():
91+ filename = str (candidate )
9792 message = "\n " .join ([str (result ), * (result .env_changed_reasons or ())])
98- self .annotate (result .test_name , decolor (message ),
99- result .find_filename (self .runtests .test_dir ))
93+ self .annotate (result .test_name , decolor (message ), filename )
10094
10195 def annotate (self , title : str , message : str , filename : str | None ,
10296 line : str | None = None , stream : TextIO | None = None ) -> None :
10397 if filename :
104- # The stdlib can be outside of the repository checkout
98+ # Make installed stdlib paths relative to the checkout
10599 path = pathlib .Path (filename )
106100 stdlib_dir = pathlib .Path (os .__file__ ).parent
107101 if path .is_relative_to (stdlib_dir ):
108102 filename = ("Lib" / path .relative_to (stdlib_dir )).as_posix ()
109- # Without a line, the annotation is not shown on the file
103+ # GitHub needs a line to show the annotation on the file
110104 line = line or "1"
111105 props = {"file" : filename , "line" : line , "title" : title }
112106 props_text = "," .join (f"{ key } ={ value .translate (self .ESCAPE_PROPERTY )} "
@@ -115,7 +109,6 @@ def annotate(self, title: str, message: str, filename: str | None,
115109 file = stream , flush = True )
116110
117111 def summary (self , results : TestResults , state : str ) -> None :
118- # Test files which failed in the last run (the re-run, if any)
119112 failed = [result for result in results .rerun_results
120113 if result .is_failed (self .runtests .fail_env_changed )]
121114 if not failed :
@@ -131,12 +124,13 @@ def summary(self, results: TestResults, state: str) -> None:
131124 for reason in result .env_changed_reasons ))
132125 for name , traceback in result .failed_test_cases :
133126 # Expand short tracebacks if there are only a few
134- is_open = ncases <= 5 and traceback .count ("\n " ) < 30
135- blocks += [f"<details{ ' open' if is_open else '' } >"
136- f"<summary>{ html .escape (name )} </summary>" ,
127+ if ncases <= 5 and traceback .count ("\n " ) < 30 :
128+ details = "<details open>"
129+ else :
130+ details = "<details>"
131+ blocks += [f"{ details } <summary>{ html .escape (name )} </summary>" ,
137132 f"```pytb\n { decolor (traceback ).rstrip ()} \n ```" ,
138133 "</details>" ]
139- # Markdown blocks are separated by an empty line
140134 with open (os .environ ["GITHUB_STEP_SUMMARY" ], "a" ,
141135 encoding = "utf-8" ) as fp :
142136 fp .write ("" .join (f"{ block } \n \n " for block in blocks ))
0 commit comments