diff --git a/mypy/build.py b/mypy/build.py
index 605368a6dc51..961198fc2fa4 100644
--- a/mypy/build.py
+++ b/mypy/build.py
@@ -145,7 +145,7 @@ def build(
sources: list[BuildSource],
options: Options,
alt_lib_path: str | None = None,
- flush_errors: Callable[[list[str], bool], None] | None = None,
+ flush_errors: Callable[[str | None, list[str], bool], None] | None = None,
fscache: FileSystemCache | None = None,
stdout: TextIO | None = None,
stderr: TextIO | None = None,
@@ -177,7 +177,9 @@ def build(
# fields for callers that want the traditional API.
messages = []
- def default_flush_errors(new_messages: list[str], is_serious: bool) -> None:
+ def default_flush_errors(
+ filename: str | None, new_messages: list[str], is_serious: bool
+ ) -> None:
messages.extend(new_messages)
flush_errors = flush_errors or default_flush_errors
@@ -197,7 +199,7 @@ def default_flush_errors(new_messages: list[str], is_serious: bool) -> None:
# Patch it up to contain either none or all none of the messages,
# depending on whether we are flushing errors.
serious = not e.use_stdout
- flush_errors(e.messages, serious)
+ flush_errors(None, e.messages, serious)
e.messages = messages
raise
@@ -206,7 +208,7 @@ def _build(
sources: list[BuildSource],
options: Options,
alt_lib_path: str | None,
- flush_errors: Callable[[list[str], bool], None],
+ flush_errors: Callable[[str | None, list[str], bool], None],
fscache: FileSystemCache | None,
stdout: TextIO,
stderr: TextIO,
@@ -600,7 +602,7 @@ def __init__(
plugin: Plugin,
plugins_snapshot: dict[str, str],
errors: Errors,
- flush_errors: Callable[[list[str], bool], None],
+ flush_errors: Callable[[str | None, list[str], bool], None],
fscache: FileSystemCache,
stdout: TextIO,
stderr: TextIO,
@@ -3458,7 +3460,11 @@ def process_stale_scc(graph: Graph, scc: list[str], manager: BuildManager) -> No
for id in stale:
graph[id].transitive_error = True
for id in stale:
- manager.flush_errors(manager.errors.file_messages(graph[id].xpath), False)
+ manager.flush_errors(
+ manager.errors.simplify_path(graph[id].xpath),
+ manager.errors.file_messages(graph[id].xpath),
+ False,
+ )
graph[id].write_cache()
graph[id].mark_as_rechecked()
diff --git a/mypy/config_parser.py b/mypy/config_parser.py
index 4dbd6477c81e..a6bf021000c1 100644
--- a/mypy/config_parser.py
+++ b/mypy/config_parser.py
@@ -152,6 +152,17 @@ def check_follow_imports(choice: str) -> str:
return choice
+def check_junit_format(choice: str) -> str:
+ choices = ["global", "per_file"]
+ if choice not in choices:
+ raise argparse.ArgumentTypeError(
+ "invalid choice '{}' (choose from {})".format(
+ choice, ", ".join(f"'{x}'" for x in choices)
+ )
+ )
+ return choice
+
+
def split_commas(value: str) -> list[str]:
# Uses a bit smarter technique to allow last trailing comma
# and to remove last `""` item from the split.
@@ -173,6 +184,7 @@ def split_commas(value: str) -> list[str]:
"files": split_and_match_files,
"quickstart_file": expand_path,
"junit_xml": expand_path,
+ "junit_format": check_junit_format,
"follow_imports": check_follow_imports,
"no_site_packages": bool,
"plugins": lambda s: [p.strip() for p in split_commas(s)],
@@ -200,6 +212,7 @@ def split_commas(value: str) -> list[str]:
"python_version": parse_version,
"mypy_path": lambda s: [expand_path(p) for p in try_split(s, "[,:]")],
"files": lambda s: split_and_match_files_list(try_split(s)),
+ "junit_format": lambda s: check_junit_format(str(s)),
"follow_imports": lambda s: check_follow_imports(str(s)),
"plugins": try_split,
"always_true": try_split,
diff --git a/mypy/main.py b/mypy/main.py
index 1aede530c33e..5e0dc17c668a 100644
--- a/mypy/main.py
+++ b/mypy/main.py
@@ -7,6 +7,7 @@
import subprocess
import sys
import time
+from collections import defaultdict
from gettext import gettext
from typing import IO, Any, Final, NoReturn, Sequence, TextIO
@@ -158,11 +159,14 @@ def run_build(
formatter = util.FancyFormatter(stdout, stderr, options.hide_error_codes)
messages = []
+ messages_by_file = defaultdict(list)
- def flush_errors(new_messages: list[str], serious: bool) -> None:
+ def flush_errors(filename: str | None, new_messages: list[str], serious: bool) -> None:
if options.pretty:
new_messages = formatter.fit_in_terminal(new_messages)
messages.extend(new_messages)
+ if new_messages:
+ messages_by_file[filename].extend(new_messages)
if options.non_interactive:
# Collect messages and possibly show them later.
return
@@ -200,7 +204,7 @@ def flush_errors(new_messages: list[str], serious: bool) -> None:
),
file=stderr,
)
- maybe_write_junit_xml(time.time() - t0, serious, messages, options)
+ maybe_write_junit_xml(time.time() - t0, serious, messages, messages_by_file, options)
return res, messages, blockers
@@ -1054,6 +1058,12 @@ def add_invertible_flag(
other_group = parser.add_argument_group(title="Miscellaneous")
other_group.add_argument("--quickstart-file", help=argparse.SUPPRESS)
other_group.add_argument("--junit-xml", help="Write junit.xml to the given file")
+ imports_group.add_argument(
+ "--junit-format",
+ choices=["global", "per_file"],
+ default="global",
+ help="If --junit-xml is set, specifies format. global: single test with all errors; per_file: one test entry per file with failures",
+ )
other_group.add_argument(
"--find-occurrences",
metavar="CLASS.MEMBER",
@@ -1483,18 +1493,32 @@ def process_cache_map(
options.cache_map[source] = (meta_file, data_file)
-def maybe_write_junit_xml(td: float, serious: bool, messages: list[str], options: Options) -> None:
+def maybe_write_junit_xml(
+ td: float,
+ serious: bool,
+ all_messages: list[str],
+ messages_by_file: dict[str | None, list[str]],
+ options: Options,
+) -> None:
if options.junit_xml:
py_version = f"{options.python_version[0]}_{options.python_version[1]}"
- util.write_junit_xml(
- td, serious, messages, options.junit_xml, py_version, options.platform
- )
+ if options.junit_format == "global":
+ util.write_junit_xml(
+ td, serious, {None: all_messages}, options.junit_xml, py_version, options.platform
+ )
+ else:
+ # per_file
+ util.write_junit_xml(
+ td, serious, messages_by_file, options.junit_xml, py_version, options.platform
+ )
def fail(msg: str, stderr: TextIO, options: Options) -> NoReturn:
"""Fail with a serious error."""
stderr.write(f"{msg}\n")
- maybe_write_junit_xml(0.0, serious=True, messages=[msg], options=options)
+ maybe_write_junit_xml(
+ 0.0, serious=True, all_messages=[msg], messages_by_file={None: [msg]}, options=options
+ )
sys.exit(2)
diff --git a/mypy/options.py b/mypy/options.py
index 8bb20dbd4410..38a87e423766 100644
--- a/mypy/options.py
+++ b/mypy/options.py
@@ -255,6 +255,8 @@ def __init__(self) -> None:
# Write junit.xml to given file
self.junit_xml: str | None = None
+ self.junit_format: str = "global" # global|per_file
+
# Caching and incremental checking options
self.incremental = True
self.cache_dir = defaults.CACHE_DIR
diff --git a/mypy/test/testerrorstream.py b/mypy/test/testerrorstream.py
index 4b98f10fc9ca..5ed112fd31e7 100644
--- a/mypy/test/testerrorstream.py
+++ b/mypy/test/testerrorstream.py
@@ -29,7 +29,7 @@ def test_error_stream(testcase: DataDrivenTestCase) -> None:
logged_messages: list[str] = []
- def flush_errors(msgs: list[str], serious: bool) -> None:
+ def flush_errors(filename: str | None, msgs: list[str], serious: bool) -> None:
if msgs:
logged_messages.append("==== Errors flushed ====")
logged_messages.extend(msgs)
diff --git a/mypy/test/testgraph.py b/mypy/test/testgraph.py
index b0d148d5ae9c..0355e75e8c34 100644
--- a/mypy/test/testgraph.py
+++ b/mypy/test/testgraph.py
@@ -50,7 +50,7 @@ def _make_manager(self) -> BuildManager:
plugin=Plugin(options),
plugins_snapshot={},
errors=errors,
- flush_errors=lambda msgs, serious: None,
+ flush_errors=lambda filename, msgs, serious: None,
fscache=fscache,
stdout=sys.stdout,
stderr=sys.stderr,
diff --git a/mypy/test/testutil.py b/mypy/test/testutil.py
index 571e4d0b11f2..d0d54ffec8c6 100644
--- a/mypy/test/testutil.py
+++ b/mypy/test/testutil.py
@@ -4,7 +4,7 @@
from unittest import TestCase, mock
from mypy.inspections import parse_location
-from mypy.util import get_terminal_width
+from mypy.util import _generate_junit_contents, get_terminal_width
class TestGetTerminalSize(TestCase):
@@ -20,3 +20,70 @@ def test_get_terminal_size_in_pty_defaults_to_80(self) -> None:
def test_parse_location_windows(self) -> None:
assert parse_location(r"C:\test.py:1:1") == (r"C:\test.py", [1, 1])
assert parse_location(r"C:\test.py:1:1:1:1") == (r"C:\test.py", [1, 1, 1, 1])
+
+
+class TestWriteJunitXml(TestCase):
+ def test_junit_pass(self) -> None:
+ serious = False
+ messages_by_file: dict[str | None, list[str]] = {}
+ expected = """
+
+
+
+
+"""
+ result = _generate_junit_contents(
+ dt=1.23,
+ serious=serious,
+ messages_by_file=messages_by_file,
+ version="3.14",
+ platform="test-plat",
+ )
+ assert result == expected
+
+ def test_junit_fail_two_files(self) -> None:
+ serious = False
+ messages_by_file: dict[str | None, list[str]] = {
+ "file1.py": ["Test failed", "another line"],
+ "file2.py": ["Another failure", "line 2"],
+ }
+ expected = """
+
+
+ Test failed
+another line
+
+
+ Another failure
+line 2
+
+
+"""
+ result = _generate_junit_contents(
+ dt=1.23,
+ serious=serious,
+ messages_by_file=messages_by_file,
+ version="3.14",
+ platform="test-plat",
+ )
+ assert result == expected
+
+ def test_serious_error(self) -> None:
+ serious = True
+ messages_by_file: dict[str | None, list[str]] = {None: ["Error line 1", "Error line 2"]}
+ expected = """
+
+
+ Error line 1
+Error line 2
+
+
+"""
+ result = _generate_junit_contents(
+ dt=1.23,
+ serious=serious,
+ messages_by_file=messages_by_file,
+ version="3.14",
+ platform="test-plat",
+ )
+ assert result == expected
diff --git a/mypy/util.py b/mypy/util.py
index d0f2f8c6cc36..7a13de427e8e 100644
--- a/mypy/util.py
+++ b/mypy/util.py
@@ -234,45 +234,85 @@ def get_mypy_comments(source: str) -> list[tuple[int, str]]:
return results
-PASS_TEMPLATE: Final = """
-
-
-
-
+JUNIT_HEADER_TEMPLATE: Final = """
+
"""
-FAIL_TEMPLATE: Final = """
-
-
+JUNIT_TESTCASE_FAIL_TEMPLATE: Final = """
{text}
-
"""
-ERROR_TEMPLATE: Final = """
-
-
+JUNIT_ERROR_TEMPLATE: Final = """
{text}
-
"""
+JUNIT_TESTCASE_PASS_TEMPLATE: Final = """
+
+"""
-def write_junit_xml(
- dt: float, serious: bool, messages: list[str], path: str, version: str, platform: str
-) -> None:
- from xml.sax.saxutils import escape
+JUNIT_FOOTER: Final = """
+"""
- if not messages and not serious:
- xml = PASS_TEMPLATE.format(time=dt, ver=version, platform=platform)
- elif not serious:
- xml = FAIL_TEMPLATE.format(
- text=escape("\n".join(messages)), time=dt, ver=version, platform=platform
- )
+
+def _generate_junit_contents(
+ dt: float,
+ serious: bool,
+ messages_by_file: dict[str | None, list[str]],
+ version: str,
+ platform: str,
+) -> str:
+ if serious:
+ failures = 0
+ errors = len(messages_by_file)
else:
- xml = ERROR_TEMPLATE.format(
- text=escape("\n".join(messages)), time=dt, ver=version, platform=platform
- )
+ failures = len(messages_by_file)
+ errors = 0
+
+ xml = JUNIT_HEADER_TEMPLATE.format(
+ errors=errors,
+ failures=failures,
+ time=dt,
+ # If there are no messages, we still write one "test" indicating success.
+ tests=len(messages_by_file) or 1,
+ )
+
+ if not messages_by_file:
+ xml += JUNIT_TESTCASE_PASS_TEMPLATE.format(time=dt, ver=version, platform=platform)
+ else:
+ for filename, messages in messages_by_file.items():
+ if filename is not None:
+ xml += JUNIT_TESTCASE_FAIL_TEMPLATE.format(
+ text="\n".join(messages),
+ filename=filename,
+ time=dt,
+ name="mypy-py{ver}-{platform} {filename}".format(
+ ver=version, platform=platform, filename=filename
+ ),
+ )
+ else:
+ xml += JUNIT_TESTCASE_FAIL_TEMPLATE.format(
+ text="\n".join(messages),
+ filename="mypy",
+ time=dt,
+ name="mypy-py{ver}-{platform}".format(ver=version, platform=platform),
+ )
+
+ xml += JUNIT_FOOTER
+
+ return xml
+
+
+def write_junit_xml(
+ dt: float,
+ serious: bool,
+ messages_by_file: dict[str | None, list[str]],
+ path: str,
+ version: str,
+ platform: str,
+) -> None:
+ xml = _generate_junit_contents(dt, serious, messages_by_file, version, platform)
# checks for a directory structure in path and creates folders if needed
xml_dirs = os.path.dirname(os.path.abspath(path))
diff --git a/mypyc/build.py b/mypyc/build.py
index 0af8908e14d0..a9082df81945 100644
--- a/mypyc/build.py
+++ b/mypyc/build.py
@@ -105,7 +105,9 @@ def emit_messages(options: Options, messages: list[str], dt: float, serious: boo
# ... you know, just in case.
if options.junit_xml:
py_version = f"{options.python_version[0]}_{options.python_version[1]}"
- write_junit_xml(dt, serious, messages, options.junit_xml, py_version, options.platform)
+ write_junit_xml(
+ dt, serious, {None: messages}, options.junit_xml, py_version, options.platform
+ )
if messages:
print("\n".join(messages))