扩展(Extending)

每个 attrs 装饰的类都有一个 __attrs_attrs__ 类属性。 它是一个包含关于每个属性元数据的 attrs.Attribute 元组。

因此,构建自己的装饰器在 attrs 之上相当简单:

>>> from attrs import define
>>> def print_attrs(cls):
...     print(cls.__attrs_attrs__)
...     return cls
>>> @print_attrs
... @define
... class C:
...     a: int
(Attribute(name='a', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=<class 'int'>, converter=None, kw_only=False, inherited=False, on_setattr=None, alias='a'),)

警告

attrs.define() / attr.s() 装饰器 必须 首先应用,因为它会放置 __attrs_attrs__! 这意味着它必须在你的装饰器 之后,因为:

@a
@b
def f():
   pass

仅仅是 语法糖 的表现形式:

def original_f():
   pass

f = a(b(original_f))

Wrapping装饰器(Wrapping the Decorator)

一种更优雅的方式是完全包装 attrs 并在其上构建一个类 DSL

一个例子是包 environ-config,它在底层使用 attrs 以声明方式定义基于环境的配置,而不暴露 attrs API。

另一个常见用例是覆盖 attrs 的默认值。

Mypy

不幸的是,装饰器包装目前会 困扰 Mypy 的 attrs 插件。 此时,最佳的解决方法是忍耐一下,编写一个伪 Mypy 插件,并变更一堆全局变量:

from mypy.plugin import Plugin
from mypy.plugins.attrs import (
   attr_attrib_makers,
   attr_class_makers,
   attr_dataclass_makers,
)

# 这些工作方式与 `attr.dataclass` 完全相同。
attr_dataclass_makers.add("my_module.method_looks_like_attr_dataclass")

# 这与 `attr.s` 的工作方式相同。
attr_class_makers.add("my_module.method_looks_like_attr_s")

# 这些是我们的 `attr.ib` 制作器。
attr_attrib_makers.add("my_module.method_looks_like_attrib")

class MyPlugin(Plugin):
    # 我们的插件什么都不做,但它必须存在,以便这个文件被加载。
    pass


def plugin(version):
    return MyPlugin

然后使用项目的 mypy.ini 告诉 Mypy 关于你的插件:

[mypy]
plugins=<path to file>

警告

请注意,目前 不可能 让 Mypy 知道你已经更改了默认值,例如 eqorder。 你只能使用这个技巧告诉 Mypy 一个类实际上是一个 attrs 类。

Pyright

通用装饰器包装在 Pyright 中通过 typing.dataclass_transform / PEP 681 得到了支持。

对于自定义包装的形式:

@typing.dataclass_transform(field_specifiers=(attr.attrib, attrs.field))
def custom_define(f):
    return attrs.define(f)

类型(Types)

attrs 提供了两种将类型信息附加到属性的方法:

这些信息对你来说是可用的:

>>> from attrs import define, field, fields
>>> @define
... class C:
...     x: int = field()
...     y = field(type=str)
>>> fields(C).x.type
<class 'int'>
>>> fields(C).y.type
<class 'str'>

目前,attrs 并不对这些信息做任何处理,但如果你想编写自己的验证器或序列化器,这非常有用!

最初,我们没有将 type 参数添加到新的 attrs.field() API,因为类型注解是首选方式。 但我们后来重新引入了它,以便 field 可以与 attrs.make_class() 函数一起使用。 我们强烈不建议在 attrs.make_class() 之外使用 type 参数。

元数据(Metadata)

如果你是一个与 attrs 集成的第三方库的作者,你可能想要利用属性元数据。

以下是有效使用元数据的一些提示:

  • 尝试使你的元数据键和值不可变。 这也会保持整个 Attribute 实例不可变。

  • 为了避免元数据键冲突,考虑从你的模块中公开你的元数据键:

    from mylib import MY_METADATA_KEY
    
    @define
    class C:
      x = field(metadata={MY_METADATA_KEY: 1})
    

    元数据应该是可组合的,因此即使你决定以以下方式之一实现你的元数据,也要考虑支持这种方法。

  • 为你的特定元数据公开 field 包装器。 如果你的用户不需要来自其他库的元数据,这是更优雅的方法。

    >>> from attrs import fields, NOTHING
    >>> MY_TYPE_METADATA = '__my_type_metadata'
    >>>
    >>> def typed(
    ...     cls, default=NOTHING, validator=None, repr=True,
    ...     eq=True, order=None, hash=None, init=True, metadata=None,
    ...     converter=None
    ... ):
    ...     metadata = metadata or {}
    ...     metadata[MY_TYPE_METADATA] = cls
    ...     return field(
    ...         default=default, validator=validator, repr=repr,
    ...         eq=eq, order=order, hash=hash, init=init,
    ...         metadata=metadata, converter=converter
    ...     )
    >>>
    >>> @define
    ... class C:
    ...     x: int = typed(int, default=1, init=False)
    >>> fields(C).x.metadata[MY_TYPE_METADATA]
    <class 'int'>
    

