From ecf30fc719e6e3af585730ec8204fd672130d658 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sat, 14 Feb 2026 16:26:10 +0000 Subject: [PATCH 1/3] Refactor/cleanup error handling code --- mypy/build.py | 37 ++----- mypy/cache.py | 10 +- mypy/checker.py | 2 +- mypy/error_formatter.py | 2 +- mypy/errors.py | 232 ++++++++++++++++------------------------ mypy/messages.py | 74 ++++++------- mypy/server/update.py | 2 +- 7 files changed, 142 insertions(+), 217 deletions(-) diff --git a/mypy/build.py b/mypy/build.py index 3faa2e09d0c97..68aa15f629414 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -61,9 +61,9 @@ LIST_GEN, LITERAL_NONE, CacheMeta, + ErrorTuple, JsonValue, ReadBuffer, - SerializedError, Tag, WriteBuffer, read_bytes, @@ -90,7 +90,7 @@ WORKER_START_TIMEOUT, ) from mypy.error_formatter import OUTPUT_CHOICES, ErrorFormatter -from mypy.errors import CompileError, ErrorInfo, Errors, ErrorTuple, report_internal_error +from mypy.errors import CompileError, ErrorInfo, Errors, report_internal_error from mypy.graph_utils import prepare_sccs, strongly_connected_components, topsort from mypy.indirection import TypeIndirectionVisitor from mypy.ipc import ( @@ -2151,7 +2151,7 @@ class State: dep_hashes: dict[str, bytes] # List of errors reported for this file last time. - error_lines: list[SerializedError] + error_lines: list[ErrorTuple] # Parent package, its parent, etc. ancestors: list[str] | None = None @@ -2380,7 +2380,7 @@ def __init__( priorities: dict[str, int], dep_line_map: dict[str, int], dep_hashes: dict[str, bytes], - error_lines: list[SerializedError], + error_lines: list[ErrorTuple], imports_ignored: dict[int, list[str]], size_hint: int = 0, ) -> None: @@ -3923,9 +3923,7 @@ def find_stale_sccs( if graph[id].error_lines: path = manager.errors.simplify_path(graph[id].xpath) formatted = manager.errors.format_messages( - path, - deserialize_codes(graph[id].error_lines), - formatter=manager.error_formatter, + path, graph[id].error_lines, formatter=manager.error_formatter ) manager.flush_errors(path, formatted, False) fresh_sccs.append(ascc) @@ -4206,7 +4204,7 @@ def process_stale_scc( continue meta, meta_file = meta_tuple meta.dep_hashes = [graph[dep].interface_hash for dep in graph[id].dependencies] - meta.error_lines = serialize_codes(errors_by_id.get(id, [])) + meta.error_lines = errors_by_id.get(id, []) write_cache_meta(meta, manager, meta_file) manager.done_sccs.add(ascc.id) manager.add_stats( @@ -4376,29 +4374,6 @@ def write_undocumented_ref_info( metastore.write(ref_info_file, json_dumps(deps_json)) -def serialize_codes(errs: list[ErrorTuple]) -> list[SerializedError]: - return [ - (path, line, column, end_line, end_column, severity, message, code.code if code else None) - for path, line, column, end_line, end_column, severity, message, code in errs - ] - - -def deserialize_codes(errs: list[SerializedError]) -> list[ErrorTuple]: - return [ - ( - path, - line, - column, - end_line, - end_column, - severity, - message, - codes.error_codes.get(code) if code else None, - ) - for path, line, column, end_line, end_column, severity, message, code in errs - ] - - # The IPC message classes and tags for communication with build workers are # in this file to avoid import cycles. # Note that we use a more compact fixed serialization format than in cache.py. diff --git a/mypy/cache.py b/mypy/cache.py index c8ccb237ed4f7..9e3b39ffaaa40 100644 --- a/mypy/cache.py +++ b/mypy/cache.py @@ -71,7 +71,9 @@ # High-level cache layout format CACHE_VERSION: Final = 5 -SerializedError: _TypeAlias = tuple[str | None, int, int, int, int, str, str, str | None] +# Type used internally to represent errors: +# (path, line, column, end_line, end_column, severity, message, code) +ErrorTuple: _TypeAlias = tuple[str | None, int, int, int, int, str, str, str | None] class CacheMeta: @@ -97,7 +99,7 @@ def __init__( dep_hashes: list[bytes], interface_hash: bytes, trans_dep_hash: bytes, - error_lines: list[SerializedError], + error_lines: list[ErrorTuple], version_id: str, ignore_all: bool, plugin_data: Any, @@ -498,7 +500,7 @@ def write_json(data: WriteBuffer, value: dict[str, Any]) -> None: write_json_value(data, value[key]) -def write_errors(data: WriteBuffer, errs: list[SerializedError]) -> None: +def write_errors(data: WriteBuffer, errs: list[ErrorTuple]) -> None: write_tag(data, LIST_GEN) write_int_bare(data, len(errs)) for path, line, column, end_line, end_column, severity, message, code in errs: @@ -513,7 +515,7 @@ def write_errors(data: WriteBuffer, errs: list[SerializedError]) -> None: write_str_opt(data, code) -def read_errors(data: ReadBuffer) -> list[SerializedError]: +def read_errors(data: ReadBuffer) -> list[ErrorTuple]: assert read_tag(data) == LIST_GEN result = [] for _ in range(read_int_bare(data)): diff --git a/mypy/checker.py b/mypy/checker.py index 33db99943b288..ea3e9f072afc8 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -2601,7 +2601,7 @@ def erase_override(t: Type) -> Type: original_arg_type, supertype, context, - secondary_context=node, + origin_context=node, ) emitted_msg = True diff --git a/mypy/error_formatter.py b/mypy/error_formatter.py index ea79affbddd0e..ebb962e4641fe 100644 --- a/mypy/error_formatter.py +++ b/mypy/error_formatter.py @@ -30,7 +30,7 @@ def report_error(self, error: "MypyError") -> str: "end_column": error.end_column, "message": error.message, "hint": None if len(error.hints) == 0 else "\n".join(error.hints), - "code": None if error.errorcode is None else error.errorcode.code, + "code": error.errorcode, "severity": error.severity, } ) diff --git a/mypy/errors.py b/mypy/errors.py index 8f14c937de46e..e0fcd5a316ef2 100644 --- a/mypy/errors.py +++ b/mypy/errors.py @@ -6,10 +6,11 @@ from collections import defaultdict from collections.abc import Callable, Iterable, Iterator from itertools import chain -from typing import Final, Literal, NoReturn, TextIO, TypeAlias as _TypeAlias, TypeVar +from typing import Final, Literal, NoReturn, TextIO, TypeVar from typing_extensions import Self from mypy import errorcodes as codes +from mypy.cache import ErrorTuple from mypy.error_formatter import ErrorFormatter from mypy.errorcodes import IMPORT, IMPORT_NOT_FOUND, IMPORT_UNTYPED, ErrorCode, mypy_error_codes from mypy.nodes import Context @@ -23,7 +24,7 @@ # Show error codes for some note-level messages (these usually appear alone # and not as a comment for a previous error-level message). -SHOW_NOTE_CODES: Final = {codes.ANNOTATION_UNCHECKED, codes.DEPRECATED} +SHOW_NOTE_CODES: Final = {codes.ANNOTATION_UNCHECKED.code, codes.DEPRECATED.code} # Do not add notes with links to error code docs to errors with these codes. # We can tweak this set as we get more experience about what is helpful and what is not. @@ -54,55 +55,43 @@ class ErrorInfo: # Description of a sequence of imports that refer to the source file # related to this error. Each item is a (path, line number) tuple. import_ctx: list[tuple[str, int]] - - # The path to source file that was the source of this error. - file = "" - - # The fully-qualified id of the source module for this error. - module: str | None = None - - # The name of the type in which this error is located at. - type: str | None = "" # Unqualified, may be None - - # The name of the function or member in which this error is located at. - function_or_member: str | None = "" # Unqualified, may be None + # Type and function/method where this error occurred. Unqualified, may be None. + local_ctx: tuple[str | None, str | None] # The line number related to this error within file. line = 0 # -1 if unknown - # The column number related to this error with file. column = 0 # -1 if unknown - # The end line number related to this error within file. end_line = 0 # -1 if unknown - # The end column number related to this error with file. end_column = 0 # -1 if unknown - # Either 'error' or 'note' severity = "" - # The error message. message = "" - # The error code. code: ErrorCode | None = None # If True, we should halt build after the file that generated this error. blocker = False - # Only report this particular messages once per program. only_once = 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]] - + # These two are used by the daemon: + # The fully-qualified id of the source module for this error. + module: str | None # Fine-grained incremental target where this was reported - target: str | None = None - - # If True, don't show this message in output, but still record the error (needed - # by mypy daemon) + target: str | None + + # Lines where `type: ignores` will have effect on this error, for most errors + # this is just [line]. But sometimes may be custom, e.g. for override errors + # in methods with multi-line definition. + origin_span: Iterable[int] + # For errors on the same line you can use this to customize their sorting + # (lower value means show first). + priority: int + # If True, don't show this message in output, but still record the error. hidden = False # For notes, specifies (optionally) the error this note is attached to. This is used to @@ -111,12 +100,9 @@ class ErrorInfo: def __init__( self, - import_ctx: list[tuple[str, int]], *, - file: str, - module: str | None, - typ: str | None, - function_or_member: str | None, + import_ctx: list[tuple[str, int]], + local_ctx: tuple[str | None, str | None], line: int, column: int, end_line: int, @@ -126,16 +112,15 @@ def __init__( code: ErrorCode | None, blocker: bool, only_once: bool, - origin: tuple[str, Iterable[int]] | None = None, - target: str | None = None, + module: str | None, + target: str | None, + origin_span: Iterable[int] | None = None, priority: int = 0, parent_error: ErrorInfo | None = None, ) -> None: self.import_ctx = import_ctx - self.file = file self.module = module - self.type = typ - self.function_or_member = function_or_member + self.local_ctx = local_ctx self.line = line self.column = column self.end_line = end_line @@ -145,7 +130,7 @@ def __init__( self.code = code self.blocker = blocker self.only_once = only_once - self.origin = origin or (file, [line]) + self.origin_span = origin_span or [line] self.target = target self.priority = priority if parent_error is not None: @@ -153,11 +138,6 @@ def __init__( self.parent_error = parent_error -# Type used internally to represent errors: -# (path, line, column, end_line, end_column, severity, message, code) -ErrorTuple: _TypeAlias = tuple[str | None, int, int, int, int, str, str, ErrorCode | None] - - class ErrorWatcher: """Context manager that can be used to keep track of new errors recorded around a given operation. @@ -579,9 +559,11 @@ def report( blocker: if True, don't continue analysis after this error severity: 'error' or 'note' only_once: if True, only report this exact message once per build - 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 + origin_span: lines where `type: ignore`s have effect for this error + (default is [line]) + offset: number of spaces to prefix this message + end_line: if known, end line of error location + end_column: if known, end column of error location parent_error: an error this note is attached to (for notes only). """ if self.scope: @@ -593,6 +575,11 @@ def report( type = None function = None + # It looks like there is a bug in how we parse f-strings, + # we cannot simply assert this yet. + if end_line is None or end_line < line: + end_line = line + if column is None: column = -1 if end_column is None: @@ -600,25 +587,22 @@ def report( end_column = -1 else: end_column = column + 1 + if line == end_line and end_column <= column: + # Be defensive, similar to the logic for lines above. + end_column = column + 1 if offset: message = " " * offset + message - if origin_span is None: - origin_span = [line] - - if end_line is None: - end_line = line - code = code or (parent_error.code if parent_error else None) + if parent_error is not None: + assert code == parent_error.code, "Must have same error code as parent" + assert severity == "note", "Only notes can have parent errors" code = code or (codes.MISC if not blocker else None) info = ErrorInfo( import_ctx=self.import_context(), - file=self.file, - module=self.current_module(), - typ=type, - function_or_member=function, + local_ctx=(type, function), line=line, column=column, end_line=end_line, @@ -628,7 +612,8 @@ def report( code=code, blocker=blocker, only_once=only_once, - origin=(self.file, origin_span), + origin_span=origin_span, + module=self.current_module(), target=self.current_target(), parent_error=parent_error, ) @@ -663,8 +648,9 @@ def _filter_error(self, file: str, info: ErrorInfo) -> bool: """ return any(w.on_error(file, info) for w in self.get_watchers()) - def add_error_info(self, info: ErrorInfo) -> None: - file, lines = info.origin + def add_error_info(self, info: ErrorInfo, *, file: str | None = None) -> None: + lines = info.origin_span + file = file or self.file # process the stack of ErrorWatchers before modifying any internal state # in case we need to filter out the error entirely # NB: we need to do this both here and in _add_error_info, otherwise we @@ -702,7 +688,7 @@ def add_error_info(self, info: ErrorInfo) -> None: # showing too many errors to make it easier to see # import-related errors. info.hidden = True - self.report_hidden_errors(info) + self.report_hidden_errors(file, info) self._add_error_info(file, info) ignored_codes = self.ignored_lines.get(file, {}).get(info.line, []) if ignored_codes and info.code: @@ -720,10 +706,7 @@ def add_error_info(self, info: ErrorInfo) -> None: ) note = ErrorInfo( import_ctx=info.import_ctx, - file=info.file, - module=info.module, - typ=info.type, - function_or_member=info.function_or_member, + local_ctx=info.local_ctx, line=info.line, column=info.column, end_line=info.end_line, @@ -733,6 +716,9 @@ def add_error_info(self, info: ErrorInfo) -> None: code=None, blocker=False, only_once=False, + module=info.module, + target=info.target, + origin_span=info.origin_span, ) self._add_error_info(file, note) if ( @@ -748,10 +734,7 @@ def add_error_info(self, info: ErrorInfo) -> None: self.only_once_messages.add(message) info = ErrorInfo( import_ctx=info.import_ctx, - file=info.file, - module=info.module, - typ=info.type, - function_or_member=info.function_or_member, + local_ctx=info.local_ctx, line=info.line, column=info.column, end_line=info.end_line, @@ -761,6 +744,9 @@ def add_error_info(self, info: ErrorInfo) -> None: code=info.code, blocker=False, only_once=True, + module=info.module, + target=info.target, + origin_span=info.origin_span, priority=20, ) self._add_error_info(file, info) @@ -777,7 +763,7 @@ def has_many_errors(self) -> bool: return True return False - def report_hidden_errors(self, info: ErrorInfo) -> None: + def report_hidden_errors(self, file: str, info: ErrorInfo) -> None: message = ( "(Skipping most remaining errors due to unresolved imports or missing stubs; " + "fix these first)" @@ -787,10 +773,7 @@ def report_hidden_errors(self, info: ErrorInfo) -> None: self.only_once_messages.add(message) new_info = ErrorInfo( import_ctx=info.import_ctx, - file=info.file, - module=info.module, - typ=None, - function_or_member=None, + local_ctx=info.local_ctx, line=info.line, column=info.column, end_line=info.end_line, @@ -800,10 +783,11 @@ def report_hidden_errors(self, info: ErrorInfo) -> None: code=None, blocker=False, only_once=True, - origin=info.origin, + module=info.module, target=info.target, + origin_span=info.origin_span, ) - self._add_error_info(info.origin[0], new_info) + self._add_error_info(file, new_info) def is_ignored_error(self, line: int, info: ErrorInfo, ignores: dict[int, list[str]]) -> bool: if info.blocker: @@ -886,10 +870,7 @@ def generate_unused_ignore_errors(self, file: str, is_typeshed: bool = False) -> # Don't use report since add_error_info will ignore the error! info = ErrorInfo( import_ctx=self.import_context(), - file=file, - module=self.current_module(), - typ=None, - function_or_member=None, + local_ctx=(None, None), line=line, column=-1, end_line=line, @@ -899,8 +880,8 @@ def generate_unused_ignore_errors(self, file: str, is_typeshed: bool = False) -> code=codes.UNUSED_IGNORE, blocker=False, only_once=False, - origin=(self.file, [line]), - target=self.target_module, + module=self.current_module(), + target=self.current_target(), ) self._add_error_info(file, info) @@ -936,10 +917,7 @@ def generate_ignore_without_code_errors( # Don't use report since add_error_info will ignore the error! info = ErrorInfo( import_ctx=self.import_context(), - file=file, - module=self.current_module(), - typ=None, - function_or_member=None, + local_ctx=(None, None), line=line, column=-1, end_line=line, @@ -949,8 +927,8 @@ def generate_ignore_without_code_errors( code=codes.IGNORE_WITHOUT_CODE, blocker=False, only_once=False, - origin=(self.file, [line]), - target=self.target_module, + module=self.current_module(), + target=self.current_target(), ) self._add_error_info(file, info) @@ -1041,7 +1019,7 @@ def format_messages_default( ): # If note has an error code, it is related to a previous error. Avoid # displaying duplicate error codes. - s = f"{s} [{code.code}]" + s = f"{s} [{code}]" a.append(s) if self.options.pretty: # Add source code fragment and a location marker. @@ -1077,7 +1055,7 @@ def file_messages(self, path: str) -> list[ErrorTuple]: error_info = self.error_info_map[path] error_info = [info for info in error_info if not info.hidden] error_info = self.remove_duplicates(self.sort_messages(error_info)) - return self.render_messages(error_info) + return self.render_messages(path, error_info) def format_messages( self, path: str, error_tuples: list[ErrorTuple], formatter: ErrorFormatter | None = None @@ -1134,7 +1112,7 @@ def targets(self) -> set[str]: info.target for errs in self.error_info_map.values() for info in errs if info.target } - def render_messages(self, errors: list[ErrorInfo]) -> list[ErrorTuple]: + def render_messages(self, file: str, errors: list[ErrorInfo]) -> list[ErrorTuple]: """Translate the messages into a sequence of tuples. Each tuple is of form (path, line, col, severity, message, code). @@ -1142,9 +1120,10 @@ def render_messages(self, errors: list[ErrorInfo]) -> list[ErrorTuple]: The path item may be None. If the line item is negative, the line number is not defined for the tuple. """ + file = self.simplify_path(file) result: list[ErrorTuple] = [] prev_import_context: list[tuple[str, int]] = [] - prev_function_or_member: str | None = None + prev_function: str | None = None prev_type: str | None = None for e in errors: @@ -1169,61 +1148,38 @@ def render_messages(self, errors: list[ErrorInfo]) -> list[ErrorTuple]: result.append((None, -1, -1, -1, -1, "note", fmt.format(path, line), None)) i -= 1 - file = self.simplify_path(e.file) - # Report context within a source file. + type, function = e.local_ctx if not self.options.show_error_context: pass - 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: + elif function != prev_function or type != prev_type: + if function is None: + if type is 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}":', None) - ) + result.append((file, -1, -1, -1, -1, "note", f'In class "{type}":', None)) else: - if e.type is None: - result.append( - ( - file, - -1, - -1, - -1, - -1, - "note", - f'In function "{e.function_or_member}":', - None, - ) - ) + + if type is None: + msg = f'In function "{function}":' else: - result.append( - ( - file, - -1, - -1, - -1, - -1, - "note", - 'In member "{}" of class "{}":'.format( - e.function_or_member, e.type - ), - None, - ) - ) - elif e.type != prev_type: - if e.type is None: + msg = 'In member "{}" of class "{}":'.format(function, type) + result.append((file, -1, -1, -1, -1, "note", msg, None)) + + elif type != prev_type: + if type is 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}":', None)) + result.append((file, -1, -1, -1, -1, "note", f'In class "{type}":', None)) + code = e.code.code if e.code is not None else None result.append( - (file, e.line, e.column, e.end_line, e.end_column, e.severity, e.message, e.code) + (file, e.line, e.column, e.end_line, e.end_column, e.severity, e.message, code) ) prev_import_context = e.import_ctx - prev_function_or_member = e.function_or_member - prev_type = e.type + prev_function = function + prev_type = type return result @@ -1239,11 +1195,7 @@ def sort_messages(self, errors: list[ErrorInfo]) -> list[ErrorInfo]: while i < len(errors): i0 = i # Find neighbouring errors with the same context and file. - while ( - i + 1 < len(errors) - and errors[i + 1].import_ctx == errors[i].import_ctx - and errors[i + 1].file == errors[i].file - ): + while i + 1 < len(errors) and errors[i + 1].import_ctx == errors[i].import_ctx: i += 1 i += 1 @@ -1428,7 +1380,7 @@ def __init__( end_line: int, end_column: int, message: str, - errorcode: ErrorCode | None, + errorcode: str | None, severity: Literal["error", "note"], ) -> None: self.file_path = file_path diff --git a/mypy/messages.py b/mypy/messages.py index 7a5614ebd896c..c2737f21c00cd 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -229,75 +229,65 @@ def prefer_simple_messages(self) -> bool: """ return self.errors.prefer_simple_messages() + def span_from_context(self, ctx: Context) -> Iterable[int]: + """This determines where a type: ignore for a given context has effect.""" + if not isinstance(ctx, Expression): + return [ctx.line] + return range(ctx.line, (ctx.end_line or ctx.line) + 1) + def report( self, msg: str, - context: Context | None, + context: Context, severity: str, + offset: int = 0, *, code: ErrorCode | None = None, - origin: Context | None = None, - offset: int = 0, - secondary_context: Context | None = None, + origin_context: Context | 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 - where # type: ignore comments have effect. + Note that context controls where error is reported, while origin_context + controls where # type: ignore comments have effect. """ - def span_from_context(ctx: Context) -> Iterable[int]: - """This determines where a type: ignore for a given context has effect.""" - if not isinstance(ctx, Expression): - return range(ctx.line, ctx.line + 1) - return range(ctx.line, (ctx.end_line or ctx.line) + 1) - - origin_span: Iterable[int] | None - if origin is not None: - origin_span = span_from_context(origin) - elif context is not None: - origin_span = span_from_context(context) - else: - origin_span = None - - if secondary_context is not None: - assert origin_span is not None - origin_span = itertools.chain(origin_span, span_from_context(secondary_context)) + origin_span = self.span_from_context(context) + if origin_context is not None: + origin_span = itertools.chain(origin_span, self.span_from_context(origin_context)) return self.errors.report( context.line if context else -1, context.column if context else -1, msg, + code=code, severity=severity, offset=offset, origin_span=origin_span, end_line=context.end_line if context else -1, end_column=context.end_column if context else -1, - code=code, parent_error=parent_error, ) def fail( self, msg: str, - context: Context | None, + context: Context, *, code: ErrorCode | None = None, - secondary_context: Context | None = None, + origin_context: Context | None = None, ) -> ErrorInfo: """Report an error message (unless disabled).""" - return self.report(msg, context, "error", code=code, secondary_context=secondary_context) + return self.report(msg, context, "error", code=code, origin_context=origin_context) def note( self, msg: str, context: Context, - origin: Context | None = None, offset: int = 0, *, code: ErrorCode | None = None, - secondary_context: Context | None = None, + origin_context: Context | None = None, parent_error: ErrorInfo | None = None, ) -> None: """Report a note (unless disabled).""" @@ -305,10 +295,9 @@ def note( msg, context, "note", - origin=origin, offset=offset, code=code, - secondary_context=secondary_context, + origin_context=origin_context, parent_error=parent_error, ) @@ -317,14 +306,21 @@ def note_multiline( messages: str, context: Context, offset: int = 0, - code: ErrorCode | None = None, *, - secondary_context: Context | None = None, + code: ErrorCode | None = None, + origin_context: Context | None = None, + parent_error: ErrorInfo | None = None, ) -> None: """Report as many notes as lines in the message (unless disabled).""" for msg in messages.splitlines(): self.report( - msg, context, "note", offset=offset, code=code, secondary_context=secondary_context + msg, + context, + "note", + offset, + code=code, + origin_context=origin_context, + parent_error=parent_error, ) # @@ -1245,7 +1241,7 @@ def argument_incompatible_with_supertype( arg_type_in_supertype: Type, supertype: str, context: Context, - secondary_context: Context, + origin_context: Context, ) -> None: target = self.override_target(name, name_in_supertype, supertype) arg_type_in_supertype_f = format_type_bare(arg_type_in_supertype, self.options) @@ -1256,7 +1252,7 @@ def argument_incompatible_with_supertype( ), context, code=codes.OVERRIDE, - secondary_context=secondary_context, + origin_context=origin_context, ) if name != "__post_init__": # `__post_init__` is special, it can be incompatible by design. @@ -1265,19 +1261,19 @@ def argument_incompatible_with_supertype( "This violates the Liskov substitution principle", context, code=codes.OVERRIDE, - secondary_context=secondary_context, + origin_context=origin_context, ) self.note( "See https://mypy.readthedocs.io/en/stable/common_issues.html#incompatible-overrides", context, code=codes.OVERRIDE, - secondary_context=secondary_context, + origin_context=origin_context, ) if name == "__eq__" and type_name: multiline_msg = self.comparison_method_example_msg(class_name=type_name) self.note_multiline( - multiline_msg, context, code=codes.OVERRIDE, secondary_context=secondary_context + multiline_msg, context, code=codes.OVERRIDE, origin_context=origin_context ) def comparison_method_example_msg(self, class_name: str) -> str: diff --git a/mypy/server/update.py b/mypy/server/update.py index f2e3554abda8d..741d08ec9d204 100644 --- a/mypy/server/update.py +++ b/mypy/server/update.py @@ -1004,7 +1004,7 @@ def key(node: FineGrainedDeferredNode) -> int: for target in targets: if target == module_id: for info in graph[module_id].early_errors: - manager.errors.add_error_info(info) + manager.errors.add_error_info(info, file=graph[module_id].xpath) # Strip semantic analysis information. saved_attrs: SavedAttributes = {} From 856abca713f9ff1fbdfe3bc354b003ce07ea1bf5 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sat, 14 Feb 2026 19:56:16 +0000 Subject: [PATCH 2/3] Some refactoring --- mypy/build.py | 142 ++++++++++++++------------ mypy/errors.py | 164 ++++++++++++------------------ mypy/messages.py | 41 +++----- mypyc/test-data/commandline.test | 2 +- test-data/unit/check-classes.test | 10 +- test-data/unit/cmdline.test | 14 ++- 6 files changed, 175 insertions(+), 198 deletions(-) diff --git a/mypy/build.py b/mypy/build.py index 68aa15f629414..0488165269ee0 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -28,6 +28,7 @@ import types from collections.abc import Callable, Iterator, Mapping, Sequence, Set as AbstractSet from heapq import heappop, heappush +from textwrap import dedent from typing import ( TYPE_CHECKING, Any, @@ -90,6 +91,7 @@ WORKER_START_TIMEOUT, ) from mypy.error_formatter import OUTPUT_CHOICES, ErrorFormatter +from mypy.errorcodes import ErrorCode from mypy.errors import CompileError, ErrorInfo, Errors, report_internal_error from mypy.graph_utils import prepare_sccs, strongly_connected_components, topsort from mypy.indirection import TypeIndirectionVisitor @@ -177,7 +179,7 @@ } # We are careful now, we can increase this in future if safe/useful. -MAX_GC_FREEZE_CYCLES = 1 +MAX_GC_FREEZE_CYCLES: Final = 1 # We store status of initial GC freeze as a global variable to avoid memory # leaks in tests, where we keep creating new BuildManagers in the same process. @@ -185,6 +187,10 @@ Graph: _TypeAlias = dict[str, "State"] +MODULE_RESOLUTION_URL: Final = ( + "https://mypy.readthedocs.io/en/stable/running_mypy.html#mapping-file-paths-to-modules" +) + class SCC: """A simple class that represents a strongly connected component (import cycle).""" @@ -953,8 +959,8 @@ def correct_rel_imp(self, file: MypyFile, imp: ImportFrom | ImportAll) -> str: if not new_id: self.errors.set_file(file.path, file.name, self.options) - self.errors.report( - imp.line, 0, "No parent module -- cannot perform relative import", blocker=True + self.error( + imp.line, "No parent module -- cannot perform relative import", blocker=True ) return new_id @@ -1183,6 +1189,36 @@ def is_transitive_scc_dep(self, from_scc_id: int, to_scc_id: int) -> bool: self.transitive_deps_cache[(dep, to_scc_id)] = False return False + def error( + self, + line: int | None, + msg: str, + code: ErrorCode | None = None, + *, + blocker: bool = False, + only_once: bool = False, + ) -> None: + if line is None: + line = column = -1 + else: + column = 0 + self.errors.report(line, column, msg, code, blocker=blocker, only_once=only_once) + + def note( + self, line: int | None, msg: str, code: ErrorCode | None = None, *, only_once: bool = False + ) -> None: + if line is None: + line = column = -1 + else: + column = 0 + self.errors.report(line, column, msg, code, severity="note", only_once=only_once) + + def note_multiline( + self, line: int | None, msg: str, code: ErrorCode | None = None, *, only_once: bool = False + ) -> None: + for msg_line in dedent(msg.lstrip("\n")).splitlines(): + self.note(line, msg_line, code, only_once=only_once) + def deps_to_json(x: dict[str, set[str]]) -> bytes: return json_dumps({k: list(v) for k, v in x.items()}) @@ -1261,7 +1297,7 @@ def write_deps_cache( if error: manager.errors.set_file(_cache_dir_prefix(manager.options), None, manager.options) - manager.errors.report(0, 0, "Error writing fine-grained dependencies cache", blocker=True) + manager.error(None, "Error writing fine-grained dependencies cache", blocker=True) def invert_deps(deps: dict[str, set[str]], graph: Graph) -> dict[str, dict[str, set[str]]]: @@ -1329,7 +1365,7 @@ def write_plugins_snapshot(manager: BuildManager) -> None: and manager.options.cache_dir != os.devnull ): manager.errors.set_file(_cache_dir_prefix(manager.options), None, manager.options) - manager.errors.report(0, 0, "Error writing plugins snapshot", blocker=True) + manager.error(None, "Error writing plugins snapshot", blocker=True) def read_plugins_snapshot(manager: BuildManager) -> dict[str, str] | None: @@ -1444,9 +1480,8 @@ def _load_json_file( manager.add_stats(data_file_load_time=time.time() - t1) except json.JSONDecodeError: manager.errors.set_file(file, None, manager.options) - manager.errors.report( - -1, - -1, + manager.error( + None, "Error reading JSON file;" " you likely have a bad cache.\n" "Try removing the {cache_dir} directory" @@ -2748,13 +2783,13 @@ def parse_inline_configuration(self, source: str) -> None: self.options = self.options.apply_changes(changes) self.manager.errors.set_file(self.xpath, self.id, self.options) for lineno, error in config_errors: - self.manager.errors.report(lineno, 0, error) + self.manager.error(lineno, error) def check_for_invalid_options(self) -> None: if self.options.mypyc and not self.options.strict_bytes: self.manager.errors.set_file(self.xpath, self.id, options=self.options) - self.manager.errors.report( - 1, 0, "Option --strict-bytes cannot be disabled when using mypyc", blocker=True + self.manager.error( + None, "Option --strict-bytes cannot be disabled when using mypyc", blocker=True ) def semantic_analysis_pass1(self) -> None: @@ -3346,8 +3381,8 @@ def module_not_found( caller_state.ignore_all or caller_state.options.ignore_errors, ) if target == "builtins": - errors.report( - line, 0, "Cannot find 'builtins' module. Typeshed appears broken!", blocker=True + manager.error( + line, "Cannot find 'builtins' module. Typeshed appears broken!", blocker=True ) errors.raise_error() else: @@ -3362,14 +3397,14 @@ def module_not_found( code = codes.IMPORT_UNTYPED else: code = codes.IMPORT - errors.report(line, 0, msg.format(module=target), code=code) + manager.error(line, msg.format(module=target), code=code) dist = stub_distribution_name(target) for note in notes: if "{stub_dist}" in note: assert dist is not None note = note.format(stub_dist=dist) - errors.report(line, 0, note, severity="note", only_once=True, code=code) + manager.note(line, note, only_once=True, code=code) if reason is ModuleNotFoundReason.APPROVED_STUBS_NOT_INSTALLED: assert dist is not None manager.missing_stub_packages.add(dist) @@ -3384,13 +3419,9 @@ def skipping_module( save_import_context = manager.errors.import_context() manager.errors.set_import_context(caller_state.import_context) manager.errors.set_file(caller_state.xpath, caller_state.id, manager.options) - manager.errors.report(line, 0, f'Import of "{id}" ignored', severity="error") - manager.errors.report( - line, - 0, - "(Using --follow-imports=error, module not passed on command line)", - severity="note", - only_once=True, + manager.error(line, f'Import of "{id}" ignored') + manager.note( + line, "(Using --follow-imports=error, module not passed on command line)", only_once=True ) manager.errors.set_import_context(save_import_context) @@ -3403,15 +3434,9 @@ def skipping_ancestor(manager: BuildManager, id: str, path: str, ancestor_for: S # so we'd need to cache the decision. manager.errors.set_import_context([]) manager.errors.set_file(ancestor_for.xpath, ancestor_for.id, manager.options) - manager.errors.report( - -1, -1, f'Ancestor package "{id}" ignored', severity="error", only_once=True - ) - manager.errors.report( - -1, - -1, - "(Using --follow-imports=error, submodule passed on command line)", - severity="note", - only_once=True, + manager.error(None, f'Ancestor package "{id}" ignored', only_once=True) + manager.note( + None, "(Using --follow-imports=error, submodule passed on command line)", only_once=True ) @@ -3680,28 +3705,21 @@ def load_graph( continue if st.id in graph: manager.errors.set_file(st.xpath, st.id, manager.options) - manager.errors.report( - -1, - -1, + manager.error( + None, f'Duplicate module named "{st.id}" (also at "{graph[st.id].xpath}")', blocker=True, ) - manager.errors.report( - -1, - -1, - "See https://mypy.readthedocs.io/en/stable/running_mypy.html#mapping-file-paths-to-modules " - "for more info", - severity="note", - ) - manager.errors.report( - -1, - -1, - "Common resolutions include: a) using `--exclude` to avoid checking one of them, " - "b) adding `__init__.py` somewhere, c) using `--explicit-package-bases` or " - "adjusting MYPYPATH", - severity="note", + manager.note_multiline( + None, + f""" + See {MODULE_RESOLUTION_URL} for more info + Common resolutions include: + a) using `--exclude` to avoid checking one of them, + b) adding `__init__.py` somewhere, + c) using `--explicit-package-bases` or adjusting `MYPYPATH` + """, ) - manager.errors.raise_error() graph[st.id] = st new.append(st) @@ -3769,26 +3787,20 @@ def load_graph( newst_path = newst.abspath if newst_path in seen_files: - manager.errors.report( - -1, - 0, + manager.error( + None, "Source file found twice under different module names: " '"{}" and "{}"'.format(seen_files[newst_path].id, newst.id), blocker=True, ) - manager.errors.report( - -1, - 0, - "See https://mypy.readthedocs.io/en/stable/running_mypy.html#mapping-file-paths-to-modules " - "for more info", - severity="note", - ) - manager.errors.report( - -1, - 0, - "Common resolutions include: a) adding `__init__.py` somewhere, " - "b) using `--explicit-package-bases` or adjusting MYPYPATH", - severity="note", + manager.note_multiline( + None, + f""" + See {MODULE_RESOLUTION_URL} for more info + Common resolutions include: + a) adding `__init__.py` somewhere, + b) using `--explicit-package-bases` or adjusting `MYPYPATH` + """, ) manager.errors.raise_error() diff --git a/mypy/errors.py b/mypy/errors.py index e0fcd5a316ef2..9691f924c523d 100644 --- a/mypy/errors.py +++ b/mypy/errors.py @@ -415,19 +415,6 @@ class Errors: # Collection of reported only_once messages. only_once_messages: set[str] - # Set to True to show "In function "foo":" messages. - show_error_context: bool = False - - # Set to True to show column numbers in error messages. - show_column_numbers: bool = False - - # Set to True to show end line and end column in error messages. - # This implies `show_column_numbers`. - show_error_end: bool = False - - # Set to True to show absolute file paths in error messages. - show_absolute_path: bool = False - # State for keeping track of the current fine-grained incremental mode target. # (See mypy.server.update for more about targets.) # Current module id. @@ -634,6 +621,64 @@ def _add_error_info(self, file: str, info: ErrorInfo) -> None: if info.code in (IMPORT, IMPORT_UNTYPED, IMPORT_NOT_FOUND): self.seen_import_error = True + def note_for_info( + self, + file: str, + info: ErrorInfo, + message: str, + code: ErrorCode | None, + *, + only_once: bool = False, + priority: int = 0, + ) -> None: + """Generate an additional note for an existing ErrorInfo. + + This skip the logic in add_error_info() and goes to _add_error_info(). + """ + info = ErrorInfo( + import_ctx=info.import_ctx, + local_ctx=info.local_ctx, + line=info.line, + column=info.column, + end_line=info.end_line, + end_column=info.end_column, + severity="note", + message=message, + code=code, + blocker=False, + only_once=only_once, + module=info.module, + target=info.target, + origin_span=info.origin_span, + priority=priority, + ) + self._add_error_info(file, info) + + def report_simple_error( + self, file: str, line: int, message: str, code: ErrorCode | None + ) -> None: + """Generate a simple error in a module. + + This skip the logic in add_error_info() and goes to _add_error_info(). + """ + info = ErrorInfo( + import_ctx=self.import_context(), + local_ctx=(None, None), + line=line, + column=-1, + end_line=line, + end_column=-1, + severity="error", + message=message, + code=code, + blocker=False, + only_once=False, + module=self.current_module(), + # TODO: can we support more precise targets? + target=self.target_module, + ) + self._add_error_info(file, info) + def get_watchers(self) -> Iterator[ErrorWatcher]: """Yield the `ErrorWatcher` stack from top to bottom.""" i = len(self._watchers) @@ -704,23 +749,7 @@ def add_error_info(self, info: ErrorInfo, *, file: str | None = None) -> None: f'Error code changed to {info.code.code}; "type: ignore" comment ' + "may be out of date" ) - note = ErrorInfo( - import_ctx=info.import_ctx, - local_ctx=info.local_ctx, - line=info.line, - column=info.column, - end_line=info.end_line, - end_column=info.end_column, - severity="note", - message=msg, - code=None, - blocker=False, - only_once=False, - module=info.module, - target=info.target, - origin_span=info.origin_span, - ) - self._add_error_info(file, note) + self.note_for_info(file, info, msg, None, only_once=False) if ( self.options.show_error_code_links and not self.options.hide_error_codes @@ -732,24 +761,7 @@ def add_error_info(self, info: ErrorInfo, *, file: str | None = None) -> None: if message in self.only_once_messages: return self.only_once_messages.add(message) - info = ErrorInfo( - import_ctx=info.import_ctx, - local_ctx=info.local_ctx, - line=info.line, - column=info.column, - end_line=info.end_line, - end_column=info.end_column, - severity="note", - message=message, - code=info.code, - blocker=False, - only_once=True, - module=info.module, - target=info.target, - origin_span=info.origin_span, - priority=20, - ) - self._add_error_info(file, info) + self.note_for_info(file, info, message, info.code, only_once=True, priority=20) def has_many_errors(self) -> bool: if self.options.many_errors_threshold < 0: @@ -771,23 +783,7 @@ def report_hidden_errors(self, file: str, info: ErrorInfo) -> None: if message in self.only_once_messages: return self.only_once_messages.add(message) - new_info = ErrorInfo( - import_ctx=info.import_ctx, - local_ctx=info.local_ctx, - line=info.line, - column=info.column, - end_line=info.end_line, - end_column=info.end_column, - severity="note", - message=message, - code=None, - blocker=False, - only_once=True, - module=info.module, - target=info.target, - origin_span=info.origin_span, - ) - self._add_error_info(file, new_info) + self.note_for_info(file, info, message, None, only_once=True) def is_ignored_error(self, line: int, info: ErrorInfo, ignores: dict[int, list[str]]) -> bool: if info.blocker: @@ -867,23 +863,8 @@ def generate_unused_ignore_errors(self, file: str, is_typeshed: bool = False) -> narrower = set(used_ignored_codes) & codes.sub_code_map[unused] if narrower: message += f", use narrower [{', '.join(narrower)}] instead of [{unused}] code" - # Don't use report since add_error_info will ignore the error! - info = ErrorInfo( - import_ctx=self.import_context(), - local_ctx=(None, None), - line=line, - column=-1, - end_line=line, - end_column=-1, - severity="error", - message=message, - code=codes.UNUSED_IGNORE, - blocker=False, - only_once=False, - module=self.current_module(), - target=self.current_target(), - ) - self._add_error_info(file, info) + # Don't use report() since add_error_info will ignore the error! + self.report_simple_error(file, line, message, code=codes.UNUSED_IGNORE) def generate_ignore_without_code_errors( self, file: str, is_warning_unused_ignores: bool, is_typeshed: bool = False @@ -914,23 +895,8 @@ def generate_ignore_without_code_errors( codes_hint = f' (consider "type: ignore[{", ".join(ignored_codes)}]" instead)' message = f'"type: ignore" comment without error code{codes_hint}' - # Don't use report since add_error_info will ignore the error! - info = ErrorInfo( - import_ctx=self.import_context(), - local_ctx=(None, None), - line=line, - column=-1, - end_line=line, - end_column=-1, - severity="error", - message=message, - code=codes.IGNORE_WITHOUT_CODE, - blocker=False, - only_once=False, - module=self.current_module(), - target=self.current_target(), - ) - self._add_error_info(file, info) + # Don't use report() since add_error_info will ignore the error! + self.report_simple_error(file, line, message, code=codes.IGNORE_WITHOUT_CODE) def num_messages(self) -> int: """Return the number of generated messages.""" diff --git a/mypy/messages.py b/mypy/messages.py index c2737f21c00cd..59b1b3db07cc8 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -312,7 +312,7 @@ def note_multiline( parent_error: ErrorInfo | None = None, ) -> None: """Report as many notes as lines in the message (unless disabled).""" - for msg in messages.splitlines(): + for msg in dedent(messages.lstrip("\n")).splitlines(): self.report( msg, context, @@ -1257,34 +1257,25 @@ def argument_incompatible_with_supertype( if name != "__post_init__": # `__post_init__` is special, it can be incompatible by design. # So, this note is misleading. - self.note( - "This violates the Liskov substitution principle", - context, - code=codes.OVERRIDE, - origin_context=origin_context, - ) - self.note( - "See https://mypy.readthedocs.io/en/stable/common_issues.html#incompatible-overrides", - context, - code=codes.OVERRIDE, - origin_context=origin_context, + invariant_message = """ + This violates the Liskov substitution principle + See https://mypy.readthedocs.io/en/stable/common_issues.html#incompatible-overrides + """ + self.note_multiline( + invariant_message, context, code=codes.OVERRIDE, origin_context=origin_context ) - if name == "__eq__" and type_name: - multiline_msg = self.comparison_method_example_msg(class_name=type_name) + eq_message = """ + It is recommended for "__eq__" to work with arbitrary objects, for example: + def __eq__(self, other: object) -> bool: + if not isinstance(other, {class_name}): + return NotImplemented + return + """.format(class_name=type_name) self.note_multiline( - multiline_msg, context, code=codes.OVERRIDE, origin_context=origin_context + eq_message, context, code=codes.OVERRIDE, origin_context=origin_context ) - def comparison_method_example_msg(self, class_name: str) -> str: - return dedent("""\ - It is recommended for "__eq__" to work with arbitrary objects, for example: - def __eq__(self, other: object) -> bool: - if not isinstance(other, {class_name}): - return NotImplemented - return - """.format(class_name=class_name)) - def return_type_incompatible_with_supertype( self, name: str, @@ -3353,7 +3344,7 @@ def append_numbers_notes( ) -> list[str]: """Explain if an unsupported type from "numbers" is used in a subtype check.""" if expected_type.type.fullname in UNSUPPORTED_NUMBERS_TYPES: - notes.append('Types from "numbers" aren\'t supported for static type checking') + notes.append('Types from "numbers" are not supported for static type checking') notes.append("See https://peps.python.org/pep-0484/#the-numeric-tower") notes.append("Consider using a protocol instead, such as typing.SupportsFloat") return notes diff --git a/mypyc/test-data/commandline.test b/mypyc/test-data/commandline.test index 36f7f508daf03..8926669100736 100644 --- a/mypyc/test-data/commandline.test +++ b/mypyc/test-data/commandline.test @@ -321,4 +321,4 @@ def f(b: bytes) -> None: pass f(bytearray()) [out] -a.py:1: error: Option --strict-bytes cannot be disabled when using mypyc +a.py: error: Option --strict-bytes cannot be disabled when using mypyc diff --git a/test-data/unit/check-classes.test b/test-data/unit/check-classes.test index f8cc3179e82a1..92d8a87615c8a 100644 --- a/test-data/unit/check-classes.test +++ b/test-data/unit/check-classes.test @@ -8921,31 +8921,31 @@ from numbers import Number, Complex, Real, Rational, Integral def f1(x: Number) -> None: pass f1(1) # E: Argument 1 to "f1" has incompatible type "int"; expected "Number" \ - # N: Types from "numbers" aren't supported for static type checking \ + # N: Types from "numbers" are not supported for static type checking \ # N: See https://peps.python.org/pep-0484/#the-numeric-tower \ # N: Consider using a protocol instead, such as typing.SupportsFloat def f2(x: Complex) -> None: pass f2(1) # E: Argument 1 to "f2" has incompatible type "int"; expected "Complex" \ - # N: Types from "numbers" aren't supported for static type checking \ + # N: Types from "numbers" are not supported for static type checking \ # N: See https://peps.python.org/pep-0484/#the-numeric-tower \ # N: Consider using a protocol instead, such as typing.SupportsFloat def f3(x: Real) -> None: pass f3(1) # E: Argument 1 to "f3" has incompatible type "int"; expected "Real" \ - # N: Types from "numbers" aren't supported for static type checking \ + # N: Types from "numbers" are not supported for static type checking \ # N: See https://peps.python.org/pep-0484/#the-numeric-tower \ # N: Consider using a protocol instead, such as typing.SupportsFloat def f4(x: Rational) -> None: pass f4(1) # E: Argument 1 to "f4" has incompatible type "int"; expected "Rational" \ - # N: Types from "numbers" aren't supported for static type checking \ + # N: Types from "numbers" are not supported for static type checking \ # N: See https://peps.python.org/pep-0484/#the-numeric-tower \ # N: Consider using a protocol instead, such as typing.SupportsFloat def f5(x: Integral) -> None: pass f5(1) # E: Argument 1 to "f5" has incompatible type "int"; expected "Integral" \ - # N: Types from "numbers" aren't supported for static type checking \ + # N: Types from "numbers" are not supported for static type checking \ # N: See https://peps.python.org/pep-0484/#the-numeric-tower \ # N: Consider using a protocol instead, such as typing.SupportsFloat diff --git a/test-data/unit/cmdline.test b/test-data/unit/cmdline.test index af9c2d1f83987..2b3f48fec4a0b 100644 --- a/test-data/unit/cmdline.test +++ b/test-data/unit/cmdline.test @@ -60,7 +60,10 @@ undef [out] dir/a.py: error: Duplicate module named "a" (also at "dir/subdir/a.py") dir/a.py: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#mapping-file-paths-to-modules for more info -dir/a.py: note: Common resolutions include: a) using `--exclude` to avoid checking one of them, b) adding `__init__.py` somewhere, c) using `--explicit-package-bases` or adjusting MYPYPATH +dir/a.py: note: Common resolutions include: +dir/a.py: note: a) using `--exclude` to avoid checking one of them, +dir/a.py: note: b) adding `__init__.py` somewhere, +dir/a.py: note: c) using `--explicit-package-bases` or adjusting `MYPYPATH` == Return code: 2 [case testCmdlineNonPackageSlash] @@ -126,7 +129,10 @@ mypy: can't decode file 'a.py': unknown encoding: uft-8 [out] two/mod/__init__.py: error: Duplicate module named "mod" (also at "one/mod/__init__.py") two/mod/__init__.py: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#mapping-file-paths-to-modules for more info -two/mod/__init__.py: note: Common resolutions include: a) using `--exclude` to avoid checking one of them, b) adding `__init__.py` somewhere, c) using `--explicit-package-bases` or adjusting MYPYPATH +two/mod/__init__.py: note: Common resolutions include: +two/mod/__init__.py: note: a) using `--exclude` to avoid checking one of them, +two/mod/__init__.py: note: b) adding `__init__.py` somewhere, +two/mod/__init__.py: note: c) using `--explicit-package-bases` or adjusting `MYPYPATH` == Return code: 2 [case testPerFileConfigSectionMultipleMatchesDisallowed] @@ -774,7 +780,9 @@ import foo.bar [out] src/foo/bar.py: error: Source file found twice under different module names: "src.foo.bar" and "foo.bar" src/foo/bar.py: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#mapping-file-paths-to-modules for more info -src/foo/bar.py: note: Common resolutions include: a) adding `__init__.py` somewhere, b) using `--explicit-package-bases` or adjusting MYPYPATH +src/foo/bar.py: note: Common resolutions include: +src/foo/bar.py: note: a) adding `__init__.py` somewhere, +src/foo/bar.py: note: b) using `--explicit-package-bases` or adjusting `MYPYPATH` == Return code: 2 [case testEnableInvalidErrorCode] From 1b359609863d6de176d71445431635189df4fc15 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sat, 14 Feb 2026 20:06:56 +0000 Subject: [PATCH 3/3] One more tweak --- mypy/build.py | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/mypy/build.py b/mypy/build.py index 0488165269ee0..2ade9597bc05f 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -3710,16 +3710,14 @@ def load_graph( f'Duplicate module named "{st.id}" (also at "{graph[st.id].xpath}")', blocker=True, ) - manager.note_multiline( - None, - f""" - See {MODULE_RESOLUTION_URL} for more info - Common resolutions include: - a) using `--exclude` to avoid checking one of them, - b) adding `__init__.py` somewhere, - c) using `--explicit-package-bases` or adjusting `MYPYPATH` - """, - ) + resolution_note = f""" + See {MODULE_RESOLUTION_URL} for more info + Common resolutions include: + a) using `--exclude` to avoid checking one of them, + b) adding `__init__.py` somewhere, + c) using `--explicit-package-bases` or adjusting `MYPYPATH` + """ + manager.note_multiline(None, resolution_note) manager.errors.raise_error() graph[st.id] = st new.append(st) @@ -3790,18 +3788,16 @@ def load_graph( manager.error( None, "Source file found twice under different module names: " - '"{}" and "{}"'.format(seen_files[newst_path].id, newst.id), + f'"{seen_files[newst_path].id}" and "{newst.id}"', blocker=True, ) - manager.note_multiline( - None, - f""" - See {MODULE_RESOLUTION_URL} for more info - Common resolutions include: - a) adding `__init__.py` somewhere, - b) using `--explicit-package-bases` or adjusting `MYPYPATH` - """, - ) + resolution_note = f""" + See {MODULE_RESOLUTION_URL} for more info + Common resolutions include: + a) adding `__init__.py` somewhere, + b) using `--explicit-package-bases` or adjusting `MYPYPATH` + """ + manager.note_multiline(None, resolution_note) manager.errors.raise_error() seen_files[newst_path] = newst