簡體   English   中英

如何使用數據類制作“僅關鍵字”字段?

[英]How to make “keyword-only” fields with dataclasses?

從 3.0 開始,只支持創建參數關鍵字:

class S3Obj:
    def __init__(self, bucket, key, *, storage_class='Standard'):
        self.bucket = bucket
        self.key = key
        self.storage_class = storage_class

如何使用數據類獲得那種簽名? 像這樣的東西,但最好沒有SyntaxError

@dataclass
class S3Obj:
    bucket: str
    key: str
    *
    storage_class: str = 'Standard'

理想情況下是聲明式的,但使用__post_init__鈎子和/或替換類裝飾器也很好 - 只要代碼是可重用的。

編輯:也許像這樣的語法,使用省略號文字

@mydataclass
class S3Obj:
    bucket: str
    key: str
    ...
    storage_class: str = 'Standard'

更新:在 Python 3.10 中,有一個新的dataclasses.KW_ONLY哨兵,其工作方式如下:

@dataclasses.dataclass
class Example:
    a: int
    b: int
    _: dataclasses.KW_ONLY
    c: int
    d: int

KW_ONLY偽字段之后的任何字段都是關鍵字。

還有一個kw_only的參數dataclasses.dataclass裝飾,這使得各個領域的關鍵字只有:

@dataclasses.dataclass(kw_only=True)
class Example:
    a: int
    b: int

它也可以通過kw_only=Truedataclasses.field標記各個領域關鍵字只。

如果僅關鍵字字段位於非僅關鍵字字段之后(可能通過繼承,或通過單獨標記僅關鍵字字段),僅關鍵字字段將在其他字段之后重新排序,特別是為了__init__的目的。 其他數據類功能將保持聲明的順序。 這種重新排序令人困惑,應該避免。


Python 3.10 之前的答案:

這樣做時,您不會從dataclasses獲得太多幫助。 沒有辦法說一個字段應該由僅關鍵字參數初始化,並且__post_init__鈎子不知道原始構造函數參數是否通過關鍵字傳遞。 此外,沒有內省InitVar的好方法,更不用說將InitVar標記為僅關鍵字。

至少,您必須替換生成的__init__ 可能最簡單的方法是手動定義__init__ 如果您不想這樣做,可能最可靠的方法是創建字段對象並在metadata中將它們標記為 kwonly ,然后在您自己的裝飾器中檢查元數據。 這比聽起來更復雜:

import dataclasses
import functools
import inspect

# Helper to make calling field() less verbose
def kwonly(default=dataclasses.MISSING, **kwargs):
    kwargs.setdefault('metadata', {})
    kwargs['metadata']['kwonly'] = True
    return dataclasses.field(default=default, **kwargs)

def mydataclass(_cls, *, init=True, **kwargs):
    if _cls is None:
        return functools.partial(mydataclass, **kwargs)

    no_generated_init = (not init or '__init__' in _cls.__dict__)
    _cls = dataclasses.dataclass(_cls, **kwargs)
    if no_generated_init:
        # No generated __init__. The user will have to provide __init__,
        # and they probably already have. We assume their __init__ does
        # what they want.
        return _cls

    fields = dataclasses.fields(_cls)
    if any(field.metadata.get('kwonly') and not field.init for field in fields):
        raise TypeError('Non-init field marked kwonly')

    # From this point on, ignore non-init fields - but we don't know
    # about InitVars yet.
    init_fields = [field for field in fields if field.init]
    for i, field in enumerate(init_fields):
        if field.metadata.get('kwonly'):
            first_kwonly = field.name
            num_kwonly = len(init_fields) - i
            break
    else:
        # No kwonly fields. Why were we called? Assume there was a reason.
        return _cls

    if not all(field.metadata.get('kwonly') for field in init_fields[-num_kwonly:]):
        raise TypeError('non-kwonly init fields following kwonly fields')

    required_kwonly = [field.name for field in init_fields[-num_kwonly:]
                       if field.default is field.default_factory is dataclasses.MISSING]

    original_init = _cls.__init__

    # Time to handle InitVars. This is going to get ugly.
    # InitVars don't show up in fields(). They show up in __annotations__,
    # but the current dataclasses implementation doesn't understand string
    # annotations, and we want an implementation that's robust against
    # changes in string annotation handling.
    # We could inspect __post_init__, except there doesn't have to be a
    # __post_init__. (It'd be weird to use InitVars with no __post_init__,
    # but it's allowed.)
    # As far as I can tell, that leaves inspecting __init__ parameters as
    # the only option.

    init_params = tuple(inspect.signature(original_init).parameters)
    if init_params[-num_kwonly] != first_kwonly:
        # InitVars following kwonly fields. We could adopt a convention like
        # "InitVars after kwonly are kwonly" - in fact, we could have adopted
        # "all fields after kwonly are kwonly" too - but it seems too likely
        # to cause confusion with inheritance.
        raise TypeError('InitVars after kwonly fields.')
    # -1 to exclude self from this count.
    max_positional = len(init_params) - num_kwonly - 1

    @functools.wraps(original_init)
    def __init__(self, *args, **kwargs):
        if len(args) > max_positional:
            raise TypeError('Too many positional arguments')
        check_required_kwargs(kwargs, required_kwonly)
        return original_init(self, *args, **kwargs)
    _cls.__init__ = __init__

    return _cls

