33import functools
44import json
55import pathlib
6+ import re
67import textwrap
78from dataclasses import dataclass
89
10+ import more_itertools
911from 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+
3646def 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
5678def _ (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
6285def _ (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+
84173if __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 ()} " )
0 commit comments