Skip to content

Commit a24c119

Browse files
authored
compress / truncate the issue body if it is longer than github allows (#4)
* properly configure flake8 * same for isort * implement a basic "truncating" summary with fallback to condensed summary * also split the name into filepath and test name * implement the merging of variants with the same name
1 parent 89ca589 commit a24c119

File tree

3 files changed

+116
-12
lines changed

3 files changed

+116
-12
lines changed

parse_logs.py

+102-12
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
import functools
44
import json
55
import pathlib
6+
import re
67
import textwrap
78
from dataclasses import dataclass
89

10+
import more_itertools
911
from pytest import CollectReport, TestReport
1012

1113

@@ -33,6 +35,14 @@ def _from_json(cls, json):
3335
return cls(**json_)
3436

3537

38+
@dataclass
39+
class PreformattedReport:
40+
filepath: str
41+
name: str
42+
variant: str | None
43+
message: str
44+
45+
3646
def parse_record(record):
3747
report_types = {
3848
"TestReport": TestReport,
@@ -47,27 +57,46 @@ def parse_record(record):
4757
return cls._from_json(record)
4858

4959

60+
nodeid_re = re.compile(r"(?P<filepath>.+)::(?P<name>.+?)(?:\[(?P<variant>.+)\])?")
61+
62+
63+
def parse_nodeid(nodeid):
64+
match = nodeid_re.fullmatch(nodeid)
65+
if match is None:
66+
raise ValueError(f"unknown test id: {nodeid}")
67+
68+
return match.groupdict()
69+
70+
5071
@functools.singledispatch
51-
def format_summary(report):
52-
return f"{report.nodeid}: {report}"
72+
def preformat_report(report):
73+
parsed = parse_nodeid(report.nodeid)
74+
return PreformattedReport(message=str(report), **parsed)
5375

5476

55-
@format_summary.register
77+
@preformat_report.register
5678
def _(report: TestReport):
79+
parsed = parse_nodeid(report.nodeid)
5780
message = report.longrepr.chain[0][1].message
58-
return f"{report.nodeid}: {message}"
81+
return PreformattedReport(message=message, **parsed)
5982

6083

61-
@format_summary.register
84+
@preformat_report.register
6285
def _(report: CollectReport):
86+
parsed = parse_nodeid(report.nodeid)
6387
message = report.longrepr.split("\n")[-1].removeprefix("E").lstrip()
64-
return f"{report.nodeid}: {message}"
88+
return PreformattedReport(message=message, **parsed)
6589

6690

67-
def format_report(reports, py_version):
68-
newline = "\n"
69-
summaries = newline.join(format_summary(r) for r in reports)
70-
message = textwrap.dedent(
91+
def format_summary(report):
92+
if report.variant is not None:
93+
return f"{report.filepath}::{report.name}[{report.variant}]: {report.message}"
94+
else:
95+
return f"{report.filepath}::{report.name}: {report.message}"
96+
97+
98+
def format_report(summaries, py_version):
99+
template = textwrap.dedent(
71100
"""\
72101
<details><summary>Python {py_version} Test Summary</summary>
73102
@@ -77,10 +106,70 @@ def format_report(reports, py_version):
77106
78107
</details>
79108
"""
80-
).format(summaries=summaries, py_version=py_version)
109+
)
110+
# can't use f-strings because that would format *before* the dedenting
111+
message = template.format(summaries="\n".join(summaries), py_version=py_version)
81112
return message
82113

83114

115+
def merge_variants(reports, max_chars, **formatter_kwargs):
116+
def format_variant_group(name, group):
117+
filepath, test_name, message = name
118+
119+
n_variants = len(group)
120+
if n_variants != 0:
121+
return f"{filepath}::{test_name}[{n_variants} failing variants]: {message}"
122+
else:
123+
return f"{filepath}::{test_name}: {message}"
124+
125+
bucket = more_itertools.bucket(reports, lambda r: (r.filepath, r.name, r.message))
126+
127+
summaries = [format_variant_group(name, list(bucket[name])) for name in bucket]
128+
formatted = format_report(summaries, **formatter_kwargs)
129+
130+
return formatted
131+
132+
133+
def truncate(reports, max_chars, **formatter_kwargs):
134+
fractions = [0.95, 0.75, 0.5, 0.25, 0.1, 0.01]
135+
136+
n_reports = len(reports)
137+
for fraction in fractions:
138+
n_selected = int(n_reports * fraction)
139+
selected_reports = reports[: int(n_reports * fraction)]
140+
report_messages = [format_summary(report) for report in selected_reports]
141+
summary = report_messages + [f"+ {n_reports - n_selected} failing tests"]
142+
formatted = format_report(summary, **formatter_kwargs)
143+
if len(formatted) <= max_chars:
144+
return formatted
145+
146+
return None
147+
148+
149+
def summarize(reports):
150+
return f"{len(reports)} failing tests"
151+
152+
153+
def compressed_report(reports, max_chars, **formatter_kwargs):
154+
strategies = [
155+
merge_variants,
156+
# merge_test_files,
157+
# merge_tests,
158+
truncate,
159+
]
160+
summaries = [format_summary(report) for report in reports]
161+
formatted = format_report(summaries, **formatter_kwargs)
162+
if len(formatted) <= max_chars:
163+
return formatted
164+
165+
for strategy in strategies:
166+
formatted = strategy(reports, max_chars=max_chars, **formatter_kwargs)
167+
if formatted is not None and len(formatted) <= max_chars:
168+
return formatted
169+
170+
return summarize(reports)
171+
172+
84173
if __name__ == "__main__":
85174
parser = argparse.ArgumentParser()
86175
parser.add_argument("filepath", type=pathlib.Path)
@@ -94,8 +183,9 @@ def format_report(reports, py_version):
94183
reports = [parse_record(json.loads(line)) for line in lines]
95184

96185
failed = [report for report in reports if report.outcome == "failed"]
186+
preformatted = [preformat_report(report) for report in failed]
97187

98-
message = format_report(failed, py_version=py_version)
188+
message = compressed_report(preformatted, max_chars=65535, py_version=py_version)
99189

100190
output_file = pathlib.Path("pytest-logs.txt")
101191
print(f"Writing output file to: {output_file.absolute()}")

pyproject.toml

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[tool.isort]
2+
profile = "black"
3+
skip_gitignore = true
4+
float_to_top = true
5+
default_section = "THIRDPARTY"

setup.cfg

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[flake8]
2+
ignore =
3+
E203 # whitespace before ':' - doesn't work well with black
4+
E402 # module level import not at top of file
5+
E501 # line too long - let black worry about that
6+
E731 # do not assign a lambda expression, use a def
7+
W503 # line break before binary operator
8+
exclude=
9+
.eggs

0 commit comments

Comments
 (0)