[ty] Correctly instantiate generic class that inherits __init__ from generic base class (#19693)

This is subtle, and the root cause became more apparent with #19604,
since we now have many more cases of superclasses and subclasses using
different typevars. The issue is easiest to see in the following:

```py
class C[T]:
    def __init__(self, t: T) -> None: ...

class D[U](C[T]):
    pass

reveal_type(C(1))  # revealed: C[int]
reveal_type(D(1))  # should be: D[int]
```

When instantiating a generic class, the `__init__` method inherits the
generic context of that class. This lets our call binding machinery
infer a specialization for that context.

Prior to this PR, the instantiation of `C` worked just fine. Its
`__init__` method would inherit the `[T]` generic context, and we would
infer `{T = int}` as the specialization based on the argument
parameters.

It didn't work for `D`. The issue is that the `__init__` method was
inheriting the generic context of the class where `__init__` was defined
(here, `C` and `[T]`). At the call site, we would then infer `{T = int}`
as the specialization — but that wouldn't help us specialize `D[U]`,
since `D` does not have `T` in its generic context!

Instead, the `__init__` method should inherit the generic context of the
class that we are performing the lookup on (here, `D` and `[U]`). That
lets us correctly infer `{U = int}` as the specialization, which we can
successfully apply to `D[U]`.

(Note that `__init__` refers to `C`'s typevars in its signature, but
that's okay; our member lookup logic already applies the `T = U`
specialization when returning a member of `C` while performing a lookup
on `D`, transforming its signature from `(Self, T) -> None` to `(Self,
U) -> None`.)

Closes https://github.com/astral-sh/ty/issues/588
This commit is contained in:
Douglas Creager 2025-08-01 15:29:18 -04:00 committed by GitHub
parent 580577e667
commit d37911685f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 122 additions and 8 deletions

View file

@ -329,6 +329,57 @@ class D(C[V, int]):
reveal_type(D(1)) # revealed: D[int]
```
### Generic class inherits `__init__` from generic base class
```py
from typing import Generic, TypeVar
T = TypeVar("T")
U = TypeVar("U")
class C(Generic[T, U]):
def __init__(self, t: T, u: U) -> None: ...
class D(C[T, U]):
pass
reveal_type(C(1, "str")) # revealed: C[int, str]
reveal_type(D(1, "str")) # revealed: D[int, str]
```
### Generic class inherits `__init__` from `dict`
This is a specific example of the above, since it was reported specifically by a user.
```py
from typing import Generic, TypeVar
T = TypeVar("T")
U = TypeVar("U")
class D(dict[T, U]):
pass
reveal_type(D(key=1)) # revealed: D[str, int]
```
### Generic class inherits `__new__` from `tuple`
(Technically, we synthesize a `__new__` method that is more precise than the one defined in typeshed
for `tuple`, so we use a different mechanism to make sure it has the right inherited generic
context. But from the user's point of view, this is another example of the above.)
```py
from typing import Generic, TypeVar
T = TypeVar("T")
U = TypeVar("U")
class C(tuple[T, U]): ...
reveal_type(C((1, 2))) # revealed: C[int, int]
```
### `__init__` is itself generic
```py

View file

@ -308,6 +308,42 @@ class D[V](C[V, int]):
reveal_type(D(1)) # revealed: D[int]
```
### Generic class inherits `__init__` from generic base class
```py
class C[T, U]:
def __init__(self, t: T, u: U) -> None: ...
class D[T, U](C[T, U]):
pass
reveal_type(C(1, "str")) # revealed: C[int, str]
reveal_type(D(1, "str")) # revealed: D[int, str]
```
### Generic class inherits `__init__` from `dict`
This is a specific example of the above, since it was reported specifically by a user.
```py
class D[T, U](dict[T, U]):
pass
reveal_type(D(key=1)) # revealed: D[str, int]
```
### Generic class inherits `__new__` from `tuple`
(Technically, we synthesize a `__new__` method that is more precise than the one defined in typeshed
for `tuple`, so we use a different mechanism to make sure it has the right inherited generic
context. But from the user's point of view, this is another example of the above.)
```py
class C[T, U](tuple[T, U]): ...
reveal_type(C((1, 2))) # revealed: C[int, int]
```
### `__init__` is itself generic
```py

View file

@ -571,10 +571,20 @@ impl<'db> ClassType<'db> {
/// Returns the inferred type of the class member named `name`. Only bound members
/// or those marked as ClassVars are considered.
///
/// You must provide the `inherited_generic_context` that we should use for the `__new__` or
/// `__init__` member. This is inherited from the containing class -­but importantly, from the
/// class that the lookup is being performed on, and not the class containing the (possibly
/// inherited) member.
///
/// Returns [`Place::Unbound`] if `name` cannot be found in this class's scope
/// directly. Use [`ClassType::class_member`] if you require a method that will
/// traverse through the MRO until it finds the member.
pub(super) fn own_class_member(self, db: &'db dyn Db, name: &str) -> PlaceAndQualifiers<'db> {
pub(super) fn own_class_member(
self,
db: &'db dyn Db,
inherited_generic_context: Option<GenericContext<'db>>,
name: &str,
) -> PlaceAndQualifiers<'db> {
fn synthesize_getitem_overload_signature<'db>(
index_annotation: Type<'db>,
return_annotation: Type<'db>,
@ -590,7 +600,7 @@ impl<'db> ClassType<'db> {
let fallback_member_lookup = || {
class_literal
.own_class_member(db, specialization, name)
.own_class_member(db, inherited_generic_context, specialization, name)
.map_type(|ty| ty.apply_optional_specialization(db, specialization))
};
@ -840,8 +850,11 @@ impl<'db> ClassType<'db> {
iterable_parameter,
]);
let synthesized_dunder =
CallableType::function_like(db, Signature::new(parameters, None));
let synthesized_dunder = CallableType::function_like(
db,
Signature::new(parameters, None)
.with_inherited_generic_context(inherited_generic_context),
);
Place::bound(synthesized_dunder).into()
}
@ -1668,7 +1681,10 @@ impl<'db> ClassLiteral<'db> {
}
lookup_result = lookup_result.or_else(|lookup_error| {
lookup_error.or_fall_back_to(db, class.own_class_member(db, name))
lookup_error.or_fall_back_to(
db,
class.own_class_member(db, self.generic_context(db), name),
)
});
}
}
@ -1716,6 +1732,7 @@ impl<'db> ClassLiteral<'db> {
pub(super) fn own_class_member(
self,
db: &'db dyn Db,
inherited_generic_context: Option<GenericContext<'db>>,
specialization: Option<Specialization<'db>>,
name: &str,
) -> PlaceAndQualifiers<'db> {
@ -1744,7 +1761,7 @@ impl<'db> ClassLiteral<'db> {
// to any method with a `@classmethod` decorator. (`__init__` would remain a special
// case, since it's an _instance_ method where we don't yet know the generic class's
// specialization.)
match (self.generic_context(db), ty, specialization, name) {
match (inherited_generic_context, ty, specialization, name) {
(
Some(generic_context),
Type::FunctionLiteral(function),
@ -1926,7 +1943,7 @@ impl<'db> ClassLiteral<'db> {
KnownClass::NamedTupleFallback
.to_class_literal(db)
.into_class_literal()?
.own_class_member(db, None, name)
.own_class_member(db, self.generic_context(db), None, name)
.place
.ignore_possibly_unbound()
}
@ -4321,7 +4338,9 @@ enum SlotsKind {
impl SlotsKind {
fn from(db: &dyn Db, base: ClassLiteral) -> Self {
let Place::Type(slots_ty, bound) = base.own_class_member(db, None, "__slots__").place
let Place::Type(slots_ty, bound) = base
.own_class_member(db, base.generic_context(db), None, "__slots__")
.place
else {
return Self::NotSpecified;
};

View file

@ -360,6 +360,14 @@ impl<'db> Signature<'db> {
Self::new(Parameters::object(db), Some(Type::Never))
}
pub(crate) fn with_inherited_generic_context(
mut self,
inherited_generic_context: Option<GenericContext<'db>>,
) -> Self {
self.inherited_generic_context = inherited_generic_context;
self
}
fn materialize(&self, db: &'db dyn Db, variance: TypeVarVariance) -> Self {
Self {
generic_context: self.generic_context,