Skip to content

Feature: Clear type info on del for local inferred variables (fixes #10005) #19386

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions mypy/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -5225,9 +5225,24 @@ def visit_del_stmt(self, s: DelStmt) -> None:
s.expr.accept(self.expr_checker)
for elt in flatten(s.expr):
if isinstance(elt, NameExpr):
self.binder.assign_type(
elt, DeletedType(source=elt.name), get_declaration(elt)
)
# For local variables, completely remove type binding to allow reuse
if (
isinstance(elt.node, Var)
and elt.node.is_inferred
and not elt.node.is_property
and not elt.node.is_classvar
and elt.node.is_local
):
# Completely remove the variable from type tracking
self.binder.cleanse(elt)
# Also remove from type map if present
if hasattr(self, "type_map") and elt in self.type_map:
del self.type_map[elt]
else:
# For non-local variables, use the existing DeletedType behavior
self.binder.assign_type(
elt, DeletedType(source=elt.name), get_declaration(elt)
)

def visit_decorator(self, e: Decorator) -> None:
for d in e.decorators:
Expand Down Expand Up @@ -5306,7 +5321,7 @@ def visit_decorator_inner(
self.fail(message_registry.BAD_CONSTRUCTOR_TYPE, e)

if e.func.original_def and isinstance(sig, FunctionLike):
# Function definition overrides function definition.
# Function signature processing continues herection definition overrides function definition.
self.check_func_def_override(e.func, sig)

def check_for_untyped_decorator(
Expand Down
278 changes: 278 additions & 0 deletions mypy/html_report.py
Original file line number Diff line number Diff line change
@@ -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 = [
"<!DOCTYPE html>",
"<html>",
"<head>",
" <meta charset='utf-8'>",
" <title>Mypy Report: " + path + "</title>",
" <link rel='stylesheet' href='../mypy-html.css'>",
" <style>",
" 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; }",
" </style>",
"</head>",
"<body>",
f" <h1>Mypy Type Check Report for {path}</h1>",
" <table>",
" <tr>",
" <th>Line</th>",
" <th>Precision</th>",
" <th>Code</th>",
" <th>Notes</th>",
" </tr>",
]

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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")

# Add CSS class based on precision
css_class = precision.lower()

html_lines.append(
f" <tr class='{css_class}'>"
f"<td>{lineno}</td>"
f"<td>{precision}</td>"
f"<td><pre>{content}</pre></td>"
f"<td>{any_info}</td>"
"</tr>"
)

html_lines.extend([" </table>", "</body>", "</html>"])

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"<br>{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 = [
"<!DOCTYPE html>",
"<html>",
"<head>",
" <meta charset='utf-8'>",
" <title>Mypy Report Index</title>",
" <link rel='stylesheet' href='mypy-html.css'>",
" <style>",
" 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; }",
" a { color: #337ab7; text-decoration: none; }",
" a:hover { text-decoration: underline; }",
" </style>",
"</head>",
"<body>",
" <h1>Mypy Type Check Report</h1>",
" <p>Generated with mypy " + __version__ + "</p>",
" <table>",
" <tr>",
" <th>Module</th>",
" <th>File</th>",
" <th>Precise</th>",
" <th>Imprecise</th>",
" <th>Any</th>",
" <th>Empty</th>",
" <th>Unanalyzed</th>",
" <th>Total</th>",
" </tr>",
]

for file_info in output_files:
counts = file_info.counts
html_lines.append(
f" <tr>"
f"<td>{file_info.module}</td>"
f"<td><a href='html/{file_info.name}.html'>{file_info.name}</a></td>"
f"<td>{counts[stats.TYPE_PRECISE]}</td>"
f"<td>{counts[stats.TYPE_IMPRECISE]}</td>"
f"<td>{counts[stats.TYPE_ANY]}</td>"
f"<td>{counts[stats.TYPE_EMPTY]}</td>"
f"<td>{counts[stats.TYPE_UNANALYZED]}</td>"
f"<td>{file_info.total()}</td>"
"</tr>"
)

html_lines.extend([" </table>", "</body>", "</html>"])

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", "<memory>")
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)
24 changes: 19 additions & 5 deletions mypy/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__":
Expand Down
37 changes: 37 additions & 0 deletions test-data/unit/check-del-reuse.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
[case testDelVariableReuse]
def test_del_reuse() -> None:
x = 1
reveal_type(x) # N: Revealed type is "builtins.int"
del x
x = "hello"
reveal_type(x) # N: Revealed type is "builtins.str"

[case testDelVariableReuseConditional]
def test_del_conditional() -> None:
x = 1
if True:
del x
x = "hello"
reveal_type(x) # N: Revealed type is "builtins.str"

[case testDelVariableNotLocal]
x = 1
def test_del_global() -> None:
global x
del x
x = "hello" # E: Incompatible types in assignment (expression has type "str", variable has type "int")

[case testDelIndexExpr]
def test_del_index() -> None:
d = {"key": 1}
del d["key"]
d["key"] = "hello" # OK - this should work normally

[case testDelAttribute]
class C:
attr: int = 1

def test_del_attr() -> None:
c = C()
del c.attr
c.attr = "hello" # E: Incompatible types in assignment (expression has type "str", target has type "int")
Loading
Loading