Skip to content

Commit 9fd0f82

Browse files
committed
Convert extending.rst to md
1 parent 3473b4d commit 9fd0f82

File tree

4 files changed

+343
-337
lines changed

4 files changed

+343
-337
lines changed

docs/examples.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,7 @@ All ``attrs`` attributes may include arbitrary metadata in the form of a read-on
477477
Metadata is not used by ``attrs``, and is meant to enable rich functionality in third-party libraries.
478478
The metadata dictionary follows the normal dictionary rules: keys need to be hashable, and both keys and values are recommended to be immutable.
479479

480-
If you're the author of a third-party library with ``attrs`` integration, please see `Extending Metadata <extending_metadata>`.
480+
If you're the author of a third-party library with ``attrs`` integration, please see `Extending Metadata <extending-metadata>`.
481481

482482

483483
Types

docs/extending.md

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
# Extending
2+
3+
Each *attrs*-decorated class has a `__attrs_attrs__` class attribute.
4+
It's a tuple of {class}`attrs.Attribute` carrying metadata about each attribute.
5+
6+
So it is fairly simple to build your own decorators on top of *attrs*:
7+
8+
```{doctest}
9+
>>> from attr import define
10+
>>> def print_attrs(cls):
11+
... print(cls.__attrs_attrs__)
12+
... return cls
13+
>>> @print_attrs
14+
... @define
15+
... class C:
16+
... a: int
17+
(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'),)
18+
```
19+
20+
:::{warning}
21+
The {func}`attrs.define` / {func}`attr.s` decorator **must** be applied first because it puts `__attrs_attrs__` in place!
22+
That means that is has to come *after* your decorator because:
23+
24+
```python
25+
@a
26+
@b
27+
def f():
28+
pass
29+
```
30+
31+
is just [syntactic sugar](https://en.wikipedia.org/wiki/Syntactic_sugar) for:
32+
33+
```python
34+
def original_f():
35+
pass
36+
37+
f = a(b(original_f))
38+
```
39+
:::
40+
41+
42+
## Wrapping the Decorator
43+
44+
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.
45+
46+
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.
47+
48+
Another common use case is to overwrite *attrs*'s defaults.
49+
50+
51+
### Mypy
52+
53+
Unfortunately, decorator wrapping currently [confuses](https://github.com/python/mypy/issues/5406) mypy's *attrs* plugin.
54+
At the moment, the best workaround is to hold your nose, write a fake *Mypy* plugin, and mutate a bunch of global variables:
55+
56+
```python
57+
from mypy.plugin import Plugin
58+
from mypy.plugins.attrs import (
59+
attr_attrib_makers,
60+
attr_class_makers,
61+
attr_dataclass_makers,
62+
)
63+
64+
# These work just like `attr.dataclass`.
65+
attr_dataclass_makers.add("my_module.method_looks_like_attr_dataclass")
66+
67+
# This works just like `attr.s`.
68+
attr_class_makers.add("my_module.method_looks_like_attr_s")
69+
70+
# These are our `attr.ib` makers.
71+
attr_attrib_makers.add("my_module.method_looks_like_attrib")
72+
73+
class MyPlugin(Plugin):
74+
# Our plugin does nothing but it has to exist so this file gets loaded.
75+
pass
76+
77+
78+
def plugin(version):
79+
return MyPlugin
80+
```
81+
82+
Then tell *Mypy* about your plugin using your project's `mypy.ini`:
83+
84+
```ini
85+
[mypy]
86+
plugins=<path to file>
87+
```
88+
89+
:::{warning}
90+
Please note that it is currently *impossible* to let mypy know that you've changed defaults like *eq* or *order*.
91+
You can only use this trick to tell *Mypy* that a class is actually an *attrs* class.
92+
:::
93+
94+
95+
### Pyright
96+
97+
Generic decorator wrapping is supported in [*Pyright*](https://github.com/microsoft/pyright) via their [`dataclass_transform`] specification.
98+
99+
For a custom wrapping of the form:
100+
101+
```
102+
def custom_define(f):
103+
return attr.define(f)
104+
```
105+
106+
This is implemented via a `__dataclass_transform__` type decorator in the custom extension's `.pyi` of the form:
107+
108+
```
109+
def __dataclass_transform__(
110+
*,
111+
eq_default: bool = True,
112+
order_default: bool = False,
113+
kw_only_default: bool = False,
114+
field_descriptors: Tuple[Union[type, Callable[..., Any]], ...] = (()),
115+
) -> Callable[[_T], _T]: ...
116+
117+
@__dataclass_transform__(field_descriptors=(attr.attrib, attr.field))
118+
def custom_define(f): ...
119+
```
120+
121+
:::{warning}
122+
`dataclass_transform` is supported **provisionally** as of `pyright` 1.1.135.
123+
124+
Both the *Pyright* [`dataclass_transform`] specification and *attrs* implementation may change in future versions.
125+
:::
126+
127+
## Types
128+
129+
*attrs* offers two ways of attaching type information to attributes:
130+
131+
- {pep}`526` annotations,
132+
- and the *type* argument to {func}`attr.ib`.
133+
134+
This information is available to you:
135+
136+
```{doctest}
137+
>>> from attr import attrib, define, field, fields
138+
>>> @define
139+
... class C:
140+
... x: int = field()
141+
... y = attrib(type=str)
142+
>>> fields(C).x.type
143+
<class 'int'>
144+
>>> fields(C).y.type
145+
<class 'str'>
146+
```
147+
148+
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!
149+
150+
(extending-metadata)=
151+
152+
## Metadata
153+
154+
If you're the author of a third-party library with *attrs* integration, you may want to take advantage of attribute metadata.
155+
156+
Here are some tips for effective use of metadata:
157+
158+
- Try making your metadata keys and values immutable.
159+
This keeps the entire {class}`~attrs.Attribute` instances immutable too.
160+
161+
- To avoid metadata key collisions, consider exposing your metadata keys from your modules.:
162+
163+
```
164+
from mylib import MY_METADATA_KEY
165+
166+
@define
167+
class C:
168+
x = field(metadata={MY_METADATA_KEY: 1})
169+
```
170+
171+
Metadata should be composable, so consider supporting this approach even if you decide implementing your metadata in one of the following ways.
172+
173+
- Expose `field` wrappers for your specific metadata.
174+
This is a more graceful approach if your users don't require metadata from other libraries.
175+
176+
```{eval-rst}
177+
.. doctest::
178+
179+
>>> from attr import fields, NOTHING
180+
>>> MY_TYPE_METADATA = '__my_type_metadata'
181+
>>>
182+
>>> def typed(
183+
... cls, default=NOTHING, validator=None, repr=True,
184+
... eq=True, order=None, hash=None, init=True, metadata=None,
185+
... converter=None
186+
... ):
187+
... metadata = metadata or {}
188+
... metadata[MY_TYPE_METADATA] = cls
189+
... return field(
190+
... default=default, validator=validator, repr=repr,
191+
... eq=eq, order=order, hash=hash, init=init,
192+
... metadata=metadata, converter=converter
193+
... )
194+
>>>
195+
>>> @define
196+
... class C:
197+
... x: int = typed(int, default=1, init=False)
198+
>>> fields(C).x.metadata[MY_TYPE_METADATA]
199+
<class 'int'>
200+
201+
```
202+
203+
(transform-fields)=
204+
205+
## Automatic Field Transformation and Modification
206+
207+
*attrs* allows you to automatically modify or transform the class' fields while the class is being created.
208+
You do this by passing a *field_transformer* hook to {func}`~attrs.define` (and friends).
209+
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.
210+
211+
This hook must have the following signature:
212+
213+
```{eval-rst}
214+
.. function:: your_hook(cls: type, fields: list[attrs.Attribute]) -> list[attrs.Attribute]
215+
:noindex:
216+
```
217+
218+
- *cls* is your class right *before* it is being converted into an attrs class.
219+
This means it does not yet have the `__attrs_attrs__` attribute.
220+
- *fields* is a list of all `attrs.Attribute` instances that will later be set to `__attrs_attrs__`.
221+
You can modify these attributes any way you want:
222+
You can add converters, change types, and even remove attributes completely or create new ones!
223+
224+
For example, let's assume that you really don't like floats:
225+
226+
```{doctest}
227+
>>> def drop_floats(cls, fields):
228+
... return [f for f in fields if f.type not in {float, 'float'}]
229+
...
230+
>>> @frozen(field_transformer=drop_floats)
231+
... class Data:
232+
... a: int
233+
... b: float
234+
... c: str
235+
...
236+
>>> Data(42, "spam")
237+
Data(a=42, c='spam')
238+
```
239+
240+
A more realistic example would be to automatically convert data that you, e.g., load from JSON:
241+
242+
```{doctest}
243+
>>> from datetime import datetime
244+
>>>
245+
>>> def auto_convert(cls, fields):
246+
... results = []
247+
... for field in fields:
248+
... if field.converter is not None:
249+
... results.append(field)
250+
... continue
251+
... if field.type in {datetime, 'datetime'}:
252+
... converter = (lambda d: datetime.fromisoformat(d) if isinstance(d, str) else d)
253+
... else:
254+
... converter = None
255+
... results.append(field.evolve(converter=converter))
256+
... return results
257+
...
258+
>>> @frozen(field_transformer=auto_convert)
259+
... class Data:
260+
... a: int
261+
... b: str
262+
... c: datetime
263+
...
264+
>>> from_json = {"a": 3, "b": "spam", "c": "2020-05-04T13:37:00"}
265+
>>> Data(**from_json) # ****
266+
Data(a=3, b='spam', c=datetime.datetime(2020, 5, 4, 13, 37))
267+
```
268+
269+
Or, perhaps you would prefer to generate dataclass-compatible `__init__` signatures via a default field *alias*.
270+
Note, *field_transformer* operates on {class}`attrs.Attribute` instances before the default private-attribute handling is applied so explicit user-provided aliases can be detected.
271+
272+
```{doctest}
273+
>>> def dataclass_names(cls, fields):
274+
... return [
275+
... field.evolve(alias=field.name)
276+
... if not field.alias
277+
... else field
278+
... for field in fields
279+
... ]
280+
...
281+
>>> @frozen(field_transformer=dataclass_names)
282+
... class Data:
283+
... public: int
284+
... _private: str
285+
... explicit: str = field(alias="aliased_name")
286+
...
287+
>>> Data(public=42, _private="spam", aliased_name="yes")
288+
Data(public=42, _private='spam', explicit='yes')
289+
```
290+
291+
## Customize Value Serialization in `asdict()`
292+
293+
*attrs* allows you to serialize instances of *attrs* classes to dicts using the {func}`attrs.asdict` function.
294+
However, the result can not always be serialized since most data types will remain as they are:
295+
296+
```{eval-rst}
297+
.. doctest::
298+
299+
>>> import json
300+
>>> import datetime
301+
>>> from attrs import asdict
302+
>>>
303+
>>> @frozen
304+
... class Data:
305+
... dt: datetime.datetime
306+
...
307+
>>> data = asdict(Data(datetime.datetime(2020, 5, 4, 13, 37)))
308+
>>> data
309+
{'dt': datetime.datetime(2020, 5, 4, 13, 37)}
310+
>>> json.dumps(data)
311+
Traceback (most recent call last):
312+
...
313+
TypeError: Object of type datetime is not JSON serializable
314+
```
315+
316+
To help you with this, {func}`~attrs.asdict` allows you to pass a *value_serializer* hook.
317+
It has the signature
318+
319+
```{eval-rst}
320+
.. function:: your_hook(inst: type, field: attrs.Attribute, value: typing.Any) -> typing.Any
321+
:noindex:
322+
```
323+
324+
```{doctest}
325+
>>> from attr import asdict
326+
>>> def serialize(inst, field, value):
327+
... if isinstance(value, datetime.datetime):
328+
... return value.isoformat()
329+
... return value
330+
...
331+
>>> data = asdict(
332+
... Data(datetime.datetime(2020, 5, 4, 13, 37)),
333+
... value_serializer=serialize,
334+
... )
335+
>>> data
336+
{'dt': '2020-05-04T13:37:00'}
337+
>>> json.dumps(data)
338+
'{"dt": "2020-05-04T13:37:00"}'
339+
```
340+
341+
[`dataclass_transform`]: https://github.com/microsoft/pyright/blob/main/specs/dataclass_transforms.md

0 commit comments

Comments
 (0)