Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
3eeb4db
gh-158895: Teach librertest to output github job summaries
cmaloney Oct 6, 2026
8fcfa5d
Only output errors, too many jobs. Try coloring a bit more
cmaloney Oct 6, 2026
e6e09bd
Fail CI on linux for experimenting with output formatting
cmaloney Oct 6, 2026
54d123f
Reformat failures to be failure first, easier visibility. Simplify fo…
cmaloney Oct 6, 2026
3468742
add error annotations
cmaloney Oct 6, 2026
ff7b75a
Add groups more of the time
cmaloney Oct 6, 2026
946c801
drop groups, error only on final failure
cmaloney Oct 6, 2026
14394da
More structured error blocks
cmaloney Oct 6, 2026
6359876
Fail more tests
cmaloney Oct 6, 2026
041e577
Adjust and simplify a little
cmaloney Oct 6, 2026
ba449b4
Annotate inline with test output
cmaloney Oct 6, 2026
59b1608
Move annotations before test output
cmaloney Oct 6, 2026
75c44b0
Have regrtest workers print annotations in better place
cmaloney Oct 6, 2026
1222c34
Full annotations from worker
cmaloney Oct 6, 2026
287df00
Remove redundant printing
cmaloney Oct 6, 2026
53024ed
Remove special summary case, more full output everywhere
cmaloney Oct 6, 2026
c2d1d79
Better message in the annotations
cmaloney Oct 6, 2026
c3cca61
Tweak message
cmaloney Oct 6, 2026
b125f6f
Move to simpler regex traceback info extraction
cmaloney Oct 6, 2026
3f320dd
lots of review changes
cmaloney Oct 7, 2026
9b1ce48
Add comment
cmaloney Oct 7, 2026
b561060
Annotations after failure, better test_regrtest coverage
cmaloney Oct 7, 2026
e506d7d
annotate env changed better. pgo runs
cmaloney Oct 7, 2026
2ca705b
simplify, rely on env var everywhere. Stop using findspec
cmaloney Oct 7, 2026
fe08263
Simplify filename extraction, use pathlib
cmaloney Oct 7, 2026
b5247ed
Simplify test
cmaloney Oct 8, 2026
9a94bf1
Simplify GitHub Actions annotations
cmaloney Oct 12, 2026
70cae99
Set GITHUB_ANNOTATIONS in setup_tests(), like USE_XML
cmaloney Oct 12, 2026
8f231a6
Simplify findall
cmaloney Oct 12, 2026
f2ba9db
Review fixes. Directly check error annotation list
cmaloney Oct 13, 2026
42d7918
Personal review adjustments, move more into TestRestul
cmaloney Oct 13, 2026
e4d41bf
Self review fixes
cmaloney Oct 13, 2026
b0e9c5e
Personal review fixes, adjust output locaiton
cmaloney Oct 13, 2026
01ad16c
Fix mypy
cmaloney Oct 13, 2026
475549a
Refactor to use a reporter of which github is one
cmaloney Oct 13, 2026
dc83a3c
Personal review fixes
cmaloney Oct 13, 2026
589e6a7
Simplify, generlize reporter
cmaloney Oct 14, 2026
24e7820
Simplify tests, reduce comments
cmaloney Oct 15, 2026
6d2f432
Personal review fixes
cmaloney Oct 15, 2026
f8189f7
Fix intentionally broken tests
cmaloney Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions Lib/test/libregrtest/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from .findtests import findtests, split_test_packages, list_cases, collect_cases
from .logger import Logger
from .pgo import setup_pgo_tests
from .reporter import get_reporter
from .result import TestResult
from .results import TestResults, EXITCODE_INTERRUPTED
from .runtests import RunTests, HuntRefleak
Expand Down Expand Up @@ -68,6 +69,7 @@ def __init__(self, ns: Namespace, _add_python_opts: bool = False):

# Logger
self.logger = Logger(self.results, self.quiet, self.pgo)
self.reporter = get_reporter()

# Actions
self.want_header: bool = ns.header
Expand Down Expand Up @@ -284,8 +286,10 @@ def _rerun_failed_tests(self, runtests: RunTests) -> RunTests:
forever=False,
fail_fast=False,
match_tests_dict=match_tests_dict,
output_on_failure=False)
output_on_failure=False,
final_run=True)
self.logger.set_tests(runtests)
self.reporter.run_started(runtests)

