diff --git a/mypy/html_report.py b/mypy/html_report.py
new file mode 100644
index 000000000000..7fd57046bd84
--- /dev/null
+++ b/mypy/html_report.py
@@ -0,0 +1,278 @@
+"""Classes for producing HTML reports about type checking results."""
+
+from __future__ import annotations
+
+import collections
+import os
+import shutil
+from typing import Any
+
+from mypy import stats
+from mypy.nodes import Expression, MypyFile
+from mypy.options import Options
+from mypy.report import (
+ AbstractReporter,
+ FileInfo,
+ iterate_python_lines,
+ register_reporter,
+ should_skip_path,
+)
+from mypy.types import Type, TypeOfAny
+from mypy.version import __version__
+
+# Map of TypeOfAny enum values to descriptive strings
+type_of_any_name_map = {
+ TypeOfAny.unannotated: "Unannotated",
+ TypeOfAny.explicit: "Explicit",
+ TypeOfAny.from_unimported_type: "Unimported",
+ TypeOfAny.from_omitted_generics: "Omitted Generics",
+ TypeOfAny.from_error: "Error",
+ TypeOfAny.special_form: "Special Form",
+ TypeOfAny.implementation_artifact: "Implementation Artifact",
+}
+
+
+class MemoryHtmlReporter(AbstractReporter):
+ """Internal reporter that generates HTML in memory.
+
+ This is used by the HTML reporter to avoid duplication.
+ """
+
+ def __init__(self, reports: Any, output_dir: str) -> None:
+ super().__init__(reports, output_dir)
+ self.css_html_path = os.path.join(reports.data_dir, "xml", "mypy-html.css")
+ self.last_html: dict[str, str] = {} # Maps file paths to HTML content
+ self.index_html: str | None = None
+ self.files: list[FileInfo] = []
+
+ def on_file(
+ self,
+ tree: MypyFile,
+ modules: dict[str, MypyFile],
+ type_map: dict[Expression, Type],
+ options: Options,
+ ) -> None:
+ try:
+ path = os.path.relpath(tree.path)
+ except ValueError:
+ return
+
+ if should_skip_path(path) or os.path.isdir(path):
+ return # `path` can sometimes be a directory, see #11334
+
+ visitor = stats.StatisticsVisitor(
+ inferred=True,
+ filename=tree.fullname,
+ modules=modules,
+ typemap=type_map,
+ all_nodes=True,
+ )
+ tree.accept(visitor)
+
+ file_info = FileInfo(path, tree._fullname)
+
+ # Generate HTML for this file
+ html_lines = [
+ "",
+ "",
+ "
",
+ " ",
+ " Mypy Report: " + path + "",
+ " ",
+ " ",
+ "",
+ "",
+ f" Mypy Type Check Report for {path}
",
+ " ",
+ " ",
+ " Line | ",
+ " Precision | ",
+ " Code | ",
+ " Notes | ",
+ "
",
+ ]
+
+ for lineno, line_text in iterate_python_lines(path):
+ status = visitor.line_map.get(lineno, stats.TYPE_EMPTY)
+ file_info.counts[status] += 1
+
+ precision = stats.precision_names[status]
+ any_info = self._get_any_info_for_line(visitor, lineno)
+
+ # Escape HTML special characters in the line content
+ content = line_text.rstrip("\n")
+ content = content.replace("&", "&").replace("<", "<").replace(">", ">")
+
+ # Add CSS class based on precision
+ css_class = precision.lower()
+
+ html_lines.append(
+ f" "
+ f"{lineno} | "
+ f"{precision} | "
+ f"{content} | "
+ f"{any_info} | "
+ "
"
+ )
+
+ html_lines.extend(["
", "", ""])
+
+ self.last_html[path] = "\n".join(html_lines)
+ self.files.append(file_info)
+
+ @staticmethod
+ def _get_any_info_for_line(visitor: stats.StatisticsVisitor, lineno: int) -> str:
+ if lineno in visitor.any_line_map:
+ result = "Any Types on this line: "
+ counter: collections.Counter[int] = collections.Counter()
+ for typ in visitor.any_line_map[lineno]:
+ counter[typ.type_of_any] += 1
+ for any_type, occurrences in counter.items():
+ result += f"
{type_of_any_name_map[any_type]} (x{occurrences})"
+ return result
+ else:
+ return ""
+
+ def on_finish(self) -> None:
+ output_files = sorted(self.files, key=lambda x: x.module)
+
+ # Generate index HTML
+ html_lines = [
+ "",
+ "",
+ "",
+ " ",
+ " Mypy Report Index",
+ " ",
+ " ",
+ "",
+ "",
+ " Mypy Type Check Report
",
+ " Generated with mypy " + __version__ + "
",
+ " ",
+ " ",
+ " Module | ",
+ " File | ",
+ " Precise | ",
+ " Imprecise | ",
+ " Any | ",
+ " Empty | ",
+ " Unanalyzed | ",
+ " Total | ",
+ "
",
+ ]
+
+ for file_info in output_files:
+ counts = file_info.counts
+ html_lines.append(
+ f" "
+ f"{file_info.module} | "
+ f"{file_info.name} | "
+ f"{counts[stats.TYPE_PRECISE]} | "
+ f"{counts[stats.TYPE_IMPRECISE]} | "
+ f"{counts[stats.TYPE_ANY]} | "
+ f"{counts[stats.TYPE_EMPTY]} | "
+ f"{counts[stats.TYPE_UNANALYZED]} | "
+ f"{file_info.total()} | "
+ "
"
+ )
+
+ html_lines.extend(["
", "", ""])
+
+ self.index_html = "\n".join(html_lines)
+
+
+class HtmlReporter(AbstractReporter):
+ """Public reporter that exports HTML directly.
+
+ This reporter generates HTML files for each Python module and an index.html file.
+ """
+
+ def __init__(self, reports: Any, output_dir: str) -> None:
+ super().__init__(reports, output_dir)
+
+ memory_reporter = reports.add_report("memory-html", "")
+ assert isinstance(memory_reporter, MemoryHtmlReporter)
+ # The dependency will be called first.
+ self.memory_html = memory_reporter
+
+ def on_file(
+ self,
+ tree: MypyFile,
+ modules: dict[str, MypyFile],
+ type_map: dict[Expression, Type],
+ options: Options,
+ ) -> None:
+ last_html = self.memory_html.last_html
+ if not last_html:
+ return
+
+ path = os.path.relpath(tree.path)
+ if path.startswith("..") or path not in last_html:
+ return
+
+ out_path = os.path.join(self.output_dir, "html", path + ".html")
+ os.makedirs(os.path.dirname(out_path), exist_ok=True)
+
+ with open(out_path, "w", encoding="utf-8") as out_file:
+ out_file.write(last_html[path])
+
+ def on_finish(self) -> None:
+ index_html = self.memory_html.index_html
+ if index_html is None:
+ return
+
+ out_path = os.path.join(self.output_dir, "index.html")
+ out_css = os.path.join(self.output_dir, "mypy-html.css")
+
+ with open(out_path, "w", encoding="utf-8") as out_file:
+ out_file.write(index_html)
+
+ # Copy CSS file if it exists
+ if os.path.exists(self.memory_html.css_html_path):
+ shutil.copyfile(self.memory_html.css_html_path, out_css)
+ else:
+ # Create a basic CSS file if the original doesn't exist
+ with open(out_css, "w", encoding="utf-8") as css_file:
+ css_file.write(
+ """
+ body { font-family: Arial, sans-serif; margin: 20px; }
+ h1 { color: #333; }
+ table { border-collapse: collapse; width: 100%; }
+ th { background-color: #f2f2f2; text-align: left; padding: 8px; }
+ td { padding: 8px; border-bottom: 1px solid #ddd; }
+ tr.precise { background-color: #dff0d8; }
+ tr.imprecise { background-color: #fcf8e3; }
+ tr.any { background-color: #f2dede; }
+ tr.empty, tr.unanalyzed { background-color: #f9f9f9; }
+ pre { margin: 0; white-space: pre-wrap; }
+ a { color: #337ab7; text-decoration: none; }
+ a:hover { text-decoration: underline; }
+ """
+ )
+
+ print("Generated HTML report:", os.path.abspath(out_path))
+
+
+# Register the reporters
+register_reporter("memory-html", MemoryHtmlReporter)
+register_reporter("html-direct", HtmlReporter)
diff --git a/mypy/messages.py b/mypy/messages.py
index 2e07d7f63498..18885b49ec29 100644
--- a/mypy/messages.py
+++ b/mypy/messages.py
@@ -404,24 +404,38 @@ def has_no_attr(
self.unsupported_left_operand(op, original_type, context)
return codes.OPERATOR
elif member == "__neg__":
+ display_type = (
+ self.pretty_callable_or_overload(original_type)
+ if isinstance(original_type, CallableType)
+ else format_type(original_type, self.options)
+ )
self.fail(
- f"Unsupported operand type for unary - ({format_type(original_type, self.options)})",
+ f"Unsupported operand type for unary - ({display_type})",
context,
code=codes.OPERATOR,
)
return codes.OPERATOR
elif member == "__pos__":
+
+ display_type = (
+ self.pretty_callable_or_overload(original_type)
+ if isinstance(original_type, CallableType)
+ else format_type(original_type, self.options)
+ )
self.fail(
- f"Unsupported operand type for unary + ({format_type(original_type, self.options)})",
+ f"Unsupported operand type for unary + ({display_type})",
context,
code=codes.OPERATOR,
)
return codes.OPERATOR
elif member == "__invert__":
+ display_type = (
+ self.pretty_callable_or_overload(original_type)
+ if isinstance(original_type, CallableType)
+ else format_type(original_type, self.options)
+ )
self.fail(
- f"Unsupported operand type for ~ ({format_type(original_type, self.options)})",
- context,
- code=codes.OPERATOR,
+ f"Unsupported operand type for ~ ({display_type})", context, code=codes.OPERATOR
)
return codes.OPERATOR
elif member == "__getitem__":