From ae4f779aceea188dc7aa7ae228f53def58f79fca Mon Sep 17 00:00:00 2001 From: Christoph Tyralla Date: Mon, 29 Dec 2025 10:54:26 +0100 Subject: [PATCH] Check functions with constrained type variable parameters for unreachability. Fixes #19256 (and removes some to-do comments) We implemented all the logic in previous PRs for loops and finally clauses. This PR just activates the available features for functions (for performance reasons, only if there are at least two expansions). I decided against grouping separate responses to `revealed_type` for individual expansions into unions, which would be more confusing than helpful in my opinion, and so also added the `IterationErrorWatcher.collect_revealed_types` option. --- mypy/checker.py | 21 ++++++++++++--------- mypy/errors.py | 3 +++ mypy/messages.py | 2 +- test-data/unit/check-narrowing.test | 14 ++++++++++++++ test-data/unit/check-unreachable-code.test | 16 +++------------- 5 files changed, 33 insertions(+), 23 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 96e41a5e1786..8224680811e4 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -1307,6 +1307,7 @@ def check_func_def( self.check_typevar_defaults(typ.variables) expanded = self.expand_typevars(defn, typ) original_typ = typ + iter_errors = IterationDependentErrors() for item, typ in expanded: old_binder = self.binder self.binder = ConditionalTypeBinder(self.options) @@ -1486,14 +1487,7 @@ def check_func_def( # We suppress reachability warnings for empty generator functions # (return; yield) which have a "yield" that's unreachable by definition # since it's only there to promote the function into a generator function. - # - # We also suppress reachability warnings when we use TypeVars with value - # restrictions: we only want to report a warning if a certain statement is - # marked as being suppressed in *all* of the expansions, but we currently - # have no good way of doing this. - # - # TODO: Find a way of working around this limitation - if _is_empty_generator_function(item) or len(expanded) >= 2: + if _is_empty_generator_function(item): self.binder.suppress_unreachable_warnings() # When checking a third-party library, we can skip function body, # if during semantic analysis we found that there are no attributes @@ -1507,7 +1501,13 @@ def check_func_def( or not isinstance(defn, FuncDef) or defn.has_self_attr_def ): - self.accept(item.body) + if len(expanded) > 1: + with IterationErrorWatcher( + self.msg.errors, iter_errors, collect_revealed_types=False + ): + self.accept(item.body) + else: + self.accept(item.body) unreachable = self.binder.is_unreachable() if new_frame is not None: self.binder.pop_frame(True, 0) @@ -1603,6 +1603,9 @@ def check_func_def( self.binder = old_binder + if len(expanded) > 1: + self.msg.iteration_dependent_errors(iter_errors) + def require_correct_self_argument(self, func: Type, defn: FuncDef) -> bool: func = get_proper_type(func) if not isinstance(func, CallableType): diff --git a/mypy/errors.py b/mypy/errors.py index edfb3bd1607a..d2d6d042587d 100644 --- a/mypy/errors.py +++ b/mypy/errors.py @@ -353,6 +353,7 @@ class IterationErrorWatcher(ErrorWatcher): making too-hasty reports.""" iteration_dependent_errors: IterationDependentErrors + collect_revealed_types: bool def __init__( self, @@ -362,6 +363,7 @@ def __init__( filter_errors: bool | Callable[[str, ErrorInfo], bool] = False, save_filtered_errors: bool = False, filter_deprecated: bool = False, + collect_revealed_types: bool = True, ) -> None: super().__init__( errors, @@ -373,6 +375,7 @@ def __init__( iteration_dependent_errors.uselessness_errors.append(set()) iteration_dependent_errors.nonoverlapping_types.append({}) iteration_dependent_errors.unreachable_lines.append(set()) + self.collect_revealed_types = collect_revealed_types def on_error(self, file: str, info: ErrorInfo) -> bool: """Filter out the "iteration-dependent" errors and notes and store their diff --git a/mypy/messages.py b/mypy/messages.py index bbcc93ebfb25..08d3beb5f589 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -1778,7 +1778,7 @@ def reveal_type(self, typ: Type, context: Context) -> None: # The `reveal_type` statement might be visited iteratively due to being # placed in a loop or so. Hence, we collect the respective types of # individual iterations so that we can report them all in one step later: - if isinstance(watcher, IterationErrorWatcher): + if isinstance(watcher, IterationErrorWatcher) and watcher.collect_revealed_types: watcher.iteration_dependent_errors.revealed_types[ (context.line, context.column, context.end_line, context.end_column) ].append(typ) diff --git a/test-data/unit/check-narrowing.test b/test-data/unit/check-narrowing.test index 03586e4109f6..551ca6cc65bf 100644 --- a/test-data/unit/check-narrowing.test +++ b/test-data/unit/check-narrowing.test @@ -2434,6 +2434,20 @@ for x in xs: y = {} # E: Need type annotation for "y" (hint: "y: dict[, ] = ...") [builtins fixtures/list.pyi] +[case testAvoidFalseUnreachableInLoopWithContrainedTypeVar] +# flags: --warn-unreachable --python-version 3.11 +from typing import TypeVar + +T = TypeVar("T", int, str) +def f(x: T) -> list[T]: + y = None + while y is None: + if y is None: + y = [] + y.append(x) + return y +[builtins fixtures/list.pyi] + [case testAvoidFalseRedundantExprInLoop] # flags: --enable-error-code redundant-expr --python-version 3.11 diff --git a/test-data/unit/check-unreachable-code.test b/test-data/unit/check-unreachable-code.test index 98c676dbf42b..f8cf1a47b1aa 100644 --- a/test-data/unit/check-unreachable-code.test +++ b/test-data/unit/check-unreachable-code.test @@ -1056,11 +1056,7 @@ def test2(x: T2) -> T2: reveal_type(x) # N: Revealed type is "builtins.str" if False: - # This is unreachable, but we don't report an error, unfortunately. - # The presence of the TypeVar with values unfortunately currently shuts - # down type-checking for this entire function. - # TODO: Find a way of removing this limitation - reveal_type(x) + reveal_type(x) # E: Statement is unreachable return x @@ -1074,20 +1070,14 @@ class Test3(Generic[T2]): reveal_type(self.x) # N: Revealed type is "builtins.str" if False: - # Same issue as above - reveal_type(self.x) + reveal_type(self.x) # E: Statement is unreachable class Test4(Generic[T3]): def __init__(self, x: T3): - # https://github.com/python/mypy/issues/9456 - # On TypeVars with value restrictions, we currently have no way - # of checking a statement for all the type expansions. - # Thus unreachable warnings are disabled if x and False: pass - # This test should fail after this limitation is removed. - if False and x: + if False and x: # E: Right operand of "and" is never evaluated pass [builtins fixtures/isinstancelist.pyi]