Skip to content

Commit 9f53896

Browse files
authored
Structure using the value of attr.ib converter, if defined (#139)
* Implement support for attrib converters * Update docs per PR feedback * Update make_dict_structure_fn to take bool variable instead of reading _prefer_attrib_converters of converter argument * Remove _passthru * Add type_ attribute to StructureHandlerNotFoundError * Update changelog * Fix broken tests * Fix linting errors * Increase test coverage
1 parent e772725 commit 9f53896

10 files changed

+247
-41
lines changed

Diff for: HISTORY.rst

+3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ History
77
* Fix ``GenConverter`` mapping structuring for unannotated dicts on Python 3.8.
88
(`#151 <https://github.com/Tinche/cattrs/issues/151>`_)
99
* The source code for generated un/structuring functions is stored in the `linecache` cache, which enables more informative stack traces when un/structuring errors happen using the `GenConverter`. This behavior can optionally be disabled to save memory.
10+
* Support using the attr converter callback during structure.
11+
By default, this is a method of last resort, but it can be elevated to the default by setting `prefer_attrib_converters=True` on `Converter` or `GenConverter`.
12+
(`#138 <https://github.com/Tinche/cattrs/issues/138>`_)
1013

1114
1.7.1 (2021-05-28)
1215
------------------

Diff for: docs/structuring.rst

+46-8
Original file line numberDiff line numberDiff line change
@@ -284,8 +284,8 @@ and their own converters work out of the box. Given a mapping ``d`` and class
284284
285285
>>> @attr.s
286286
... class A:
287-
... a = attr.ib()
288-
... b = attr.ib(converter=int)
287+
... a: int = attr.ib()
288+
... b: int = attr.ib()
289289
...
290290
>>> cattr.structure({'a': 1, 'b': '2'}, A)
291291
A(a=1, b=2)
@@ -298,8 +298,8 @@ Classes like these deconstructed into tuples can be structured using
298298
299299
>>> @attr.s
300300
... class A:
301-
... a = attr.ib()
302-
... b = attr.ib(converter=int)
301+
... a: str = attr.ib()
302+
... b: int = attr.ib()
303303
...
304304
>>> cattr.structure_attrs_fromtuple(['string', '2'], A)
305305
A(a='string', b=2)
@@ -312,15 +312,53 @@ Loading from tuples can be made the default by creating a new ``Converter`` with
312312
>>> converter = cattr.Converter(unstruct_strat=cattr.UnstructureStrategy.AS_TUPLE)
313313
>>> @attr.s
314314
... class A:
315-
... a = attr.ib()
316-
... b = attr.ib(converter=int)
315+
... a: str = attr.ib()
316+
... b: int = attr.ib()
317317
...
318318
>>> converter.structure(['string', '2'], A)
319319
A(a='string', b=2)
320320
321321
Structuring from tuples can also be made the default for specific classes only;
322322
see registering custom structure hooks below.
323323
324+
325+
Using attribute types and converters
326+
------------------------------------
327+
328+
By default, calling "structure" will use hooks registered using ``cattr.register_structure_hook``,
329+
to convert values to the attribute type, and fallback to invoking any converters registered on
330+
attributes with ``attrib``.
331+
332+
.. doctest::
333+
334+
>>> from ipaddress import IPv4Address, ip_address
335+
>>> converter = cattr.Converter()
336+
337+
# Note: register_structure_hook has not been called, so this will fallback to 'ip_address'
338+
>>> @attr.s
339+
... class A:
340+
... a: IPv4Address = attr.ib(converter=ip_address)
341+
342+
>>> converter.structure({'a': '127.0.0.1'}, A)
343+
A(a=IPv4Address('127.0.0.1'))
344+
345+
Priority is still given to hooks registered with ``cattr.register_structure_hook``, but this priority
346+
can be inverted by setting ``prefer_attrib_converters`` to ``True``.
347+
348+
.. doctest::
349+
350+
>>> converter = cattr.Converter(prefer_attrib_converters=True)
351+
352+
>>> converter.register_structure_hook(int, lambda v, t: int(v))
353+
354+
>>> @attr.s
355+
... class A:
356+
... a: int = attr.ib(converter=lambda v: int(v) + 5)
357+
358+
>>> converter.structure({'a': '10'}, A)
359+
A(a=15)
360+
361+
324362
Complex ``attrs`` classes and dataclasses
325363
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
326364
@@ -352,7 +390,7 @@ attributes holding ``attrs`` classes and dataclasses.
352390
...
353391
>>> @attr.s
354392
... class B:
355-
... b = attr.ib(type=A) # Legacy syntax.
393+
... b: A = attr.ib()
356394
...
357395
>>> cattr.structure({'b': {'a': '1'}}, B)
358396
B(b=A(a=1))
@@ -376,7 +414,7 @@ Here's an example involving a simple, classic (i.e. non-``attrs``) Python class.
376414
>>> cattr.structure({'a': 1}, C)
377415
Traceback (most recent call last):
378416
...
379-
ValueError: Unsupported type: <class '__main__.C'>. Register a structure hook for it.
417+
StructureHandlerNotFoundError: Unsupported type: <class '__main__.C'>. Register a structure hook for it.
380418
>>>
381419
>>> cattr.register_structure_hook(C, lambda d, t: C(**d))
382420
>>> cattr.structure({'a': 1}, C)

Diff for: src/cattr/converters.py

+41-15
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from collections import Counter
22
from collections.abc import MutableSet as AbcMutableSet
3+
from dataclasses import Field
34
from enum import Enum
45
from functools import lru_cache
5-
from typing import Any, Callable, Dict, Optional, Tuple, Type, TypeVar
6+
from typing import Any, Callable, Dict, Optional, Tuple, Type, TypeVar, Union
67

8+
from attr import Attribute
79
from attr import has as attrs_has
810
from attr import resolve_types
911

@@ -34,6 +36,7 @@
3436
)
3537
from .disambiguators import create_uniq_field_dis_func
3638
from .dispatch import MultiStrategyDispatch
39+
from .errors import StructureHandlerNotFoundError
3740
from .gen import (
3841
AttributeOverride,
3942
make_dict_structure_fn,
@@ -72,14 +75,17 @@ class Converter(object):
7275
"_dict_factory",
7376
"_union_struct_registry",
7477
"_structure_func",
78+
"_prefer_attrib_converters",
7579
)
7680

