Skip to content

Commit 2cf4132

Browse files
committed
Implement support for attrib converters
1 parent 41c4945 commit 2cf4132

8 files changed

+183
-25
lines changed

docs/structuring.rst

+42-4
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ and their own converters work out of the box. Given a mapping ``d`` and class
285285
>>> @attr.s
286286
... class A:
287287
... a = attr.ib()
288-
... b = attr.ib(converter=int)
288+
... b = attr.ib()
289289
...
290290
>>> cattr.structure({'a': 1, 'b': '2'}, A)
291291
A(a=1, b=2)
@@ -299,7 +299,7 @@ Classes like these deconstructed into tuples can be structured using
299299
>>> @attr.s
300300
... class A:
301301
... a = attr.ib()
302-
... b = attr.ib(converter=int)
302+
... b = attr.ib(type=int)
303303
...
304304
>>> cattr.structure_attrs_fromtuple(['string', '2'], A)
305305
A(a='string', b=2)
@@ -313,14 +313,52 @@ Loading from tuples can be made the default by creating a new ``Converter`` with
313313
>>> @attr.s
314314
... class A:
315315
... a = attr.ib()
316-
... b = attr.ib(converter=int)
316+
... b = attr.ib(type=int)
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 = attr.ib(type=IPv4Address, 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 = attr.ib(type=int, 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
@@ -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)

src/cattr/converters.py

+33-13
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

@@ -33,6 +35,7 @@
3335
)
3436
from .disambiguators import create_uniq_field_dis_func
3537
from .dispatch import MultiStrategyDispatch
38+
from .errors import StructureHandlerNotFoundError
3639
from .gen import (
3740
AttributeOverride,
3841
make_dict_structure_fn,
@@ -71,14 +74,17 @@ class Converter(object):
7174
"_dict_factory",
7275
"_union_struct_registry",
7376
"_structure_func",
77+
"_prefer_attrib_converters",
7478
)
7579

7680
def __init__(
7781
self,
7882
dict_factory: Callable[[], Any] = dict,
7983
unstruct_strat: UnstructureStrategy = UnstructureStrategy.AS_DICT,
84+
prefer_attrib_converters: bool = False,
8085
) -> None:
8186
unstruct_strat = UnstructureStrategy(unstruct_strat)
87+
self._prefer_attrib_converters = prefer_attrib_converters
8288

8389
# Create a per-instance cache.
8490
if unstruct_strat is UnstructureStrategy.AS_DICT:
@@ -299,7 +305,7 @@ def _structure_default(self, obj, cl):
299305
"Unsupported type: {0}. Register a structure hook for "
300306
"it.".format(cl)
301307
)
302-
raise ValueError(msg)
308+
raise StructureHandlerNotFoundError(msg)
303309

