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}

", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ] + + 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"" + f"" + f"" + f"" + "" + ) + + html_lines.extend(["
LinePrecisionCodeNotes
{lineno}{precision}
{content}
{any_info}
", "", ""]) + + 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__ + "

", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ] + + for file_info in output_files: + counts = file_info.counts + html_lines.append( + f" " + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + "" + ) + + html_lines.extend(["
ModuleFilePreciseImpreciseAnyEmptyUnanalyzedTotal
{file_info.module}{file_info.name}{counts[stats.TYPE_PRECISE]}{counts[stats.TYPE_IMPRECISE]}{counts[stats.TYPE_ANY]}{counts[stats.TYPE_EMPTY]}{counts[stats.TYPE_UNANALYZED]}{file_info.total()}
", "", ""]) + + 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__":