[ty] Fix subtyping/assignability of function- and class-literal types to callback protocols (#20363)

## Summary

Fixes https://github.com/astral-sh/ty/issues/377.

We were treating any function as being assignable to any callback
protocol, because we were trying to figure out a type's `Callable`
supertype by looking up the `__call__` attribute on the type's
meta-type. But a function-literal's meta-type is `types.FunctionType`,
and `types.FunctionType.__call__` is `(...) -> Any`, which is not very
helpful!

While working on this PR, I also realised that assignability between
class-literals and callback protocols was somewhat broken too, so I
fixed that at the same time.

## Test Plan

Added mdtests
This commit is contained in:
Alex Waygood 2025-09-12 22:20:09 +01:00 committed by GitHub
parent c7f6b85fb3
commit 98708976e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 137 additions and 29 deletions

View file

@ -3268,13 +3268,6 @@ impl<'db> Type<'db> {
policy: InstanceFallbackShadowsNonDataDescriptor,
member_policy: MemberLookupPolicy,
) -> PlaceAndQualifiers<'db> {
// TODO: this is a workaround for the fact that looking up the `__call__` attribute on the
// meta-type of a `Callable` type currently returns `Unbound`. We should fix this by inferring
// a more sophisticated meta-type for `Callable` types; that would allow us to remove this branch.
if name == "__call__" && matches!(self, Type::Callable(_) | Type::DataclassTransformer(_)) {
return Place::bound(self).into();
}
let (
PlaceAndQualifiers {
place: meta_attr,

View file

@ -541,17 +541,34 @@ impl<'a, 'db> ProtocolMember<'a, 'db> {
) -> ConstraintSet<'db> {
match &self.kind {
ProtocolMemberKind::Method(method) => {
let Place::Type(attribute_type, Boundness::Bound) = other
.invoke_descriptor_protocol(
db,
self.name,
Place::Unbound.into(),
InstanceFallbackShadowsNonDataDescriptor::No,
MemberLookupPolicy::default(),
)
.place
else {
return ConstraintSet::from(false);
// `__call__` members must be special cased for several reasons:
//
// 1. Looking up `__call__` on the meta-type of a `Callable` type returns `Place::Unbound` currently
// 2. Looking up `__call__` on the meta-type of a function-literal type currently returns a type that
// has an extremely vague signature (`(*args, **kwargs) -> Any`), which is not useful for protocol
// checking.
// 3. Looking up `__call__` on the meta-type of a class-literal, generic-alias or subclass-of type is
// unfortunately not sufficient to obtain the `Callable` supertypes of these types, due to the
// complex interaction between `__new__`, `__init__` and metaclass `__call__`.
let attribute_type = if self.name == "__call__" {
let Some(attribute_type) = other.into_callable(db) else {
return ConstraintSet::from(false);
};
attribute_type
} else {
let Place::Type(attribute_type, Boundness::Bound) = other
.invoke_descriptor_protocol(
db,
self.name,
Place::Unbound.into(),
InstanceFallbackShadowsNonDataDescriptor::No,
MemberLookupPolicy::default(),
)
.place
else {
return ConstraintSet::from(false);
};
attribute_type
};
let proto_member_as_bound_method = method.bind_self(db);