簡體   English   中英

延遲調用在裝飾器中獲得的 python 類方法

[英]Defered invocation of a python classmethod obtained in a decorator

我有一個裝飾器用來包裝這樣的類方法:

class Class(object):
   @register_classmethod      
   @classmethod
   def my_class_method(cls):
       ... 

我的裝飾器得到一個classmethod當我嘗試調用它時,它拋出class method is not callable

這是一個示例,其中包含一個過於簡化的裝飾器實現:

from typing import Callable

all_methods: list[Callable[[type], None]] = []

def register_classmethod(classmeth: Callable[[type], None]) -> Callable[[type], None]:
    all_methods.append(classmeth)
    return classmeth

class Class(object):
    @register_classmethod      
    @classmethod
    def my_class_method(cls) -> None:
        print(f"Hello from {cls}.my_class_method")

    @classmethod
    def run_registered_classmethods(cls) -> None:
        for classmeth in all_methods:
            classmeth(cls)

Class.run_registered_classmethods()

雖然mypy --strict對輸入非常滿意,但在執行時我得到:

$ python3 testscripts/test-classmethod-call.py 
Traceback (most recent call last):
  File ".../test-classmethod-call.py", line 20, in <module>
    Class.run_registered_classmethods()
  File ".../test-classmethod-call.py", line 18, in run_registered_classmethods
    classmeth(cls)
TypeError: 'classmethod' object is not callable

現在,我確實在重構@classmethod上沒有顯式my_class_method的代碼,並且該代碼運行良好:

$ python3 testscripts/test-classmethod-call.py 
Hello from <class '__main__.Class'>.my_class_method

然而,通過上面的類型注釋, mypy盡職盡責地指出我們正在嘗試在此處注冊一個實例方法

testscripts/test-classmethod-call.py:10: error: Argument 1 to "register_classmethod" has incompatible type "Callable[[Class], None]"; expected "Callable[[type], None]"  [arg-type]

注意:我認為這也是python 如果我只有 object 如何調用類方法所面臨的問題,但它的初始表述可能不夠准確。

解決方案的解釋和開始

看起來我們在此上下文中得到的是 class 方法下的描述符 object。 我認為我們需要將它綁定到我們的包裝器描述符中,例如。 從 3.11 開始使用MethodType ,如此處 所示

class ClassMethod:
    "Emulate PyClassMethod_Type() in Objects/funcobject.c"

    def __init__(self, f):
        self.f = f

    def __get__(self, obj, cls=None):
        if cls is None:
            cls = type(obj)
        if hasattr(type(self.f), '__get__'):
            # This code path was added in Python 3.9
            # and was deprecated in Python 3.11.
            return self.f.__get__(cls, cls)
        return MethodType(self.f, cls)

但是我們不能將類方法 object 傳遞給MethodType ,並且必須在其(未記錄的 AFAICT) __func__成員中挖掘它。

現在這樣做了:

    @classmethod
    def run_registered_classmethods(cls) -> None:
        for classmeth in all_methods:
            bound_method = types.MethodType(classmeth.__func__, cls)
            bound_method()

然而,這讓我們回到了一個新的類型問題: classmethod修飾的方法具有classmethod類型,但被注釋為Callable以便用戶程序理解它,這導致mypy抱怨:

testscripts/test-classmethod-call.py:19: error: "Callable[[type], None]" has no attribute "__func__"  [attr-defined]

我們可以通過assert(isinstance(...))的方式教他關於真實類型的知識,並最終得到有效且類型良好的代碼:

    @classmethod
    def run_registered_classmethods(cls) -> None:
        for classmeth in all_methods:
            assert isinstance(classmeth, classmethod)
            bound_method = types.MethodType(classmeth.__func__, cls)
            bound_method()

這可行,但assert確實有運行時成本。 所以我們想以更好的方式給出提示,例如使用typing.cast()

    @classmethod
    def run_registered_classmethods(cls) -> None:
        for classmeth in all_methods:
            bound_method = types.MethodType(cast(classmethod, classmeth).__func__, cls)
            bound_method()

但是,如果mypy表面上對此感到滿意,那么使用它的--strict選項表明我們的輸入並不像它應該的那樣精確:

testscripts/test-classmethod-call.py:20: error: Missing type parameters for generic type "classmethod"  [type-arg]

所以classmethod是泛型類型? 很確定我在文檔中沒有找到任何提示。 幸運的是, reveal_type()和一些直覺似乎暗示泛型類型參數是 class 方法的返回類型:

testscripts/test-classmethod-call.py:21: note: Revealed type is "def [_R_co] (def (*Any, **Any) -> _R_co`1) -> builtins.classmethod[_R_co`1]"

