From cdc5bff1f0f2cf61f5988f711f86109db69806b1 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Mon, 12 Oct 2026 00:19:27 +0100 Subject: [PATCH 1/3] Add basic implementation --- docs/source/error_codes.rst | 37 +++++++++ mypy/nativeparse.py | 117 +++++++++++++++++++++++---- mypy/nodes.py | 4 + mypy/parse.py | 1 + mypy/test/test_nativeparse.py | 6 +- test-data/unit/check-deprecated.test | 2 +- test-data/unit/check-fastparse.test | 3 +- test-data/unit/check-ignore.test | 93 +++++++++++++++++++++ 8 files changed, 241 insertions(+), 22 deletions(-) diff --git a/docs/source/error_codes.rst b/docs/source/error_codes.rst index e6aad9234ab3d..820b469e92748 100644 --- a/docs/source/error_codes.rst +++ b/docs/source/error_codes.rst @@ -53,6 +53,43 @@ codes prefixed with ``mypy:`` will be validated: # OK: error code may be used by other type checker. y = 2 # type: ignore[something-else] +An alternative way to specify mypy-specific type ignores is to use +``# mypy: ignore[one-code, other-code]``. Such form is equivalent to writing +``# type: ignore[mypy:one-code, mypy:other-code]``. You can have both +``# type: ignore[...]`` and ``# mypy: ignore[...]`` on the same line, in this +case the error code lists will be merged. + +Note that to allow better interoperability with other tools, mypy recognizes +``# type: ignore`` and ``mypy: ignore`` in any position in a comment, including +after another "magic" comment used by other tools. To temporarily disable +an ignore, place it after an empty (i.e. whitespace only) comment. For example: + +.. code-block:: python + + from typing import Any + + # No error, type ignore is active. + x: str = 1 # noqa # pragma: no cover # type: ignore[assignment] + + # Error: Incompatible types in assignment (expression has type "int", variable has type "str") + y: str = 1 ## type: ignore[assignment] + + # Error: Incompatible types in assignment (expression has type "int", variable has type "str") + z: str = 1 # # type: ignore[assignment] + +The only exception to this logic, is if there is no other code on a line +except for the comment. In this case type ignores preceded by another comment +are disabled. This is done to avoid spurious errors about unused ignores in +a commented out block of code: + +.. code-block:: python + + # mypy: warn-unused-ignores + + x: int = 1 + # OK, no unused-ignore error below. + # x = "no way" # type: ignore[assignment] + Enabling/disabling specific error codes globally ------------------------------------------------ diff --git a/mypy/nativeparse.py b/mypy/nativeparse.py index 8b0a261a925fc..7e7c348d50dfc 100644 --- a/mypy/nativeparse.py +++ b/mypy/nativeparse.py @@ -235,14 +235,25 @@ def native_parse( b, errors, ignores, + mypy_ignores, import_bytes, is_partial_package, uses_template_strings, source_hash, mypy_comments, ) = parse_to_binary_ast(filename, options, source, skip_function_bodies) + ignores, extra_errors = normalize_error_codes(ignores) errors.extend(extra_errors) + mypy_ignores, extra_errors = normalize_error_codes( + mypy_ignores, verify_all=True, prohibit_prefix=True + ) + errors.extend(extra_errors) + + ignores = merge_type_ignores(ignores, mypy_ignores) + ignores, extra_errors = remove_duplicate_codes(ignores) + errors.extend(extra_errors) + node = MypyFile([], []) node.path = filename node.raw_data = FileRawData( @@ -258,29 +269,89 @@ def native_parse( return node, errors, ignores -def normalize_error_codes(ignores: TypeIgnores) -> tuple[TypeIgnores, list[ParseError]]: +def normalize_error_codes( + ignores: TypeIgnores, verify_all: bool = False, prohibit_prefix: bool = False +) -> tuple[TypeIgnores, list[ParseError]]: """Remove mypy: prefix from error codes (if present).""" normalized = [] extra_errors: list[ParseError] = [] for line, codes in ignores: new_codes = [] for c in codes: - if not c.startswith("mypy:"): - new_codes.append(c) - continue + prefixed = c.startswith("mypy:") c = c.removeprefix("mypy:") - if c in error_codes: - new_codes.append(c) + # Always record unknown codes, so that type: ignore[unknown] + # will not become blanket ignore. + new_codes.append(c) + if (prefixed or verify_all) and c not in error_codes: + extra_errors.append( + { + "line": line, + "column": -1, + "message": f'Unrecognized error code "{c}"', + "blocker": False, + "code": "misc", + } + ) + if prefixed and prohibit_prefix: + extra_errors.append( + { + "line": line, + "column": -1, + "message": 'Error codes in "mypy: ignore" should not be prefixed', + "blocker": False, + "code": "misc", + } + ) + normalized.append((line, new_codes)) + return normalized, extra_errors + + +def merge_type_ignores(ignores: TypeIgnores, mypy_ignores: TypeIgnores) -> TypeIgnores: + """Merge error codes specified in # type: ignore[...] and in # mypy: ignore[...].""" + merged = [] + ignores_by_line = dict(ignores) + mypy_ignores_by_line = dict(mypy_ignores) + for line in sorted(ignores_by_line.keys() | mypy_ignores_by_line.keys()): + codes = ignores_by_line.get(line) + mypy_codes = mypy_ignores_by_line.get(line) + if codes is not None and mypy_codes is None: + new_codes = codes + elif codes is None and mypy_codes is not None: + new_codes = mypy_codes + else: + assert codes is not None and mypy_codes is not None + if not codes or not mypy_codes: + # If at least one ignore is blanket, make the merged ignore blanket. + # We are not overly strict here, in case someone want to quickly + # silence everything. + new_codes = [] + else: + new_codes = codes + mypy_codes + merged.append((line, new_codes)) + return merged + + +def remove_duplicate_codes(ignores: TypeIgnores) -> tuple[TypeIgnores, list[ParseError]]: + normalized = [] + extra_errors: list[ParseError] = [] + for line, codes in ignores: + seen = set() + new_codes = [] + for c in codes: + if c in seen: + extra_errors.append( + { + "line": line, + "column": -1, + "message": f'Duplicate error code "{c}"', + "blocker": False, + "code": "misc", + } + ) continue - extra_errors.append( - { - "line": line, - "column": -1, - "message": f'Unrecognized error code "{c}"', - "blocker": False, - "code": "misc", - } - ) + seen.add(c) + new_codes.append(c) normalized.append((line, new_codes)) return normalized, extra_errors @@ -327,7 +398,17 @@ def parse_to_binary_ast( options: Options, source: str | bytes | None = None, skip_function_bodies: bool = False, -) -> tuple[bytes, list[ParseError], TypeIgnores, bytes, bool, bool, str, list[tuple[int, str]]]: +) -> tuple[ + bytes, + list[ParseError], + TypeIgnores, + TypeIgnores, + bytes, + bool, + bool, + str, + list[tuple[int, str]], +]: try: ast_bytes, errors, ignores, import_bytes, ast_data = ast_serialize.parse( filename, @@ -337,7 +418,7 @@ def parse_to_binary_ast( platform=options.platform, always_true=options.always_true, always_false=options.always_false, - cache_version=5, + cache_version=7, ) except ValueError as exc: if source is None: @@ -366,6 +447,7 @@ def parse_to_binary_ast( ast_bytes, errors, ignores, + ast_data["mypy_ignores"], import_bytes, ast_data["is_partial_package"], ast_data["uses_template_strings"], @@ -796,6 +878,7 @@ def read_func_def(state: State, data: ReadBuffer) -> FuncDef: func_def = FuncDef(name, arguments, body, typ=typ, type_args=type_params) if is_async: func_def.is_coroutine = True + func_def.has_type_comments = read_bool(data) read_loc(data, func_def) if type_params: state.check_min_version( diff --git a/mypy/nodes.py b/mypy/nodes.py index dc86550cebe47..20f7a0df804c4 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -1114,6 +1114,7 @@ class FuncDef(FuncItem, SymbolNode, Statement): "docstring", "deprecated", "original_first_arg", + "has_type_comments", ) __match_args__ = ("name", "arguments", "type", "body") @@ -1156,6 +1157,9 @@ def __init__( # We record this status to avoid multiple (similar but different) errors in case # of partial types etc. self.is_invalid_redefinition = False + # Record if the function definition has any type comments (including per-argument). + # This is only available with the native parser (and always False otherwise). + self.has_type_comments = False @property def name(self) -> str: diff --git a/mypy/parse.py b/mypy/parse.py index cc1b9153f5ddf..5ffbd20c674df 100644 --- a/mypy/parse.py +++ b/mypy/parse.py @@ -95,6 +95,7 @@ def load_from_raw( # Report parse errors, this replicates the logic in parse(). all_errors = raw_data.raw_errors + state.errors errors.set_file(fnam, module, options=options) + errors.set_file_ignored_lines(fnam, tree.ignored_lines, options.ignore_errors) for error in all_errors: # Note we never raise in this function, so it should not be called in coordinator. report_parse_error(error, errors) diff --git a/mypy/test/test_nativeparse.py b/mypy/test/test_nativeparse.py index efe3be63c97c8..9ac377365038e 100644 --- a/mypy/test/test_nativeparse.py +++ b/mypy/test/test_nativeparse.py @@ -268,15 +268,15 @@ def locs(start_line: int, start_column: int, end_line: int, end_column: int) -> def test_trivial_binary_data_from_file(self) -> None: with temp_source("print('hello')") as fnam: - b, _, _, _, _, _, _, _ = parse_to_binary_ast(fnam, Options()) + b, _, _, _, _, _, _, _, _ = parse_to_binary_ast(fnam, Options()) self._assert_trivial_binary_data(b) def test_trivial_binary_data_from_string_source(self) -> None: - b, _, _, _, _, _, _, _ = parse_to_binary_ast("", Options(), "print('hello')") + b, _, _, _, _, _, _, _, _ = parse_to_binary_ast("", Options(), "print('hello')") self._assert_trivial_binary_data(b) def test_trivial_binary_data_from_bytes_source(self) -> None: - b, _, _, _, _, _, _, _ = parse_to_binary_ast("", Options(), b"print('hello')") + b, _, _, _, _, _, _, _, _ = parse_to_binary_ast("", Options(), b"print('hello')") self._assert_trivial_binary_data(b) def test_invalid_bytes_raises(self) -> None: diff --git a/test-data/unit/check-deprecated.test b/test-data/unit/check-deprecated.test index 607e9d7679561..10c5a7e697190 100644 --- a/test-data/unit/check-deprecated.test +++ b/test-data/unit/check-deprecated.test @@ -49,7 +49,7 @@ from typing_extensions import deprecated @deprecated("use f2 instead") def f() -> None: ... -f # E: function __main__.f is deprecated: use f2 instead # type: ignore[deprecated] +f # E: function __main__.f is deprecated: use f2 instead f(1) # E: function __main__.f is deprecated: use f2 instead \ # E: Too many arguments for "f" f[1] # E: function __main__.f is deprecated: use f2 instead \ diff --git a/test-data/unit/check-fastparse.test b/test-data/unit/check-fastparse.test index eaa3cd9a14fc8..73d9103348474 100644 --- a/test-data/unit/check-fastparse.test +++ b/test-data/unit/check-fastparse.test @@ -77,7 +77,8 @@ for i in (1, 2, 3, 100): # type: str # type: ignore pass [builtins fixtures/tuple.pyi] -[case testFastParseVariableCommentThenIgnore] +-- New parser has a different stance on this. +[case testFastParseVariableCommentThenIgnore_old_parser] a="test" # type: int #comment # type: ignore # E: Incompatible types in assignment (expression has type "str", variable has type "int") [case testFastParseProperty] diff --git a/test-data/unit/check-ignore.test b/test-data/unit/check-ignore.test index 71394dbc7da33..231733c809b07 100644 --- a/test-data/unit/check-ignore.test +++ b/test-data/unit/check-ignore.test @@ -309,3 +309,96 @@ x = 1 # type: ignore[mypy:foobar] main:2: error: Incompatible types in assignment (expression has type "int", variable has type "str") main:2: note: Error code "assignment" not covered by "type: ignore[arg-type, other:foobar]" comment main:3: error: Unrecognized error code "foobar" +main:3: note: Error code "misc" not covered by "type: ignore[foobar]" comment +main:3: error: Incompatible types in assignment (expression has type "int", variable has type "str") +main:3: note: Error code "assignment" not covered by "type: ignore[foobar]" comment + +[case testIgnoreNonBlockingParseError] +# flags: --python-version=3.10 +m[*(), :] # type: ignore[syntax] +[builtins fixtures/tuple.pyi] +[out] +main:2: error: Name "m" is not defined +main:2: note: Error code "name-defined" not covered by "type: ignore[syntax]" comment + +[case testIgnoreNonBlockingParseErrorSilent] +# cmd: mypy -m a +# flags: --python-version=3.10 --follow-import=silent +[file a.py] +import m +reveal_type(m.x) # N: Revealed type is "builtins.int" +[file m.py] +x: int +x = m[*(), :] +[builtins fixtures/tuple.pyi] +[out] + +[case testIgnoresWorkAfterOtherComments] +x: str = 1 # noqa # pragma: no cover # type: ignore +y: str = 1 # noqa # pragma: no cover # mypy: ignore +z: str = 1 # noqa # pragma: no cover # type: ignore[assignment] +t: str = 1 # noqa # pragma: no cover # mypy: ignore[assignment] + +x1: str = 1 ## type: ignore +y1: str = 1 ## mypy: ignore +z1: str = 1 ## type: ignore[assignment] +t1: str = 1 ## mypy: ignore[assignment] + +x2: str = 1 # noqa ## type: ignore +y2: str = 1 # noqa ## mypy: ignore +z2: str = 1 # noqa ## type: ignore[assignment] +t2: str = 1 # noqa ## mypy: ignore[assignment] +[out] +main:6: error: Incompatible types in assignment (expression has type "int", variable has type "str") +main:7: error: Incompatible types in assignment (expression has type "int", variable has type "str") +main:8: error: Incompatible types in assignment (expression has type "int", variable has type "str") +main:9: error: Incompatible types in assignment (expression has type "int", variable has type "str") +main:11: error: Incompatible types in assignment (expression has type "int", variable has type "str") +main:12: error: Incompatible types in assignment (expression has type "int", variable has type "str") +main:13: error: Incompatible types in assignment (expression has type "int", variable has type "str") +main:14: error: Incompatible types in assignment (expression has type "int", variable has type "str") + +[case testPrefixInMypyCommentNotAllowed] +x: str = 1 # mypy: ignore[mypy:assignment] +[out] +main:1: error: Error codes in "mypy: ignore" should not be prefixed +main:1: note: Error code "misc" not covered by "type: ignore[assignment]" comment + +[case testDuplicateErrorCodeNotAllowed] +x: str = 1 # mypy: ignore[assignment] # type: ignore[assignment] +[out] +main:1: error: Duplicate error code "assignment" +main:1: note: Error code "misc" not covered by "type: ignore[assignment]" comment + +[case testUnusedMypyIgnoreReported] +# flags: --warn-unused-ignores +x = 1 # mypy: ignore +y = 1 # mypy: ignore[assignment] +z: str = 1 # mypy: ignore[assignment, arg-type] +[out] +main:2: error: Unused "type: ignore" comment +main:3: error: Unused "type: ignore" comment +main:4: error: Unused "type: ignore[arg-type]" comment + +[case testNoUnusedIgnoreInCommentedBlock] +# flags: --warn-unused-ignores +# x: str = 1 # mypy: ignore +# y: str = 1 # type: ignore +# z: str = 1 # mypy: ignore[assignment] +# t: str = 1 # type: ignore[assignment] + +[case testIgnoreErrorCodesMerged] +def f() -> None: pass + +f(x) # mypy: ignore[name-defined] # type: ignore[call-arg] +f(x) # type: ignore[name-defined] # mypy: ignore[call-arg] + +[case testBlanketIgnoreWinsWhenMixed] +x # mypy: ignore # type: ignore[assignment] +y # mypy: ignore[assignment] # type: ignore + +[case testUnknownErrorCodeNotAllowedInMypyIgnore] +1 # mypy: ignore[whatever] +[out] +main:1: error: Unrecognized error code "whatever" +main:1: note: Error code "misc" not covered by "type: ignore[whatever]" comment From c5f50b94c58c8b5e63b2387a90c766aeeca8d64e Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Mon, 12 Oct 2026 00:48:14 +0100 Subject: [PATCH 2/3] Typos --- docs/source/error_codes.rst | 2 +- mypy/nativeparse.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/error_codes.rst b/docs/source/error_codes.rst index 820b469e92748..a1706668c9511 100644 --- a/docs/source/error_codes.rst +++ b/docs/source/error_codes.rst @@ -61,7 +61,7 @@ case the error code lists will be merged. Note that to allow better interoperability with other tools, mypy recognizes ``# type: ignore`` and ``mypy: ignore`` in any position in a comment, including -after another "magic" comment used by other tools. To temporarily disable +after another "magic" comments used by other tools. To temporarily disable an ignore, place it after an empty (i.e. whitespace only) comment. For example: .. code-block:: python diff --git a/mypy/nativeparse.py b/mypy/nativeparse.py index 7e7c348d50dfc..e3e1e921f7e7a 100644 --- a/mypy/nativeparse.py +++ b/mypy/nativeparse.py @@ -323,7 +323,7 @@ def merge_type_ignores(ignores: TypeIgnores, mypy_ignores: TypeIgnores) -> TypeI assert codes is not None and mypy_codes is not None if not codes or not mypy_codes: # If at least one ignore is blanket, make the merged ignore blanket. - # We are not overly strict here, in case someone want to quickly + # We are not overly strict here, in case someone wants to quickly # silence everything. new_codes = [] else: From dfd31ad76699a9432daf4b60e287905649e25ae1 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Tue, 13 Oct 2026 00:52:47 +0100 Subject: [PATCH 3/3] CR: more explicit error --- mypy/message_registry.py | 3 +++ mypy/nativeparse.py | 3 ++- test-data/unit/check-ignore.test | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/mypy/message_registry.py b/mypy/message_registry.py index c7201639fa356..f42eee23a5772 100644 --- a/mypy/message_registry.py +++ b/mypy/message_registry.py @@ -311,6 +311,9 @@ def with_additional_msg(self, info: str) -> ErrorMessage: codes.SYNTAX, ) INVALID_TYPE_IGNORE: Final = ErrorMessage('Invalid "type: ignore" comment', codes.SYNTAX) +NO_PREFIX_IN_MYPY_IGNORE: Final = ( + 'Error codes in "mypy: ignore" should not be prefixed with "mypy:"' +) TYPE_COMMENT_SYNTAX_ERROR_VALUE: Final = ErrorMessage( 'Syntax error in type comment "{}"', codes.SYNTAX ) diff --git a/mypy/nativeparse.py b/mypy/nativeparse.py index e3e1e921f7e7a..43b3fd6fbeec9 100644 --- a/mypy/nativeparse.py +++ b/mypy/nativeparse.py @@ -48,6 +48,7 @@ ) from mypy.errorcodes import error_codes from mypy.errors import CompileError +from mypy.message_registry import NO_PREFIX_IN_MYPY_IGNORE from mypy.nodes import ( ARG_KINDS, ARG_POS, @@ -298,7 +299,7 @@ def normalize_error_codes( { "line": line, "column": -1, - "message": 'Error codes in "mypy: ignore" should not be prefixed', + "message": NO_PREFIX_IN_MYPY_IGNORE, "blocker": False, "code": "misc", } diff --git a/test-data/unit/check-ignore.test b/test-data/unit/check-ignore.test index 231733c809b07..f40ec8e643942 100644 --- a/test-data/unit/check-ignore.test +++ b/test-data/unit/check-ignore.test @@ -361,7 +361,7 @@ main:14: error: Incompatible types in assignment (expression has type "int", var [case testPrefixInMypyCommentNotAllowed] x: str = 1 # mypy: ignore[mypy:assignment] [out] -main:1: error: Error codes in "mypy: ignore" should not be prefixed +main:1: error: Error codes in "mypy: ignore" should not be prefixed with "mypy:" main:1: note: Error code "misc" not covered by "type: ignore[assignment]" comment [case testDuplicateErrorCodeNotAllowed]