From ccd30def020feb156444c198c3703f92a4c53db8 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sat, 7 Jun 2025 14:55:35 +0100 Subject: [PATCH 1/2] Fix and simplify error de-duplication --- mypy/checker.py | 13 +- mypy/checkexpr.py | 8 +- mypy/errors.py | 150 +++++--------- mypy/messages.py | 189 ++++++++---------- mypy/plugin.py | 3 +- mypy/typeanal.py | 5 +- test-data/unit/check-classes.test | 22 ++ test-data/unit/check-narrowing.test | 9 +- test-data/unit/check-protocols.test | 25 +++ .../fine-grained-dataclass-transform.test | 1 - 10 files changed, 195 insertions(+), 230 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index d6eac718f008..d420ecb22a38 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -25,7 +25,7 @@ from mypy.constraints import SUPERTYPE_OF from mypy.erasetype import erase_type, erase_typevars, remove_instance_last_known_values from mypy.errorcodes import TYPE_VAR, UNUSED_AWAITABLE, UNUSED_COROUTINE, ErrorCode -from mypy.errors import Errors, ErrorWatcher, LoopErrorWatcher, report_internal_error +from mypy.errors import ErrorInfo, Errors, ErrorWatcher, LoopErrorWatcher, report_internal_error from mypy.expandtype import expand_type from mypy.literals import Key, extract_var_from_literal_hash, literal, literal_hash from mypy.maptype import map_instance_to_supertype @@ -7207,7 +7207,7 @@ def check_subtype( if extra_info: msg = msg.with_additional_msg(" (" + ", ".join(extra_info) + ")") - self.fail(msg, context) + error = self.fail(msg, context) for note in notes: self.msg.note(note, context, code=msg.code) if note_msg: @@ -7218,7 +7218,7 @@ def check_subtype( and supertype.type.is_protocol and isinstance(subtype, (CallableType, Instance, TupleType, TypedDictType)) ): - self.msg.report_protocol_problems(subtype, supertype, context, code=msg.code) + self.msg.report_protocol_problems(subtype, supertype, context, parent_error=error) if isinstance(supertype, CallableType) and isinstance(subtype, Instance): call = find_member("__call__", subtype, subtype, is_operator=True) if call: @@ -7547,12 +7547,11 @@ def temp_node(self, t: Type, context: Context | None = None) -> TempNode: def fail( self, msg: str | ErrorMessage, context: Context, *, code: ErrorCode | None = None - ) -> None: + ) -> ErrorInfo: """Produce an error message.""" if isinstance(msg, ErrorMessage): - self.msg.fail(msg.value, context, code=msg.code) - return - self.msg.fail(msg, context, code=code) + return self.msg.fail(msg.value, context, code=msg.code) + return self.msg.fail(msg, context, code=code) def note( self, diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index e0c7e829309c..07106f7473ca 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -2647,7 +2647,7 @@ def check_arg( elif self.has_abstract_type_part(caller_type, callee_type): self.msg.concrete_only_call(callee_type, context) elif not is_subtype(caller_type, callee_type, options=self.chk.options): - code = self.msg.incompatible_argument( + error = self.msg.incompatible_argument( n, m, callee, @@ -2658,10 +2658,12 @@ def check_arg( outer_context=outer_context, ) self.msg.incompatible_argument_note( - original_caller_type, callee_type, context, code=code + original_caller_type, callee_type, context, parent_error=error ) if not self.msg.prefer_simple_messages(): - self.chk.check_possible_missing_await(caller_type, callee_type, context, code) + self.chk.check_possible_missing_await( + caller_type, callee_type, context, error.code + ) def check_overload_call( self, diff --git a/mypy/errors.py b/mypy/errors.py index 6aa19ed7c5a0..7a173f16d196 100644 --- a/mypy/errors.py +++ b/mypy/errors.py @@ -38,8 +38,6 @@ codes.OVERRIDE, } -allowed_duplicates: Final = ["@overload", "Got:", "Expected:", "Expected setter type:"] - 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. @@ -93,9 +91,6 @@ class ErrorInfo: # Only report this particular messages once per program. only_once = False - # Do not remove duplicate copies of this message (ignored if only_once is True). - allow_dups = False - # Actual origin of the error message as tuple (path, line number, end line number) # If end line number is unknown, use line number. origin: tuple[str, Iterable[int]] @@ -107,6 +102,10 @@ class ErrorInfo: # by mypy daemon) hidden = False + # For notes, specifies (optionally) the error this note is attached to. This is used to + # simplify error code matching and de-duplication logic for complex multi-line notes. + parent_error: ErrorInfo | None = None + def __init__( self, import_ctx: list[tuple[str, int]], @@ -124,10 +123,10 @@ def __init__( code: ErrorCode | None, blocker: bool, only_once: bool, - allow_dups: bool, origin: tuple[str, Iterable[int]] | None = None, target: str | None = None, priority: int = 0, + parent_error: ErrorInfo | None = None, ) -> None: self.import_ctx = import_ctx self.file = file @@ -143,17 +142,17 @@ def __init__( self.code = code self.blocker = blocker self.only_once = only_once - self.allow_dups = allow_dups self.origin = origin or (file, [line]) self.target = target self.priority = priority + if parent_error is not None: + assert severity == "note", "Only notes can specify parent errors" + self.parent_error = parent_error # Type used internally to represent errors: -# (path, line, column, end_line, end_column, severity, message, allow_dups, code) -ErrorTuple: _TypeAlias = tuple[ - Optional[str], int, int, int, int, str, str, bool, Optional[ErrorCode] -] +# (path, line, column, end_line, end_column, severity, message, code) +ErrorTuple: _TypeAlias = tuple[Optional[str], int, int, int, int, str, str, Optional[ErrorCode]] class ErrorWatcher: @@ -446,12 +445,12 @@ def report( severity: str = "error", file: str | None = None, only_once: bool = False, - allow_dups: bool = False, origin_span: Iterable[int] | None = None, offset: int = 0, end_line: int | None = None, end_column: int | None = None, - ) -> None: + parent_error: ErrorInfo | None = None, + ) -> ErrorInfo: """Report message at the given line using the current error context. Args: @@ -463,10 +462,10 @@ def report( severity: 'error' or 'note' file: if non-None, override current file as context only_once: if True, only report this exact message once per build - allow_dups: if True, allow duplicate copies of this message (ignored if only_once) origin_span: if non-None, override current context as origin (type: ignores have effect here) end_line: if non-None, override current context as end + parent_error: an error this note is attached to (for notes only). """ if self.scope: type = self.scope.current_type_name() @@ -496,6 +495,7 @@ def report( if end_line is None: end_line = line + code = code or (parent_error.code if parent_error else None) code = code or (codes.MISC if not blocker else None) info = ErrorInfo( @@ -513,11 +513,12 @@ def report( code=code, blocker=blocker, only_once=only_once, - allow_dups=allow_dups, origin=(self.file, origin_span), target=self.current_target(), + parent_error=parent_error, ) self.add_error_info(info) + return info def _add_error_info(self, file: str, info: ErrorInfo) -> None: assert file not in self.flushed_files @@ -616,7 +617,6 @@ def add_error_info(self, info: ErrorInfo) -> None: code=None, blocker=False, only_once=False, - allow_dups=False, ) self._add_error_info(file, note) if ( @@ -645,7 +645,6 @@ def add_error_info(self, info: ErrorInfo) -> None: code=info.code, blocker=False, only_once=True, - allow_dups=False, priority=20, ) self._add_error_info(file, info) @@ -685,7 +684,6 @@ def report_hidden_errors(self, info: ErrorInfo) -> None: code=None, blocker=False, only_once=True, - allow_dups=False, origin=info.origin, target=info.target, ) @@ -788,7 +786,6 @@ def generate_unused_ignore_errors(self, file: str) -> None: code=codes.UNUSED_IGNORE, blocker=False, only_once=False, - allow_dups=False, ) self._add_error_info(file, info) @@ -840,7 +837,6 @@ def generate_ignore_without_code_errors( code=codes.IGNORE_WITHOUT_CODE, blocker=False, only_once=False, - allow_dups=False, ) self._add_error_info(file, info) @@ -907,17 +903,7 @@ def format_messages( severity 'error'). """ a: list[str] = [] - for ( - file, - line, - column, - end_line, - end_column, - severity, - message, - allow_dups, - code, - ) in error_tuples: + for file, line, column, end_line, end_column, severity, message, code in error_tuples: s = "" if file is not None: if self.options.show_column_numbers and line >= 0 and column >= 0: @@ -972,8 +958,8 @@ def file_messages(self, path: str, formatter: ErrorFormatter | None = None) -> l error_info = self.error_info_map[path] error_info = [info for info in error_info if not info.hidden] - error_tuples = self.render_messages(self.sort_messages(error_info)) - error_tuples = self.remove_duplicates(error_tuples) + error_info = self.remove_duplicates(self.sort_messages(error_info)) + error_tuples = self.render_messages(error_info) if formatter is not None: errors = create_errors(error_tuples) @@ -1025,7 +1011,7 @@ def targets(self) -> set[str]: def render_messages(self, errors: list[ErrorInfo]) -> list[ErrorTuple]: """Translate the messages into a sequence of tuples. - Each tuple is of form (path, line, col, severity, message, allow_dups, code). + Each tuple is of form (path, line, col, severity, message, code). The rendered sequence includes information about error contexts. The path item may be None. If the line item is negative, the line number is not defined for the tuple. @@ -1054,9 +1040,7 @@ def render_messages(self, errors: list[ErrorInfo]) -> list[ErrorTuple]: # Remove prefix to ignore from path (if present) to # simplify path. path = remove_path_prefix(path, self.ignore_prefix) - result.append( - (None, -1, -1, -1, -1, "note", fmt.format(path, line), e.allow_dups, None) - ) + result.append((None, -1, -1, -1, -1, "note", fmt.format(path, line), None)) i -= 1 file = self.simplify_path(e.file) @@ -1067,22 +1051,10 @@ def render_messages(self, errors: list[ErrorInfo]) -> list[ErrorTuple]: elif e.function_or_member != prev_function_or_member or e.type != prev_type: if e.function_or_member is None: if e.type is None: - result.append( - (file, -1, -1, -1, -1, "note", "At top level:", e.allow_dups, None) - ) + result.append((file, -1, -1, -1, -1, "note", "At top level:", None)) else: result.append( - ( - file, - -1, - -1, - -1, - -1, - "note", - f'In class "{e.type}":', - e.allow_dups, - None, - ) + (file, -1, -1, -1, -1, "note", f'In class "{e.type}":', None) ) else: if e.type is None: @@ -1095,7 +1067,6 @@ def render_messages(self, errors: list[ErrorInfo]) -> list[ErrorTuple]: -1, "note", f'In function "{e.function_or_member}":', - e.allow_dups, None, ) ) @@ -1111,32 +1082,17 @@ def render_messages(self, errors: list[ErrorInfo]) -> list[ErrorTuple]: 'In member "{}" of class "{}":'.format( e.function_or_member, e.type ), - e.allow_dups, None, ) ) elif e.type != prev_type: if e.type is None: - result.append( - (file, -1, -1, -1, -1, "note", "At top level:", e.allow_dups, None) - ) + result.append((file, -1, -1, -1, -1, "note", "At top level:", None)) else: - result.append( - (file, -1, -1, -1, -1, "note", f'In class "{e.type}":', e.allow_dups, None) - ) + result.append((file, -1, -1, -1, -1, "note", f'In class "{e.type}":', None)) result.append( - ( - file, - e.line, - e.column, - e.end_line, - e.end_column, - e.severity, - e.message, - e.allow_dups, - e.code, - ) + (file, e.line, e.column, e.end_line, e.end_column, e.severity, e.message, e.code) ) prev_import_context = e.import_ctx @@ -1198,40 +1154,24 @@ def sort_within_context(self, errors: list[ErrorInfo]) -> list[ErrorInfo]: result.extend(a) return result - def remove_duplicates(self, errors: list[ErrorTuple]) -> list[ErrorTuple]: - """Remove duplicates from a sorted error list.""" - res: list[ErrorTuple] = [] - i = 0 - while i < len(errors): - dup = False - # Use slightly special formatting for member conflicts reporting. - conflicts_notes = False - j = i - 1 - # Find duplicates, unless duplicates are allowed. - if not errors[i][7]: - while j >= 0 and errors[j][0] == errors[i][0]: - if errors[j][6].strip() == "Got:": - conflicts_notes = True - j -= 1 - j = i - 1 - while j >= 0 and errors[j][0] == errors[i][0] and errors[j][1] == errors[i][1]: - if ( - errors[j][5] == errors[i][5] - and - # Allow duplicate notes in overload conflicts reporting. - not ( - (errors[i][5] == "note" and errors[i][6].strip() in allowed_duplicates) - or (errors[i][6].strip().startswith("def ") and conflicts_notes) - ) - and errors[j][6] == errors[i][6] - ): # ignore column - dup = True - break - j -= 1 - if not dup: - res.append(errors[i]) - i += 1 - return res + def remove_duplicates(self, errors: list[ErrorInfo]) -> list[ErrorInfo]: + filtered_errors = [] + seen_by_line: defaultdict[int, set[tuple[str, str]]] = defaultdict(set) + removed = set() + for err in errors: + if err.parent_error is not None: + # Notes with specified parent are removed together with error below. + filtered_errors.append(err) + elif (err.severity, err.message) not in seen_by_line[err.line]: + filtered_errors.append(err) + seen_by_line[err.line].add((err.severity, err.message)) + else: + removed.add(err) + return [ + err + for err in filtered_errors + if err.parent_error is None or err.parent_error not in removed + ] class CompileError(Exception): @@ -1380,7 +1320,7 @@ def create_errors(error_tuples: list[ErrorTuple]) -> list[MypyError]: latest_error_at_location: dict[_ErrorLocation, MypyError] = {} for error_tuple in error_tuples: - file_path, line, column, _, _, severity, message, _, errorcode = error_tuple + file_path, line, column, _, _, severity, message, errorcode = error_tuple if file_path is None: continue diff --git a/mypy/messages.py b/mypy/messages.py index 9c4c141c4a79..46ade80df61d 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -230,9 +230,9 @@ def report( file: str | None = None, origin: Context | None = None, offset: int = 0, - allow_dups: bool = False, secondary_context: Context | None = None, - ) -> None: + parent_error: ErrorInfo | None = None, + ) -> ErrorInfo: """Report an error or note (unless disabled). Note that context controls where error is reported, while origin controls @@ -267,7 +267,7 @@ def span_from_context(ctx: Context) -> Iterable[int]: assert origin_span is not None origin_span = itertools.chain(origin_span, span_from_context(secondary_context)) - self.errors.report( + return self.errors.report( context.line if context else -1, context.column if context else -1, msg, @@ -278,7 +278,7 @@ def span_from_context(ctx: Context) -> Iterable[int]: end_line=context.end_line if context else -1, end_column=context.end_column if context else -1, code=code, - allow_dups=allow_dups, + parent_error=parent_error, ) def fail( @@ -288,18 +288,11 @@ def fail( *, code: ErrorCode | None = None, file: str | None = None, - allow_dups: bool = False, secondary_context: Context | None = None, - ) -> None: + ) -> ErrorInfo: """Report an error message (unless disabled).""" - self.report( - msg, - context, - "error", - code=code, - file=file, - allow_dups=allow_dups, - secondary_context=secondary_context, + return self.report( + msg, context, "error", code=code, file=file, secondary_context=secondary_context ) def note( @@ -309,10 +302,10 @@ def note( file: str | None = None, origin: Context | None = None, offset: int = 0, - allow_dups: bool = False, *, code: ErrorCode | None = None, secondary_context: Context | None = None, + parent_error: ErrorInfo | None = None, ) -> None: """Report a note (unless disabled).""" self.report( @@ -322,9 +315,9 @@ def note( file=file, origin=origin, offset=offset, - allow_dups=allow_dups, code=code, secondary_context=secondary_context, + parent_error=parent_error, ) def note_multiline( @@ -333,7 +326,6 @@ def note_multiline( context: Context, file: str | None = None, offset: int = 0, - allow_dups: bool = False, code: ErrorCode | None = None, *, secondary_context: Context | None = None, @@ -346,7 +338,6 @@ def note_multiline( "note", file=file, offset=offset, - allow_dups=allow_dups, code=code, secondary_context=secondary_context, ) @@ -574,7 +565,7 @@ def unsupported_operand_types( context: Context, *, code: ErrorCode = codes.OPERATOR, - ) -> None: + ) -> ErrorInfo: """Report unsupported operand types for a binary operation. Types can be Type objects or strings. @@ -595,7 +586,7 @@ def unsupported_operand_types( msg = f"Unsupported operand types for {op} (likely involving Union)" else: msg = f"Unsupported operand types for {op} ({left_str} and {right_str})" - self.fail(msg, context, code=code) + return self.fail(msg, context, code=code) def unsupported_left_operand(self, op: str, typ: Type, context: Context) -> None: if self.are_type_names_disabled(): @@ -627,7 +618,7 @@ def incompatible_argument( object_type: Type | None, context: Context, outer_context: Context, - ) -> ErrorCode | None: + ) -> ErrorInfo: """Report an error about an incompatible argument type. The argument type is arg_type, argument number is n and the @@ -655,27 +646,24 @@ def incompatible_argument( if name.startswith(f'"{variant}" of'): if op == "in" or variant != method: # Reversed order of base/argument. - self.unsupported_operand_types( + return self.unsupported_operand_types( op, arg_type, base, context, code=codes.OPERATOR ) else: - self.unsupported_operand_types( + return self.unsupported_operand_types( op, base, arg_type, context, code=codes.OPERATOR ) - return codes.OPERATOR if name.startswith('"__getitem__" of'): - self.invalid_index_type( + return self.invalid_index_type( arg_type, callee.arg_types[n - 1], base, context, code=codes.INDEX ) - return codes.INDEX if name.startswith('"__setitem__" of'): if n == 1: - self.invalid_index_type( + return self.invalid_index_type( arg_type, callee.arg_types[n - 1], base, context, code=codes.INDEX ) - return codes.INDEX else: arg_type_str, callee_type_str = format_type_distinctly( arg_type, callee.arg_types[n - 1], options=self.options @@ -686,8 +674,7 @@ def incompatible_argument( error_msg = ( message_registry.INCOMPATIBLE_TYPES_IN_ASSIGNMENT.with_additional_msg(info) ) - self.fail(error_msg.value, context, code=error_msg.code) - return error_msg.code + return self.fail(error_msg.value, context, code=error_msg.code) target = f"to {name} " @@ -841,18 +828,18 @@ def incompatible_argument( code = codes.TYPEDDICT_ITEM else: code = codes.ARG_TYPE - self.fail(msg, context, code=code) + error = self.fail(msg, context, code=code) if notes: for note_msg in notes: self.note(note_msg, context, code=code) - return code + return error def incompatible_argument_note( self, original_caller_type: ProperType, callee_type: ProperType, context: Context, - code: ErrorCode | None, + parent_error: ErrorInfo, ) -> None: if self.prefer_simple_messages(): return @@ -861,26 +848,28 @@ def incompatible_argument_note( ): if isinstance(callee_type, Instance) and callee_type.type.is_protocol: self.report_protocol_problems( - original_caller_type, callee_type, context, code=code + original_caller_type, callee_type, context, parent_error=parent_error ) if isinstance(callee_type, UnionType): for item in callee_type.items: item = get_proper_type(item) if isinstance(item, Instance) and item.type.is_protocol: self.report_protocol_problems( - original_caller_type, item, context, code=code + original_caller_type, item, context, parent_error=parent_error ) if isinstance(callee_type, CallableType) and isinstance(original_caller_type, Instance): call = find_member( "__call__", original_caller_type, original_caller_type, is_operator=True ) if call: - self.note_call(original_caller_type, call, context, code=code) + self.note_call(original_caller_type, call, context, code=parent_error.code) if isinstance(callee_type, Instance) and callee_type.type.is_protocol: call = find_member("__call__", callee_type, callee_type, is_operator=True) if call: - self.note_call(callee_type, call, context, code=code) - self.maybe_note_concatenate_pos_args(original_caller_type, callee_type, context, code) + self.note_call(callee_type, call, context, code=parent_error.code) + self.maybe_note_concatenate_pos_args( + original_caller_type, callee_type, context, parent_error.code + ) def maybe_note_concatenate_pos_args( self, @@ -922,11 +911,11 @@ def invalid_index_type( context: Context, *, code: ErrorCode, - ) -> None: + ) -> ErrorInfo: index_str, expected_str = format_type_distinctly( index_type, expected_type, options=self.options ) - self.fail( + return self.fail( "Invalid index type {} for {}; expected type {}".format( index_str, base_str, expected_str ), @@ -1193,16 +1182,16 @@ def signature_incompatible_with_supertype( original: ProperType, override: ProperType, ) -> None: - code = codes.OVERRIDE target = self.override_target(name, name_in_super, supertype) - self.fail(f'Signature of "{name}" incompatible with {target}', context, code=code) + error = self.fail( + f'Signature of "{name}" incompatible with {target}', context, code=codes.OVERRIDE + ) original_str, override_str = format_type_distinctly( original, override, options=self.options, bare=True ) INCLUDE_DECORATOR = True # Include @classmethod and @staticmethod decorators, if any - ALLOW_DUPS = True # Allow duplicate notes, needed when signatures are duplicates ALIGN_OFFSET = 1 # One space, to account for the difference between error and note OFFSET = 4 # Four spaces, so that notes will look like this: # error: Signature of "f" incompatible with supertype "A" @@ -1210,69 +1199,49 @@ def signature_incompatible_with_supertype( # note: def f(self) -> str # note: Subclass: # note: def f(self, x: str) -> None - self.note( - "Superclass:", context, offset=ALIGN_OFFSET + OFFSET, allow_dups=ALLOW_DUPS, code=code - ) + self.note("Superclass:", context, offset=ALIGN_OFFSET + OFFSET, parent_error=error) if isinstance(original, (CallableType, Overloaded)): self.pretty_callable_or_overload( original, context, offset=ALIGN_OFFSET + 2 * OFFSET, add_class_or_static_decorator=INCLUDE_DECORATOR, - allow_dups=ALLOW_DUPS, - code=code, + parent_error=error, ) else: - self.note( - original_str, - context, - offset=ALIGN_OFFSET + 2 * OFFSET, - allow_dups=ALLOW_DUPS, - code=code, - ) + self.note(original_str, context, offset=ALIGN_OFFSET + 2 * OFFSET, parent_error=error) - self.note( - "Subclass:", context, offset=ALIGN_OFFSET + OFFSET, allow_dups=ALLOW_DUPS, code=code - ) + self.note("Subclass:", context, offset=ALIGN_OFFSET + OFFSET, parent_error=error) if isinstance(override, (CallableType, Overloaded)): self.pretty_callable_or_overload( override, context, offset=ALIGN_OFFSET + 2 * OFFSET, add_class_or_static_decorator=INCLUDE_DECORATOR, - allow_dups=ALLOW_DUPS, - code=code, + parent_error=error, ) else: - self.note( - override_str, - context, - offset=ALIGN_OFFSET + 2 * OFFSET, - allow_dups=ALLOW_DUPS, - code=code, - ) + self.note(override_str, context, offset=ALIGN_OFFSET + 2 * OFFSET, parent_error=error) def pretty_callable_or_overload( self, tp: CallableType | Overloaded, context: Context, *, + parent_error: ErrorInfo, offset: int = 0, add_class_or_static_decorator: bool = False, - allow_dups: bool = False, - code: ErrorCode | None = None, ) -> None: if isinstance(tp, CallableType): if add_class_or_static_decorator: decorator = pretty_class_or_static_decorator(tp) if decorator is not None: - self.note(decorator, context, offset=offset, allow_dups=allow_dups, code=code) + self.note(decorator, context, offset=offset, parent_error=parent_error) self.note( pretty_callable(tp, self.options), context, offset=offset, - allow_dups=allow_dups, - code=code, + parent_error=parent_error, ) elif isinstance(tp, Overloaded): self.pretty_overload( @@ -1280,8 +1249,7 @@ def pretty_callable_or_overload( context, offset, add_class_or_static_decorator=add_class_or_static_decorator, - allow_dups=allow_dups, - code=code, + parent_error=parent_error, ) def argument_incompatible_with_supertype( @@ -1533,14 +1501,14 @@ def incompatible_self_argument( def incompatible_conditional_function_def( self, defn: FuncDef, old_type: FunctionLike, new_type: FunctionLike ) -> None: - self.fail("All conditional function variants must have identical signatures", defn) + error = self.fail("All conditional function variants must have identical signatures", defn) if isinstance(old_type, (CallableType, Overloaded)) and isinstance( new_type, (CallableType, Overloaded) ): self.note("Original:", defn) - self.pretty_callable_or_overload(old_type, defn, offset=4) + self.pretty_callable_or_overload(old_type, defn, offset=4, parent_error=error) self.note("Redefinition:", defn) - self.pretty_callable_or_overload(new_type, defn, offset=4) + self.pretty_callable_or_overload(new_type, defn, offset=4, parent_error=error) def cannot_instantiate_abstract_class( self, class_name: str, abstract_attributes: dict[str, bool], context: Context @@ -2120,7 +2088,7 @@ def report_protocol_problems( supertype: Instance, context: Context, *, - code: ErrorCode | None, + parent_error: ErrorInfo, ) -> None: """Report possible protocol conflicts between 'subtype' and 'supertype'. @@ -2184,7 +2152,7 @@ def report_protocol_problems( subtype.type.name, supertype.type.name ), context, - code=code, + parent_error=parent_error, ) else: self.note( @@ -2192,9 +2160,9 @@ def report_protocol_problems( subtype.type.name, supertype.type.name, plural_s(missing) ), context, - code=code, + parent_error=parent_error, ) - self.note(", ".join(missing), context, offset=OFFSET, code=code) + self.note(", ".join(missing), context, offset=OFFSET, parent_error=parent_error) elif len(missing) > MAX_ITEMS or len(missing) == len(supertype.type.protocol_members): # This is an obviously wrong type: too many missing members return @@ -2212,7 +2180,11 @@ def report_protocol_problems( or supertype.type.has_param_spec_type ): type_name = format_type(subtype, self.options, module_names=True) - self.note(f"Following member(s) of {type_name} have conflicts:", context, code=code) + self.note( + f"Following member(s) of {type_name} have conflicts:", + context, + parent_error=parent_error, + ) for name, got, exp, is_lvalue in conflict_types[:MAX_ITEMS]: exp = get_proper_type(exp) got = get_proper_type(got) @@ -2233,45 +2205,56 @@ def report_protocol_problems( ), context, offset=OFFSET, - code=code, + parent_error=parent_error, ) if is_lvalue and is_subtype(got, exp, options=self.options): self.note( "Setter types should behave contravariantly", context, offset=OFFSET, - code=code, + parent_error=parent_error, ) else: self.note( - "Expected{}:".format(setter_suffix), context, offset=OFFSET, code=code + "Expected{}:".format(setter_suffix), + context, + offset=OFFSET, + parent_error=parent_error, ) if isinstance(exp, CallableType): self.note( pretty_callable(exp, self.options, skip_self=class_obj or is_module), context, offset=2 * OFFSET, - code=code, + parent_error=parent_error, ) else: assert isinstance(exp, Overloaded) self.pretty_overload( - exp, context, 2 * OFFSET, code=code, skip_self=class_obj or is_module + exp, + context, + 2 * OFFSET, + parent_error=parent_error, + skip_self=class_obj or is_module, ) - self.note("Got:", context, offset=OFFSET, code=code) + self.note("Got:", context, offset=OFFSET, parent_error=parent_error) if isinstance(got, CallableType): self.note( pretty_callable(got, self.options, skip_self=class_obj or is_module), context, offset=2 * OFFSET, - code=code, + parent_error=parent_error, ) else: assert isinstance(got, Overloaded) self.pretty_overload( - got, context, 2 * OFFSET, code=code, skip_self=class_obj or is_module + got, + context, + 2 * OFFSET, + parent_error=parent_error, + skip_self=class_obj or is_module, ) - self.print_more(conflict_types, context, OFFSET, MAX_ITEMS, code=code) + self.print_more(conflict_types, context, OFFSET, MAX_ITEMS, code=parent_error.code) # Report flag conflicts (i.e. settable vs read-only etc.) conflict_flags = get_bad_protocol_flags(subtype, supertype, class_obj=class_obj) @@ -2282,7 +2265,7 @@ def report_protocol_problems( supertype.type.name, name ), context, - code=code, + parent_error=parent_error, ) if not class_obj and IS_CLASSVAR in superflags and IS_CLASSVAR not in subflags: self.note( @@ -2290,14 +2273,14 @@ def report_protocol_problems( supertype.type.name, name ), context, - code=code, + parent_error=parent_error, ) if IS_SETTABLE in superflags and IS_SETTABLE not in subflags: self.note( "Protocol member {}.{} expected settable variable," " got read-only attribute".format(supertype.type.name, name), context, - code=code, + parent_error=parent_error, ) if IS_CLASS_OR_STATIC in superflags and IS_CLASS_OR_STATIC not in subflags: self.note( @@ -2305,7 +2288,7 @@ def report_protocol_problems( supertype.type.name, name ), context, - code=code, + parent_error=parent_error, ) if ( class_obj @@ -2316,7 +2299,7 @@ def report_protocol_problems( "Only class variables allowed for class object access on protocols," ' {} is an instance variable of "{}"'.format(name, subtype.type.name), context, - code=code, + parent_error=parent_error, ) if class_obj and IS_CLASSVAR in superflags: self.note( @@ -2324,9 +2307,9 @@ def report_protocol_problems( supertype.type.name, name ), context, - code=code, + parent_error=parent_error, ) - self.print_more(conflict_flags, context, OFFSET, MAX_ITEMS, code=code) + self.print_more(conflict_flags, context, OFFSET, MAX_ITEMS, code=parent_error.code) def pretty_overload( self, @@ -2334,25 +2317,23 @@ def pretty_overload( context: Context, offset: int, *, + parent_error: ErrorInfo, add_class_or_static_decorator: bool = False, - allow_dups: bool = False, - code: ErrorCode | None = None, skip_self: bool = False, ) -> None: for item in tp.items: - self.note("@overload", context, offset=offset, allow_dups=allow_dups, code=code) + self.note("@overload", context, offset=offset, parent_error=parent_error) if add_class_or_static_decorator: decorator = pretty_class_or_static_decorator(item) if decorator is not None: - self.note(decorator, context, offset=offset, allow_dups=allow_dups, code=code) + self.note(decorator, context, offset=offset, parent_error=parent_error) self.note( pretty_callable(item, self.options, skip_self=skip_self), context, offset=offset, - allow_dups=allow_dups, - code=code, + parent_error=parent_error, ) def print_more( diff --git a/mypy/plugin.py b/mypy/plugin.py index de075866d613..831721eb193c 100644 --- a/mypy/plugin.py +++ b/mypy/plugin.py @@ -124,6 +124,7 @@ class C: pass from mypy_extensions import mypyc_attr, trait from mypy.errorcodes import ErrorCode +from mypy.errors import ErrorInfo from mypy.lookup import lookup_fully_qualified from mypy.message_registry import ErrorMessage from mypy.nodes import ( @@ -240,7 +241,7 @@ def type_context(self) -> list[Type | None]: @abstractmethod def fail( self, msg: str | ErrorMessage, ctx: Context, /, *, code: ErrorCode | None = None - ) -> None: + ) -> ErrorInfo | None: """Emit an error message at given location.""" raise NotImplementedError diff --git a/mypy/typeanal.py b/mypy/typeanal.py index a8d5f1b304fe..cce643c16d20 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -9,6 +9,7 @@ from mypy import errorcodes as codes, message_registry, nodes from mypy.errorcodes import ErrorCode +from mypy.errors import ErrorInfo from mypy.expandtype import expand_type from mypy.message_registry import ( INVALID_PARAM_SPEC_LOCATION, @@ -1990,7 +1991,9 @@ def tuple_type(self, items: list[Type], line: int, column: int) -> TupleType: class MsgCallback(Protocol): - def __call__(self, __msg: str, __ctx: Context, *, code: ErrorCode | None = None) -> None: ... + def __call__( + self, __msg: str, __ctx: Context, *, code: ErrorCode | None = None + ) -> ErrorInfo | None: ... def get_omitted_any( diff --git a/test-data/unit/check-classes.test b/test-data/unit/check-classes.test index dc421cbd43b9..a99fc193ccce 100644 --- a/test-data/unit/check-classes.test +++ b/test-data/unit/check-classes.test @@ -8736,3 +8736,25 @@ class NoopPowerResource: def hardware_type(self) -> None: # E: Invalid property setter signature self.hardware_type = None # Note: intentionally recursive [builtins fixtures/property.pyi] + +[case testOverrideErrorReportingNoDuplicates] +from typing import Callable, Protocol, Generic, TypeVar + +def nested() -> None: + class B: + def meth(self, x: str) -> int: ... + class C(B): + def meth(self) -> str: # E: Signature of "meth" incompatible with supertype "B" \ + # N: Superclass: \ + # N: def meth(self, x: str) -> int \ + # N: Subclass: \ + # N: def meth(self) -> str + pass + x = defer() + +T = TypeVar("T") +def deco(fn: Callable[[], T]) -> Callable[[], list[T]]: ... + +@deco +def defer() -> int: ... +[builtins fixtures/list.pyi] diff --git a/test-data/unit/check-narrowing.test b/test-data/unit/check-narrowing.test index 6febe253d316..40da64361bab 100644 --- a/test-data/unit/check-narrowing.test +++ b/test-data/unit/check-narrowing.test @@ -2366,7 +2366,6 @@ class A: if z: z[0] + "v" # E: Unsupported operand types for + ("int" and "str") z.append(1) - [builtins fixtures/primitives.pyi] [case testPersistentUnreachableLinesNestedInInpersistentUnreachableLines] @@ -2379,7 +2378,6 @@ while True: if y is not None: reveal_type(y) # E: Statement is unreachable x = 1 - [builtins fixtures/bool.pyi] [case testAvoidFalseRedundantCastInLoops] @@ -2401,7 +2399,6 @@ def main_no_cast(p: Processor) -> None: ed = cast(str, ...) while True: ed = p(ed) # E: Argument 1 has incompatible type "Union[str, int]"; expected "str" - [builtins fixtures/bool.pyi] [case testAvoidFalseUnreachableInLoop1] @@ -2414,7 +2411,6 @@ x: int | None x = 1 while x is not None or b(): x = f() - [builtins fixtures/bool.pyi] [case testAvoidFalseUnreachableInLoop2] @@ -2425,7 +2421,6 @@ while y is None: if y is None: y = [] y.append(1) - [builtins fixtures/list.pyi] [case testAvoidFalseUnreachableInLoop3] @@ -2436,8 +2431,7 @@ y = None for x in xs: if x is not None: if y is None: - y = {} # E: Need type annotation for "y" (hint: "y: Dict[, ] = ...") - + y = {} # E: Need type annotation for "y" (hint: "y: dict[, ] = ...") [builtins fixtures/list.pyi] [case testAvoidFalseRedundantExprInLoop] @@ -2450,7 +2444,6 @@ x: int | None x = 1 while x is not None and b(): x = f() - [builtins fixtures/primitives.pyi] [case testNarrowingTypeVarMultiple] diff --git a/test-data/unit/check-protocols.test b/test-data/unit/check-protocols.test index f2b8fc7a0e14..bc05e5dacf13 100644 --- a/test-data/unit/check-protocols.test +++ b/test-data/unit/check-protocols.test @@ -4551,3 +4551,28 @@ class Test(Generic[T]): t = Test(Mock()) reveal_type(t) # N: Revealed type is "__main__.Test[Any]" [builtins fixtures/dict.pyi] + +[case testProtocolErrorReportingNoDuplicates] +from typing import Callable, Protocol, Generic, TypeVar + +class P(Protocol): + def meth(self) -> int: ... + +class C: + def meth(self) -> str: ... + +def foo() -> None: + c: P = C() # E: Incompatible types in assignment (expression has type "C", variable has type "P") \ + # N: Following member(s) of "C" have conflicts: \ + # N: Expected: \ + # N: def meth(self) -> int \ + # N: Got: \ + # N: def meth(self) -> str + x = defer() + +T = TypeVar("T") +def deco(fn: Callable[[], T]) -> Callable[[], list[T]]: ... + +@deco +def defer() -> int: ... +[builtins fixtures/list.pyi] diff --git a/test-data/unit/fine-grained-dataclass-transform.test b/test-data/unit/fine-grained-dataclass-transform.test index 89628256fda5..76ffeeb347c7 100644 --- a/test-data/unit/fine-grained-dataclass-transform.test +++ b/test-data/unit/fine-grained-dataclass-transform.test @@ -88,7 +88,6 @@ class A(Dataclass): main:7: error: Unexpected keyword argument "x" for "B" builtins.pyi:14: note: "B" defined here main:7: error: Unexpected keyword argument "y" for "B" -builtins.pyi:14: note: "B" defined here == [case frozenInheritanceViaDefault] From c5562c59c8130ca1e65a6baacee91076ce46620f Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sat, 7 Jun 2025 18:12:21 +0100 Subject: [PATCH 2/2] More tests --- test-data/unit/check-classes.test | 2 +- test-data/unit/check-functions.test | 39 +++++++++++++++++++++++++++++ test-data/unit/check-protocols.test | 2 +- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/test-data/unit/check-classes.test b/test-data/unit/check-classes.test index 5786cc95e8fd..af0dd4fb8eed 100644 --- a/test-data/unit/check-classes.test +++ b/test-data/unit/check-classes.test @@ -8738,7 +8738,7 @@ class NoopPowerResource: [builtins fixtures/property.pyi] [case testOverrideErrorReportingNoDuplicates] -from typing import Callable, Protocol, Generic, TypeVar +from typing import Callable, TypeVar def nested() -> None: class B: diff --git a/test-data/unit/check-functions.test b/test-data/unit/check-functions.test index 4b980f102c52..ceb7af433dce 100644 --- a/test-data/unit/check-functions.test +++ b/test-data/unit/check-functions.test @@ -3655,3 +3655,42 @@ reveal_type(C().x6) # N: Revealed type is "def (x: builtins.int) -> builtins.st reveal_type(C().x7) # E: Invalid self argument "C" to attribute function "x7" with type "Callable[[int], str]" \ # N: Revealed type is "def () -> builtins.str" [builtins fixtures/classmethod.pyi] + +[case testFunctionRedefinitionDeferred] +from typing import Callable, TypeVar + +def outer() -> None: + if bool(): + def inner() -> str: ... + else: + def inner() -> int: ... # E: All conditional function variants must have identical signatures \ + # N: Original: \ + # N: def inner() -> str \ + # N: Redefinition: \ + # N: def inner() -> int + x = defer() + +T = TypeVar("T") +def deco(fn: Callable[[], T]) -> Callable[[], list[T]]: ... + +@deco +def defer() -> int: ... +[builtins fixtures/list.pyi] + +[case testCheckFunctionErrorContextDuplicateDeferred] +# flags: --show-error-context +from typing import Callable, TypeVar + +def a() -> None: + def b() -> None: + 1 + "" + x = defer() + +T = TypeVar("T") +def deco(fn: Callable[[], T]) -> Callable[[], list[T]]: ... + +@deco +def defer() -> int: ... +[out] +main: note: In function "a": +main:6: error: Unsupported operand types for + ("int" and "str") diff --git a/test-data/unit/check-protocols.test b/test-data/unit/check-protocols.test index bc05e5dacf13..4d7f46e5de2b 100644 --- a/test-data/unit/check-protocols.test +++ b/test-data/unit/check-protocols.test @@ -4553,7 +4553,7 @@ reveal_type(t) # N: Revealed type is "__main__.Test[Any]" [builtins fixtures/dict.pyi] [case testProtocolErrorReportingNoDuplicates] -from typing import Callable, Protocol, Generic, TypeVar +from typing import Callable, Protocol, TypeVar class P(Protocol): def meth(self) -> int: ...