Skip to content

Commit 75c44b0

Browse files
committed
Have regrtest workers print annotations in better place
1 parent 59b1608 commit 75c44b0

7 files changed

Lines changed: 82 additions & 35 deletions

File tree

‎Lib/test/libregrtest/main.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,9 @@ def _rerun_failed_tests(self, runtests: RunTests) -> RunTests:
284284
forever=False,
285285
fail_fast=False,
286286
match_tests_dict=match_tests_dict,
287-
output_on_failure=False)
287+
output_on_failure=False,
288+
# Annotate tests which fail again
289+
github_annotations=bool(os.environ.get("GITHUB_STEP_SUMMARY")))
288290
self.logger.set_tests(runtests)
289291

290292
msg = f"Re-running {len(tests)} failed tests in verbose mode"
@@ -395,8 +397,8 @@ def run_test(
395397
result = run_single_test(test_name, runtests)
396398

397399
self.results.accumulate_result(result, runtests)
398-
if runtests.rerun and result.is_failed(runtests.fail_env_changed):
399-
result.print_github_annotations()
400+
if runtests.github_annotations:
401+
result.print_github_crash_annotation(runtests.fail_env_changed)
400402

401403
return result
402404

@@ -559,6 +561,7 @@ def create_run_tests(self, tests: TestTuple) -> RunTests:
559561
hunt_refleak=self.hunt_refleak,
560562
test_dir=self.test_dir,
561563
use_junit=(self.junit_filename is not None),
564+
github_annotations=False,
562565
coverage=self.coverage,
563566
memory_limit=self.memory_limit,
564567
gc_threshold=self.gc_threshold,

‎Lib/test/libregrtest/result.py‎

Lines changed: 25 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import dataclasses
22
import json
33
import os
4-
from _colorize import decolor, get_colors # type: ignore[import-not-found]
4+
from _colorize import get_colors # type: ignore[import-not-found]
55
from typing import Any
66

77
from .utils import (
88
StrJSON, TestName, FilterTuple,
9-
format_duration, normalize_test_name, print_warning, traceback_location)
9+
format_duration, normalize_test_name, print_warning, github_annotation)
1010

1111

1212
@dataclasses.dataclass(slots=True)
@@ -179,37 +179,34 @@ def __str__(self) -> str:
179179
def has_meaningful_duration(self):
180180
return State.has_meaningful_duration(self.state)
181181

182-
def print_github_annotations(self) -> None:
183-
"""Print a GitHub Actions error annotation per failed test case."""
184-
if not os.environ.get("GITHUB_STEP_SUMMARY"):
185-
return
186-
187-
def escape(text: str) -> str:
188-
return (text.replace("%", "%25").replace("\r", "%0D")
189-
.replace("\n", "%0A"))
190-
191-
def escape_property(text: str) -> str:
192-
return escape(text).replace(":", "%3A").replace(",", "%2C")
182+
def get_github_annotations(self) -> list[tuple[str, str]]:
183+
"""(test case name, message) of each failed test case.
193184
185+
Without test case details (crash, timeout, env changed, etc.), return
186+
the test name and the test result.
187+
"""
188+
if not os.environ.get("GITHUB_STEP_SUMMARY"):
189+
return []
194190
annotations = (self.errors or []) + (self.failures or [])
195191
if not annotations:
196-
# No test case details: crash, timeout, env changed, etc.
197192
message = "\n".join([str(self), *(self.env_changed_reasons or ())])
198193
annotations = [(self.test_name, message)]
199-
for title, message in annotations:
200-
message = decolor(message)
201-
props: dict[str, str | int] = {}
202-
if location := traceback_location(message):
203-
props |= location
204-
# The job log only shows the message: start it with the location
205-
position = ":".join(str(location[key])
206-
for key in ("file", "line", "col")
207-
if key in location)
208-
message = f"{position}\n{message}"
209-
props["title"] = title
210-
props_text = ",".join(f"{key}={escape_property(str(value))}"
211-
for key, value in props.items())
212-
print(f"::error {props_text}::{escape(message)}", flush=True)
194+
return annotations
195+
196+
def print_github_annotations(self) -> None:
197+
"""Print a GitHub Actions error annotation per failed test case."""
198+
for title, message in self.get_github_annotations():
199+
print(github_annotation(title, message), flush=True)
200+
201+
def print_github_crash_annotation(self, fail_env_changed: bool) -> None:
202+
"""Annotate a failed test without test case failures.
203+
204+
Test case failures are annotated by the test runner, where they are
205+
reported (see RegressionTestResult.printErrorList()).
206+
"""
207+
if (self.is_failed(fail_env_changed)
208+
and not self.errors and not self.failures):
209+
self.print_github_annotations()
213210