def check_required_kwargs(kwargs, required):
    # Not strictly necessary, but if we don't do this, error messages for
    # required kwonly args will list them as positional instead of
    # keyword-only.
    missing = [name for name in required if name not in kwargs]
    if not missing:
        return
    # We don't bother to exactly match the built-in logic's exception
    raise TypeError(f"__init__ missing required keyword-only argument(s): {missing}")

用法示例:

@mydataclass
class S3Obj:
    bucket: str
    key: str
    storage_class: str = kwonly('Standard')

這經過了一些測試,但沒有我想要的那么徹底。


您無法使用...獲得您建議的語法,因為...不會做任何元類或裝飾器可以看到的事情。 您可以通過實際觸發名稱查找或分配的東西獲得非常接近的東西,例如kwonly_start = True ,因此元類可以看到它發生。 然而,編寫一個健壯的實現是很復雜的,因為有很多事情需要專門處理。 繼承, typing.ClassVardataclasses.InitVar ,在注釋中向前引用,等會如果處理不當造成的所有問題。 繼承可能會導致最多的問題。

不處理所有繁瑣位的概念驗證可能如下所示:

# Does not handle inheritance, InitVar, ClassVar, or anything else
# I'm forgetting.

class POCMetaDict(dict):
    def __setitem__(self, key, item):
        # __setitem__ instead of __getitem__ because __getitem__ is
        # easier to trigger by accident.
        if key == 'kwonly_start':
            self['__non_kwonly'] = len(self['__annotations__'])
        super().__setitem__(key, item)

class POCMeta(type):
    @classmethod
    def __prepare__(cls, name, bases, **kwargs):
        return POCMetaDict()
    def __new__(cls, name, bases, classdict, **kwargs):
        classdict.pop('kwonly_start')
        non_kwonly = classdict.pop('__non_kwonly')

        newcls = super().__new__(cls, name, bases, classdict, **kwargs)
        newcls = dataclass(newcls)

        if non_kwonly is None:
            return newcls

        original_init = newcls.__init__

        @functools.wraps(original_init)
        def __init__(self, *args, **kwargs):
            if len(args) > non_kwonly:
                raise TypeError('Too many positional arguments')
            return original_init(self, *args, **kwargs)

        newcls.__init__ = __init__
        return newcls

你會像這樣使用它

class S3Obj(metaclass=POCMeta):
    bucket: str
    key: str

    kwonly_start = True

    storage_class: str = 'Standard'

這是未經測試的。

我想知道為什么這不是數據類 API 的一部分,這對我來說似乎很重要。

如果所有參數都是關鍵字參數,那么它可能會更簡單一些,以下內容就足夠了?

from dataclasses import dataclass
from functools import wraps

def kwargs_only(cls):
    
    @wraps(cls)
    def call(**kwargs):
        return cls(**kwargs)
    
    return call

@kwargs_only
@dataclass
class Coordinates:
    latitude: float = 0
    longitude: float = 0

這並不完美,因為使用位置參數時的錯誤是指call

--------------------------------------------------------
TypeError              Traceback (most recent call last)
<ipython-input-24-fb588c816ecf> in <module>
----> 1 c = Coordinates(1, longitude=2)
      2 help(c)

TypeError: call() takes 0 positional arguments but 1 was given

類似地,數據類的構造函數文檔已經過時並且沒有反映新的約束。

如果只有一些關鍵字字段,也許這個?

def kwargs(*keywords):
    
    def decorator(cls):
        @wraps(cls)
        def call(*args, **kwargs):
            if any(kw not in kwargs for kw in keywords):
                raise TypeError(f"{cls.__name__}.__init__() requires {keywords} as keyword arguments")
            return cls(*args, **kwargs)
        
        return call

    return decorator


@kwargs('longitude')
@dataclass(frozen=True)
class Coordinates:
    latitude: float
    longitude: float = 0

暫無
暫無

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

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