(是的,哎喲!

但是,如果cast(classmethod[None], classmeth)mypy讀取 OK,則python本身不太高興: TypeError: 'type' object is not subscriptable

所以我們還必須讓解釋器和類型檢查器使用typing.TYPE_CHECKING來查看不同的代碼,這給我們帶來了以下內容:

import types
from typing import Callable, cast, TYPE_CHECKING

all_methods: list[Callable[[type], None]] = []

def register_classmethod(classmeth: Callable[[type], None]) -> Callable[[type], None]:
    all_methods.append(classmeth)
    return classmeth

class Class(object):

    @register_classmethod      
    @classmethod
    def my_class_method(cls) -> None:
        pass

    @classmethod
    def run_registered_classmethods(cls) -> None:
        for classmeth in all_methods:
            if TYPE_CHECKING:
                realclassmethod = cast(classmethod[None], classmeth)
            else:
                realclassmethod = classmeth
            bound_method = types.MethodType(realclassmethod.__func__, cls)
            bound_method()

Class.run_registered_classmethods()

...傳遞為:

$ mypy --strict testscripts/test-classmethod-call.py 
Success: no issues found in 1 source file
$ python3 testscripts/test-classmethod-call.py 
Hello from <class '__main__.Class'>.my_class_method

對於我們想要簡單和可讀的東西來說,這似乎過於復雜,並且可能會提供像下面這樣的通用幫助器來使所有這些更有用——我對它不是很滿意,即使它通過了所有以上測試:

_ClassType = TypeVar("_ClassType")
_RType = TypeVar("_RType")
def bound_class_method(classmeth: Callable[[type[_ClassType]], _RType],
                       cls: type[_ClassType]) -> Callable[[], _RType]:
    if TYPE_CHECKING:
        realclassmethod = cast(classmethod[None], classmeth)
    else:
        realclassmethod = classmeth
    return types.MethodType(realclassmethod.__func__, cls)

它不處理任意 arguments 到 class 方法,我們可能會繞過使用ParamSpec 但這仍然使用了類__func__ classmethod泛型類型參數):文檔沒有提到它們。

  • 難道不應該有一個簡單的方法來做到這一點嗎?
  • 有沒有更好的辦法?

編輯:到目前為止的精選答案摘要

這些答案中有大量信息,謝謝:)

我發現其中最有用的是:

  • 我們今天不能寫一個注解,讓一個用另一個裝飾器而不是@classmethod裝飾的方法得到它的第一個參數cls type[Class]而不是Class (@droooze)
  • 因此,我們有幾個選項系列,其中沒有一個是完美的:
    1. 接受要裝飾的方法沒有 class 方法簽名這一事實; 讓裝飾器在用classmethod包裝方法之前注冊方法,以避免處理后者的內部結構,然后返回包裝后的版本 (@chepner)。

      請注意,當我們需要在我們的類方法中使用cls作為類型時,我們可以這樣做:

       @register_classmethod def my_class_method(cls) -> None: klass = cast(type[Class], cls) print(f"Hello from {klass}.my_class_method")

      遺憾的是 class 名稱必須被硬編碼,並且如果我們想比Callable[[Any], None]做得更好,則必須進一步修改register_classmethod參數的類型注釋

    2. 忍受顯式添加@classmethod並使用它的內部結構,這也可能很煩人,因為強制使用該附加裝飾器會導致 API 更改 (@droooze)

    3. 告訴類型檢查器我們的裝飾器是特殊的,比如classmethod ,它的第一個參數是type[Class] ,否則它會被注釋為Class 缺點是(除了編寫和維護插件的成本),每個 static 檢查器都需要一個單獨的插件。

Class 方法不可調用; 他們定義了一個__get__方法,該方法返回一個可調用method實例,該實例將 class 作為第一個參數傳遞給基礎 function。

我可能會讓register_classmethod既存儲function又返回一個 class 方法:

all_methods: list[classmethod] = []

def register_classmethod(classmeth: classmethod) -> classmethod:
    all_methods.append(classmeth)
    return classmethod(classmeth)

class Class(object):
    @register_classmethod      
    def my_class_method(cls) -> None:
        print(f"Hello from {cls}.my_class_method")

    @classmethod
    def run_registered_classmethods(cls) -> None:
        for classmeth in all_methods:
            classmeth(cls)

這樣, run_registered_classmethods不需要擔心描述符協議:它只是直接運行底層 function。

內置裝飾器@property@classmethod@staticmethod可能是 4 種主要類型檢查器實現中的每一個的特例,這意味着與其他裝飾器的交互可能沒有任何意義,即使您理論上有類型注釋正確。

對於mypy@classmethod是特殊情況,因此

  1. 它被轉換為collections.abc.Callable ,即使classmethod甚至沒有__call__方法;
  2. 它裝飾的可調用對象將其第一個參數轉換為type[<owning class>] 事實上,如果你用 builtins.classmethod 裝飾它,你甚至可以獲得第一個參數type[<owning class>] builtins.classmethod的唯一方法; 任何類型構造的其他自定義實現都不會起作用,即使是在主體中沒有實現的classmethod的直接子類也是如此。

