[ty] Treat Callable dunder members as bound method descriptors (#20860)
Some checks are pending
CI / cargo clippy (push) Blocked by required conditions
CI / cargo test (linux) (push) Blocked by required conditions
CI / cargo test (linux, release) (push) Blocked by required conditions
CI / cargo test (windows) (push) Blocked by required conditions
CI / cargo test (wasm) (push) Blocked by required conditions
CI / cargo build (release) (push) Waiting to run
CI / cargo build (msrv) (push) Blocked by required conditions
CI / Determine changes (push) Waiting to run
CI / cargo fmt (push) Waiting to run
CI / cargo fuzz build (push) Blocked by required conditions
CI / fuzz parser (push) Blocked by required conditions
CI / test scripts (push) Blocked by required conditions
CI / ecosystem (push) Blocked by required conditions
CI / Fuzz for new ty panics (push) Blocked by required conditions
CI / cargo shear (push) Blocked by required conditions
CI / ty completion evaluation (push) Blocked by required conditions
CI / python package (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / formatter instabilities and black similarity (push) Blocked by required conditions
CI / test ruff-lsp (push) Blocked by required conditions
CI / check playground (push) Blocked by required conditions
CI / benchmarks instrumented (ruff) (push) Blocked by required conditions
CI / benchmarks instrumented (ty) (push) Blocked by required conditions
CI / benchmarks walltime (medium|multithreaded) (push) Blocked by required conditions
CI / benchmarks walltime (small|large) (push) Blocked by required conditions
[ty Playground] Release / publish (push) Waiting to run

## Summary

Dunder methods (at least the ones defined in the standard library)
always take an instance of the class as the first parameter. So it seems
reasonable to generally treat them as bound method descriptors if they
are defined via a `Callable` type.

This removes just a few false positives from the ecosystem, but solves
three user-reported issues:

closes https://github.com/astral-sh/ty/issues/908
closes https://github.com/astral-sh/ty/issues/1143
closes https://github.com/astral-sh/ty/issues/1209

In addition to the change here, I also considered [making `ClassVar`s
bound method descriptors](https://github.com/astral-sh/ruff/pull/20861).
However, there was zero ecosystem impact. So I think we can also close
https://github.com/astral-sh/ty/issues/491 with this PR.

closes https://github.com/astral-sh/ty/issues/491

## Test Plan

Added regression test
This commit is contained in:
David Peter 2025-10-14 14:27:52 +02:00 committed by GitHub
parent ac2c530377
commit 6341bb7403
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 82 additions and 11 deletions

View file

@ -204,3 +204,37 @@ class Calculator:
reveal_type(Calculator().square_then_round(3.14)) # revealed: Unknown | int
```
## Use case: Treating dunder methods as bound-method descriptors
pytorch defines a `__pow__` dunder attribute on [`TensorBase`] in a similar way to the following
example. We generally treat dunder attributes as bound-method descriptors since they all take a
`self` argument. This allows us to type-check the following code correctly:
```py
from typing import Callable
def pow_impl(tensor: Tensor, exponent: int) -> Tensor:
raise NotImplementedError
class Tensor:
__pow__: Callable[[Tensor, int], Tensor] = pow_impl
Tensor() ** 2
```
The following example is also taken from a real world project. Here, the `__lt__` dunder attribute
is not declared. The attribute type is therefore inferred as `Unknown | Callable[…]`, but we still
treat it as a bound-method descriptor:
```py
def make_comparison_operator(name: str) -> Callable[[Matrix, Matrix], bool]:
raise NotImplementedError
class Matrix:
__lt__ = make_comparison_operator("lt")
Matrix() < Matrix()
```
[`tensorbase`]: https://github.com/pytorch/pytorch/blob/f3913ea641d871f04fa2b6588a77f63efeeb9f10/torch/_tensor.py#L1084-L1092

View file

@ -1181,18 +1181,17 @@ static_assert(not is_assignable_to(EggsLegacy, Callable[..., Any])) # error: [s
An instance type is assignable to a compatible callable type if the instance type's class has a
callable `__call__` attribute.
TODO: for the moment, we don't consider the callable type as a bound-method descriptor, but this may
change for better compatibility with mypy/pyright.
```py
from __future__ import annotations
from typing import Callable
from ty_extensions import static_assert, is_assignable_to
def call_impl(a: int) -> str:
def call_impl(a: A, x: int) -> str:
return ""
class A:
__call__: Callable[[int], str] = call_impl
__call__: Callable[[A, int], str] = call_impl
static_assert(is_assignable_to(A, Callable[[int], str]))
static_assert(not is_assignable_to(A, Callable[[int], int]))

View file

@ -1635,18 +1635,17 @@ f(a)
An instance type can be a subtype of a compatible callable type if the instance type's class has a
callable `__call__` attribute.
TODO: for the moment, we don't consider the callable type as a bound-method descriptor, but this may
change for better compatibility with mypy/pyright.
```py
from __future__ import annotations
from typing import Callable
from ty_extensions import static_assert, is_subtype_of
def call_impl(a: int) -> str:
def call_impl(a: A, x: int) -> str:
return ""
class A:
__call__: Callable[[int], str] = call_impl
__call__: Callable[[A, int], str] = call_impl
static_assert(is_subtype_of(A, Callable[[int], str]))
static_assert(not is_subtype_of(A, Callable[[int], int]))

View file

@ -11116,6 +11116,23 @@ impl<'db> IntersectionType<'db> {
}
}
/// Map a type transformation over all positive elements of the intersection. Leave the
/// negative elements unchanged.
pub(crate) fn map_positive(
self,
db: &'db dyn Db,
mut transform_fn: impl FnMut(&Type<'db>) -> Type<'db>,
) -> Type<'db> {
let mut builder = IntersectionBuilder::new(db);
for ty in self.positive(db) {
builder = builder.add_positive(transform_fn(ty));
}
for ty in self.negative(db) {
builder = builder.add_negative(*ty);
}
builder.build()
}
pub(crate) fn map_with_boundness(
self,
db: &'db dyn Db,

View file

@ -2014,7 +2014,29 @@ impl<'db> ClassLiteral<'db> {
name: &str,
policy: MemberLookupPolicy,
) -> PlaceAndQualifiers<'db> {
self.class_member_inner(db, None, name, policy)
fn into_function_like_callable<'d>(db: &'d dyn Db, ty: Type<'d>) -> Type<'d> {
match ty {
Type::Callable(callable_ty) => {
Type::Callable(CallableType::new(db, callable_ty.signatures(db), true))
}
Type::Union(union) => {
union.map(db, |element| into_function_like_callable(db, *element))
}
Type::Intersection(intersection) => intersection
.map_positive(db, |element| into_function_like_callable(db, *element)),
_ => ty,
}
}
let mut member = self.class_member_inner(db, None, name, policy);
// We generally treat dunder attributes with `Callable` types as function-like callables.
// See `callables_as_descriptors.md` for more details.
if name.starts_with("__") && name.ends_with("__") {
member = member.map_type(|ty| into_function_like_callable(db, ty));
}
member
}
fn class_member_inner(