Skip to content

Commit b7d5dc9

Browse files
authored
[ty] Add tests for interactions of @classmethod, @staticmethod, and protocol method members (#20555)
1 parent e1bb74b commit b7d5dc9

File tree

1 file changed

+113
-4
lines changed

1 file changed

+113
-4
lines changed

crates/ty_python_semantic/resources/mdtest/protocols.md

Lines changed: 113 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1761,7 +1761,7 @@ class `T` has a method `m` which is assignable to the `Callable` supertype of th
17611761

17621762
```py
17631763
from typing import Protocol
1764-
from ty_extensions import is_subtype_of, static_assert
1764+
from ty_extensions import is_subtype_of, is_assignable_to, static_assert
17651765

17661766
class P(Protocol):
17671767
def m(self, x: int, /) -> None: ...
@@ -1773,12 +1773,30 @@ class NotSubtype:
17731773
def m(self, x: int) -> int:
17741774
return 42
17751775

1776+
class NominalWithClassMethod:
1777+
@classmethod
1778+
def m(cls, x: int) -> None: ...
1779+
1780+
class NominalWithStaticMethod:
1781+
@staticmethod
1782+
def m(_, x: int) -> None: ...
1783+
17761784
class DefinitelyNotSubtype:
17771785
m = None
17781786

17791787
static_assert(is_subtype_of(NominalSubtype, P))
1780-
static_assert(not is_subtype_of(DefinitelyNotSubtype, P))
1781-
static_assert(not is_subtype_of(NotSubtype, P))
1788+
static_assert(not is_assignable_to(DefinitelyNotSubtype, P))
1789+
static_assert(not is_assignable_to(NotSubtype, P))
1790+
1791+
# `m` has the correct signature when accessed on instances of `NominalWithClassMethod`,
1792+
# but not when accessed on the class object `NominalWithClassMethod` itself
1793+
#
1794+
# TODO: this should pass
1795+
static_assert(not is_assignable_to(NominalWithClassMethod, P)) # error: [static-assert-error]
1796+
1797+
# Conversely, `m` has the correct signature when accessed on the class object
1798+
# `NominalWithStaticMethod`, but not when accessed on instances of `NominalWithStaticMethod`
1799+
static_assert(not is_assignable_to(NominalWithStaticMethod, P))
17821800
```
17831801

17841802
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
20122030
static_assert(not is_assignable_to(NominalReturningSelfNotGeneric, UsesSelf)) # error: [static-assert-error]
20132031
```
20142032

2033+
## Subtyping of protocols with `@classmethod` or `@staticmethod` members
2034+
2035+
The typing spec states that protocols may have `@classmethod` or `@staticmethod` method members.
2036+
However, as of 2025/09/24, the spec does not elaborate on how these members should behave with
2037+
regards to subtyping and assignability (nor are there any tests in the typing conformance suite).
2038+
Ty's behaviour is therefore derived from first principles and the
2039+
[mypy test suite](https://github.com/python/mypy/blob/354bea6352ee7a38b05e2f42c874e7d1f7bf557a/test-data/unit/check-protocols.test#L1231-L1263).
2040+
2041+
A protocol `P` with a `@classmethod` method member `x` can only be satisfied by a nominal type `N`
2042+
if `N.x` is a callable object that evaluates to the same type whether it is accessed on inhabitants
2043+
of `N` or inhabitants of `type[N]`, *and* the signature of `N.x` is equivalent to the signature of
2044+
`P.x` after the descriptor protocol has been invoked on `P.x`:
2045+
2046+
```py
2047+
from typing import Protocol
2048+
from ty_extensions import static_assert, is_subtype_of, is_assignable_to, is_equivalent_to, is_disjoint_from
2049+
2050+
class PClassMethod(Protocol):
2051+
@classmethod
2052+
def x(cls, val: int) -> str: ...
2053+
2054+
class PStaticMethod(Protocol):
2055+
@staticmethod
2056+
def x(val: int) -> str: ...
2057+
2058+
class NNotCallable:
2059+
x = None
2060+
2061+
class NInstanceMethod:
2062+
def x(self, val: int) -> str:
2063+
return "foo"
2064+
2065+
class NClassMethodGood:
2066+
@classmethod
2067+
def x(cls, val: int) -> str:
2068+
return "foo"
2069+
2070+
class NClassMethodBad:
2071+
@classmethod
2072+
def x(cls, val: str) -> int:
2073+
return 42
2074+
2075+
class NStaticMethodGood:
2076+
@staticmethod
2077+
def x(val: int) -> str:
2078+
return "foo"
2079+
2080+
class NStaticMethodBad:
2081+
@staticmethod
2082+
def x(cls, val: int) -> str:
2083+
return "foo"
2084+
2085+
# `PClassMethod.x` and `PStaticMethod.x` evaluate to callable types with equivalent signatures
2086+
# whether you access them on the protocol class or instances of the protocol.
2087+
# That means that they are equivalent protocols!
2088+
static_assert(is_equivalent_to(PClassMethod, PStaticMethod))
2089+
2090+
# TODO: these should all pass
2091+
static_assert(not is_assignable_to(NNotCallable, PClassMethod)) # error: [static-assert-error]
2092+
static_assert(not is_assignable_to(NNotCallable, PStaticMethod)) # error: [static-assert-error]
2093+
static_assert(is_disjoint_from(NNotCallable, PClassMethod)) # error: [static-assert-error]
2094+
static_assert(is_disjoint_from(NNotCallable, PStaticMethod)) # error: [static-assert-error]
2095+
2096+
# `NInstanceMethod.x` has the correct type when accessed on an instance of
2097+
# `NInstanceMethod`, but not when accessed on the class object itself
2098+
#
2099+
# TODO: these should pass
2100+
static_assert(not is_assignable_to(NInstanceMethod, PClassMethod)) # error: [static-assert-error]
2101+
static_assert(not is_assignable_to(NInstanceMethod, PStaticMethod)) # error: [static-assert-error]
2102+
2103+
# A nominal type with a `@staticmethod` can satisfy a protocol with a `@classmethod`
2104+
# if the staticmethod duck-types the same as the classmethod member
2105+
# both when accessed on the class and when accessed on an instance of the class
2106+
# The same also applies for a nominal type with a `@classmethod` and a protocol
2107+
# with a `@staticmethod` member
2108+
static_assert(is_assignable_to(NClassMethodGood, PClassMethod))
2109+
static_assert(is_assignable_to(NClassMethodGood, PStaticMethod))
2110+
# TODO: these should all pass:
2111+
static_assert(is_subtype_of(NClassMethodGood, PClassMethod)) # error: [static-assert-error]
2112+
static_assert(is_subtype_of(NClassMethodGood, PStaticMethod)) # error: [static-assert-error]
2113+
static_assert(not is_assignable_to(NClassMethodBad, PClassMethod)) # error: [static-assert-error]
2114+
static_assert(not is_assignable_to(NClassMethodBad, PStaticMethod)) # error: [static-assert-error]
2115+
2116+
static_assert(is_assignable_to(NStaticMethodGood, PClassMethod))
2117+
static_assert(is_assignable_to(NStaticMethodGood, PStaticMethod))
2118+
# TODO: these should all pass:
2119+
static_assert(is_subtype_of(NStaticMethodGood, PClassMethod)) # error: [static-assert-error]
2120+
static_assert(is_subtype_of(NStaticMethodGood, PStaticMethod)) # error: [static-assert-error]
2121+
static_assert(not is_assignable_to(NStaticMethodBad, PClassMethod)) # error: [static-assert-error]
2122+
static_assert(not is_assignable_to(NStaticMethodBad, PStaticMethod)) # error: [static-assert-error]
2123+
```
2124+
20152125
## Equivalence of protocols with method or property members
20162126

20172127
Two protocols `P1` and `P2`, both with a method member `x`, are considered equivalent if the
@@ -2846,7 +2956,6 @@ Add tests for:
28462956
- Protocols with instance-method members, including:
28472957
- Protocols with methods that have parameters or the return type unannotated
28482958
- Protocols with methods that have parameters or the return type annotated with `Any`
2849-
- Protocols with `@classmethod` and `@staticmethod`
28502959
- Assignability of non-instance types to protocols with instance-method members (e.g. a
28512960
class-literal type can be a subtype of `Sized` if its metaclass has a `__len__` method)
28522961
- Protocols with methods that have annotated `self` parameters.

0 commit comments

Comments
 (0)