Skip to content

Commit 77b8edd

Browse files
committed
Add hooks for field transformation and for asdict serialization
1 parent 28d75db commit 77b8edd

11 files changed

Lines changed: 397 additions & 19 deletions

File tree

changelog.d/653.change.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
``attr.s()`` now has a *field_transformer* hook that is called for all ``Attribute``s and returns a (modified or updated) list of ``Attribute`` instances.
2+
``attr.asdict()`` has a *value_serializer* hook that can change the way values are converted.
3+
Both hooks are meant to help with data (de)serialization workflows.

conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def pytest_configure(config):
1818
collect_ignore.extend(
1919
[
2020
"tests/test_annotations.py",
21+
"tests/test_hooks.py",
2122
"tests/test_init_subclass.py",
2223
"tests/test_next_gen.py",
2324
]

docs/api.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Core
2424

2525
.. autodata:: attr.NOTHING
2626

27-
.. autofunction:: attr.s(these=None, repr_ns=None, repr=None, cmp=None, hash=None, init=None, slots=False, frozen=False, weakref_slot=True, str=False, auto_attribs=False, kw_only=False, cache_hash=False, auto_exc=False, eq=None, order=None, auto_detect=False, collect_by_mro=False, getstate_setstate=None, on_setattr=None)
27+
.. autofunction:: attr.s(these=None, repr_ns=None, repr=None, cmp=None, hash=None, init=None, slots=False, frozen=False, weakref_slot=True, str=False, auto_attribs=False, kw_only=False, cache_hash=False, auto_exc=False, eq=None, order=None, auto_detect=False, collect_by_mro=False, getstate_setstate=None, on_setattr=None, field_transformer=None)
2828

2929
.. note::
3030

docs/extending.rst

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,107 @@ Here are some tips for effective use of metadata:
159159
... x = typed(int, default=1, init=False)
160160
>>> attr.fields(C).x.metadata[MY_TYPE_METADATA]
161161
<class 'int'>
162+
163+
164+
Automatic field transformation and modification
165+
------------------------------------------------
166+
167+
Attrs allows you to automatically modify or transform the class' fields while the class is being created.
168+
You do this by passing a *field_transformer* hook to :func:`attr.define()` (and its friends).
169+
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.
170+
171+
| This hook must have the signature:
172+
| :code:`your_hook(cls: type, fields: List[Attribute]) -> List[Attribute]`
173+
174+
- *cls* is your class right *before* it is being converted into an attrs class.
175+
This means it does not yet have the ``__attrs_attrs__`` attribute.
176+
177+
- *fields* is a list of all :class:`attr.Attribute` instances that will later be set to ``__attrs_attrs__``. You can modify these attributes any way you want:
178+
You can add converters, change types, and even remove attributes completely or create new ones!
179+
180+
For example, let's assume that you really don't like floats:
181+
182+
.. doctest::
183+
184+
>>> def drop_floats(cls, fields):
185+
... return [f for f in fields if f.type not in {float, 'float'}]
186+
...
187+
>>> @attr.frozen(field_transformer=drop_floats)
188+
... class Data:
189+
... a: int
190+
... b: float
191+
... c: str
192+
...
193+
>>> Data(42, "spam")
194+
Data(a=42, c='spam')
195+
196+
A more realistic example would be to automatically convert data that you, e.g., load from JSON:
197+
198+
.. doctest::
199+
200+
>>> from datetime import datetime
201+
>>>
202+
>>> def auto_convert(cls, fields):
203+
... results = []
204+
... for field in fields:
205+
... if field.converter is not None:
206+
... results.append(field)
207+
... continue
208+
... if field.type in {datetime, 'datetime'}:
209+
... converter = (lambda d: datetime.fromisoformat(d) if isinstance(d, str) else d)
210+
... else:
211+
... converter = None
212+
... results.append(field._assoc(converter=converter))
213+
... return results
214+
...
215+
>>> @attr.frozen(field_transformer=auto_convert)
216+
... class Data:
217+
... a: int
218+
... b: str
219+
... c: datetime
220+
...
221+
>>> from_json = {"a": 3, "b": "spam", "c": "2020-05-04T13:37:00"}
222+
>>> Data(**from_json) # ****
223+
Data(a=3, b='spam', c=datetime.datetime(2020, 5, 4, 13, 37))
224+
225+
226+
Change value serialization in ``asdict()``
227+
------------------------------------------
228+
229+
Attrs allows you to serialize instances of attrs classes to dicts using the :func:`attr.asdict()` function.
230+
However, the result can not always be serialized since most data types will remain as they are:
231+
232+
.. doctest::
233+
234+
>>> import json
235+
>>> import datetime
236+
>>>
237+
>>> @attr.frozen
238+
... class Data:
239+
... dt: datetime.datetime
240+
...
241+
>>> data = attr.asdict(Data(datetime.datetime(2020, 5, 4, 13, 37)))
242+
>>> data
243+
{'dt': datetime.datetime(2020, 5, 4, 13, 37)}
244+
>>> json.dumps(data)
245+
Traceback (most recent call last):
246+
...
247+
TypeError: Object of type datetime is not JSON serializable
248+
249+
To help you with this, :attr:`attr.asdict()` allows you to pass a *value_serializer* hook. It has the signature :code:`your_hook(inst: type, field: Attribute, value: Any) -> Any`:
250+
251+
.. doctest::
252+
253+
>>> def serialize(inst, field, value):
254+
... if isinstance(value, datetime.datetime):
255+
... return value.isoformat()
256+
... return value
257+
...
258+
>>> data = attr.asdict(
259+
... Data(datetime.datetime(2020, 5, 4, 13, 37)),
260+
... value_serializer=serialize,
261+
... )
262+
>>> data
263+
{'dt': '2020-05-04T13:37:00'}
264+
>>> json.dumps(data)
265+
'{"dt": "2020-05-04T13:37:00"}'

src/attr/__init__.pyi

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ _OnSetAttrType = Callable[[Any, Attribute[Any], Any], Any]
4646
_OnSetAttrArgType = Union[
4747
_OnSetAttrType, List[_OnSetAttrType], setters._NoOpType
4848
]
49+
_FieldTransformer = Callable[[type, List[Attribute]], List[Attribute]]
4950
# FIXME: in reality, if multiple validators are passed they must be in a list
5051
# or tuple, but those are invariant and so would prevent subtypes of
5152
# _ValidatorType from working when passed in a list or tuple.
@@ -274,6 +275,7 @@ def attrs(
274275
auto_detect: bool = ...,
275276
getstate_setstate: Optional[bool] = ...,
276277
on_setattr: Optional[_OnSetAttrArgType] = ...,
278+
field_transformer: Optional[_FieldTransformer] = ...,
277279
) -> _C: ...
278280
@overload
279281
def attrs(
@@ -297,6 +299,7 @@ def attrs(
297299
auto_detect: bool = ...,
298300
getstate_setstate: Optional[bool] = ...,
299301
on_setattr: Optional[_OnSetAttrArgType] = ...,
302+
field_transformer: Optional[_FieldTransformer] = ...,
300303
) -> Callable[[_C], _C]: ...
301304
@overload
302305
def define(
@@ -319,6 +322,7 @@ def define(
319322
auto_detect: bool = ...,
320323
getstate_setstate: Optional[bool] = ...,
321324
on_setattr: Optional[_OnSetAttrArgType] = ...,
325+
field_transformer: Optional[_FieldTransformer] = ...,
322326
) -> _C: ...
323327
@overload
324328
def define(
@@ -341,6 +345,7 @@ def define(
341345
auto_detect: bool = ...,
342346
getstate_setstate: Optional[bool] = ...,
343347
on_setattr: Optional[_OnSetAttrArgType] = ...,
348+
field_transformer: Optional[_FieldTransformer] = ...,
344349
) -> Callable[[_C], _C]: ...
345350

346351
mutable = define
@@ -382,6 +387,7 @@ def make_class(
382387
eq: Optional[bool] = ...,
383388
order: Optional[bool] = ...,
384389
on_setattr: Optional[_OnSetAttrArgType] = ...,
390+
field_transformer: Optional[_FieldTransformer] = ...,
385391
) -> type: ...
386392

387393
# _funcs --
@@ -397,6 +403,7 @@ def asdict(
397403
filter: Optional[_FilterType[Any]] = ...,
398404
dict_factory: Type[Mapping[Any, Any]] = ...,
399405
retain_collection_types: bool = ...,
406+
value_serializer: Optional[Callable[[type, Attribute, Any], Any]] = ...,
400407
) -> Dict[str, Any]: ...
401408

402409
# TODO: add support for returning NamedTuple from the mypy plugin

src/attr/_funcs.py

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ def asdict(
1313
filter=None,
1414
dict_factory=dict,
1515
retain_collection_types=False,
16+
value_serializer=None,
1617
):
1718
"""
1819
Return the ``attrs`` attribute values of *inst* as a dict.
@@ -32,6 +33,9 @@ def asdict(
3233
:param bool retain_collection_types: Do not convert to ``list`` when
3334
encountering an attribute whose type is ``tuple`` or ``set``. Only
3435
meaningful if ``recurse`` is ``True``.
36+
:param callable value_serializer: A hook that is called for every attribute
37+
or dict key/value. It receives the current instance, field and value
38+
and must return the (updated) value.
3539
3640
:rtype: return type of *dict_factory*
3741
@@ -40,24 +44,36 @@ def asdict(
4044
4145
.. versionadded:: 16.0.0 *dict_factory*
4246
.. versionadded:: 16.1.0 *retain_collection_types*
47+
.. versionadded:: 20.3.0 *value_serializer*
4348
"""
4449
attrs = fields(inst.__class__)
4550
rv = dict_factory()
4651
for a in attrs:
4752
v = getattr(inst, a.name)
53+
if value_serializer is not None:
54+
v = value_serializer(inst, a, v)
4855
if filter is not None and not filter(a, v):
4956
continue
5057
if recurse is True:
5158
if has(v.__class__):
5259
rv[a.name] = asdict(
53-
v, True, filter, dict_factory, retain_collection_types
60+
v,
61+
True,
62+
filter,
63+
dict_factory,
64+
retain_collection_types,
65+
value_serializer,
5466
)
5567
elif isinstance(v, (tuple, list, set)):
5668
cf = v.__class__ if retain_collection_types is True else list
5769
rv[a.name] = cf(
5870
[
5971
_asdict_anything(
60-
i, filter, dict_factory, retain_collection_types
72+
i,
73+
filter,
74+
dict_factory,
75+
retain_collection_types,
76+
value_serializer,
6177
)
6278
for i in v
6379
]
@@ -67,10 +83,18 @@ def asdict(
6783
rv[a.name] = df(
6884
(
6985
_asdict_anything(
70-
kk, filter, df, retain_collection_types
86+
kk,
87+
filter,
88+
df,
89+
retain_collection_types,
90+
value_serializer,
7191
),
7292
_asdict_anything(
73-
vv, filter, df, retain_collection_types
93+
vv,
94+
filter,
95+
df,
96+
retain_collection_types,
97+
value_serializer,
7498
),
7599
)
76100
for kk, vv in iteritems(v)
@@ -82,19 +106,36 @@ def asdict(
82106
return rv
83107

84108

85-
def _asdict_anything(val, filter, dict_factory, retain_collection_types):
109+
def _asdict_anything(
110+
val,
111+
filter,
112+
dict_factory,
113+
retain_collection_types,
114+
value_serializer,
115+
):
86116
"""
87117
``asdict`` only works on attrs instances, this works on anything.
88118
"""
89119
if getattr(val.__class__, "__attrs_attrs__", None) is not None:
90120
# Attrs class.
91-
rv = asdict(val, True, filter, dict_factory, retain_collection_types)
121+
rv = asdict(
122+
val,
123+
True,
124+
filter,
125+
dict_factory,
126+
retain_collection_types,
127+
value_serializer,
128+
)
92129
elif isinstance(val, (tuple, list, set)):
93130
cf = val.__class__ if retain_collection_types is True else list
94131
rv = cf(
95132
[
96133
_asdict_anything(
97-
i, filter, dict_factory, retain_collection_types
134+
i,
135+
filter,
136+
dict_factory,
137+
retain_collection_types,
138+
value_serializer,
98139
)
99140
for i in val
100141
]
@@ -103,13 +144,19 @@ def _asdict_anything(val, filter, dict_factory, retain_collection_types):
103144
df = dict_factory
104145
rv = df(
105146
(
106-
_asdict_anything(kk, filter, df, retain_collection_types),
107-
_asdict_anything(vv, filter, df, retain_collection_types),
147+
_asdict_anything(
148+
kk, filter, df, retain_collection_types, value_serializer
149+
),
150+
_asdict_anything(
151+
vv, filter, df, retain_collection_types, value_serializer
152+
),
108153
)
109154
for kk, vv in iteritems(val)
110155
)
111156
else:
112157
rv = val
158+
if value_serializer is not None:
159+
rv = value_serializer(None, None, rv)
113160
return rv
114161

115162

0 commit comments

Comments
 (0)