214211
def set_env_changed(self, *reasons):
215212
if self.state is None or self.state == State.PASSED:

‎Lib/test/libregrtest/run_workers.py‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -613,9 +613,8 @@ def _process_result(self, item: QueueOutput) -> TestResult:
613613
result = mp_result.result
614614
self.results.accumulate_result(result, self.runtests)
615615
self.display_result(mp_result)
616-
if self.runtests.rerun and result.is_failed(self.runtests.fail_env_changed):
617-
# Annotate before the test output
618-
result.print_github_annotations()
616+
if self.runtests.github_annotations:
617+
result.print_github_crash_annotation(self.runtests.fail_env_changed)
619618

620619
# Display worker stdout
621620
if not self.runtests.output_on_failure:

‎Lib/test/libregrtest/runtests.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ class RunTests:
9393
hunt_refleak: HuntRefleak | None
9494
test_dir: StrPath | None
9595
use_junit: bool
96+
# Print a GitHub Actions error annotation before each failure report
97+
github_annotations: bool
9698
coverage: bool
9799
memory_limit: str | None
98100
gc_threshold: int | None

‎Lib/test/libregrtest/setup.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ def setup_tests(runtests: RunTests) -> None:
119119
else:
120120
support.junit_xml_list = None
121121

122+
from .testresult import RegressionTestResult
123+
RegressionTestResult.GITHUB_ANNOTATIONS = runtests.github_annotations
124+
122125
if runtests.memory_limit is not None:
123126
support.set_memlimit(runtests.memory_limit)
124127

‎Lib/test/libregrtest/testresult.py‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
import traceback
1010
import unittest
1111
from test import support
12-
from test.libregrtest.utils import sanitize_xml
12+
from test.libregrtest.utils import github_annotation, sanitize_xml
1313

1414
class RegressionTestResult(unittest.TextTestResult):
1515
USE_XML = False
16+
GITHUB_ANNOTATIONS = False
1617

1718
def __init__(self, stream, descriptions, verbosity):
1819
super().__init__(stream=stream, descriptions=descriptions,
@@ -130,6 +131,25 @@ def addUnexpectedSuccess(self, test):
130131
self._add_result(test, outcome='UNEXPECTED_SUCCESS')
131132
super().addUnexpectedSuccess(test)
132133

134+
def printErrorList(self, flavour, errors):
135+
if not self.GITHUB_ANNOTATIONS:
136+
super().printErrorList(flavour, errors)
137+
return
138+
stream = self.stream
139+
for test, err in errors:
140+
# Precede each failure report with a GitHub Actions annotation
141+
# which has the report as message, so that the annotation links
142+
# to the report in the job log.
143+
self.stream = unittest.runner._WritelnDecorator(io.StringIO())
144+
try:
145+
super().printErrorList(flavour, [(test, err)])
146+
report = self.stream.getvalue()
147+
finally:
148+
self.stream = stream
149+
stream.writeln(github_annotation(str(test), report))
150+
stream.write(report)
151+
stream.flush()
152+
133153
def get_xml_element(self):
134154
if not self.USE_XML:
135155
raise ValueError("USE_XML is false")

‎Lib/test/libregrtest/utils.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import tempfile
1515
import textwrap
1616
import types
17+
from _colorize import decolor # type: ignore[import-not-found]
1718
from collections.abc import Callable
1819
_winapi: types.ModuleType | None
1920
try:
@@ -174,6 +175,28 @@ def traceback_location(traceback: str) -> dict[str, str | int] | None:
174175
return location
175176

176177

178+
def github_annotation(title: str, message: str) -> str:
179+
"""Format a GitHub Actions error annotation.
180+
181+
Locate it at the last frame of the traceback in message, if any.
182+
"""
183+
def escape(text: str) -> str:
184+
return (text.replace("%", "%25").replace("\r", "%0D")
185+
.replace("\n", "%0A"))
186+
187+
def escape_property(text: str) -> str:
188+
return escape(text).replace(":", "%3A").replace(",", "%2C")
189+
190+
message = decolor(message)
191+
props: dict[str, str | int] = {}
192+
if location := traceback_location(message):
193+
props |= location
194+
props["title"] = title
195+
props_text = ",".join(f"{key}={escape_property(str(value))}"
196+
for key, value in props.items())
197+
return f"::error {props_text}::{escape(message.rstrip())}"
198+
199+
177200
def regrtest_unraisable_hook(unraisable) -> None:
178201
global orig_unraisablehook
179202
support.set_environment_altered(

0 commit comments

Comments
 (0)