自动生成字段的转换和修改(Automatic Field Transformation and Modification)

attrs 允许你在类创建时自动修改或转换类的字段。 你可以通过将 field_transformer 钩子传递给 define()(及其相关函数)来实现这一点。 其主要目的是基于字段类型自动添加转换器,以帮助 API 客户端和其他类型化数据加载器的开发。

此钩子必须具有以下签名:

your_hook(cls: type, fields: list[attrs.Attribute]) list[attrs.Attribute]
  • cls 是在转换为 attrs 类之前的类。 这意味着它尚未拥有 __attrs_attrs__ 属性。

  • fields 是将来将设置为 __attrs_attrs__ 的所有 attrs.Attribute 实例的列表。 你可以以任何方式修改这些属性: 你可以添加转换器、改变类型,甚至完全删除属性或创建新的属性!

例如,假设你真的不喜欢浮点数:

>>> def drop_floats(cls, fields):
...     return [f for f in fields if f.type not in {float, 'float'}]
...
>>> @frozen(field_transformer=drop_floats)
... class Data:
...     a: int
...     b: float
...     c: str
...
>>> Data(42, "spam")
Data(a=42, c='spam')

一个更现实的例子是自动转换你从 JSON 加载的数据:

>>> from datetime import datetime
>>>
>>> def auto_convert(cls, fields):
...     results = []
...     for field in fields:
...         if field.converter is not None:
...             results.append(field)
...             continue
...         if field.type in {datetime, 'datetime'}:
...             converter = (lambda d: datetime.fromisoformat(d) if isinstance(d, str) else d)
...         else:
...             converter = None
...         results.append(field.evolve(converter=converter))
...     return results
...
>>> @frozen(field_transformer=auto_convert)
... class Data:
...     a: int
...     b: str
...     c: datetime
...
>>> from_json = {"a": 3, "b": "spam", "c": "2020-05-04T13:37:00"}
>>> Data(**from_json)  # ****
Data(a=3, b='spam', c=datetime.datetime(2020, 5, 4, 13, 37))

或者,你可能更愿意通过默认字段 别名 生成与数据类兼容的 __init__ 签名。 请注意,field_transformer 在应用默认私有属性处理之前对 attrs.Attribute 实例进行操作,因此可以检测到显式用户提供的别名。

>>> def dataclass_names(cls, fields):
...     return [
...         field.evolve(alias=field.name)
...         if not field.alias
...         else field
...         for field in fields
...     ]
...
>>> @frozen(field_transformer=dataclass_names)
... class Data:
...     public: int
...     _private: str
...     explicit: str = field(alias="aliased_name")
...
>>> Data(public=42, _private="spam", aliased_name="yes")
Data(public=42, _private='spam', explicit='yes')

asdict() 中自定义字段的序列化(Customize Value Serialization in asdict())

attrs 允许你使用 attrs.asdict() 函数将 attrs 类的实例序列化为字典。 然而,结果并不总是可以序列化,因为大多数数据类型将保持原样:

>>> import json
>>> import datetime
>>> from attrs import asdict
>>>
>>> @frozen
... class Data:
...    dt: datetime.datetime
...
>>> data = asdict(Data(datetime.datetime(2020, 5, 4, 13, 37)))
>>> data
{'dt': datetime.datetime(2020, 5, 4, 13, 37)}
>>> json.dumps(data)
Traceback (most recent call last):
   ...
TypeError: Object of type datetime is not JSON serializable

为了解决这个问题,asdict() 允许你传递一个 value_serializer 钩子。 它的签名为:

your_hook(inst: type, field: attrs.Attribute, value: Any) Any
>>> from attr import asdict
>>> def serialize(inst, field, value):
...     if isinstance(value, datetime.datetime):
...         return value.isoformat()
...     return value
...
>>> data = asdict(
...     Data(datetime.datetime(2020, 5, 4, 13, 37)),
...     value_serializer=serialize,
... )
>>> data
{'dt': '2020-05-04T13:37:00'}
>>> json.dumps(data)
'{"dt": "2020-05-04T13:37:00"}'