Skip to content

Commit 475549a

Browse files
committed
Refactor to use a reporter of which github is one
1 parent 01ad16c commit 475549a

9 files changed

Lines changed: 171 additions & 129 deletions

File tree

‎Lib/test/libregrtest/main.py‎

Lines changed: 21 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import sysconfig
77
import time
88
import trace
9-
from _colorize import decolor, get_colors # type: ignore[import-not-found]
9+
from _colorize import get_colors # type: ignore[import-not-found]
1010
from typing import NoReturn
1111

1212
from test.support import os_helper, MS_WINDOWS, flush_std_streams
@@ -15,6 +15,7 @@
1515
from .findtests import findtests, split_test_packages, list_cases, collect_cases
1616
from .logger import Logger
1717
from .pgo import setup_pgo_tests
18+
from .reporter import get_reporter
1819
from .result import TestResult
1920
from .results import TestResults, EXITCODE_INTERRUPTED
2021
from .runtests import RunTests, HuntRefleak
@@ -285,7 +286,7 @@ def _rerun_failed_tests(self, runtests: RunTests) -> RunTests:
285286
fail_fast=False,
286287
match_tests_dict=match_tests_dict,
287288
output_on_failure=False,
288-
github_annotations=bool(self.github_summary))
289+
github_summary=self.github_summary)
289290
self.logger.set_tests(runtests)
290291

291292
msg = f"Re-running {len(tests)} failed tests in verbose mode"
@@ -298,7 +299,7 @@ def _rerun_failed_tests(self, runtests: RunTests) -> RunTests:
298299
self.run_tests_sequentially(runtests)
299300
return runtests
300301

301-
def rerun_failed_tests(self, runtests: RunTests) -> None:
302+
def rerun_failed_tests(self, runtests: RunTests) -> RunTests:
302303
ansi = get_colors()
303304
red, reset = ansi.BOLD_RED, ansi.RESET
304305

@@ -308,7 +309,7 @@ def rerun_failed_tests(self, runtests: RunTests) -> None:
308309
"Re-running failed tests is not supported with --python "
309310
"host runner option."
310311
)
311-
return
312+
return runtests
312313

313314
self.first_state = self.get_state()
314315

@@ -323,6 +324,7 @@ def rerun_failed_tests(self, runtests: RunTests) -> None:
323324
printlist(self.results.bad)
324325

325326
self.display_result(rerun_runtests)
327+
return rerun_runtests
326328

