pythonpython-typingclass-methodpyrightpython-descriptors

How to correctly type-annotate a classmethod-like descriptor in python?


classonlymethod decorator/descriptor defined below is like the built-in classmethod, but you're not allowed to call the method on instances, only on the class itself.

from typing import Concatenate, Self
from collections.abc import Callable
from functools import partial


class classonlymethod[C, **P, R]:
    def __init__(self, func: Callable[Concatenate[type[C], P], R], /):
        self._func = func

    def __get__(self, instance: None, owner: type[C], /) -> Callable[P, R]:
        if instance is not None:
            raise AttributeError('class-only method')

        return partial(self._func, owner)


class Foo:
    @classonlymethod
    def bar(cls: type[Self]) -> None:
        raise NotImplementedError()

However, pyright complains on the definition of Foo.bar:

Type of parameter "cls" must be a supertype of its class "Foo" (reportGeneralTypeIssues)

Which is odd, since it matches annotations of classmethod in typeshed.

I don't understand this error. Surely a Foo (denoted by Self) is a (non-strict) supertype of itself? I tried other ways to refer to Foo in the cls annotation, but they didn't work either.

What's going on here and how do I fix it? Is it a pyright bug?


Solution

  • Since type checkers special-case classmethod, I settled on reframing the class-only constraint as an additional decorator on top of another descriptor of appropriate type:

    from typing import Protocol
    from typing import overload
    from collections.abc import Callable
    
    
    class _Descriptor[C, **P, R](Protocol):
        @overload
        def __get__(self, instance: None | C, owner: type[C], /) -> Callable[P, R]:
            ...
    
        @overload
        def __get__(self, instance: C, /) -> Callable[P, R]:
            ...
    
    class classonly[C, **P, R]:
        def __init__(self, descriptor: _Descriptor[C, P, R], /):
            # can check that descriptor.__get__ exists here at runtime if necessary
            self._descriptor = descriptor
    
        def __get__(self, instance: None, owner: type[C], /) -> Callable[P, R]:
            if instance is not None:
                raise AttributeError('class-only method')
    
            return self._descriptor.__get__(None, owner)
    
    
    class Foo:
        @classonly
        @classmethod
        def bar(cls) -> int:  # fine
            return 0
    
    a = Foo.bar()    # fine: int
    b = Foo().bar()  # error: Cannot access attribute "bar" for class "Foo"
    

    See pyright. As a bonus, it also words with staticmethod.

    @InSync and @STerliakov, thank you for your insights.