314 lines
9.7 KiB
ReStructuredText
314 lines
9.7 KiB
ReStructuredText
Extending
|
|
=========
|
|
|
|
Each ``attrs``-decorated class has a ``__attrs_attrs__`` class attribute.
|
|
It's a tuple of `attrs.Attribute` carrying metadata about each attribute.
|
|
|
|
So it is fairly simple to build your own decorators on top of ``attrs``:
|
|
|
|
.. doctest::
|
|
|
|
>>> from attr 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),)
|
|
|
|
|
|
.. warning::
|
|
|
|
The `attrs.define`/`attr.s` decorator **must** be applied first because it puts ``__attrs_attrs__`` in place!
|
|
That means that is has to come *after* your decorator because::
|
|
|
|
@a
|
|
@b
|
|
def f():
|
|
pass
|
|
|
|
is just `syntactic sugar <https://en.wikipedia.org/wiki/Syntactic_sugar>`_ for::
|
|
|
|
def original_f():
|
|
pass
|
|
|
|
f = a(b(original_f))
|
|
|
|
|
|
Wrapping the Decorator
|
|
----------------------
|
|
|
|
A more elegant way can be to wrap ``attrs`` altogether and build a class `DSL <https://en.wikipedia.org/wiki/Domain-specific_language>`_ on top of it.
|
|
|
|
An example for that is the package `environ-config <https://github.com/hynek/environ-config>`_ that uses ``attrs`` under the hood to define environment-based configurations declaratively without exposing ``attrs`` APIs at all.
|
|
|
|
Another common use case is to overwrite ``attrs``'s defaults.
|
|
|
|
Mypy
|
|
^^^^
|
|
|
|
Unfortunately, decorator wrapping currently `confuses <https://github.com/python/mypy/issues/5406>`_ mypy's ``attrs`` plugin.
|
|
At the moment, the best workaround is to hold your nose, write a fake mypy plugin, and mutate a bunch of global variables::
|
|
|
|
from mypy.plugin import Plugin
|
|
from mypy.plugins.attrs import (
|
|
attr_attrib_makers,
|
|
attr_class_makers,
|
|
attr_dataclass_makers,
|
|
)
|
|
|
|
# These work just like `attr.dataclass`.
|
|
attr_dataclass_makers.add("my_module.method_looks_like_attr_dataclass")
|
|
|
|
# This works just like `attr.s`.
|
|
attr_class_makers.add("my_module.method_looks_like_attr_s")
|
|
|
|
# These are our `attr.ib` makers.
|
|
attr_attrib_makers.add("my_module.method_looks_like_attrib")
|
|
|
|
class MyPlugin(Plugin):
|
|
# Our plugin does nothing but it has to exist so this file gets loaded.
|
|
pass
|
|
|
|
|
|
def plugin(version):
|
|
return MyPlugin
|
|
|
|
|
|
Then tell mypy about your plugin using your project's ``mypy.ini``:
|
|
|
|
.. code:: ini
|
|
|
|
[mypy]
|
|
plugins=<path to file>
|
|
|
|
|
|
.. warning::
|
|
Please note that it is currently *impossible* to let mypy know that you've changed defaults like *eq* or *order*.
|
|
You can only use this trick to tell mypy that a class is actually an ``attrs`` class.
|
|
|
|
Pyright
|
|
^^^^^^^
|
|
|
|
Generic decorator wrapping is supported in `pyright <https://github.com/microsoft/pyright>`_ via their dataclass_transform_ specification.
|
|
|
|
For a custom wrapping of the form::
|
|
|
|
def custom_define(f):
|
|
return attr.define(f)
|
|
|
|
This is implemented via a ``__dataclass_transform__`` type decorator in the custom extension's ``.pyi`` of the form::
|
|
|
|
def __dataclass_transform__(
|
|
*,
|
|
eq_default: bool = True,
|
|
order_default: bool = False,
|
|
kw_only_default: bool = False,
|
|
field_descriptors: Tuple[Union[type, Callable[..., Any]], ...] = (()),
|
|
) -> Callable[[_T], _T]: ...
|
|
|
|
@__dataclass_transform__(field_descriptors=(attr.attrib, attr.field))
|
|
def custom_define(f): ...
|
|
|
|
.. warning::
|
|
|
|
``dataclass_transform`` is supported **provisionally** as of ``pyright`` 1.1.135.
|
|
|
|
Both the ``pyright`` dataclass_transform_ specification and ``attrs`` implementation may change in future versions.
|
|
|
|
|
|
Types
|
|
-----
|
|
|
|
``attrs`` offers two ways of attaching type information to attributes:
|
|
|
|
- :pep:`526` annotations on Python 3.6 and later,
|
|
- and the *type* argument to `attr.ib`.
|
|
|
|
This information is available to you:
|
|
|
|
.. doctest::
|
|
|
|
>>> from attr import attrib, define, field, fields
|
|
>>> @define
|
|
... class C:
|
|
... x: int = field()
|
|
... y = attrib(type=str)
|
|
>>> fields(C).x.type
|
|
<class 'int'>
|
|
>>> fields(C).y.type
|
|
<class 'str'>
|
|
|
|
Currently, ``attrs`` doesn't do anything with this information but it's very useful if you'd like to write your own validators or serializers!
|
|
|
|
|
|
.. _extending_metadata:
|
|
|
|
Metadata
|
|
--------
|
|
|
|
If you're the author of a third-party library with ``attrs`` integration, you may want to take advantage of attribute metadata.
|
|
|
|
Here are some tips for effective use of metadata:
|
|
|
|
- Try making your metadata keys and values immutable.
|
|
This keeps the entire ``Attribute`` instances immutable too.
|
|
|
|
- To avoid metadata key collisions, consider exposing your metadata keys from your modules.::
|
|
|
|
from mylib import MY_METADATA_KEY
|
|
|
|
@define
|
|
class C:
|
|
x = field(metadata={MY_METADATA_KEY: 1})
|
|
|
|
Metadata should be composable, so consider supporting this approach even if you decide implementing your metadata in one of the following ways.
|
|
|
|
- Expose ``field`` wrappers for your specific metadata.
|
|
This is a more graceful approach if your users don't require metadata from other libraries.
|
|
|
|
.. doctest::
|
|
|
|
>>> from attr 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={},
|
|
... converter=None
|
|
... ):
|
|
... metadata = dict() if not metadata else metadata
|
|
... 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'>
|
|
|
|
|
|
.. _transform-fields:
|
|
|
|
Automatic Field Transformation and Modification
|
|
-----------------------------------------------
|
|
|
|
``attrs`` allows you to automatically modify or transform the class' fields while the class is being created.
|
|
You do this by passing a *field_transformer* hook to `attr.define` (and its friends).
|
|
Its main purpose is to automatically add converters to attributes based on their type to aid the development of API clients and other typed data loaders.
|
|
|
|
This hook must have the following signature:
|
|
|
|
.. function:: your_hook(cls: type, fields: list[attrs.Attribute]) -> list[attrs.Attribute]
|
|
:noindex:
|
|
|
|
- *cls* is your class right *before* it is being converted into an attrs class.
|
|
This means it does not yet have the ``__attrs_attrs__`` attribute.
|
|
|
|
- *fields* is a list of all `attrs.Attribute` instances that will later be set to ``__attrs_attrs__``.
|
|
You can modify these attributes any way you want:
|
|
You can add converters, change types, and even remove attributes completely or create new ones!
|
|
|
|
For example, let's assume that you really don't like floats:
|
|
|
|
.. doctest::
|
|
|
|
>>> 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')
|
|
|
|
A more realistic example would be to automatically convert data that you, e.g., load from JSON:
|
|
|
|
.. doctest::
|
|
|
|
>>> 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))
|
|
|
|
|
|
Customize Value Serialization in ``asdict()``
|
|
---------------------------------------------
|
|
|
|
``attrs`` allows you to serialize instances of ``attrs`` classes to dicts using the `attrs.asdict` function.
|
|
However, the result can not always be serialized since most data types will remain as they are:
|
|
|
|
.. doctest::
|
|
|
|
>>> 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
|
|
|
|
To help you with this, `attr.asdict` allows you to pass a *value_serializer* hook.
|
|
It has the signature
|
|
|
|
.. function:: your_hook(inst: type, field: attrs.Attribute, value: typing.Any) -> typing.Any
|
|
:noindex:
|
|
|
|
.. doctest::
|
|
|
|
>>> 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"}'
|
|
|
|
*****
|
|
|
|
.. _dataclass_transform: https://github.com/microsoft/pyright/blob/master/specs/dataclass_transforms.md
|