Skip to content

Commit 8c57df0

Browse files
authored
Allow mypy to output a junit file with per-file results (#16388)
Adds a new `--junit-format` flag to MyPy, which affects the structure of the junit file written when `--junit-xml` is specified (it has no effect when not writing a junit file). This flag can take `global` or `per_file` as values: * `--junit-format=global` (the default) preserves the existing junit structure, creating a junit file specifying a single "test" for the entire mypy run. * `--junit-format=per_file` will cause the junit file to have one test entry per file with failures (or a single entry, as in the existing behavior, in the case when typechecking passes). In some settings it can be useful to know which files had typechecking failures (for example, a CI system might want to display failures by file); while that information can be parsed out of the error messages in the existing junit files, it's much more convenient to have that represented in the junit structure. Tests for the old and new junit structure have been added.
1 parent 93e6de4 commit 8c57df0

File tree

9 files changed

+197
-43
lines changed

9 files changed

+197
-43
lines changed

mypy/build.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ def build(
145145
sources: list[BuildSource],
146146
options: Options,
147147
alt_lib_path: str | None = None,
148-
flush_errors: Callable[[list[str], bool], None] | None = None,
148+
flush_errors: Callable[[str | None, list[str], bool], None] | None = None,
149149
fscache: FileSystemCache | None = None,
150150
stdout: TextIO | None = None,
151151
stderr: TextIO | None = None,
@@ -177,7 +177,9 @@ def build(
177177
# fields for callers that want the traditional API.
178178
messages = []
179179

180-
def default_flush_errors(new_messages: list[str], is_serious: bool) -> None:
180+
def default_flush_errors(
181+
filename: str | None, new_messages: list[str], is_serious: bool
182+
) -> None:
181183
messages.extend(new_messages)
182184

183185
flush_errors = flush_errors or default_flush_errors
@@ -197,7 +199,7 @@ def default_flush_errors(new_messages: list[str], is_serious: bool) -> None:
197199
# Patch it up to contain either none or all none of the messages,
198200
# depending on whether we are flushing errors.
199201
serious = not e.use_stdout
200-
flush_errors(e.messages, serious)
202+
flush_errors(None, e.messages, serious)
201203
e.messages = messages
202204
raise
203205

@@ -206,7 +208,7 @@ def _build(
206208
sources: list[BuildSource],
207209
options: Options,
208210
alt_lib_path: str | None,
209-
flush_errors: Callable[[list[str], bool], None],
211+
flush_errors: Callable[[str | None, list[str], bool], None],
210212
fscache: FileSystemCache | None,
211213
stdout: TextIO,
212214
stderr: TextIO,
@@ -600,7 +602,7 @@ def __init__(
600602
plugin: Plugin,
601603
plugins_snapshot: dict[str, str],
602604
errors: Errors,
603-
flush_errors: Callable[[list[str], bool], None],
605+
flush_errors: Callable[[str | None, list[str], bool], None],
604606
fscache: FileSystemCache,
605607
stdout: TextIO,
606608
stderr: TextIO,
@@ -3458,7 +3460,11 @@ def process_stale_scc(graph: Graph, scc: list[str], manager: BuildManager) -> No
34583460
for id in stale:
34593461
graph[id].transitive_error = True
34603462
for id in stale:
3461-
manager.flush_errors(manager.errors.file_messages(graph[id].xpath), False)
3463+
manager.flush_errors(
3464+
manager.errors.simplify_path(graph[id].xpath),
3465+
manager.errors.file_messages(graph[id].xpath),
3466+
False,
3467+
)
34623468
graph[id].write_cache()
34633469
graph[id].mark_as_rechecked()
34643470

mypy/config_parser.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,17 @@ def check_follow_imports(choice: str) -> str:
152152
return choice
153153

154154

155+
def check_junit_format(choice: str) -> str:
156+
choices = ["global", "per_file"]
157+
if choice not in choices:
158+
raise argparse.ArgumentTypeError(
159+
"invalid choice '{}' (choose from {})".format(
160+
choice, ", ".join(f"'{x}'" for x in choices)
161+
)
162+
)
163+
return choice
164+
165+
155166
def split_commas(value: str) -> list[str]:
156167
# Uses a bit smarter technique to allow last trailing comma
157168
# and to remove last `""` item from the split.
@@ -173,6 +184,7 @@ def split_commas(value: str) -> list[str]:
173184
"files": split_and_match_files,
174185
"quickstart_file": expand_path,
175186
"junit_xml": expand_path,
187+
"junit_format": check_junit_format,
176188
"follow_imports": check_follow_imports,
177189
"no_site_packages": bool,
178190
"plugins": lambda s: [p.strip() for p in split_commas(s)],
@@ -200,6 +212,7 @@ def split_commas(value: str) -> list[str]:
200212
"python_version": parse_version,
201213
"mypy_path": lambda s: [expand_path(p) for p in try_split(s, "[,:]")],
202214
"files": lambda s: split_and_match_files_list(try_split(s)),
215+
"junit_format": lambda s: check_junit_format(str(s)),
203216
"follow_imports": lambda s: check_follow_imports(str(s)),
204217
"plugins": try_split,
205218
"always_true": try_split,

mypy/main.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import subprocess
88
import sys
99
import time
10+
from collections import defaultdict
1011
from gettext import gettext
1112
from typing import IO, Any, Final, NoReturn, Sequence, TextIO
1213

@@ -158,11 +159,14 @@ def run_build(
158159
formatter = util.FancyFormatter(stdout, stderr, options.hide_error_codes)
159160

160161
messages = []
162+
messages_by_file = defaultdict(list)
161163

162-
def flush_errors(new_messages: list[str], serious: bool) -> None:
164+
def flush_errors(filename: str | None, new_messages: list[str], serious: bool) -> None:
163165
if options.pretty:
164166
new_messages = formatter.fit_in_terminal(new_messages)
165167
messages.extend(new_messages)
168+
if new_messages:
169+
messages_by_file[filename].extend(new_messages)
166170
if options.non_interactive:
167171
# Collect messages and possibly show them later.
168172
return
@@ -200,7 +204,7 @@ def flush_errors(new_messages: list[str], serious: bool) -> None:
200204
),
201205
file=stderr,
202206
)
203-
maybe_write_junit_xml(time.time() - t0, serious, messages, options)
207+
maybe_write_junit_xml(time.time() - t0, serious, messages, messages_by_file, options)
204208
return res, messages, blockers
205209

206210

@@ -1054,6 +1058,12 @@ def add_invertible_flag(
10541058
other_group = parser.add_argument_group(title="Miscellaneous")
10551059
other_group.add_argument("--quickstart-file", help=argparse.SUPPRESS)
10561060
other_group.add_argument("--junit-xml", help="Write junit.xml to the given file")
1061+
imports_group.add_argument(
1062+
"--junit-format",
1063+
choices=["global", "per_file"],
1064+
default="global",
1065+
help="If --junit-xml is set, specifies format. global: single test with all errors; per_file: one test entry per file with failures",
1066+
)
10571067
other_group.add_argument(
10581068
"--find-occurrences",
10591069
metavar="CLASS.MEMBER",
@@ -1483,18 +1493,32 @@ def process_cache_map(
14831493
options.cache_map[source] = (meta_file, data_file)
14841494

14851495

1486-
def maybe_write_junit_xml(td: float, serious: bool, messages: list[str], options: Options) -> None:
1496+
def maybe_write_junit_xml(
1497+
td: float,
1498+
serious: bool,
1499+
all_messages: list[str],
1500+
messages_by_file: dict[str | None, list[str]],
1501+
options: Options,
1502+
) -> None:
14871503
if options.junit_xml:
14881504
py_version = f"{options.python_version[0]}_{options.python_version[1]}"
1489-
util.write_junit_xml(
1490-
td, serious, messages, options.junit_xml, py_version, options.platform
1491-
)
1505+
if options.junit_format == "global":
1506+
util.write_junit_xml(
1507+
td, serious, {None: all_messages}, options.junit_xml, py_version, options.platform
1508+
)
1509+
else:
1510+
# per_file
1511+
util.write_junit_xml(
1512+
td, serious, messages_by_file, options.junit_xml, py_version, options.platform
1513+
)
14921514

14931515

14941516
def fail(msg: str, stderr: TextIO, options: Options) -> NoReturn:
14951517
"""Fail with a serious error."""
14961518
stderr.write(f"{msg}\n")
1497-
maybe_write_junit_xml(0.0, serious=True, messages=[msg], options=options)
1519+
maybe_write_junit_xml(
1520+
0.0, serious=True, all_messages=[msg], messages_by_file={None: [msg]}, options=options
1521+
)
14981522
sys.exit(2)
14991523

15001524

mypy/options.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,8 @@ def __init__(self) -> None:
255255
# Write junit.xml to given file
256256
self.junit_xml: str | None = None
257257

258+
self.junit_format: str = "global" # global|per_file
259+
258260
# Caching and incremental checking options
259261
self.incremental = True
260262
self.cache_dir = defaults.CACHE_DIR

mypy/test/testerrorstream.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def test_error_stream(testcase: DataDrivenTestCase) -> None:
2929

3030
logged_messages: list[str] = []
3131

32-
def flush_errors(msgs: list[str], serious: bool) -> None:
32+
def flush_errors(filename: str | None, msgs: list[str], serious: bool) -> None:
3333
if msgs:
3434
logged_messages.append("==== Errors flushed ====")
3535
logged_messages.extend(msgs)

mypy/test/testgraph.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def _make_manager(self) -> BuildManager:
5050
plugin=Plugin(options),
5151
plugins_snapshot={},
5252
errors=errors,
53-
flush_errors=lambda msgs, serious: None,
53+
flush_errors=lambda filename, msgs, serious: None,
5454
fscache=fscache,
5555
stdout=sys.stdout,
5656
stderr=sys.stderr,

mypy/test/testutil.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from unittest import TestCase, mock
55

66
from mypy.inspections import parse_location
7-
from mypy.util import get_terminal_width
7+
from mypy.util import _generate_junit_contents, get_terminal_width
88

99

1010
class TestGetTerminalSize(TestCase):
@@ -20,3 +20,70 @@ def test_get_terminal_size_in_pty_defaults_to_80(self) -> None:
2020
def test_parse_location_windows(self) -> None:
2121
assert parse_location(r"C:\test.py:1:1") == (r"C:\test.py", [1, 1])
2222
assert parse_location(r"C:\test.py:1:1:1:1") == (r"C:\test.py", [1, 1, 1, 1])
23+
24+
25+
class TestWriteJunitXml(TestCase):
26+
def test_junit_pass(self) -> None:
27+
serious = False
28+
messages_by_file: dict[str | None, list[str]] = {}
29+
expected = """<?xml version="1.0" encoding="utf-8"?>
30+
<testsuite errors="0" failures="0" name="mypy" skips="0" tests="1" time="1.230">
31+
<testcase classname="mypy" file="mypy" line="1" name="mypy-py3.14-test-plat" time="1.230">
32+
</testcase>
33+
</testsuite>
34+
"""
35+
result = _generate_junit_contents(
36+
dt=1.23,
37+
serious=serious,
38+
messages_by_file=messages_by_file,
39+
version="3.14",
40+
platform="test-plat",
41+
)
42+
assert result == expected
43+
44+
def test_junit_fail_two_files(self) -> None:
45+
serious = False
46+
messages_by_file: dict[str | None, list[str]] = {
47+
"file1.py": ["Test failed", "another line"],
48+
"file2.py": ["Another failure", "line 2"],
49+
}
50+
expected = """<?xml version="1.0" encoding="utf-8"?>
51+
<testsuite errors="0" failures="2" name="mypy" skips="0" tests="2" time="1.230">
52+
<testcase classname="mypy" file="file1.py" line="1" name="mypy-py3.14-test-plat file1.py" time="1.230">
53+
<failure message="mypy produced messages">Test failed
54+
another line</failure>
55+
</testcase>
56+
<testcase classname="mypy" file="file2.py" line="1" name="mypy-py3.14-test-plat file2.py" time="1.230">
57+
<failure message="mypy produced messages">Another failure
58+
line 2</failure>
59+
</testcase>
60+
</testsuite>
61+
"""
62+
result = _generate_junit_contents(
63+
dt=1.23,
64+
serious=serious,
65+
messages_by_file=messages_by_file,
66+
version="3.14",
67+
platform="test-plat",
68+
)
69+
assert result == expected
70+
71+
def test_serious_error(self) -> None:
72+
serious = True
73+
messages_by_file: dict[str | None, list[str]] = {None: ["Error line 1", "Error line 2"]}
74+
expected = """<?xml version="1.0" encoding="utf-8"?>
75+
<testsuite errors="1" failures="0" name="mypy" skips="0" tests="1" time="1.230">
76+
<testcase classname="mypy" file="mypy" line="1" name="mypy-py3.14-test-plat" time="1.230">
77+
<failure message="mypy produced messages">Error line 1
78+
Error line 2</failure>
79+
</testcase>
80+
</testsuite>
81+
"""
82+
result = _generate_junit_contents(
83+
dt=1.23,
84+
serious=serious,
85+
messages_by_file=messages_by_file,
86+
version="3.14",
87+
platform="test-plat",
88+
)
89+
assert result == expected

mypy/util.py

Lines changed: 66 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -234,45 +234,85 @@ def get_mypy_comments(source: str) -> list[tuple[int, str]]:
234234
return results
235235

236236

237-
PASS_TEMPLATE: Final = """<?xml version="1.0" encoding="utf-8"?>
238-
<testsuite errors="0" failures="0" name="mypy" skips="0" tests="1" time="{time:.3f}">
239-
<testcase classname="mypy" file="mypy" line="1" name="mypy-py{ver}-{platform}" time="{time:.3f}">
240-
</testcase>
241-
</testsuite>
237+
JUNIT_HEADER_TEMPLATE: Final = """<?xml version="1.0" encoding="utf-8"?>
238+
<testsuite errors="{errors}" failures="{failures}" name="mypy" skips="0" tests="{tests}" time="{time:.3f}">
242239
"""
243240

244-
FAIL_TEMPLATE: Final = """<?xml version="1.0" encoding="utf-8"?>
245-
<testsuite errors="0" failures="1" name="mypy" skips="0" tests="1" time="{time:.3f}">
246-
<testcase classname="mypy" file="mypy" line="1" name="mypy-py{ver}-{platform}" time="{time:.3f}">
241+
JUNIT_TESTCASE_FAIL_TEMPLATE: Final = """ <testcase classname="mypy" file="{filename}" line="1" name="{name}" time="{time:.3f}">
247242
<failure message="mypy produced messages">{text}</failure>
248243
</testcase>
249-
</testsuite>
250244
"""
251245

252-
ERROR_TEMPLATE: Final = """<?xml version="1.0" encoding="utf-8"?>
253-
<testsuite errors="1" failures="0" name="mypy" skips="0" tests="1" time="{time:.3f}">
254-
<testcase classname="mypy" file="mypy" line="1" name="mypy-py{ver}-{platform}" time="{time:.3f}">
246+
JUNIT_ERROR_TEMPLATE: Final = """ <testcase classname="mypy" file="mypy" line="1" name="mypy-py{ver}-{platform}" time="{time:.3f}">
255247
<error message="mypy produced errors">{text}</error>
256248
</testcase>
257-
</testsuite>
258249
"""
259250

251+
JUNIT_TESTCASE_PASS_TEMPLATE: Final = """ <testcase classname="mypy" file="mypy" line="1" name="mypy-py{ver}-{platform}" time="{time:.3f}">
252+
</testcase>
253+
"""
260254

261-
def write_junit_xml(
262-
dt: float, serious: bool, messages: list[str], path: str, version: str, platform: str
263-
) -> None:
264-
from xml.sax.saxutils import escape
255+
JUNIT_FOOTER: Final = """</testsuite>
256+
"""
265257

266-
if not messages and not serious:
267-
xml = PASS_TEMPLATE.format(time=dt, ver=version, platform=platform)
268-
elif not serious:
269-
xml = FAIL_TEMPLATE.format(
270-
text=escape("\n".join(messages)), time=dt, ver=version, platform=platform
271-
)
258+
259+
def _generate_junit_contents(
260+
dt: float,
261+
serious: bool,
262+
messages_by_file: dict[str | None, list[str]],
263+
version: str,
264+
platform: str,
265+
) -> str:
266+
if serious:
267+
failures = 0
268+
errors = len(messages_by_file)
272269
else:
273-
xml = ERROR_TEMPLATE.format(
274-
text=escape("\n".join(messages)), time=dt, ver=version, platform=platform
275-
)
270+
failures = len(messages_by_file)
271+
errors = 0
272+
273+
xml = JUNIT_HEADER_TEMPLATE.format(
274+
errors=errors,
275+
failures=failures,
276+
time=dt,
277+
# If there are no messages, we still write one "test" indicating success.
278+
tests=len(messages_by_file) or 1,
279+
)
280+
281+
if not messages_by_file:
282+
xml += JUNIT_TESTCASE_PASS_TEMPLATE.format(time=dt, ver=version, platform=platform)
283+
else:
284+
for filename, messages in messages_by_file.items():
285+
if filename is not None:
286+
xml += JUNIT_TESTCASE_FAIL_TEMPLATE.format(
287+
text="\n".join(messages),
288+
filename=filename,
289+
time=dt,
290+
name="mypy-py{ver}-{platform} {filename}".format(
291+
ver=version, platform=platform, filename=filename
292+
),
293+
)
294+
else:
295+
xml += JUNIT_TESTCASE_FAIL_TEMPLATE.format(
296+
text="\n".join(messages),
297+
filename="mypy",
298+
time=dt,
299+
name="mypy-py{ver}-{platform}".format(ver=version, platform=platform),
300+
)
301+
302+
xml += JUNIT_FOOTER
303+
304+
return xml
305+
306+
307+
def write_junit_xml(
308+
dt: float,
309+
serious: bool,
310+
messages_by_file: dict[str | None, list[str]],
311+
path: str,
312+
version: str,
313+
platform: str,
314+
) -> None:
315+
xml = _generate_junit_contents(dt, serious, messages_by_file, version, platform)
276316

277317
# checks for a directory structure in path and creates folders if needed
278318
xml_dirs = os.path.dirname(os.path.abspath(path))

0 commit comments

Comments
 (0)