From 491221b361de263a4739c33500d0ceb632f1a36f Mon Sep 17 00:00:00 2001 From: Hmanbo Date: Sat, 15 Aug 2026 19:49:22 +0800 Subject: [PATCH] Report ranges for ignore-without-code errors Locate type ignore comments in source and attach their ranges to the synthesized diagnostic. Fixes #21856. --- mypy/build.py | 6 +++ mypy/errors.py | 69 +++++++++++++++++++++++++++++-- test-data/unit/check-columns.test | 8 ++++ 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/mypy/build.py b/mypy/build.py index 96a67105c816c..364d45d75ba02 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -3211,6 +3211,12 @@ def parse_file(self, *, temporary: bool = False, raw_data: FileRawData | None = if raw_data is None: source = self.get_source() + if ( + self.source is not None + and codes.IGNORE_WITHOUT_CODE in self.options.enabled_error_codes + and codes.IGNORE_WITHOUT_CODE not in self.options.disabled_error_codes + ): + self.manager.errors.set_source_lines(self.xpath, source.splitlines()) else: source = "" manager = self.manager diff --git a/mypy/errors.py b/mypy/errors.py index 19f01205d5565..79501f8cb97c5 100644 --- a/mypy/errors.py +++ b/mypy/errors.py @@ -1,7 +1,10 @@ from __future__ import annotations +import io import os.path +import re import sys +import tokenize import traceback from collections import defaultdict from collections.abc import Callable, Iterable, Iterator @@ -61,6 +64,8 @@ codes.OVERRIDE, } +TYPE_IGNORE_PATTERN: Final = re.compile(r"#\s*type:\s*ignore\b") + BASE_RTD_URL: Final = "https://mypy.rtfd.io/en/stable/_refs.html#code" # Keep track of the original error code when the error code of a message is changed. @@ -464,6 +469,12 @@ class Errors: # (path -> line -> error-codes) ignored_lines: dict[str, dict[int, list[str]]] + # Source locations of type ignore comments (path -> line -> (column, end column)). + type_ignore_ranges: dict[str, dict[int, tuple[int, int]]] + + # Sources supplied directly instead of read from the filesystem. + source_lines: dict[str, list[str]] + # Lines that were skipped during semantic analysis e.g. due to ALWAYS_FALSE, MYPY_FALSE, # or platform/version checks. Those lines would not be type-checked. skipped_lines: dict[str, set[int]] @@ -517,6 +528,8 @@ def initialize(self) -> None: self.import_ctx = [] self.function_or_member = [None] self.ignored_lines = {} + self.type_ignore_ranges = {} + self.source_lines = {} self.skipped_lines = {} self.used_ignored_lines = defaultdict(lambda: defaultdict(list)) self.ignored_files = set() @@ -566,9 +579,42 @@ def set_file_ignored_lines( self, file: str, ignored_lines: dict[int, list[str]], ignore_all: bool = False ) -> None: self.ignored_lines[file] = ignored_lines + self.type_ignore_ranges.pop(file, None) if ignore_all: self.ignored_files.add(file) + def set_source_lines(self, file: str, source_lines: list[str]) -> None: + self.source_lines[file] = source_lines + self.type_ignore_ranges.pop(file, None) + + def type_ignore_range(self, file: str, line: int) -> tuple[int, int]: + if file not in self.type_ignore_ranges: + source_lines = self.source_lines.pop(file, None) + if source_lines is None and self.read_source is not None: + source_path = self.find_shadow_file_mapping(file) or file + source_lines = self.read_source(source_path) + + ranges: dict[int, tuple[int, int]] = {} + if source_lines is not None: + source = "\n".join(source_lines) + try: + tokens = tokenize.generate_tokens(io.StringIO(source).readline) + for token in tokens: + if token.type == tokenize.COMMENT and TYPE_IGNORE_PATTERN.match( + token.string + ): + token_line = token.start[0] + source_line = source_lines[token_line - 1] + # AST columns, and thus mypy columns, are UTF-8 byte offsets. + column = len(source_line[: token.start[1]].encode("utf-8")) + end_column = len(source_line[: token.end[1]].encode("utf-8")) + ranges[token_line] = (column, end_column) + except (IndentationError, tokenize.TokenError): + pass + self.type_ignore_ranges[file] = ranges + + return self.type_ignore_ranges[file].get(line, (-1, -1)) + def set_skipped_lines(self, file: str, skipped_lines: set[int]) -> None: self.skipped_lines[file] = skipped_lines @@ -728,7 +774,14 @@ def note_for_info( self._add_error_info(file, info) def report_simple_error( - self, file: str, line: int, message: str, code: ErrorCode | None + self, + file: str, + line: int, + message: str, + code: ErrorCode | None, + *, + column: int = -1, + end_column: int = -1, ) -> None: """Generate a simple error in a module. @@ -738,9 +791,9 @@ def report_simple_error( import_ctx=self.import_context(), local_ctx=(None, None), line=line, - column=-1, + column=column, end_line=line, - end_column=-1, + end_column=end_column, severity="error", message=message, code=code, @@ -960,7 +1013,15 @@ def generate_ignore_without_code_errors( message = f'"type: ignore" comment without error code{codes_hint}' # Don't use report() since add_error_info will ignore the error! - self.report_simple_error(file, line, message, code=codes.IGNORE_WITHOUT_CODE) + column, end_column = self.type_ignore_range(file, line) + self.report_simple_error( + file, + line, + message, + code=codes.IGNORE_WITHOUT_CODE, + column=column, + end_column=end_column, + ) def num_messages(self) -> int: """Return the number of generated messages.""" diff --git a/test-data/unit/check-columns.test b/test-data/unit/check-columns.test index dd8389c195583..ec5bcb06ddb37 100644 --- a/test-data/unit/check-columns.test +++ b/test-data/unit/check-columns.test @@ -408,6 +408,14 @@ main:2:10:2:17: error: Incompatible types in assignment (expression has type "st main:6:3:7:1: error: Argument 1 to "f" has incompatible type "int"; expected "str" main:8:1:8:4: error: Value of type "int" is not indexable +[case testIgnoreWithoutCodeEndColumn] +# flags: --enable-error-code ignore-without-code --show-error-end +a: int = "a" # type: ignore +b: str = "# type: ignore" # type: ignore +[out] +main:2:14:2:27: error: "type: ignore" comment without error code (consider "type: ignore[assignment]" instead) +main:3:27:3:40: error: "type: ignore" comment without error code + [case testColumnsMissingPositionalArgShiftDetected] def f(x: int, y: str, z: bytes, aa: int) -> None: ... f(1, b'x', 1) # E:6: Missing positional argument "y" in call to "f"