From 24703d784fc5ae8a976843d968d35d050aa12e70 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Mon, 21 Sep 2026 00:45:25 +0100 Subject: [PATCH 1/2] Unify type alias deferral logic --- mypy/semanal.py | 16 +++----- test-data/unit/check-recursive-types.test | 45 +++++++++++++++++++++++ 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/mypy/semanal.py b/mypy/semanal.py index cab56021c55e4..2f1a88ae05c4d 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -4334,9 +4334,8 @@ def check_and_set_up_type_alias(self, s: AssignmentStmt) -> bool: elif isinstance(s.rvalue, RefExpr): s.rvalue.is_alias_rvalue = True + updated = False if existing: - # An alias gets updated. - updated = False if isinstance(existing.node, TypeAlias): # Invalidate recursive status cache in case it was previously set. existing.node._is_recursive = None @@ -4352,15 +4351,6 @@ def check_and_set_up_type_alias(self, s: AssignmentStmt) -> bool: # Otherwise just replace existing placeholder with type alias *in place*. existing._node = alias_node updated = True - # TODO: switch type aliases to if has_placeholder(): process_placeholder() pattern. - # Type aliases are last notable exception from this logic. - if updated: - if self.final_iteration: - self.cannot_resolve_name(lvalue.name, "name", s) - return True - else: - # We need to defer so that this change can get propagated to base classes. - self.defer(s, force_progress=True) else: self.add_symbol(lvalue.name, alias_node, s) if isinstance(rvalue, RefExpr) and isinstance(rvalue.node, TypeAlias): @@ -4368,6 +4358,10 @@ def check_and_set_up_type_alias(self, s: AssignmentStmt) -> bool: current_node = existing.node if existing else alias_node assert isinstance(current_node, TypeAlias) self.disable_invalid_recursive_aliases(s, current_node, s.rvalue) + # Check for placeholders only after we disable invalid recursive aliases. + # Otherwise, we may get an infinite recursion while visiting the target. + if updated or has_placeholder(res): + self.process_placeholder(lvalue.name, "name", s, force_progress=updated) if self.is_class_scope(): assert self.type is not None if self.type.is_protocol: diff --git a/test-data/unit/check-recursive-types.test b/test-data/unit/check-recursive-types.test index 9eff3d98fbe09..169283b0ce1e2 100644 --- a/test-data/unit/check-recursive-types.test +++ b/test-data/unit/check-recursive-types.test @@ -1040,3 +1040,48 @@ def f(obj: OneClass) -> None: else: reveal_type(obj) # N: Revealed type is "builtins.list[...]" [builtins fixtures/isinstancelist.pyi] + +[case testAliasWithTypeVarDefaultPlaceholderInImportCycle] +import a +from a import Base +reveal_type(Base().inst()) +reveal_type(Base().group()) +[file a.py] +from typing import Generic, TYPE_CHECKING +from typing_extensions import TypeVar + +if TYPE_CHECKING: + from c import Inst + from d import Group + +T = TypeVar("T", default="Inst") + +class Base(Generic[T]): + def group(self) -> "Group": ... + def inst(self) -> T: ... + +[file b.py] +from typing import Callable, Union +from a import Base + +Alias = Union[Base, Callable[[], None]] + +[file c.py] +from a import Base + +class Inst: + def make(self) -> Base: ... + +[file d.py] +from typing import TYPE_CHECKING, Tuple +from typing_extensions import TypedDict + +if TYPE_CHECKING: + from b import Alias + +class Group(TypedDict): + params: "Tuple[Alias, ...]" +[builtins fixtures/tuple.pyi] +[out] +main:3: note: Revealed type is "c.Inst" +main:4: note: Revealed type is "TypedDict('d.Group', {'params': builtins.tuple[a.Base[c.Inst] | (def ()), ...]})" From 87342c4a2e82c832e592945c6d5e4c79f4fd4ca0 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Mon, 21 Sep 2026 00:56:58 +0100 Subject: [PATCH 2/2] Handle new syntax as well; better tests --- mypy/semanal.py | 12 ++----- test-data/unit/check-python312.test | 41 +++++++++++++++++++++++ test-data/unit/check-recursive-types.test | 10 ++---- 3 files changed, 47 insertions(+), 16 deletions(-) diff --git a/mypy/semanal.py b/mypy/semanal.py index 2f1a88ae05c4d..196a185500884 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -5893,12 +5893,12 @@ def visit_type_alias_stmt(self, s: TypeAliasStmt) -> None: alias_node.default_depends = default_depends s.alias_node = alias_node + updated = False if ( existing and isinstance(existing.node, (PlaceholderNode, TypeAlias)) and existing.node.line == s.line ): - updated = False if isinstance(existing.node, TypeAlias): # Invalidate recursive status cache in case it was previously set. existing.node._is_recursive = None @@ -5916,20 +5916,14 @@ def visit_type_alias_stmt(self, s: TypeAliasStmt) -> None: # Otherwise just replace existing placeholder with type alias *in place*. existing._node = alias_node updated = True - - if updated: - if self.final_iteration: - self.cannot_resolve_name(s.name.name, "name", s) - return - else: - # We need to defer so that this change can get propagated to base classes. - self.defer(s, force_progress=True) else: self.add_symbol(s.name.name, alias_node, s) current_node = existing.node if existing else alias_node assert isinstance(current_node, TypeAlias) self.disable_invalid_recursive_aliases(s, current_node, s.value) + if updated or has_placeholder(res): + self.process_placeholder(s.name.name, "name", s, force_progress=updated) s.name.accept(self) finally: self.pop_type_args(s.type_args) diff --git a/test-data/unit/check-python312.test b/test-data/unit/check-python312.test index dbee1e06c69de..89f242a00333e 100644 --- a/test-data/unit/check-python312.test +++ b/test-data/unit/check-python312.test @@ -2332,3 +2332,44 @@ class N(NamedTuple): n = N((1, 2)) reveal_type(n) # N: Revealed type is "tuple[tuple[builtins.int, builtins.int], fallback=__main__.N]" [builtins fixtures/tuple.pyi] + +[case testAliasWithTypeVarDefaultPlaceholderInImportCycleNewSyntax] +import a +from a import Base +reveal_type(Base().inst()) # N: Revealed type is "c.Inst" +reveal_type(Base().group()) # N: Revealed type is "TypedDict('d.Group', {'params': builtins.tuple[a.Base[c.Inst] | (def ()), ...]})" +[file a.py] +from typing import Generic, TYPE_CHECKING, TypeVar + +if TYPE_CHECKING: + from c import Inst + from d import Group + +T = TypeVar("T", default="Inst") + +class Base(Generic[T]): + def group(self) -> "Group": ... + def inst(self) -> T: ... + +[file b.py] +from typing import Callable, Union +from a import Base + +type Alias = Union[Base, Callable[[], None]] + +[file c.py] +from a import Base + +class Inst: + def make(self) -> Base: ... + +[file d.py] +from typing import TYPE_CHECKING, Tuple +from typing_extensions import TypedDict + +if TYPE_CHECKING: + from b import Alias + +class Group(TypedDict): + params: "Tuple[Alias, ...]" +[builtins fixtures/tuple.pyi] diff --git a/test-data/unit/check-recursive-types.test b/test-data/unit/check-recursive-types.test index 169283b0ce1e2..5d81e76ba8c6e 100644 --- a/test-data/unit/check-recursive-types.test +++ b/test-data/unit/check-recursive-types.test @@ -1044,11 +1044,10 @@ def f(obj: OneClass) -> None: [case testAliasWithTypeVarDefaultPlaceholderInImportCycle] import a from a import Base -reveal_type(Base().inst()) -reveal_type(Base().group()) +reveal_type(Base().inst()) # N: Revealed type is "c.Inst" +reveal_type(Base().group()) # N: Revealed type is "TypedDict('d.Group', {'params': builtins.tuple[a.Base[c.Inst] | (def ()), ...]})" [file a.py] -from typing import Generic, TYPE_CHECKING -from typing_extensions import TypeVar +from typing import Generic, TYPE_CHECKING, TypeVar if TYPE_CHECKING: from c import Inst @@ -1082,6 +1081,3 @@ if TYPE_CHECKING: class Group(TypedDict): params: "Tuple[Alias, ...]" [builtins fixtures/tuple.pyi] -[out] -main:3: note: Revealed type is "c.Inst" -main:4: note: Revealed type is "TypedDict('d.Group', {'params': builtins.tuple[a.Base[c.Inst] | (def ()), ...]})"