Skip to content

Commit 557d964

Browse files
committed
Add direct HTML reporter to replace XSLT approach (fixes #909)
1 parent f174dca commit 557d964

File tree

2 files changed

+285
-0
lines changed

2 files changed

+285
-0
lines changed

mypy/html_report.py

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
"""Classes for producing HTML reports about type checking results."""
2+
3+
from __future__ import annotations
4+
5+
import collections
6+
import os
7+
import shutil
8+
from typing import Any
9+
10+
from mypy import stats
11+
from mypy.nodes import Expression, MypyFile
12+
from mypy.options import Options
13+
from mypy.report import AbstractReporter, FileInfo, iterate_python_lines, register_reporter, should_skip_path
14+
from mypy.types import Type, TypeOfAny
15+
from mypy.version import __version__
16+
17+
# Map of TypeOfAny enum values to descriptive strings
18+
type_of_any_name_map = {
19+
TypeOfAny.unannotated: "Unannotated",
20+
TypeOfAny.explicit: "Explicit",
21+
TypeOfAny.from_unimported_type: "Unimported",
22+
TypeOfAny.from_omitted_generics: "Omitted Generics",
23+
TypeOfAny.from_error: "Error",
24+
TypeOfAny.special_form: "Special Form",
25+
TypeOfAny.implementation_artifact: "Implementation Artifact",
26+
}
27+
28+
29+
class MemoryHtmlReporter(AbstractReporter):
30+
"""Internal reporter that generates HTML in memory.
31+
32+
This is used by the HTML reporter to avoid duplication.
33+
"""
34+
35+
def __init__(self, reports: Any, output_dir: str) -> None:
36+
super().__init__(reports, output_dir)
37+
self.css_html_path = os.path.join(reports.data_dir, "xml", "mypy-html.css")
38+
self.last_html: dict[str, str] = {} # Maps file paths to HTML content
39+
self.index_html: str | None = None
40+
self.files: list[FileInfo] = []
41+
42+
def on_file(
43+
self,
44+
tree: MypyFile,
45+
modules: dict[str, MypyFile],
46+
type_map: dict[Expression, Type],
47+
options: Options,
48+
) -> None:
49+
try:
50+
path = os.path.relpath(tree.path)
51+
except ValueError:
52+
return
53+
54+
if should_skip_path(path) or os.path.isdir(path):
55+
return # `path` can sometimes be a directory, see #11334
56+
57+
visitor = stats.StatisticsVisitor(
58+
inferred=True,
59+
filename=tree.fullname,
60+
modules=modules,
61+
typemap=type_map,
62+
all_nodes=True,
63+
)
64+
tree.accept(visitor)
65+
66+
file_info = FileInfo(path, tree._fullname)
67+
68+
# Generate HTML for this file
69+
html_lines = [
70+
"<!DOCTYPE html>",
71+
"<html>",
72+
"<head>",
73+
" <meta charset='utf-8'>",
74+
" <title>Mypy Report: " + path + "</title>",
75+
" <link rel='stylesheet' href='../mypy-html.css'>",
76+
" <style>",
77+
" body { font-family: Arial, sans-serif; margin: 20px; }",
78+
" h1 { color: #333; }",
79+
" table { border-collapse: collapse; width: 100%; }",
80+
" th { background-color: #f2f2f2; text-align: left; padding: 8px; }",
81+
" td { padding: 8px; border-bottom: 1px solid #ddd; }",
82+
" tr.precise { background-color: #dff0d8; }",
83+
" tr.imprecise { background-color: #fcf8e3; }",
84+
" tr.any { background-color: #f2dede; }",
85+
" tr.empty, tr.unanalyzed { background-color: #f9f9f9; }",
86+
" pre { margin: 0; white-space: pre-wrap; }",
87+
" </style>",
88+
"</head>",
89+
"<body>",
90+
f" <h1>Mypy Type Check Report for {path}</h1>",
91+
" <table>",
92+
" <tr>",
93+
" <th>Line</th>",
94+
" <th>Precision</th>",
95+
" <th>Code</th>",
96+
" <th>Notes</th>",
97+
" </tr>"
98+
]
99+
100+
for lineno, line_text in iterate_python_lines(path):
101+
status = visitor.line_map.get(lineno, stats.TYPE_EMPTY)
102+
file_info.counts[status] += 1
103+
104+
precision = stats.precision_names[status]
105+
any_info = self._get_any_info_for_line(visitor, lineno)
106+
107+
# Escape HTML special characters in the line content
108+
content = line_text.rstrip("\n")
109+
content = content.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
110+
111+
# Add CSS class based on precision
112+
css_class = precision.lower()
113+
114+
html_lines.append(
115+
f" <tr class='{css_class}'>"
116+
f"<td>{lineno}</td>"
117+
f"<td>{precision}</td>"
118+
f"<td><pre>{content}</pre></td>"
119+
f"<td>{any_info}</td>"
120+
"</tr>"
121+
)
122+
123+
html_lines.extend([
124+
" </table>",
125+
"</body>",
126+
"</html>"
127+
])
128+
129+
self.last_html[path] = "\n".join(html_lines)
130+
self.files.append(file_info)
131+
132+
@staticmethod
133+
def _get_any_info_for_line(visitor: stats.StatisticsVisitor, lineno: int) -> str:
134+
if lineno in visitor.any_line_map:
135+
result = "Any Types on this line: "
136+
counter: collections.Counter[int] = collections.Counter()
137+
for typ in visitor.any_line_map[lineno]:
138+
counter[typ.type_of_any] += 1
139+
for any_type, occurrences in counter.items():
140+
result += f"<br>{type_of_any_name_map[any_type]} (x{occurrences})"
141+
return result
142+
else:
143+
return ""
144+
145+
def on_finish(self) -> None:
146+
output_files = sorted(self.files, key=lambda x: x.module)
147+
148+
# Generate index HTML
149+
html_lines = [
150+
"<!DOCTYPE html>",
151+
"<html>",
152+
"<head>",
153+
" <meta charset='utf-8'>",
154+
" <title>Mypy Report Index</title>",
155+
" <link rel='stylesheet' href='mypy-html.css'>",
156+
" <style>",
157+
" body { font-family: Arial, sans-serif; margin: 20px; }",
158+
" h1 { color: #333; }",
159+
" table { border-collapse: collapse; width: 100%; }",
160+
" th { background-color: #f2f2f2; text-align: left; padding: 8px; }",
161+
" td { padding: 8px; border-bottom: 1px solid #ddd; }",
162+
" a { color: #337ab7; text-decoration: none; }",
163+
" a:hover { text-decoration: underline; }",
164+
" </style>",
165+
"</head>",
166+
"<body>",
167+
" <h1>Mypy Type Check Report</h1>",
168+
" <p>Generated with mypy " + __version__ + "</p>",
169+
" <table>",
170+
" <tr>",
171+
" <th>Module</th>",
172+
" <th>File</th>",
173+
" <th>Precise</th>",
174+
" <th>Imprecise</th>",
175+
" <th>Any</th>",
176+
" <th>Empty</th>",
177+
" <th>Unanalyzed</th>",
178+
" <th>Total</th>",
179+
" </tr>"
180+
]
181+
182+
for file_info in output_files:
183+
counts = file_info.counts
184+
html_lines.append(
185+
f" <tr>"
186+
f"<td>{file_info.module}</td>"
187+
f"<td><a href='html/{file_info.name}.html'>{file_info.name}</a></td>"
188+
f"<td>{counts[stats.TYPE_PRECISE]}</td>"
189+
f"<td>{counts[stats.TYPE_IMPRECISE]}</td>"
190+
f"<td>{counts[stats.TYPE_ANY]}</td>"
191+
f"<td>{counts[stats.TYPE_EMPTY]}</td>"
192+
f"<td>{counts[stats.TYPE_UNANALYZED]}</td>"
193+
f"<td>{file_info.total()}</td>"
194+
"</tr>"
195+
)
196+
197+
html_lines.extend([
198+
" </table>",
199+
"</body>",
200+
"</html>"
201+
])
202+
203+
self.index_html = "\n".join(html_lines)
204+
205+
206+
class HtmlReporter(AbstractReporter):
207+
"""Public reporter that exports HTML directly.
208+
209+
This reporter generates HTML files for each Python module and an index.html file.
210+
"""
211+
212+
def __init__(self, reports: Any, output_dir: str) -> None:
213+
super().__init__(reports, output_dir)
214+
215+
memory_reporter = reports.add_report("memory-html", "<memory>")
216+
assert isinstance(memory_reporter, MemoryHtmlReporter)
217+
# The dependency will be called first.
218+
self.memory_html = memory_reporter
219+
220+
def on_file(
221+
self,
222+
tree: MypyFile,
223+
modules: dict[str, MypyFile],
224+
type_map: dict[Expression, Type],
225+
options: Options,
226+
) -> None:
227+
last_html = self.memory_html.last_html
228+
if not last_html:
229+
return
230+
231+
path = os.path.relpath(tree.path)
232+
if path.startswith("..") or path not in last_html:
233+
return
234+
235+
out_path = os.path.join(self.output_dir, "html", path + ".html")
236+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
237+
238+
with open(out_path, "w", encoding="utf-8") as out_file:
239+
out_file.write(last_html[path])
240+
241+
def on_finish(self) -> None:
242+
index_html = self.memory_html.index_html
243+
if index_html is None:
244+
return
245+
246+
out_path = os.path.join(self.output_dir, "index.html")
247+
out_css = os.path.join(self.output_dir, "mypy-html.css")
248+
249+
with open(out_path, "w", encoding="utf-8") as out_file:
250+
out_file.write(index_html)
251+
252+
# Copy CSS file if it exists
253+
if os.path.exists(self.memory_html.css_html_path):
254+
shutil.copyfile(self.memory_html.css_html_path, out_css)
255+
else:
256+
# Create a basic CSS file if the original doesn't exist
257+
with open(out_css, "w", encoding="utf-8") as css_file:
258+
css_file.write("""
259+
body { font-family: Arial, sans-serif; margin: 20px; }
260+
h1 { color: #333; }
261+
table { border-collapse: collapse; width: 100%; }
262+
th { background-color: #f2f2f2; text-align: left; padding: 8px; }
263+
td { padding: 8px; border-bottom: 1px solid #ddd; }
264+
tr.precise { background-color: #dff0d8; }
265+
tr.imprecise { background-color: #fcf8e3; }
266+
tr.any { background-color: #f2dede; }
267+
tr.empty, tr.unanalyzed { background-color: #f9f9f9; }
268+
pre { margin: 0; white-space: pre-wrap; }
269+
a { color: #337ab7; text-decoration: none; }
270+
a:hover { text-decoration: underline; }
271+
""")
272+
273+
print("Generated HTML report:", os.path.abspath(out_path))
274+
275+
276+
# Register the reporters
277+
register_reporter("memory-html", MemoryHtmlReporter)
278+
register_reporter("html-direct", HtmlReporter)

mypy/report.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,9 @@ def __init__(self, reports: Reports, output_dir: str) -> None:
474474
self.schema = etree.XMLSchema(etree.parse(xsd_path))
475475
self.last_xml: Any | None = None
476476
self.files: list[FileInfo] = []
477+
478+
479+
477480

478481
# XML doesn't like control characters, but they are sometimes
479482
# legal in source code (e.g. comments, string literals).
@@ -532,6 +535,10 @@ def on_file(
532535
self.last_xml = doc
533536
self.files.append(file_info)
534537

538+
539+
540+
541+
535542
@staticmethod
536543
def _get_any_info_for_line(visitor: stats.StatisticsVisitor, lineno: int) -> str:
537544
if lineno in visitor.any_line_map:

0 commit comments

Comments
 (0)