Allow calling classmethods on protocol classes#3642
Open
mikeleppane wants to merge 1 commit into
Open
Conversation
Calling a `@classmethod` on a Protocol class object — e.g. `DateTime.now(...)`
in the datetype library, or prefect's `...is_callback_with_parameters()` — wrongly
produced a `bad-argument-type` ("Only concrete classes may be assigned to
`type[P]` because `P` is a protocol"). mypy, pyright, and ty all accept this; the
typing spec permits classmethods as protocol members.
The receiver of a method call is checked as a synthesized first argument against
the method's implicit `cls`/`self` parameter, reusing the ordinary
argument-to-parameter subtype path. For a classmethod on a bare protocol class
that check is `type[P]` against `type[P]`, which the subtype rule that forbids
assigning a protocol class object to an explicit `type[Protocol]` slot rejected —
correct for user-written `type[P]` positions, wrong for the implicit receiver,
which is always the protocol itself. Because the false positive and the genuine
error reduce to the identical `got <: want`, they can only be told apart by call
context, not by type shape.
Carry that context: a `binding_self` flag on `CallContext`, set only while
checking the synthesized receiver argument, makes the protocol-concrete-class
rule stand down for the receiver and fall through to the normal class-object
promotion that binds and solves it. Explicit `type[P]` parameters/assignments
still reject protocol class objects, protocol instantiation is still blocked, and
abstract protocol methods are still uncallable.
Fixes facebook#3375
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3375
What
Calling a
@classmethodon aProtocolclass object no longer emits a spuriousbad-argument-typeerror. For example,datetype'sDateTime.now(UTC)andprefect's...is_callback_with_parameters()now type-check cleanly, matchingmypy, pyright, and ty.
Why
The typing spec permits classmethods as protocol members, and all three reference
checkers (mypy/pyright/ty) accept calling them on the protocol class. Pyrefly was
the lone outlier, rejecting valid code.
Root cause: the receiver of a method call is checked as a synthesized first
argument against the method's implicit
cls/selfparameter, reusing theordinary argument-to-parameter subtype path. For a classmethod on a bare protocol
class that check is
type[P] <: type[P], which tripped the rule that forbidsassigning a protocol class object to an explicit
type[Protocol]slot. Thatrule is correct for user-written positions (
x: type[P] = P,f(cls: type[P]),where the holder might instantiate
cls()), but wrong for the implicit receiver —which is always the protocol itself, and is never instantiated by the binding.
The false positive and the genuine error reduce to the identical
got <: want(in the non-generic case, literally
type[P]vstype[P]), so they cannot bedistinguished by type shape — only by call context.
How
Carry that one bit of context:
CallContext(solver.rs) gains abinding_self: boolflag (builderwith_binding_self(), getteris_binding_self()), cleared bywith_outside_contextsince it is a call-argument-position fact, not generalsubset state.
callable_infer_params(callable.rs) sets the flag only on thesynthesized receiver argument (arg #0 when present); every user-provided
argument keeps the normal context.
subset.rs) gains the guard&& !self.active_call_context.is_binding_self(). When checking the receiver, therule stands down and the match falls through to the normal
(ClassDef, Type::Type(..))arm, whichpromote_silentlys the class object andbinds/solves it correctly.
This preserves the protocol-
type[]semantics everywhere else and avoids theheavier alternative of changing the classmethod receiver representation (which
would ripple into
Selfresolution and bound-method display across everyclassmethod). No subset-cache change is needed: this rule's
wantisType::Type(...), whichcan_be_recursivenever memoizes.Test plan
Seven regression tests in
pyrefly/lib/test/protocol.rs:test_protocol_classmethod_callDT.make(5)isDT[int], no errortest_protocol_classmethod_call_non_genericP.make()isP, no error (thegot ≡ wantcase)test_protocol_classmethod_returns_selfSelf→ no errortest_protocol_overloaded_classmethod_callnowshape) →DT[int]test_type_protocol_param_still_rejectedf(cls: type[P]); f(P)still errorstest_protocol_classmethod_non_receiver_type_param_still_rejectedtype[P]param still rejectsP(exemption confined to arg #0)test_protocol_abstract_classmethod_still_rejectedCannot call abstract method)