From 803a7374fba449c80bb78d0f36cab5568786aeb5 Mon Sep 17 00:00:00 2001 From: Moussa Date: Sun, 11 Oct 2026 20:33:11 -0500 Subject: [PATCH 1/2] Unwrap an identity type alias when checking an explicit self-type annotation is_expected_self_type() already unwraps Annotated[Self, ...] so a method like "def m(self: Annotated[Self, ...])" is recognized as equivalent to a bare "self: Self". A generic type alias that's equally transparent (e.g. "type Ident[T] = T") had no equivalent handling, so "self: Ident[Self]" was wrongly rejected with "Method cannot have explicit self annotation and Self type". Adds expand_type_alias_for_self_check(), which substitutes the alias's own type parameters with the arguments it was referenced with (using mypy's existing substitution, mypy.expandtype.expand_type) and recurses is_expected_self_type() on the result -- the same way the existing Annotated[...] case recurses on its one argument. This is handled in both places an explicit self-type annotation is checked: the plain/ instance-method branch (self: Ident[Self]) and the classmethod branch, which needs it in two different positions depending on how the alias and type[...] are nested -- cls: type[Ident[Self]] unwraps through the existing TypeType case first and lands in the plain branch, while cls: Ident[type[Self]] needs the same handling in the classmethod branch's own UnboundType case, recursing with is_classmethod=True preserved -- same as the ANNOTATED_TYPE_NAMES case right next to it. Using real substitution instead of hand-matching "is the target exactly the alias's sole type parameter" also covers, without any extra code: an alias with extra, unused type parameters, in any order (e.g. type Extra[T, S] = T used as Extra[Self, int] or Extra[int, Self]); and a target that only reduces to the expected shape once substituted (e.g. type UnionSame[T] = T | T, which collapses once both T's become Self). Self supplied for an alias's *unused* parameter is correctly still rejected, since the substitution never looks at that slot. Also drops the redundant "sym is not None" repeated on every branch below the symbol lookup, in both the classmethod and instance-method cases, in favor of a single early return when the lookup fails -- the control flow in each branch already made the None case fall through to the same result, so this part is a pure refactor with no behavior change. expand_type_alias_for_self_check()'s args parameter takes UnboundType.args directly, which mypy's own types.py types as a Sequence[Type] (stored internally as a tuple), not a list -- declared as list[Type] at first, which only surfaced as a real error under mypy's own self-check (runtests.py self), not under ordinary testing. Fixed to Sequence[Type], imported from collections.abc to match this module's existing convention for Callable/Collection/Iterable/Iterator. Verified: bare self: Self, Annotated[Self, ...], an explicit concrete self-type, and a non-Self alias argument (Ident[int], which should and does still raise a real self-type error) are all unaffected; both classmethod orderings, extra-unused-parameter aliases (both orders), and the union-collapse case all work; Self in an alias's unused slot still correctly errors. Self-type test suite 152/152. runtests.py self: 10 errors, matching an unmodified checkout exactly (pre-existing environment gaps -- missing psutil/tomli/filelock/setuptools stubs), confirmed by running the identical check against the unmodified base commit. Full test suite (mypy/test + mypyc/test): 13627 passed, 410 skipped, 10 xfailed; the 12 failures and 3 collection errors present are pre-existing environment gaps (missing attrs/mypyc-build deps), confirmed identical on an unmodified checkout. The code above and in this commit was written by Claude and reviewed by Moussa Mikhail. The issue and the PR description accompanying it are his own, written by him. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01REqZNys4H8tiFU7Bb6Qj2B --- mypy/semanal.py | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/mypy/semanal.py b/mypy/semanal.py index 52861230af37..ed989e0d80c9 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -51,7 +51,7 @@ from __future__ import annotations import re -from collections.abc import Callable, Collection, Iterable, Iterator +from collections.abc import Callable, Collection, Iterable, Iterator, Sequence from contextlib import contextmanager from typing import Any, Final, TypeAlias as _TypeAlias, TypeGuard, TypeVar, cast from typing_extensions import assert_never @@ -60,6 +60,7 @@ from mypy.constant_fold import constant_fold_expr from mypy.errorcodes import PROPERTY_DECORATOR, ErrorCode from mypy.errors import Errors, report_internal_error +from mypy.expandtype import expand_type from mypy.exprtotype import TypeTranslationError, expr_to_unanalyzed_type from mypy.message_registry import CANNOT_DECLARE_TYPE_OF_SPECIAL_FORM, ErrorMessage from mypy.messages import ( @@ -1198,20 +1199,43 @@ def is_expected_self_type(self, typ: Type, is_classmethod: bool) -> bool: return self.is_expected_self_type(typ.item, is_classmethod=False) if isinstance(typ, UnboundType): sym = self.lookup_qualified(typ.name, typ, suppress_errors=True) - if sym is not None and sym.fullname in TYPE_NAMES and typ.args: + if sym is None or not typ.args: + return False + if sym.fullname in TYPE_NAMES: return self.is_expected_self_type(typ.args[0], is_classmethod=False) - if sym is not None and sym.fullname in ANNOTATED_TYPE_NAMES and typ.args: + if sym.fullname in ANNOTATED_TYPE_NAMES: return self.is_expected_self_type(typ.args[0], is_classmethod=True) + expanded = self.expand_type_alias_for_self_check(sym.node, typ.args) + if expanded is not None: + return self.is_expected_self_type(expanded, is_classmethod=True) return False + if isinstance(typ, TypeVarType): return typ == self.type.self_type if isinstance(typ, UnboundType): sym = self.lookup_qualified(typ.name, typ, suppress_errors=True) - if sym is not None and sym.fullname in ANNOTATED_TYPE_NAMES and typ.args: + if sym is None: + return False + if sym.fullname in ANNOTATED_TYPE_NAMES and typ.args: return self.is_expected_self_type(typ.args[0], is_classmethod=False) - return sym is not None and sym.fullname in SELF_TYPE_NAMES + expanded = self.expand_type_alias_for_self_check(sym.node, typ.args) + if expanded is not None: + return self.is_expected_self_type(expanded, is_classmethod=False) + return sym.fullname in SELF_TYPE_NAMES return False + def expand_type_alias_for_self_check( + self, node: SymbolNode | None, args: Sequence[Type] + ) -> Type | None: + """Substitute a type alias's own type parameters with the given arguments + (e.g. "Ident[Self]" for "type Ident[T] = T" gives back "Self"), so the + result can be checked like any other type. + """ + if not isinstance(node, TypeAlias) or len(node.alias_tvars) != len(args): + return None + env = {tvar.id: arg for tvar, arg in zip(node.alias_tvars, args)} + return expand_type(node.target, env) + def set_original_def(self, previous: Node | None, new: FuncDef | Decorator) -> bool: """If 'new' conditionally redefine 'previous', set 'previous' as original From 5a326cd6cbf1e5bd27e7f6c485edb9c1e925e360 Mon Sep 17 00:00:00 2001 From: Moussa Date: Sun, 11 Oct 2026 20:33:28 -0500 Subject: [PATCH 2/2] Add tests for the identity-alias self-annotation fix Adds testTypingSelfIdentityTypeAlias (mirroring the existing testTypingSelfAnnotatedType) plus a minimal regression case, covering: self: Ident[Self], both orderings of the classmethod form (cls: Ident[type[Self]] and cls: type[Ident[Self]]), and a negative case confirming a non-Self alias argument (Ident[int]) still raises the real self-type error. Adds testTypingSelfIdentityTypeAliasSubstitution covering what the substitution-based implementation handles beyond the literal "type X[T] = T" shape, for free: a union that only collapses to Self once substituted (type UnionSame[T] = T | T), an alias with an extra unused type parameter in either position (type Extra[T, S] = T and type Extra2[S, T] = T), the classmethod form of the same, and a negative case confirming Self supplied for an alias's *unused* parameter is still correctly rejected, not silently accepted. Writing the classmethod cases in the first test is what surfaced the gap the previous commit's classmethod-branch handling fixes -- cls: Ident[type[Self]] was still being wrongly rejected until that commit added the same handling to the classmethod branch, not just the plain branch. Separately, found and worked around an unrelated pre-existing mypy issue while writing these tests: a generic type alias whose value is *exactly* a bare type parameter (type Ident[T] = T) crashes the full checker (AssertionError: Var(TypeVar), in checker.named_type) under the test suite's minimal typing.pyi stub, where TypeVar is a dummy (`TypeVar = 0`) rather than a real class -- checker.py's alias-value-expression check calls named_type("typing.TypeVar") and asserts it resolves to a TypeInfo. A wrapped alias (type Wrapped[T] = list[T]) does not trigger it, so it's specific to the bare-identity shape. [typing fixtures/typing-full.pyi] (which has a real TypeVar class) sidesteps it; the new cases declare it for that reason. Not touched here -- it's unrelated to self-annotation handling and worth its own report. Verified: all new cases pass; self-type test suite 152/152 (was 149 before either commit). Full test suite (mypy/test + mypyc/test): 13627 passed, 410 skipped, 10 xfailed, with the same 12 failures / 3 collection errors as before these changes, confirmed identical to an unmodified checkout (pre-existing environment gaps: missing attrs/mypyc-build deps). The code above and in this commit was written by Claude and reviewed by Moussa Mikhail. The issue and the PR description accompanying it are his own, written by him. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01REqZNys4H8tiFU7Bb6Qj2B --- test-data/unit/check-selftype.test | 78 ++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/test-data/unit/check-selftype.test b/test-data/unit/check-selftype.test index 6f73df5e05c8..d69e8b84ed4f 100644 --- a/test-data/unit/check-selftype.test +++ b/test-data/unit/check-selftype.test @@ -1567,6 +1567,84 @@ reveal_type(C.foo) # N: Revealed type is "def [Self <: __main__.C] (self: Self`2 reveal_type(C.bar) # N: Revealed type is "def (x: builtins.int)" [builtins fixtures/tuple.pyi] +[case testTypingSelfIdentityTypeAliasMinimal] +# flags: --python-version 3.12 +type Ident[T] = T +def f(x: Ident[int]) -> Ident[int]: + return x +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypingSelfIdentityTypeAlias] +# flags: --python-version 3.12 +from typing import Self + +type Ident[T] = T + +class C: + def foo(self: Ident[Self], x: int) -> None: + ... + + @classmethod + def bar(cls: Ident[type[Self]], x: int) -> None: + ... + + @classmethod + def baz(cls: type[Ident[Self]], x: int) -> None: + ... + +reveal_type(C.foo) # N: Revealed type is "def [Self <: __main__.C] (self: Self`10, x: builtins.int)" +reveal_type(C.bar) # N: Revealed type is "def (x: builtins.int)" +reveal_type(C.baz) # N: Revealed type is "def (x: builtins.int)" + +class D: + def qux(self: Ident[int]) -> None: # E: The erased type of self "builtins.int" is not a supertype of its class "__main__.D" + ... +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypingSelfIdentityTypeAliasSubstitution] +# flags: --python-version 3.12 +# The identity-alias check works by substituting the alias's own arguments into +# its target (mypy.expandtype.expand_type) and checking the result, rather than +# only recognizing the literal shape "type X[T] = T". This covers, for free: +# extra unused type parameters, any parameter order, and a target that only +# collapses to Self once its members are substituted with the same type. +from typing import Self + +type Extra[T, S] = T +type Extra2[S, T] = T +type UnionSame[T] = T | T + +class C: + def union_collapses(self: UnionSame[Self]) -> None: + ... + + def extra_unused_first(self: Extra[Self, int]) -> None: + ... + + def extra_unused_second(self: Extra2[int, Self]) -> None: + ... + + @classmethod + def extra_classmethod(cls: Extra[type[Self], int]) -> None: + ... + +reveal_type(C.union_collapses) # N: Revealed type is "def [Self <: __main__.C] (self: Self`10 | Self`10)" +reveal_type(C.extra_unused_first) # N: Revealed type is "def [Self <: __main__.C] (self: Self`11)" +reveal_type(C.extra_unused_second) # N: Revealed type is "def [Self <: __main__.C] (self: Self`12)" +reveal_type(C.extra_classmethod) # N: Revealed type is "def ()" + +class D: + # Self supplied for the alias's *unused* type parameter must not be mistaken + # for the one that actually reaches the target -- this must still be a real + # self-type error, not silently accepted. + def wrong_slot(self: Extra[int, Self]) -> None: # E: Method cannot have explicit self annotation and Self type \ + # E: The erased type of self "builtins.int" is not a supertype of its class "__main__.D" + ... +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] + [case testTypingSelfOverload] from typing import Self, overload, Union