7781
def __init__(
7882
self,
7983
dict_factory: Callable[[], Any] = dict,
8084
unstruct_strat: UnstructureStrategy = UnstructureStrategy.AS_DICT,
85+
prefer_attrib_converters: bool = False,
8186
) -> None:
8287
unstruct_strat = UnstructureStrategy(unstruct_strat)
88+
self._prefer_attrib_converters = prefer_attrib_converters
8389

8490
# Create a per-instance cache.
8591
if unstruct_strat is UnstructureStrategy.AS_DICT:
@@ -292,7 +298,11 @@ def _structure_default(self, obj, cl):
292298
return obj
293299

294300
if is_generic(cl):
295-
fn = make_dict_structure_fn(cl, self)
301+
fn = make_dict_structure_fn(
302+
cl,
303+
self,
304+
_cattrs_prefer_attrib_converters=self._prefer_attrib_converters,
305+
)
296306
self.register_structure_hook(cl, fn)
297307
return fn(obj)
298308

@@ -301,7 +311,7 @@ def _structure_default(self, obj, cl):
301311
"Unsupported type: {0}. Register a structure hook for "
302312
"it.".format(cl)
303313
)
304-
raise ValueError(msg)
314+
raise StructureHandlerNotFoundError(msg, type_=cl)
305315

