Skip to content

Allow calling classmethods on protocol classes#3642

Open
mikeleppane wants to merge 1 commit into
facebook:mainfrom
mikeleppane:fix/protocol-classmethod-cls-3375
Open

Allow calling classmethods on protocol classes#3642
mikeleppane wants to merge 1 commit into
facebook:mainfrom
mikeleppane:fix/protocol-classmethod-cls-3375

Conversation

@mikeleppane
Copy link
Copy Markdown
Contributor

Fixes #3375

What

Calling a @classmethod on a Protocol class object no longer emits a spurious
bad-argument-type error. For example, datetype's DateTime.now(UTC) and
prefect's ...is_callback_with_parameters() now type-check cleanly, matching
mypy, pyright, and ty.

from typing import Protocol

class P(Protocol):
    @classmethod
    def make(cls) -> "P": ...

P.make()   # before: ERROR "Only concrete classes may be assigned to type[P] ..."
           # after:  OK

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/self parameter, reusing the
ordinary 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 forbids
assigning a protocol class object to an explicit type[Protocol] slot. That
rule 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] vs type[P]), so they cannot be
distinguished by type shape — only by call context.

How

Carry that one bit of context:

  • CallContext (solver.rs) gains a binding_self: bool flag (builder
    with_binding_self(), getter is_binding_self()), cleared by
    with_outside_context since it is a call-argument-position fact, not general
    subset state.
  • callable_infer_params (callable.rs) sets the flag only on the
    synthesized receiver argument (arg #0 when present); every user-provided
    argument keeps the normal context.
  • The protocol-concrete-class rule (subset.rs) gains the guard
    && !self.active_call_context.is_binding_self(). When checking the receiver, the
    rule stands down and the match falls through to the normal
    (ClassDef, Type::Type(..)) arm, which promote_silentlys the class object and
    binds/solves it correctly.

This preserves the protocol-type[] semantics everywhere else and avoids the
heavier alternative of changing the classmethod receiver representation (which
would ripple into Self resolution and bound-method display across every
classmethod). No subset-cache change is needed: this rule's want is
Type::Type(...), which can_be_recursive never memoizes.

Test plan

Seven regression tests in pyrefly/lib/test/protocol.rs:

Test Asserts
test_protocol_classmethod_call generic protocol classmethod → DT.make(5) is DT[int], no error
test_protocol_classmethod_call_non_generic P.make() is P, no error (the got ≡ want case)
test_protocol_classmethod_returns_self classmethod returning Self → no error
test_protocol_overloaded_classmethod_call overloaded classmethod (datetype's now shape) → DT[int]
test_type_protocol_param_still_rejected negative: f(cls: type[P]); f(P) still errors
test_protocol_classmethod_non_receiver_type_param_still_rejected negative: a classmethod's non-receiver type[P] param still rejects P (exemption confined to arg #0)
test_protocol_abstract_classmethod_still_rejected negative: abstract protocol classmethod still uncallable (Cannot call abstract method)

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
@meta-cla meta-cla Bot added the cla signed label Jun 2, 2026
@github-actions github-actions Bot added the size/l label Jun 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Type error when calling protocol classmethod in datetype library

1 participant