327329
def _run_bisect(self, runtests: RunTests, test: str, progress: str) -> bool:
328330
print()
@@ -396,7 +398,6 @@ def run_test(
396398
result = run_single_test(test_name, runtests)
397399

398400
self.results.accumulate_result(result, runtests)
399-
result.print_github_annotation(runtests)
400401

401402
return result
402403

@@ -463,7 +464,8 @@ def _run_tests_mp(self, runtests: RunTests, num_workers: int) -> None:
463464
from .run_workers import RunWorkers
464465
RunWorkers(num_workers, runtests, self.logger, self.results).run()
465466

466-
def finalize_tests(self, coverage: trace.CoverageResults | None) -> None:
467+
def finalize_tests(self, coverage: trace.CoverageResults | None,
468+
runtests: RunTests) -> None:
467469
if self.next_single_filename:
468470
if self.next_single_test:
469471
with open(self.next_single_filename, 'w') as fp:
@@ -482,24 +484,11 @@ def finalize_tests(self, coverage: trace.CoverageResults | None) -> None:
482484
if self.junit_filename:
483485
self.results.write_junit(self.junit_filename)
484486

485-
self.write_github_summary()
486-
487-
def write_github_summary(self) -> None:
488-
# Tests which failed in the last run (the re-run, if any)
489-
failed = self.results.rerun_results
490-
# Only write a summary on failure: a CI run has many test jobs
491-
exitcode = self.results.get_exitcode(self.fail_env_changed,
492-
self.fail_rerun)
493-
if not self.github_summary or not failed or not exitcode:
494-
return
495-
ncases = sum(len(result.failed_test_cases) for result in failed)
496-
with open(self.github_summary, "a", encoding="utf-8") as fp:
497-
fp.write(f"## {decolor(self.get_state())}: "
498-
f"{count(len(failed), 'test file')} and "
499-
f"{count(ncases, 'test case')} failed\n\n")
500-
for result in failed:
501-
# Expand the tracebacks of up to 5 test cases
502-
fp.write(result.format_github_summary(expand=ncases <= 5))
487+
# Test files which failed in the last run (the re-run, if any)
488+
failed = [result for result in self.results.rerun_results
489+
if result.is_failed(self.fail_env_changed)]
490+
if failed:
491+
get_reporter(runtests).run_failed(failed, self.get_state())
503492

504493
def display_summary(self) -> None:
505494
if self.first_runtests is None:
@@ -538,10 +527,10 @@ def create_run_tests(self, tests: TestTuple) -> RunTests:
538527
hunt_refleak=self.hunt_refleak,
539528
test_dir=self.test_dir,
540529
use_junit=(self.junit_filename is not None),
541-
# Only annotate the failure that results in job failure.
542-
# Ex: When reruning failed tests, only annotate the the final run.
543-
github_annotations=(bool(self.github_summary) and
544-
not (self.want_rerun and not self.python_cmd)),
530+
# Only report the failures which fail the job: when failed tests
531+
# are re-run, only report the re-run.
532+
github_summary=(None if self.want_rerun and not self.python_cmd
533+
else self.github_summary),
545534
coverage=self.coverage,
546535
memory_limit=self.memory_limit,
547536
gc_threshold=self.gc_threshold,
@@ -612,8 +601,10 @@ def _run_tests(self, selected: TestTuple, tests: TestList | None) -> int:
612601
coverage = self.results.get_coverage_results()
613602
self.display_result(runtests)
614603

604+
# The re-run, if any, is the last run
605+
last_runtests = runtests
615606
if self.want_rerun and self.results.need_rerun():
616-
self.rerun_failed_tests(runtests)
607+
last_runtests = self.rerun_failed_tests(runtests)
617608

618609
if self.want_bisect and self.results.need_rerun():
619610
self.run_bisect(runtests)
@@ -622,7 +613,7 @@ def _run_tests(self, selected: TestTuple, tests: TestList | None) -> int:
622613
self.logger.stop_load_tracker()
623614

624615
self.display_summary()
625-
self.finalize_tests(coverage)
616+
self.finalize_tests(coverage, last_runtests)
626617

627618
return self.results.get_exitcode(self.fail_env_changed,
628619
self.fail_rerun)

‎Lib/test/libregrtest/reporter.py‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import html
2+
import os
3+
import pathlib
4+
import re
5+
from _colorize import decolor # type: ignore[import-not-found]
6+
from typing import TextIO
7+
8+
from .findtests import findtestdir
9+
from .result import TestResult
10+
from .runtests import RunTests
11+
from .utils import count
12+
13+
14+
class Reporter:
15+
"""Report test failures, for example to a CI service.
16+
17+
The default reporter does nothing.
18+
"""
19+
20+
def test_case_failed(self, test_case: str, traceback: str,
21+
stream: TextIO) -> None:
22+
"""Report a failed test case.
23+
24+
Called just after unittest printed the failure, or at the end of the
25+
test file in quiet mode (failures are not printed).
26+
"""
27+
28+
def test_result(self, result: TestResult, runtests: RunTests) -> None:
29+
"""Report the result of a test file, in the main process."""
30+
31+
def run_failed(self, failed: list[TestResult], state: str) -> None:
32+
"""Report a failed run: failed is the list of failed test files."""
33+
34+
35+
def get_reporter(runtests: RunTests) -> Reporter:
36+
if runtests.github_summary:
37+
return GitHubActionsReporter(runtests.github_summary)
38+
return Reporter()
39+
40+
41+
class 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+
"""
46+
47+
# Escape the data and the properties of workflow commands
48+
ESCAPE_DATA = str.maketrans({"%": "%25", "\r": "%0D", "\n": "%0A"})
49+
ESCAPE_PROPERTY = ESCAPE_DATA | str.maketrans({":": "%3A", ",": "%2C"})
50+
51+
def __init__(self, summary_filename: str) -> None:
52+
self.summary_filename = summary_filename
53+
54+
def test_case_failed(self, test_case: str, traceback: str,
55+
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)
71+
72+
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().
76+
if (not result.is_failed(runtests.fail_env_changed)
77+
or result.failed_test_cases):
78+
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
92+
message = "\n".join([str(result), *(result.env_changed_reasons or ())])
93+
self.annotate(result.test_name, decolor(message), filename)
94+
95+
def annotate(self, title: str, message: str, filename: str | None,
96+
line: str | None = None, stream: TextIO | None = None) -> None:
97+
if filename:
98+
# Normalize stdlib path to be relative inside the repository
99+
# checkout.
100+
path = pathlib.Path(filename)
101+
stdlib_dir = pathlib.Path(os.__file__).parent
102+
if path.is_relative_to(stdlib_dir):
103+
filename = ("Lib" / path.relative_to(stdlib_dir)).as_posix()
104+
props = {"file": filename, "line": line, "title": title}
105+
props_text = ",".join(f"{key}={value.translate(self.ESCAPE_PROPERTY)}"
106+
for key, value in props.items() if value)
107+
print(f"::error {props_text}::{message.translate(self.ESCAPE_DATA)}",
108+
file=stream, flush=True)
109+
110+
def run_failed(self, failed: list[TestResult], state: str) -> None:
111+
ncases = sum(len(result.failed_test_cases) for result in failed)
112+
blocks = [f"## {decolor(state)}: "
113+
f"{count(len(failed), 'test file')} and "
114+
f"{count(ncases, 'test case')} failed"]
115+
for result in failed:
116+
blocks.append(f"### {decolor(str(result))}")
117+
if result.env_changed_reasons:
118+
blocks.append("\n".join(f"- {reason}"
119+
for reason in result.env_changed_reasons))
120+
for name, traceback in result.failed_test_cases:
121+
# Only default open short tracebacks of up to 5 test cases.
122+
is_open = ncases <= 5 and traceback.count("\n") < 30
123+
blocks += [f"<details{' open' if is_open else ''}>"
124+
f"<summary>{html.escape(name)}</summary>",
125+
f"```pytb\n{decolor(traceback).rstrip()}\n```",
126+
"</details>"]
127+
# Separate Markdown blocks with an empty line
128+
with open(self.summary_filename, "a", encoding="utf-8") as fp:
129+
fp.write("".join(f"{block}\n\n" for block in blocks))
130+

‎Lib/test/libregrtest/result.py‎

Lines changed: 2 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,11 @@
11
import dataclasses
2-
import html
32
import json
4-
import pathlib
5-
from _colorize import decolor, get_colors # type: ignore[import-not-found]
3+
from _colorize import get_colors # type: ignore[import-not-found]
64
from typing import Any
75

8-
from .findtests import findtestdir
9-
from .runtests import RunTests
106
from .utils import (
117
StrJSON, TestName, FilterTuple,
12-
format_duration, normalize_test_name, print_warning,
13-
print_github_annotation)
8+
format_duration, normalize_test_name, print_warning)
149

1510

1611
@dataclasses.dataclass(slots=True)
@@ -190,44 +185,6 @@ def has_meaningful_duration(self):
190185
def failed_test_cases(self) -> list[tuple[str, str]]:
191186
return (self.errors or []) + (self.failures or [])
192187

193-
def format_github_summary(self, expand: bool) -> str:
194-
"""Format the Markdown section of the GitHub Actions job summary."""
195-
blocks = [f"### {decolor(str(self))}"]
196-
if self.env_changed_reasons:
197-
blocks.append("\n".join(f"- {reason}"
198-
for reason in self.env_changed_reasons))
199-
for name, traceback in self.failed_test_cases:
200-
# Only default open short tracebacks.
201-
is_open = expand and traceback.count("\n") < 30
202-
blocks += [f"<details{' open' if is_open else ''}>"
203-
f"<summary>{html.escape(name)}</summary>",
204-
f"```pytb\n{decolor(traceback).rstrip()}\n```",
205-
"</details>"]
206-
207-
# Separate Markdown blocks with an empty line
208-
return "".join(f"{block}\n\n" for block in blocks)
209-
210-
def print_github_annotation(self, runtests: RunTests) -> None:
211-
"""Annotate a failed test file without failed test cases."""
212-
if (not runtests.github_annotations
213-
or not self.is_failed(runtests.fail_env_changed)
214-
or self.failed_test_cases):
215-
return
216-
filename = self.filename
217-
if not filename:
218-
# The worker crashed or timed out before reporting the test file:
219-
# convert python import test name to relative file path.
220-
# Ex: "test.test_x.test_y" is test_x/test_y.py
221-
name = self.test_name.removeprefix("test.")
222-
path = pathlib.Path(findtestdir(runtests.test_dir),
223-
*name.split("."))
224-
if path.with_suffix(".py").exists():
225-
filename = str(path.with_suffix(".py"))
226-
else:
227-
filename = str(path / "__init__.py")
228-
message = "\n".join([str(self), *(self.env_changed_reasons or ())])
229-
print_github_annotation(self.test_name, message, filename)
230-
231188
def set_env_changed(self, *reasons):
232189
if self.state is None or self.state == State.PASSED:
233190
self.state = State.ENV_CHANGED

‎Lib/test/libregrtest/results.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from _colorize import get_colors # type: ignore[import-not-found]
44
lazy from xml.etree.ElementTree import Element
55

6+
from .reporter import get_reporter
67
from .runtests import RunTests
78
from .result import State, TestResult, TestStats, Location
89
from .utils import (
@@ -142,6 +143,7 @@ def accumulate_result(self, result: TestResult, runtests: RunTests) -> None:
142143
xml_data = result.xml_data
143144
if xml_data:
144145
self.add_junit(xml_data)
146+
get_reporter(runtests).test_result(result, runtests)
145147

146148
def get_coverage_results(self) -> trace.CoverageResults:
147149
counts = {loc: 1 for loc in self.covered_lines}

‎Lib/test/libregrtest/run_workers.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -624,7 +624,6 @@ def _process_result(self, item: QueueOutput) -> TestResult:
624624
stdout = mp_result.worker_stdout
625625
if stdout:
626626
print(stdout, flush=True)
627-
result.print_github_annotation(self.runtests)
628627

629628
return result
630629

‎Lib/test/libregrtest/runtests.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ class RunTests:
9393
hunt_refleak: HuntRefleak | None
9494
test_dir: StrPath | None
9595
use_junit: bool
96-
github_annotations: bool
96+
# GitHub Actions job summary file, to report the failures of this run
97+
github_summary: str | None
9798
coverage: bool
9899
memory_limit: str | None
99100
gc_threshold: int | None

‎Lib/test/libregrtest/setup.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from _colorize import can_colorize # type: ignore[import-not-found]
1212

1313
from .filter import set_match_tests
14+
from .reporter import get_reporter
1415
from .runtests import RunTests
1516
lazy from .testresult import RegressionTestResult
1617
from .utils import (
@@ -113,7 +114,7 @@ def setup_tests(runtests: RunTests) -> None:
113114

114115
set_match_tests(runtests.match_tests)
115116

116-
RegressionTestResult.GITHUB_ANNOTATIONS = runtests.github_annotations
117+
RegressionTestResult.reporter = get_reporter(runtests)
117118
if runtests.use_junit:
118119
support.junit_xml_list = []
119120
RegressionTestResult.USE_XML = True

0 commit comments

Comments
 (0)