306316
@staticmethod
307317
def _structure_call(obj, cl):
@@ -328,18 +338,34 @@ def structure_attrs_fromtuple(
328338
conv_obj = [] # A list of converter parameters.
329339
for a, value in zip(fields(cl), obj): # type: ignore
330340
# We detect the type by the metadata.
331-
converted = self._structure_attr_from_tuple(a, a.name, value)
341+
converted = self._structure_attribute(a, value)
332342
conv_obj.append(converted)
333343

334344
return cl(*conv_obj) # type: ignore
335345

336-
def _structure_attr_from_tuple(self, a, _, value):
346+
def _structure_attribute(
347+
self, a: Union[Attribute, Field], value: Any
348+
) -> Any:
337349
"""Handle an individual attrs attribute."""
338350
type_ = a.type
351+
attrib_converter = getattr(a, "converter", None)
352+
if self._prefer_attrib_converters and attrib_converter:
353+
# A attrib converter is defined on this attribute, and prefer_attrib_converters is set
354+
# to give these priority over registered structure hooks. So, pass through the raw
355+
# value, which attrs will flow into the converter
356+
return value
339357
if type_ is None:
340358
# No type metadata.
341359
return value
342-
return self._structure_func.dispatch(type_)(value, type_)
360+
361+
try:
362+
return self._structure_func.dispatch(type_)(value, type_)
363+
except StructureHandlerNotFoundError:
364+
if attrib_converter:
365+
# Return the original value and fallback to using an attrib converter.
366+
return value
367+
else:
368+
raise
343369

344370
def structure_attrs_fromdict(
345371
self, obj: Mapping[str, Any], cl: Type[T]
@@ -348,10 +374,7 @@ def structure_attrs_fromdict(
348374
# For public use.
349375

350376
conv_obj = {} # Start with a fresh dict, to ignore extra keys.
351-
dispatch = self._structure_func.dispatch
352377
for a in fields(cl): # type: ignore
353-
# We detect the type by metadata.
354-
type_ = a.type
355378
name = a.name
356379

357380
try:
@@ -362,9 +385,7 @@ def structure_attrs_fromdict(
362385
if name[0] == "_":
363386
name = name[1:]
364387

365-
conv_obj[name] = (
366-
dispatch(type_)(val, type_) if type_ is not None else val
367-
)
388+
conv_obj[name] = self._structure_attribute(a, val)
368389

369390
return cl(**conv_obj) # type: ignore
370391

@@ -484,9 +505,10 @@ def _get_dis_func(union):
484505
)
485506

486507
if not all(has(get_origin(e) or e) for e in union_types):
487-
raise ValueError(
508+
raise StructureHandlerNotFoundError(
488509
"Only unions of attr classes supported "
489-
"currently. Register a loads hook manually."
510+
"currently. Register a loads hook manually.",
511+
type_=union,
490512
)
491513
return create_uniq_field_dis_func(*union_types)
492514

@@ -509,9 +531,12 @@ def __init__(
509531
forbid_extra_keys: bool = False,
510532
type_overrides: Mapping[Type, AttributeOverride] = {},
511533
unstruct_collection_overrides: Mapping[Type, Callable] = {},
534+
prefer_attrib_converters: bool = False,
512535
):
513536
super().__init__(
514-
dict_factory=dict_factory, unstruct_strat=unstruct_strat
537+
dict_factory=dict_factory,
538+
unstruct_strat=unstruct_strat,
539+
prefer_attrib_converters=prefer_attrib_converters,
515540
)
516541
self.omit_if_default = omit_if_default
517542
self.forbid_extra_keys = forbid_extra_keys
@@ -662,6 +687,7 @@ def gen_structure_attrs_fromdict(self, cl: Type[T]) -> T:
662687
cl,
663688
self,
664689
_cattrs_forbid_extra_keys=self.forbid_extra_keys,
690+
_cattrs_prefer_attrib_converters=self._prefer_attrib_converters,
665691
**attrib_overrides,
666692
)
667693
self._structure_func.register_cls_list([(cl, h)], direct=True)

Diff for: src/cattr/dispatch.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
import attr
55

6+
from .errors import StructureHandlerNotFoundError
7+
68

79
@attr.s
810
class _DispatchNotFound:
@@ -121,4 +123,6 @@ def dispatch(self, typ):
121123
return handler(typ)
122124
else:
123125
return handler
124-
raise KeyError("unable to find handler for {0}".format(typ))
126+
raise StructureHandlerNotFoundError(
127+
f"unable to find handler for {typ}", type_=typ
128+
)

Diff for: src/cattr/errors.py

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from typing import Type
2+
3+
4+
class StructureHandlerNotFoundError(Exception):
5+
"""Error raised when structuring cannot find a handler for converting inputs into :attr:`type_`."""
6+
7+
def __init__(self, message: str, type_: Type) -> None:
8+
super().__init__(message)
9+
self.type_ = type_

Diff for: src/cattr/gen.py

+40-9
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1+
import functools
12
import linecache
23
import re
34
import uuid
45
from dataclasses import is_dataclass
5-
from typing import Any, Optional, Type, TypeVar
6+
from typing import Any, Optional, TYPE_CHECKING, Type, TypeVar
67

78
import attr
89
from attr import NOTHING, resolve_types
910

1011
from ._compat import adapted_fields, get_args, get_origin, is_bare, is_generic
12+
from .errors import StructureHandlerNotFoundError
13+
14+
if TYPE_CHECKING:
15+
from cattr.converters import Converter
1116

1217

1318
@attr.s(slots=True, frozen=True)
@@ -130,9 +135,10 @@ def generate_mapping(cl: Type, old_mapping):
130135

131136
def make_dict_structure_fn(
132137
cl: Type,
133-
converter,
138+
converter: "Converter",
134139
_cattrs_forbid_extra_keys: bool = False,
135140
_cattrs_use_linecache: bool = True,
141+
_cattrs_prefer_attrib_converters: bool = False,
136142
**kwargs,
137143
):
138144
"""Generate a specialized dict structuring function for an attrs class."""
@@ -185,26 +191,40 @@ def make_dict_structure_fn(
185191
# For each attribute, we try resolving the type here and now.
186192
# If a type is manually overwritten, this function should be
187193
# regenerated.
188-
if type is not None:
194+
if _cattrs_prefer_attrib_converters and a.converter is not None:
195+
# The attribute has defined its own conversion, so pass
196+
# the original value through without invoking cattr hooks
197+
handler = None
198+
elif type is not None:
189199
handler = converter._structure_func.dispatch(type)
190200
else:
191201
handler = converter.structure
192202

203+
if not _cattrs_prefer_attrib_converters and a.converter is not None:
204+
handler = _fallback_to_passthru(handler)
205+
193206
struct_handler_name = f"structure_{an}"
194207
globs[struct_handler_name] = handler
195208

196209
ian = an if (is_dc or an[0] != "_") else an[1:]
197210
kn = an if override.rename is None else override.rename
198211
globs[f"type_{an}"] = type
199212
if a.default is NOTHING:
200-
lines.append(
201-
f" '{ian}': {struct_handler_name}(o['{kn}'], type_{an}),"
202-
)
213+
if handler:
214+
lines.append(
215+
f" '{ian}': {struct_handler_name}(o['{kn}'], type_{an}),"
216+
)
217+
else:
218+
lines.append(f" '{ian}': o['{kn}'],")
203219
else:
204220
post_lines.append(f" if '{kn}' in o:")
205-
post_lines.append(
206-
f" res['{ian}'] = {struct_handler_name}(o['{kn}'], type_{an})"
207-
)
221+
if handler:
222+
post_lines.append(
223+
f" res['{ian}'] = {struct_handler_name}(o['{kn}'], type_{an})"
224+
)
225+
else:
226+
post_lines.append(f" res['{ian}'] = o['{kn}']")
227+
208228
lines.append(" }")
209229
if _cattrs_forbid_extra_keys:
210230
allowed_fields = {a.name for a in attrs}
@@ -237,6 +257,17 @@ def make_dict_structure_fn(
237257
return globs[fn_name]
238258

239259

260+
def _fallback_to_passthru(func):
261+
@functools.wraps(func)
262+
def invoke(obj, type_):
263+
try:
264+
return func(obj, type_)
265+
except StructureHandlerNotFoundError:
266+
return obj
267+
268+
return invoke
269+
270+
240271
def make_iterable_unstructure_fn(cl: Any, converter, unstructure_to=None):
241272
"""Generate a specialized unstructure function for an iterable."""
242273
handler = converter.unstructure

0 commit comments

Comments
 (0)