304310
@staticmethod
305311
def _structure_call(obj, cl):
@@ -320,18 +326,34 @@ def structure_attrs_fromtuple(
320326
conv_obj = [] # A list of converter parameters.
321327
for a, value in zip(fields(cl), obj): # type: ignore
322328
# We detect the type by the metadata.
323-
converted = self._structure_attr_from_tuple(a, a.name, value)
329+
converted = self._structure_attribute(a, value)
324330
conv_obj.append(converted)
325331

326332
return cl(*conv_obj) # type: ignore
327333

328-
def _structure_attr_from_tuple(self, a, _, value):
334+
def _structure_attribute(
335+
self, a: Union[Attribute, Field], value: Any
336+
) -> Any:
329337
"""Handle an individual attrs attribute."""
330338
type_ = a.type
339+
attrib_converter = getattr(a, "converter", None)
340+
if self._prefer_attrib_converters and attrib_converter:
341+
# A attrib converter is defined on this attribute, and prefer_attrib_converters is set
342+
# to give these priority over registered structure hooks. So, pass through the raw
343+
# value, which attrs will flow into the converter
344+
return value
331345
if type_ is None:
332346
# No type metadata.
333347
return value
334-
return self._structure_func.dispatch(type_)(value, type_)
348+
349+
try:
350+
return self._structure_func.dispatch(type_)(value, type_)
351+
except StructureHandlerNotFoundError:
352+
if attrib_converter:
353+
# Return the original value and fallback to using an attrib converter.
354+
return value
355+
else:
356+
raise
335357

336358
def structure_attrs_fromdict(
337359
self, obj: Mapping[str, Any], cl: Type[T]
@@ -340,10 +362,7 @@ def structure_attrs_fromdict(
340362
# For public use.
341363

342364
conv_obj = {} # Start with a fresh dict, to ignore extra keys.
343-
dispatch = self._structure_func.dispatch
344365
for a in fields(cl): # type: ignore
345-
# We detect the type by metadata.
346-
type_ = a.type
347366
name = a.name
348367

349368
try:
@@ -354,9 +373,7 @@ def structure_attrs_fromdict(
354373
if name[0] == "_":
355374
name = name[1:]
356375

357-
conv_obj[name] = (
358-
dispatch(type_)(val, type_) if type_ is not None else val
359-
)
376+
conv_obj[name] = self._structure_attribute(a, val)
360377

361378
return cl(**conv_obj) # type: ignore
362379

@@ -476,7 +493,7 @@ def _get_dis_func(union):
476493
)
477494

478495
if not all(has(get_origin(e) or e) for e in union_types):
479-
raise ValueError(
496+
raise StructureHandlerNotFoundError(
480497
"Only unions of attr classes supported "
481498
"currently. Register a loads hook manually."
482499
)
@@ -501,9 +518,12 @@ def __init__(
501518
forbid_extra_keys: bool = False,
502519
type_overrides: Mapping[Type, AttributeOverride] = {},
503520
unstruct_collection_overrides: Mapping[Type, Callable] = {},
521+
prefer_attrib_converters: bool = False,
504522
):
505523
super().__init__(
506-
dict_factory=dict_factory, unstruct_strat=unstruct_strat
524+
dict_factory=dict_factory,
525+
unstruct_strat=unstruct_strat,
526+
prefer_attrib_converters=prefer_attrib_converters,
507527
)
508528
self.omit_if_default = omit_if_default
509529
self.forbid_extra_keys = forbid_extra_keys

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+
"unable to find handler for {0}".format(typ)
128+
)

src/cattr/errors.py

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
class StructureHandlerNotFoundError(Exception):
2+
pass

src/cattr/gen.py

+25-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import functools
12
import re
23
from dataclasses import is_dataclass
34
from typing import Any, Optional, Type, TypeVar
@@ -6,6 +7,7 @@
67
from attr import NOTHING, resolve_types
78

89
from ._compat import adapted_fields, get_args, get_origin, is_generic
10+
from .errors import StructureHandlerNotFoundError
911

1012

1113
@attr.s(slots=True, frozen=True)
@@ -162,11 +164,18 @@ def make_dict_structure_fn(
162164
# For each attribute, we try resolving the type here and now.
163165
# If a type is manually overwritten, this function should be
164166
# regenerated.
165-
if type is not None:
167+
if converter._prefer_attrib_converters and a.converter is not None:
168+
# The attribute has defined its own conversion, so pass
169+
# the original value through without invoking cattr hooks
170+
handler = _passthru
171+
elif type is not None:
166172
handler = converter._structure_func.dispatch(type)
167173
else:
168174
handler = converter.structure
169175

176+
if not converter._prefer_attrib_converters and a.converter is not None:
177+
handler = _fallback_to_passthru(handler)
178+
170179
struct_handler_name = f"__cattr_struct_handler_{an}"
171180
globs[struct_handler_name] = handler
172181

@@ -201,6 +210,21 @@ def make_dict_structure_fn(
201210
return globs[fn_name]
202211

203212

213+
def _passthru(obj, _):
214+
return obj
215+
216+
217+
def _fallback_to_passthru(func):
218+
@functools.wraps(func)
219+
def invoke(obj, type_):
220+
try:
221+
return func(obj, type_)
222+
except StructureHandlerNotFoundError:
223+
return obj
224+
225+
return invoke
226+
227+
204228
def make_iterable_unstructure_fn(cl: Any, converter, unstructure_to=None):
205229
"""Generate a specialized unstructure function for an iterable."""
206230
handler = converter.unstructure

tests/test_function_dispatch.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import pytest
22

3-
from cattr.dispatch import FunctionDispatch
3+
from cattr.dispatch import FunctionDispatch, StructureHandlerNotFoundError
44

55

66
def test_function_dispatch():
77
dispatch = FunctionDispatch()
88

9-
with pytest.raises(KeyError):
9+
with pytest.raises(StructureHandlerNotFoundError):
1010
dispatch.dispatch(float)
1111

1212
test_func = object()

tests/test_structure.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from cattr import Converter
3434
from cattr._compat import is_bare, is_union_type
3535
from cattr.converters import NoneType
36+
from cattr.errors import StructureHandlerNotFoundError
3637

3738
from . import (
3839
dicts_of_primitives,
@@ -326,9 +327,9 @@ def test_structuring_enums(data, enum):
326327
def test_structuring_unsupported():
327328
"""Loading unsupported classes should throw."""
328329
converter = Converter()
329-
with raises(ValueError):
330+
with raises(StructureHandlerNotFoundError):
330331
converter.structure(1, Converter)
331-
with raises(ValueError):
332+
with raises(StructureHandlerNotFoundError):
332333
converter.structure(1, Union[int, str])
333334

334335

0 commit comments

Comments
 (0)