88from .findtests import findtestdir
99from .result import TestResult
1010from .runtests import RunTests
11- from .utils import count
11+ from .utils import StrPath , count
1212
1313
1414class Reporter :
@@ -22,27 +22,58 @@ def test_case_failed(self, test_case: str, traceback: str,
2222 """Report a failed test case.
2323
2424 Called just after unittest printed the failure, or at the end of the
25- test file in quiet mode (failures are not printed) .
25+ test file in quiet mode.
2626 """
2727
2828 def test_result (self , result : TestResult , runtests : RunTests ) -> None :
2929 """Report the result of a test file, in the main process."""
3030
3131 def run_failed (self , failed : list [TestResult ], state : str ) -> None :
32- """Report a failed run: failed is the list of failed test files ."""
32+ """Report the test files which failed the run ."""
3333
34+ @staticmethod
35+ def parse_traceback (traceback : str ) -> tuple [str | None , str | None , str ]:
36+ """Parse the traceback of a failure into (filename, line, exception).
3437
35- def get_reporter (runtests : RunTests ) -> Reporter :
36- if runtests .github_summary :
37- return GitHubActionsReporter (runtests .github_summary )
38+ The location is the last frame in a test file, or else the first frame
39+ (the test).
40+ """
41+ frames = re .findall (r'^ *File "(.*)", line (\d+)' , traceback ,
42+ re .MULTILINE )
43+ test_frames = [frame for frame in frames
44+ if pathlib .Path (frame [0 ]).name .startswith ('test_' )]
45+ filename , line = (test_frames [- 1 ] if test_frames
46+ else frames [0 ] if frames
47+ else (None , None ))
48+ exception = re .split (r'^ File .*\n(?: .*\n)*' , traceback ,
49+ flags = re .MULTILINE )[- 1 ].strip ()
50+ return filename , line , exception
51+
52+ @staticmethod
53+ def test_filename (result : TestResult ,
54+ test_dir : StrPath | None ) -> str | None :
55+ """Get the filename of a test, or None if it is not found."""
56+ if result .filename :
57+ return result .filename
58+ # The worker crashed or timed out before importing the test:
59+ # "test.test_x.test_y" is test_x/test_y.py or test_x/test_y/__init__.py
60+ name = result .test_name .removeprefix ("test." )
61+ path = pathlib .Path (findtestdir (test_dir ), * name .split ("." ))
62+ for filename in (path .with_suffix (".py" ), path / "__init__.py" ):
63+ if filename .exists ():
64+ return str (filename )
65+ return None
66+
67+
68+ def get_reporter (github_summary : str | None ) -> Reporter :
69+ if github_summary :
70+ return GitHubActionsReporter (github_summary )
3871 return Reporter ()
3972
4073
4174class GitHubActionsReporter (Reporter ):
42- """Report test failures to GitHub Actions.
43-
44- Annotate failures in the job log and list them in the job summary.
45- """
75+ """Annotate failures in the GitHub Actions job log and list them in the
76+ job summary."""
4677
4778 # Escape the data and the properties of workflow commands
4879 ESCAPE_DATA = str .maketrans ({"%" : "%25" , "\r " : "%0D" , "\n " : "%0A" })
@@ -53,54 +84,29 @@ def __init__(self, summary_filename: str) -> None:
5384
5485 def test_case_failed (self , test_case : str , traceback : str ,
5586 stream : TextIO ) -> None :
56- traceback = decolor (traceback )
57- # Get a file + line number from the backtrace: the last frame in a
58- # test file, or else the first frame (the test). Without frames, the
59- # failure is still annotated, just not located.
60- filename = line = None
61- if frames := re .findall (r'^ *File "(.*)", line (\d+)' , traceback ,
62- re .MULTILINE ):
63- test_frames = [frame for frame in frames
64- if pathlib .Path (frame [0 ]).name .startswith ('test_' )]
65- filename , line = test_frames [- 1 ] if test_frames else frames [0 ]
66- # Only keep the exception: strip the traceback frames, each one a
67- # ' File' line followed by its indented source lines
68- message = re .split (r'^ File .*\n(?: .*\n)*' , traceback ,
69- flags = re .MULTILINE )[- 1 ].strip ()
70- self .annotate (test_case , message , filename , line , stream )
87+ # The job log has the full traceback: only keep the exception
88+ filename , line , exception = self .parse_traceback (decolor (traceback ))
89+ self .annotate (test_case , exception , filename , line , stream )
7190
7291 def test_result (self , result : TestResult , runtests : RunTests ) -> None :
73- # Annotate a test file which failed without failed test cases, for
74- # example: crash, timeout, env changed. Failed test cases are
75- # annotated by test_case_failed().
92+ # Failed test cases are annotated by test_case_failed()
7693 if (not result .is_failed (runtests .fail_env_changed )
7794 or result .failed_test_cases ):
7895 return
79- filename = result .filename
80- if not filename :
81- # The worker crashed or timed out before reporting the test file:
82- # convert python import test name to relative file path.
83- # Ex: "test.test_x.test_y" is test_x/test_y.py
84- name = result .test_name .removeprefix ("test." )
85- path = pathlib .Path (findtestdir (runtests .test_dir ),
86- * name .split ("." ))
87- if path .with_suffix (".py" ).exists ():
88- filename = str (path .with_suffix (".py" ))
89- elif (path / "__init__.py" ).exists ():
90- filename = str (path / "__init__.py" )
91- # else not a test file (ex: "<regrtest worker>" bug): no location
9296 message = "\n " .join ([str (result ), * (result .env_changed_reasons or ())])
93- self .annotate (result .test_name , decolor (message ), filename )
97+ self .annotate (result .test_name , decolor (message ),
98+ self .test_filename (result , runtests .test_dir ))
9499
95100 def annotate (self , title : str , message : str , filename : str | None ,
96101 line : str | None = None , stream : TextIO | None = None ) -> None :
97102 if filename :
98- # Normalize stdlib path to be relative inside the repository
99- # checkout.
103+ # The stdlib can be outside of the repository checkout
100104 path = pathlib .Path (filename )
101105 stdlib_dir = pathlib .Path (os .__file__ ).parent
102106 if path .is_relative_to (stdlib_dir ):
103107 filename = ("Lib" / path .relative_to (stdlib_dir )).as_posix ()
108+ # Without a line, the annotation is not shown on the file
109+ line = line or "1"
104110 props = {"file" : filename , "line" : line , "title" : title }
105111 props_text = "," .join (f"{ key } ={ value .translate (self .ESCAPE_PROPERTY )} "
106112 for key , value in props .items () if value )
@@ -118,13 +124,12 @@ def run_failed(self, failed: list[TestResult], state: str) -> None:
118124 blocks .append ("\n " .join (f"- { reason } "
119125 for reason in result .env_changed_reasons ))
120126 for name , traceback in result .failed_test_cases :
121- # Only default open short tracebacks of up to 5 test cases.
127+ # Expand short tracebacks if there are only a few
122128 is_open = ncases <= 5 and traceback .count ("\n " ) < 30
123129 blocks += [f"<details{ ' open' if is_open else '' } >"
124130 f"<summary>{ html .escape (name )} </summary>" ,
125131 f"```pytb\n { decolor (traceback ).rstrip ()} \n ```" ,
126132 "</details>" ]
127- # Separate Markdown blocks with an empty line
133+ # Markdown blocks are separated by an empty line
128134 with open (self .summary_filename , "a" , encoding = "utf-8" ) as fp :
129135 fp .write ("" .join (f"{ block } \n \n " for block in blocks ))
130-
0 commit comments