正如您所發現的,這就是運行時錯誤的原因。

如果你專門使用mypy ,在你給出的例子中,我會像這樣調整它:

from __future__ import annotations

import collections.abc as cx
import typing as t

clsT = t.TypeVar("clsT", bound=type)
P = t.ParamSpec("P")
R_co = t.TypeVar("R_co", covariant=True)

all_methods: list[classmethod[t.Any]] = []

def register_classmethod(classmeth: cx.Callable[[clsT], R_co]) -> classmethod[R_co]:
    # The assertion performs type-narrowing; see 
    # https://mypy.readthedocs.io/en/stable/type_narrowing.html
    assert isinstance(classmeth, classmethod)
    all_methods.append(classmeth)  # type: ignore[unreachable]
    return classmeth

class Class(object):
    @register_classmethod
    @classmethod
    def my_class_method(cls) -> None:
        print(f"Hello from {cls}.my_class_method")

    # Not a callable!
    my_class_method()  # mypy: "classmethod[None]" not callable [operator]

    @classmethod
    def run_registered_classmethods(cls) -> None:
        for classmeth in all_methods:
            classmeth.__func__(cls)

    # Too many arguments
    @register_classmethod  # mypy: Argument 1 to "register_classmethod" has incompatible type "Callable[[Type[Class], int], None]"; expected "Callable[[type], None]" [arg-type]
    @classmethod
    def bad_too_many_args(cls, a: int) -> None:
        return


Class.run_registered_classmethods()

如果你對classmethod做更多的事情並且需要在所有范圍內進行適當的類型檢查,我會重新實現classmethod的類型,如下所示:

from __future__ import annotations

import collections.abc as cx
import typing as t


# This is strictly unnecessary, but demonstrates a more accurately implemented
# `classmethod`. Accessing this from inside the class body, from an instance, or from
# a class works as expected.
# Unfortunately, you cannot use `ClassMethod` as a decorator and expect
# the first parameter to be typed correctly (see explanation 2.)
if t.TYPE_CHECKING:
    import sys

    clsT = t.TypeVar("clsT", bound=type)
    P = t.ParamSpec("P")
    R_co = t.TypeVar("R_co", covariant=True)

    class ClassMethod(t.Generic[clsT, P, R_co]):
        # Largely re-implemented from typeshed stubs; see
        # https://github.com/python/typeshed/blob/d2d706f9d8b1a568ff9ba1acf81ef8f6a6b99b12/stdlib/builtins.pyi#L128-L139
        @property
        def __func__(self) -> cx.Callable[t.Concatenate[clsT, P], R_co]: ...
        @property
        def __isabstractmethod__(self) -> bool: ...
        def __new__(cls, __f: cx.Callable[t.Concatenate[clsT, P], R_co]) -> ClassMethod[clsT, P, R_co]:...
        def __get__(self, __obj: t.Any, __type: type) -> cx.Callable[P, R_co]: ...
        if sys.version_info >= (3, 10):
            __name__: str
            __qualname__: str
            @property
            def __wrapped__(self) -> cx.Callable[t.Concatenate[clsT, P], R_co]: ...  # Same as `__func__`
else:
    ClassMethod = classmethod


all_methods: list[ClassMethod[type, [], t.Any]] = []


def register_classmethod(
    classmeth: cx.Callable[[clsT], R_co]
) -> ClassMethod[clsT, [], R_co]:
    # The assertion performs type-narrowing; see 
    # https://mypy.readthedocs.io/en/stable/type_narrowing.html
    assert isinstance(classmeth, ClassMethod)
    all_methods.append(classmeth)  # type: ignore[unreachable]
    return classmeth


class Class(object):
    @register_classmethod
    @classmethod
    def my_class_method(cls) -> None:
        print(f"Hello from {cls}.my_class_method")

    # Not a callable! Fixes problem given in explanation 1.
    my_class_method()  # mypy: "ClassMethod[Type[Class], [], None]" not callable [operator]

    @classmethod
    def run_registered_classmethods(cls) -> None:
        for classmeth in all_methods:
            classmeth.__func__(cls)
            # Not enough arguments
            classmeth.__func__()  # mypy: Too few arguments [call-arg]

    # Too many arguments - `typing.ParamSpec` is working correctly
    @register_classmethod  # mypy: Argument 1 to "register_classmethod" has incompatible type "Callable[[Type[Class], int], None]"; expected "Callable[[type], None]" [arg-type]
    @classmethod
    def bad_too_many_args(cls, a: int) -> None:
        return


Class.run_registered_classmethods()


# `__get__` working correctly - on the descriptor protocol.
# These two error out both for static type checking and runtime.
Class.my_class_method(type)  # mypy: Too few arguments [call-arg]
Class.my_class_method.__func__  # mypy: "Callable[[], None]" has no attribute "__func__" [attr-defined]

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM