[ty] Add tests for interactions of @classmethod, @staticmethod, and protocol method members (#20555)

This commit is contained in:
Alex Waygood 2025-09-25 10:14:53 +01:00 committed by GitHub
parent e1bb74b25a
commit b7d5dc98c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1761,7 +1761,7 @@ class `T` has a method `m` which is assignable to the `Callable` supertype of th
```py ```py
from typing import Protocol from typing import Protocol
from ty_extensions import is_subtype_of, static_assert from ty_extensions import is_subtype_of, is_assignable_to, static_assert
class P(Protocol): class P(Protocol):
def m(self, x: int, /) -> None: ... def m(self, x: int, /) -> None: ...
@ -1773,12 +1773,30 @@ class NotSubtype:
def m(self, x: int) -> int: def m(self, x: int) -> int:
return 42 return 42
class NominalWithClassMethod:
@classmethod
def m(cls, x: int) -> None: ...
class NominalWithStaticMethod:
@staticmethod
def m(_, x: int) -> None: ...
class DefinitelyNotSubtype: class DefinitelyNotSubtype:
m = None m = None
static_assert(is_subtype_of(NominalSubtype, P)) static_assert(is_subtype_of(NominalSubtype, P))
static_assert(not is_subtype_of(DefinitelyNotSubtype, P)) static_assert(not is_assignable_to(DefinitelyNotSubtype, P))
static_assert(not is_subtype_of(NotSubtype, P)) static_assert(not is_assignable_to(NotSubtype, P))
# `m` has the correct signature when accessed on instances of `NominalWithClassMethod`,
# but not when accessed on the class object `NominalWithClassMethod` itself
#
# TODO: this should pass
static_assert(not is_assignable_to(NominalWithClassMethod, P)) # error: [static-assert-error]
# Conversely, `m` has the correct signature when accessed on the class object
# `NominalWithStaticMethod`, but not when accessed on instances of `NominalWithStaticMethod`
static_assert(not is_assignable_to(NominalWithStaticMethod, P))
``` ```
A callable instance attribute is not sufficient for a type to satisfy a protocol with a method A callable instance attribute is not sufficient for a type to satisfy a protocol with a method
@ -2012,6 +2030,98 @@ static_assert(not is_assignable_to(NominalReturningSelfNotGeneric, LegacyFunctio
static_assert(not is_assignable_to(NominalReturningSelfNotGeneric, UsesSelf)) # error: [static-assert-error] static_assert(not is_assignable_to(NominalReturningSelfNotGeneric, UsesSelf)) # error: [static-assert-error]
``` ```
## Subtyping of protocols with `@classmethod` or `@staticmethod` members
The typing spec states that protocols may have `@classmethod` or `@staticmethod` method members.
However, as of 2025/09/24, the spec does not elaborate on how these members should behave with
regards to subtyping and assignability (nor are there any tests in the typing conformance suite).
Ty's behaviour is therefore derived from first principles and the
[mypy test suite](https://github.com/python/mypy/blob/354bea6352ee7a38b05e2f42c874e7d1f7bf557a/test-data/unit/check-protocols.test#L1231-L1263).
A protocol `P` with a `@classmethod` method member `x` can only be satisfied by a nominal type `N`
if `N.x` is a callable object that evaluates to the same type whether it is accessed on inhabitants
of `N` or inhabitants of `type[N]`, *and* the signature of `N.x` is equivalent to the signature of
`P.x` after the descriptor protocol has been invoked on `P.x`:
```py
from typing import Protocol
from ty_extensions import static_assert, is_subtype_of, is_assignable_to, is_equivalent_to, is_disjoint_from
class PClassMethod(Protocol):
@classmethod
def x(cls, val: int) -> str: ...
class PStaticMethod(Protocol):
@staticmethod
def x(val: int) -> str: ...
class NNotCallable:
x = None
class NInstanceMethod:
def x(self, val: int) -> str:
return "foo"
class NClassMethodGood:
@classmethod
def x(cls, val: int) -> str:
return "foo"
class NClassMethodBad:
@classmethod
def x(cls, val: str) -> int:
return 42
class NStaticMethodGood:
@staticmethod
def x(val: int) -> str:
return "foo"
class NStaticMethodBad:
@staticmethod
def x(cls, val: int) -> str:
return "foo"
# `PClassMethod.x` and `PStaticMethod.x` evaluate to callable types with equivalent signatures
# whether you access them on the protocol class or instances of the protocol.
# That means that they are equivalent protocols!
static_assert(is_equivalent_to(PClassMethod, PStaticMethod))
# TODO: these should all pass
static_assert(not is_assignable_to(NNotCallable, PClassMethod)) # error: [static-assert-error]
static_assert(not is_assignable_to(NNotCallable, PStaticMethod)) # error: [static-assert-error]
static_assert(is_disjoint_from(NNotCallable, PClassMethod)) # error: [static-assert-error]
static_assert(is_disjoint_from(NNotCallable, PStaticMethod)) # error: [static-assert-error]
# `NInstanceMethod.x` has the correct type when accessed on an instance of
# `NInstanceMethod`, but not when accessed on the class object itself
#
# TODO: these should pass
static_assert(not is_assignable_to(NInstanceMethod, PClassMethod)) # error: [static-assert-error]
static_assert(not is_assignable_to(NInstanceMethod, PStaticMethod)) # error: [static-assert-error]
# A nominal type with a `@staticmethod` can satisfy a protocol with a `@classmethod`
# if the staticmethod duck-types the same as the classmethod member
# both when accessed on the class and when accessed on an instance of the class
# The same also applies for a nominal type with a `@classmethod` and a protocol
# with a `@staticmethod` member
static_assert(is_assignable_to(NClassMethodGood, PClassMethod))
static_assert(is_assignable_to(NClassMethodGood, PStaticMethod))
# TODO: these should all pass:
static_assert(is_subtype_of(NClassMethodGood, PClassMethod)) # error: [static-assert-error]
static_assert(is_subtype_of(NClassMethodGood, PStaticMethod)) # error: [static-assert-error]
static_assert(not is_assignable_to(NClassMethodBad, PClassMethod)) # error: [static-assert-error]
static_assert(not is_assignable_to(NClassMethodBad, PStaticMethod)) # error: [static-assert-error]
static_assert(is_assignable_to(NStaticMethodGood, PClassMethod))
static_assert(is_assignable_to(NStaticMethodGood, PStaticMethod))
# TODO: these should all pass:
static_assert(is_subtype_of(NStaticMethodGood, PClassMethod)) # error: [static-assert-error]
static_assert(is_subtype_of(NStaticMethodGood, PStaticMethod)) # error: [static-assert-error]
static_assert(not is_assignable_to(NStaticMethodBad, PClassMethod)) # error: [static-assert-error]
static_assert(not is_assignable_to(NStaticMethodBad, PStaticMethod)) # error: [static-assert-error]
```
## Equivalence of protocols with method or property members ## Equivalence of protocols with method or property members
Two protocols `P1` and `P2`, both with a method member `x`, are considered equivalent if the Two protocols `P1` and `P2`, both with a method member `x`, are considered equivalent if the
@ -2846,7 +2956,6 @@ Add tests for:
- Protocols with instance-method members, including: - Protocols with instance-method members, including:
- Protocols with methods that have parameters or the return type unannotated - Protocols with methods that have parameters or the return type unannotated
- Protocols with methods that have parameters or the return type annotated with `Any` - Protocols with methods that have parameters or the return type annotated with `Any`
- Protocols with `@classmethod` and `@staticmethod`
- Assignability of non-instance types to protocols with instance-method members (e.g. a - Assignability of non-instance types to protocols with instance-method members (e.g. a
class-literal type can be a subtype of `Sized` if its metaclass has a `__len__` method) class-literal type can be a subtype of `Sized` if its metaclass has a `__len__` method)
- Protocols with methods that have annotated `self` parameters. - Protocols with methods that have annotated `self` parameters.