msg = f"Re-running {len(tests)} failed tests in verbose mode"
if not self.single_process:
Expand Down Expand Up @@ -395,6 +399,7 @@ def run_test(
result = run_single_test(test_name, runtests)

self.results.accumulate_result(result, runtests)
self.reporter.test_result(result)

return result

Expand Down Expand Up @@ -459,7 +464,8 @@ def get_state(self) -> str:

def _run_tests_mp(self, runtests: RunTests, num_workers: int) -> None:
from .run_workers import RunWorkers
RunWorkers(num_workers, runtests, self.logger, self.results).run()
RunWorkers(num_workers, runtests, self.logger, self.results,
self.reporter).run()

def finalize_tests(self, coverage: trace.CoverageResults | None) -> None:
if self.next_single_filename:
Expand All @@ -480,6 +486,8 @@ def finalize_tests(self, coverage: trace.CoverageResults | None) -> None:
if self.junit_filename:
self.results.write_junit(self.junit_filename)

self.reporter.summary(self.results, self.get_state())

def display_summary(self) -> None:
if self.first_runtests is None:
raise ValueError(
Expand Down Expand Up @@ -517,6 +525,7 @@ def create_run_tests(self, tests: TestTuple) -> RunTests:
hunt_refleak=self.hunt_refleak,
test_dir=self.test_dir,
use_junit=(self.junit_filename is not None),
final_run=not self.want_rerun or bool(self.python_cmd),
coverage=self.coverage,
memory_limit=self.memory_limit,
gc_threshold=self.gc_threshold,
Expand Down Expand Up @@ -568,6 +577,7 @@ def _run_tests(self, selected: TestTuple, tests: TestList | None) -> int:
runtests = runtests.copy(tests=case_ids, case_groups=case_groups)
self.first_runtests = runtests
self.logger.set_tests(runtests)
self.reporter.run_started(runtests)

if (runtests.hunt_refleak is not None) and (not self.num_workers):
# gh-109739: WindowsLoadTracker thread interferes with refleak check
Expand Down
137 changes: 137 additions & 0 deletions Lib/test/libregrtest/reporter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import html
import os
import pathlib
import re
from _colorize import decolor # type: ignore[import-not-found]
from typing import TextIO

from .findtests import findtestdir
from .result import TestResult
from .results import TestResults
from .runtests import RunTests
from .utils import count


class Reporter:
"""Report test failures, for example to a CI service. A no-op here."""

def run_started(self, runtests: RunTests) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def run_started(self, runtests: RunTests) -> None:
def test_started(self, runtests: RunTests) -> None:

Maybe, for parity with the other methods?

@cmaloney cmaloney Oct 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "run" concept is different than a "test" or "test suite" hence the different name. Parallels RunTests vs. a TestResult

"""Called at the start of each run, and in the test process."""

def test_case_failed(self, test_case: str, traceback: str,
stream: TextIO) -> None:
"""Called in the test process after a test case failed."""

def test_result(self, result: TestResult) -> None:
"""Called in the main process after a test file completed."""

def summary(self, results: TestResults, state: str) -> None:
"""Called in the main process after all runs."""

@staticmethod
def parse_traceback(traceback: str) -> tuple[str | None, str | None, str]:
"""Return (filename, line, exception) of a failure."""
# A frame is its "File" line and its source lines
frames = list(re.finditer(
r'^ *File "(.*)", line (\d+).*\n(?: .*\n)*', traceback,
re.MULTILINE))
filename = line = None
exception = traceback
if frames:
filename, line = frames[0].groups()
exception = traceback[frames[-1].end():]
# Prefer the last frame in a test file, the assertion
for frame in frames:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I'm wrong but will this get executed only when the above comparison is True?

@cmaloney cmaloney Oct 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct.In general this tries to find a filename from the traceback but if we fail that's okay (it shouldn't block the test run from finishing). The if frames translates to "if the length of the list of frames is > 1".

The loop then finds the last frame with a name that starts with "test_" and uses that for the annotation. Definitely not a perfect metric but seems to work reasonably for getting back to the test filename.

if pathlib.Path(frame[1]).name.startswith("test_"):
filename, line = frame.groups()
return filename, line, exception.strip()


def get_reporter() -> Reporter:
if GitHubActionsReporter.enabled():
return GitHubActionsReporter()
return Reporter()


class GitHubActionsReporter(Reporter):
"""Annotate failures in the job log and list them in the job summary."""

ESCAPE_DATA = str.maketrans({"%": "%25", "\r": "%0D", "\n": "%0A"})
ESCAPE_PROPERTY = ESCAPE_DATA | str.maketrans({":": "%3A", ",": "%2C"})

@staticmethod
def enabled() -> bool:
return "GITHUB_STEP_SUMMARY" in os.environ

def run_started(self, runtests: RunTests) -> None:
self.runtests = runtests

def test_case_failed(self, test_case: str, traceback: str,
stream: TextIO) -> None:
if not self.runtests.final_run:
return
filename, line, exception = self.parse_traceback(decolor(traceback))
self.annotate(test_case, exception, filename, line, stream)

def test_result(self, result: TestResult) -> None:
if not self.runtests.final_run:
return
if not result.is_failed(self.runtests.fail_env_changed):
return
# Already annotated by test_case_failed()
if result.failed_test_cases:
return
# "test.test_x.test_y" is test_x/test_y.py or test_x/test_y/__init__.py
name = result.test_name.removeprefix("test.")
path = pathlib.Path(findtestdir(self.runtests.test_dir),
*name.split("."))
filename = None
for candidate in (path.with_suffix(".py"), path / "__init__.py"):
if candidate.exists():
filename = str(candidate)
message = "\n".join([str(result), *(result.env_changed_reasons or ())])
self.annotate(result.test_name, decolor(message), filename)

def annotate(self, title: str, message: str, filename: str | None,
line: str | None = None,
stream: TextIO | None = None) -> None:
if filename:
# Make installed stdlib paths relative to the checkout
path = pathlib.Path(filename)
stdlib_dir = pathlib.Path(os.__file__).parent
if path.is_relative_to(stdlib_dir):
filename = ("Lib" / path.relative_to(stdlib_dir)).as_posix()
# GitHub needs a line to show the annotation on the file
line = line or "1"
props = {"file": filename, "line": line, "title": title}
props_text = ",".join(f"{key}={value.translate(self.ESCAPE_PROPERTY)}"
for key, value in props.items() if value)
print(f"::error {props_text}::{message.translate(self.ESCAPE_DATA)}",
file=stream, flush=True)

def summary(self, results: TestResults, state: str) -> None:
failed = [result for result in results.rerun_results
if result.is_failed(self.runtests.fail_env_changed)]
if not failed:
return
ncases = sum(len(result.failed_test_cases) for result in failed)
blocks = [f"## {decolor(state)}: "

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
blocks = [f"## {decolor(state)}: "
blocks = [f"## 🛑 {decolor(state)}: "

Maybe it makes it a little easier to parse vs just markdown text?
Maybe also lowercase the state? I think it is all caps currently

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we output success as well as failure definitely, this only outputs failures to github summaries. I tried outputting both success and failures but we run 30+ test jobs and that just made a lot of summaries that needed to be browsed past and weren't particularly useful.

f"{count(len(failed), 'test file')} and "
f"{count(ncases, 'test case')} failed"]
for result in failed:
blocks.append(f"### {decolor(str(result))}")
if result.env_changed_reasons:
blocks.append("\n".join(
f"- {reason}" for reason in result.env_changed_reasons))
for name, traceback in result.failed_test_cases:
# Expand short tracebacks if there are only a few
if ncases <= 5 and traceback.count("\n") < 30:
details = "<details open>"
else:
details = "<details>"
blocks += [f"{details}<summary>{html.escape(name)}</summary>",
f"```pytb\n{decolor(traceback).rstrip()}\n```",
"</details>"]
with open(os.environ["GITHUB_STEP_SUMMARY"], "a",
encoding="utf-8") as fp:
fp.write("".join(f"{block}\n\n" for block in blocks))
4 changes: 4 additions & 0 deletions Lib/test/libregrtest/result.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,10 @@ def __str__(self) -> str:
def has_meaningful_duration(self):
return State.has_meaningful_duration(self.state)

@property
def failed_test_cases(self) -> list[tuple[str, str]]:
return (self.errors or []) + (self.failures or [])

def set_env_changed(self, *reasons):
if self.state is None or self.state == State.PASSED:
self.state = State.ENV_CHANGED
Expand Down
7 changes: 6 additions & 1 deletion Lib/test/libregrtest/run_workers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from test.support import os_helper, MS_WINDOWS

from .logger import Logger
from .reporter import Reporter
from .result import TestResult, State
from .results import TestResults
from .runtests import RunTests, WorkerRunTests, JsonFile, JsonFileType
Expand Down Expand Up @@ -499,13 +500,15 @@ def get_running(workers: list[WorkerThread]) -> str | None:

class RunWorkers:
def __init__(self, num_workers: int, runtests: RunTests,
logger: Logger, results: TestResults) -> None:
logger: Logger, results: TestResults,
reporter: Reporter) -> None:
self.num_workers = num_workers
self.runtests = runtests
self.logger = logger
self.log = logger.log
self.display_progress = logger.display_progress
self.results: TestResults = results
self.reporter = reporter
self.live_worker_count = 0

self.output: queue.Queue[QueueContent] = queue.Queue()
Expand Down Expand Up @@ -624,6 +627,8 @@ def _process_result(self, item: QueueOutput) -> TestResult:
stdout = mp_result.worker_stdout
if stdout:
print(stdout, flush=True)
# Report after the output: env changed warnings, crash traceback
self.reporter.test_result(result)

return result

Expand Down
2 changes: 2 additions & 0 deletions Lib/test/libregrtest/runtests.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ class RunTests:
hunt_refleak: HuntRefleak | None
test_dir: StrPath | None
use_junit: bool
# Failed tests are not re-run after this run
final_run: bool
coverage: bool
memory_limit: str | None
gc_threshold: int | None
Expand Down
6 changes: 5 additions & 1 deletion Lib/test/libregrtest/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
from _colorize import can_colorize # type: ignore[import-not-found]

from .filter import set_match_tests
from .reporter import get_reporter
from .runtests import RunTests
from .testresult import RegressionTestResult
from .utils import (
setup_unraisable_hook, setup_threading_excepthook,
adjust_rlimit_nofile)
Expand Down Expand Up @@ -112,9 +114,11 @@ def setup_tests(runtests: RunTests) -> None:

set_match_tests(runtests.match_tests)

reporter = get_reporter()
reporter.run_started(runtests)
RegressionTestResult.reporter = reporter
if runtests.use_junit:
support.junit_xml_list = []
from .testresult import RegressionTestResult
RegressionTestResult.USE_XML = True
else:
support.junit_xml_list = None
Expand Down
13 changes: 13 additions & 0 deletions Lib/test/libregrtest/testresult.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@
import traceback
import unittest
from test import support
from test.libregrtest.reporter import Reporter
from test.libregrtest.utils import sanitize_xml

class RegressionTestResult(unittest.TextTestResult):
USE_XML = False
reporter = Reporter()

def __init__(self, stream, descriptions, verbosity):
super().__init__(stream=stream, descriptions=descriptions,
Expand Down Expand Up @@ -130,6 +132,14 @@ def addUnexpectedSuccess(self, test):
self._add_result(test, outcome='UNEXPECTED_SUCCESS')
super().addUnexpectedSuccess(test)

def printErrorList(self, flavour, errors):
for test, err in errors:
super().printErrorList(flavour, [(test, err)])
self.report_failure(test, err)

def report_failure(self, test, err):
self.reporter.test_case_failed(str(test), err, self.stream)

def get_xml_element(self):
if not self.USE_XML:
raise ValueError("USE_XML is false")
Expand All @@ -146,6 +156,9 @@ def __init__(self, stream, buffer=False):

def run(self, test):
test(self.result)
# Failures are not printed in quiet mode
for test, err in self.result.errors + self.result.failures:
self.result.report_failure(test, err)
return self.result

def get_test_runner_class(verbosity, buffer=False):
Expand Down
Loading
Loading