Skip to content

Commit 4322d4f

Browse files
authored
Improve the handling of "iteration dependent" errors and notes in finally clauses. (#19270)
Fixes #19269 This PR refactors the logic implemented in #19118 (which only targeted repeatedly checked loops) and applies it to repeatedly checked finally clauses. I moved nearly all relevant code to the class `LoopErrorWatcher`, which now has the more general name `IterationErrorWatcher`, to avoid code duplication. However, one duplication is left, which concerns error reporting. It would be nice and easy to move this functionality to `IterationErrorWatcher`, too, but this would result in import cycles, and I am unsure if working with `TYPE_CHECKING` and postponed importing is acceptable in such cases (both for Mypy and Mypyc). After the refactoring, it should not be much effort to apply the logic to other cases where code sections are analysed iteratively. However, the only thing that comes to my mind is the repeated checking of functions with arguments that contain constrained type variables. I will check it. If anyone finds a similar case and the solution is as simple as expected, we could add the fix to this PR, of course.
1 parent ffb6928 commit 4322d4f

File tree

5 files changed

+148
-58
lines changed

5 files changed

+148
-58
lines changed

mypy/checker.py

Lines changed: 28 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,14 @@
2525
from mypy.constraints import SUPERTYPE_OF
2626
from mypy.erasetype import erase_type, erase_typevars, remove_instance_last_known_values
2727
from mypy.errorcodes import TYPE_VAR, UNUSED_AWAITABLE, UNUSED_COROUTINE, ErrorCode
28-
from mypy.errors import ErrorInfo, Errors, ErrorWatcher, LoopErrorWatcher, report_internal_error
28+
from mypy.errors import (
29+
ErrorInfo,
30+
Errors,
31+
ErrorWatcher,
32+
IterationDependentErrors,
33+
IterationErrorWatcher,
34+
report_internal_error,
35+
)
2936
from mypy.expandtype import expand_type
3037
from mypy.literals import Key, extract_var_from_literal_hash, literal, literal_hash
3138
from mypy.maptype import map_instance_to_supertype
@@ -598,26 +605,15 @@ def accept_loop(
598605
# on without bound otherwise)
599606
widened_old = len(self.widened_vars)
600607

601-
# one set of `unreachable`, `redundant-expr`, and `redundant-casts` errors
602-
# per iteration step:
603-
uselessness_errors = []
604-
# one set of unreachable line numbers per iteration step:
605-
unreachable_lines = []
606-
# one set of revealed types per line where `reveal_type` is used (each
607-
# created set can grow during the iteration):
608-
revealed_types = defaultdict(set)
608+
iter_errors = IterationDependentErrors()
609609
iter = 1
610610
while True:
611611
with self.binder.frame_context(can_skip=True, break_frame=2, continue_frame=1):
612612
if on_enter_body is not None:
613613
on_enter_body()
614614

615-
with LoopErrorWatcher(self.msg.errors) as watcher:
615+
with IterationErrorWatcher(self.msg.errors, iter_errors) as watcher:
616616
self.accept(body)
617-
uselessness_errors.append(watcher.uselessness_errors)
618-
unreachable_lines.append(watcher.unreachable_lines)
619-
for key, values in watcher.revealed_types.items():
620-
revealed_types[key].update(values)
621617

622618
partials_new = sum(len(pts.map) for pts in self.partial_types)
623619
widened_new = len(self.widened_vars)
@@ -639,29 +635,10 @@ def accept_loop(
639635
if iter == 20:
640636
raise RuntimeError("Too many iterations when checking a loop")
641637

642-
# Report only those `unreachable`, `redundant-expr`, and `redundant-casts`
643-
# errors that could not be ruled out in any iteration step:
644-
persistent_uselessness_errors = set()
645-
for candidate in set(itertools.chain(*uselessness_errors)):
646-
if all(
647-
(candidate in errors) or (candidate[2] in lines)
648-
for errors, lines in zip(uselessness_errors, unreachable_lines)
649-
):
650-
persistent_uselessness_errors.add(candidate)
651-
for error_info in persistent_uselessness_errors:
652-
context = Context(line=error_info[2], column=error_info[3])
653-
context.end_line = error_info[4]
654-
context.end_column = error_info[5]
655-
self.msg.fail(error_info[1], context, code=error_info[0])
656-
657-
# Report all types revealed in at least one iteration step:
658-
for note_info, types in revealed_types.items():
659-
sorted_ = sorted(types, key=lambda typ: typ.lower())
660-
revealed = sorted_[0] if len(types) == 1 else f"Union[{', '.join(sorted_)}]"
661-
context = Context(line=note_info[1], column=note_info[2])
662-
context.end_line = note_info[3]
663-
context.end_column = note_info[4]
664-
self.note(f'Revealed type is "{revealed}"', context)
638+
for error_info in watcher.yield_error_infos():
639+
self.msg.fail(*error_info[:2], code=error_info[2])
640+
for note_info in watcher.yield_note_infos(self.options):
641+
self.note(*note_info)
665642

666643
# If exit_condition is set, assume it must be False on exit from the loop:
667644
if exit_condition:
@@ -4960,6 +4937,9 @@ def type_check_raise(self, e: Expression, s: RaiseStmt, optional: bool = False)
49604937

49614938
def visit_try_stmt(self, s: TryStmt) -> None:
49624939
"""Type check a try statement."""
4940+
4941+
iter_errors = None
4942+
49634943
# Our enclosing frame will get the result if the try/except falls through.
49644944
# This one gets all possible states after the try block exited abnormally
49654945
# (by exception, return, break, etc.)
@@ -4974,7 +4954,9 @@ def visit_try_stmt(self, s: TryStmt) -> None:
49744954
self.visit_try_without_finally(s, try_frame=bool(s.finally_body))
49754955
if s.finally_body:
49764956
# First we check finally_body is type safe on all abnormal exit paths
4977-
self.accept(s.finally_body)
4957+
iter_errors = IterationDependentErrors()
4958+
with IterationErrorWatcher(self.msg.errors, iter_errors) as watcher:
4959+
self.accept(s.finally_body)
49784960

49794961
if s.finally_body:
49804962
# Then we try again for the more restricted set of options
@@ -4988,8 +4970,15 @@ def visit_try_stmt(self, s: TryStmt) -> None:
49884970
# type checks in both contexts, but only the resulting types
49894971
# from the latter context affect the type state in the code
49904972
# that follows the try statement.)
4973+
assert iter_errors is not None
49914974
if not self.binder.is_unreachable():
4992-
self.accept(s.finally_body)
4975+
with IterationErrorWatcher(self.msg.errors, iter_errors) as watcher:
4976+
self.accept(s.finally_body)
4977+
4978+
for error_info in watcher.yield_error_infos():
4979+
self.msg.fail(*error_info[:2], code=error_info[2])
4980+
for note_info in watcher.yield_note_infos(self.options):
4981+
self.msg.note(*note_info)
49934982

49944983
def visit_try_without_finally(self, s: TryStmt, try_frame: bool) -> None:
49954984
"""Type check a try statement, ignoring the finally block.

mypy/errors.py

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
import sys
55
import traceback
66
from collections import defaultdict
7-
from collections.abc import Iterable
7+
from collections.abc import Iterable, Iterator
8+
from itertools import chain
89
from typing import Callable, Final, NoReturn, Optional, TextIO, TypeVar
910
from typing_extensions import Literal, Self, TypeAlias as _TypeAlias
1011

1112
from mypy import errorcodes as codes
1213
from mypy.error_formatter import ErrorFormatter
1314
from mypy.errorcodes import IMPORT, IMPORT_NOT_FOUND, IMPORT_UNTYPED, ErrorCode, mypy_error_codes
15+
from mypy.nodes import Context
1416
from mypy.options import Options
1517
from mypy.scope import Scope
1618
from mypy.util import DEFAULT_SOURCE_OFFSET, is_typeshed_file
@@ -219,23 +221,43 @@ def filtered_errors(self) -> list[ErrorInfo]:
219221
return self._filtered
220222

221223

222-
class LoopErrorWatcher(ErrorWatcher):
223-
"""Error watcher that filters and separately collects `unreachable` errors,
224-
`redundant-expr` and `redundant-casts` errors, and revealed types when analysing
225-
loops iteratively to help avoid making too-hasty reports."""
224+
class IterationDependentErrors:
225+
"""An `IterationDependentErrors` instance serves to collect the `unreachable`,
226+
`redundant-expr`, and `redundant-casts` errors, as well as the revealed types,
227+
handled by the individual `IterationErrorWatcher` instances sequentially applied to
228+
the same code section."""
226229

227-
# Meaning of the tuple items: ErrorCode, message, line, column, end_line, end_column:
228-
uselessness_errors: set[tuple[ErrorCode, str, int, int, int, int]]
230+
# One set of `unreachable`, `redundant-expr`, and `redundant-casts` errors per
231+
# iteration step. Meaning of the tuple items: ErrorCode, message, line, column,
232+
# end_line, end_column.
233+
uselessness_errors: list[set[tuple[ErrorCode, str, int, int, int, int]]]
229234

230-
# Meaning of the tuple items: function_or_member, line, column, end_line, end_column:
235+
# One set of unreachable line numbers per iteration step. Not only the lines where
236+
# the error report occurs but really all unreachable lines.
237+
unreachable_lines: list[set[int]]
238+
239+
# One set of revealed types for each `reveal_type` statement. Each created set can
240+
# grow during the iteration. Meaning of the tuple items: function_or_member, line,
241+
# column, end_line, end_column:
231242
revealed_types: dict[tuple[str | None, int, int, int, int], set[str]]
232243

233-
# Not only the lines where the error report occurs but really all unreachable lines:
234-
unreachable_lines: set[int]
244+
def __init__(self) -> None:
245+
self.uselessness_errors = []
246+
self.unreachable_lines = []
247+
self.revealed_types = defaultdict(set)
248+
249+
250+
class IterationErrorWatcher(ErrorWatcher):
251+
"""Error watcher that filters and separately collects `unreachable` errors,
252+
`redundant-expr` and `redundant-casts` errors, and revealed types when analysing
253+
code sections iteratively to help avoid making too-hasty reports."""
254+
255+
iteration_dependent_errors: IterationDependentErrors
235256

236257
def __init__(
237258
self,
238259
errors: Errors,
260+
iteration_dependent_errors: IterationDependentErrors,
239261
*,
240262
filter_errors: bool | Callable[[str, ErrorInfo], bool] = False,
241263
save_filtered_errors: bool = False,
@@ -247,31 +269,71 @@ def __init__(
247269
save_filtered_errors=save_filtered_errors,
248270
filter_deprecated=filter_deprecated,
249271
)
250-
self.uselessness_errors = set()
251-
self.unreachable_lines = set()
252-
self.revealed_types = defaultdict(set)
272+
self.iteration_dependent_errors = iteration_dependent_errors
273+
iteration_dependent_errors.uselessness_errors.append(set())
274+
iteration_dependent_errors.unreachable_lines.append(set())
253275

254276
def on_error(self, file: str, info: ErrorInfo) -> bool:
277+
"""Filter out the "iteration-dependent" errors and notes and store their
278+
information to handle them after iteration is completed."""
279+
280+
iter_errors = self.iteration_dependent_errors
255281

256282
if info.code in (codes.UNREACHABLE, codes.REDUNDANT_EXPR, codes.REDUNDANT_CAST):
257-
self.uselessness_errors.add(
283+
iter_errors.uselessness_errors[-1].add(
258284
(info.code, info.message, info.line, info.column, info.end_line, info.end_column)
259285
)
260286
if info.code == codes.UNREACHABLE:
261-
self.unreachable_lines.update(range(info.line, info.end_line + 1))
287+
iter_errors.unreachable_lines[-1].update(range(info.line, info.end_line + 1))
262288
return True
263289

264290
if info.code == codes.MISC and info.message.startswith("Revealed type is "):
265291
key = info.function_or_member, info.line, info.column, info.end_line, info.end_column
266292
types = info.message.split('"')[1]
267293
if types.startswith("Union["):
268-
self.revealed_types[key].update(types[6:-1].split(", "))
294+
iter_errors.revealed_types[key].update(types[6:-1].split(", "))
269295
else:
270-
self.revealed_types[key].add(types)
296+
iter_errors.revealed_types[key].add(types)
271297
return True
272298

273299
return super().on_error(file, info)
274300

301+
def yield_error_infos(self) -> Iterator[tuple[str, Context, ErrorCode]]:
302+
"""Report only those `unreachable`, `redundant-expr`, and `redundant-casts`
303+
errors that could not be ruled out in any iteration step."""
304+
305+
persistent_uselessness_errors = set()
306+
iter_errors = self.iteration_dependent_errors
307+
for candidate in set(chain(*iter_errors.uselessness_errors)):
308+
if all(
309+
(candidate in errors) or (candidate[2] in lines)
310+
for errors, lines in zip(
311+
iter_errors.uselessness_errors, iter_errors.unreachable_lines
312+
)
313+
):
314+
persistent_uselessness_errors.add(candidate)
315+
for error_info in persistent_uselessness_errors:
316+
context = Context(line=error_info[2], column=error_info[3])
317+
context.end_line = error_info[4]
318+
context.end_column = error_info[5]
319+
yield error_info[1], context, error_info[0]
320+
321+
def yield_note_infos(self, options: Options) -> Iterator[tuple[str, Context]]:
322+
"""Yield all types revealed in at least one iteration step."""
323+
324+
for note_info, types in self.iteration_dependent_errors.revealed_types.items():
325+
sorted_ = sorted(types, key=lambda typ: typ.lower())
326+
if len(types) == 1:
327+
revealed = sorted_[0]
328+
elif options.use_or_syntax():
329+
revealed = " | ".join(sorted_)
330+
else:
331+
revealed = f"Union[{', '.join(sorted_)}]"
332+
context = Context(line=note_info[1], column=note_info[2])
333+
context.end_line = note_info[3]
334+
context.end_column = note_info[4]
335+
yield f'Revealed type is "{revealed}"', context
336+
275337

276338
class Errors:
277339
"""Container for compile errors.

test-data/unit/check-narrowing.test

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2446,6 +2446,25 @@ while x is not None and b():
24462446
x = f()
24472447
[builtins fixtures/primitives.pyi]
24482448

2449+
[case testAvoidFalseUnreachableInFinally]
2450+
# flags: --allow-redefinition-new --local-partial-types --warn-unreachable
2451+
def f() -> None:
2452+
try:
2453+
x = 1
2454+
if int():
2455+
x = ""
2456+
return
2457+
if int():
2458+
x = None
2459+
return
2460+
finally:
2461+
reveal_type(x) # N: Revealed type is "Union[builtins.int, builtins.str, None]"
2462+
if isinstance(x, str):
2463+
reveal_type(x) # N: Revealed type is "builtins.str"
2464+
reveal_type(x) # N: Revealed type is "Union[builtins.int, builtins.str, None]"
2465+
2466+
[builtins fixtures/isinstancelist.pyi]
2467+
24492468
[case testNarrowingTypeVarMultiple]
24502469
from typing import TypeVar
24512470

test-data/unit/check-redefine2.test

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -791,8 +791,7 @@ def f3() -> None:
791791
x = ""
792792
return
793793
finally:
794-
reveal_type(x) # N: Revealed type is "Union[builtins.int, builtins.str]" \
795-
# N: Revealed type is "builtins.int"
794+
reveal_type(x) # N: Revealed type is "Union[builtins.int, builtins.str]"
796795
reveal_type(x) # N: Revealed type is "builtins.int"
797796

798797
def f4() -> None:

test-data/unit/check-union-error-syntax.test

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,24 @@ from typing import Literal, Union
5555
x : Union[Literal[1], None]
5656
x = 3 # E: Incompatible types in assignment (expression has type "Literal[3]", variable has type "Optional[Literal[1]]")
5757
[builtins fixtures/tuple.pyi]
58+
59+
[case testUnionSyntaxRecombined]
60+
# flags: --python-version 3.10 --force-union-syntax --allow-redefinition-new --local-partial-types
61+
# The following revealed type is recombined because the finally body is visited twice.
62+
try:
63+
x = 1
64+
x = ""
65+
finally:
66+
reveal_type(x) # N: Revealed type is "Union[builtins.int, builtins.str]"
67+
[builtins fixtures/isinstancelist.pyi]
68+
69+
[case testOrSyntaxRecombined]
70+
# flags: --python-version 3.10 --no-force-union-syntax --allow-redefinition-new --local-partial-types
71+
# The following revealed type is recombined because the finally body is visited twice.
72+
# ToDo: Improve this recombination logic, especially (but not only) for the "or syntax".
73+
try:
74+
x = 1
75+
x = ""
76+
finally:
77+
reveal_type(x) # N: Revealed type is "builtins.int | builtins.str | builtins.str"
78+
[builtins fixtures/isinstancelist.pyi]

0 commit comments

Comments
 (0)