Skip to content

Commit

Permalink
Implement support for attrib converters
Browse files Browse the repository at this point in the history
  • Loading branch information
natemcmaster committed May 2, 2021
1 parent 41c4945 commit 2cf4132
Show file tree
Hide file tree
Showing 8 changed files with 183 additions and 25 deletions.
46 changes: 42 additions & 4 deletions docs/structuring.rst
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ and their own converters work out of the box. Given a mapping ``d`` and class
>>> @attr.s
... class A:
... a = attr.ib()
... b = attr.ib(converter=int)
... b = attr.ib()
...
>>> cattr.structure({'a': 1, 'b': '2'}, A)
A(a=1, b=2)
Expand All @@ -299,7 +299,7 @@ Classes like these deconstructed into tuples can be structured using
>>> @attr.s
... class A:
... a = attr.ib()
... b = attr.ib(converter=int)
... b = attr.ib(type=int)
...
>>> cattr.structure_attrs_fromtuple(['string', '2'], A)
A(a='string', b=2)
Expand All @@ -313,14 +313,52 @@ Loading from tuples can be made the default by creating a new ``Converter`` with
>>> @attr.s
... class A:
... a = attr.ib()
... b = attr.ib(converter=int)
... b = attr.ib(type=int)
...
>>> converter.structure(['string', '2'], A)
A(a='string', b=2)
Structuring from tuples can also be made the default for specific classes only;
see registering custom structure hooks below.
Using attribute types and converters
------------------------------------
By default, calling "structure" will use hooks registered using ``cattr.register_structure_hook``,
to convert values to the attribute type, and fallback to invoking any converters registered on
attributes with ``attrib``.
.. doctest::
>>> from ipaddress import IPv4Address, ip_address
>>> converter = cattr.Converter()
# Note: register_structure_hook has not been called, so this will fallback to 'ip_address'
>>> @attr.s
... class A:
... a = attr.ib(type=IPv4Address, converter=ip_address)
>>> converter.structure({'a': '127.0.0.1'}, A)
A(a=IPv4Address('127.0.0.1'))
Priority is still given to hooks registered with ``cattr.register_structure_hook``, but this priority
can be inverted by setting ``prefer_attrib_converters`` to ``True``.
.. doctest::
>>> converter = cattr.Converter(prefer_attrib_converters=True)
>>> converter.register_structure_hook(int, lambda v, t: int(v))
>>> @attr.s
... class A:
... a = attr.ib(type=int, converter=lambda v: int(v) + 5)
>>> converter.structure({'a': '10'}, A)
A(a=15)
Complex ``attrs`` classes and dataclasses
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down Expand Up @@ -376,7 +414,7 @@ Here's an example involving a simple, classic (i.e. non-``attrs``) Python class.
>>> cattr.structure({'a': 1}, C)
Traceback (most recent call last):
...
ValueError: Unsupported type: <class '__main__.C'>. Register a structure hook for it.
StructureHandlerNotFoundError: Unsupported type: <class '__main__.C'>. Register a structure hook for it.
>>>
>>> cattr.register_structure_hook(C, lambda d, t: C(**d))
>>> cattr.structure({'a': 1}, C)
Expand Down
46 changes: 33 additions & 13 deletions src/cattr/converters.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from collections import Counter
from collections.abc import MutableSet as AbcMutableSet
from dataclasses import Field
from enum import Enum
from functools import lru_cache
from typing import Any, Callable, Dict, Optional, Tuple, Type, TypeVar
from typing import Any, Callable, Dict, Optional, Tuple, Type, TypeVar, Union

from attr import Attribute
from attr import has as attrs_has
from attr import resolve_types

Expand Down Expand Up @@ -33,6 +35,7 @@
)
from .disambiguators import create_uniq_field_dis_func
from .dispatch import MultiStrategyDispatch
from .errors import StructureHandlerNotFoundError
from .gen import (
AttributeOverride,
make_dict_structure_fn,
Expand Down Expand Up @@ -71,14 +74,17 @@ class Converter(object):
"_dict_factory",
"_union_struct_registry",
"_structure_func",
"_prefer_attrib_converters",
)

def __init__(
self,
dict_factory: Callable[[], Any] = dict,
unstruct_strat: UnstructureStrategy = UnstructureStrategy.AS_DICT,
prefer_attrib_converters: bool = False,
) -> None:
unstruct_strat = UnstructureStrategy(unstruct_strat)
self._prefer_attrib_converters = prefer_attrib_converters

# Create a per-instance cache.
if unstruct_strat is UnstructureStrategy.AS_DICT:
Expand Down Expand Up @@ -299,7 +305,7 @@ def _structure_default(self, obj, cl):
"Unsupported type: {0}. Register a structure hook for "
"it.".format(cl)
)
raise ValueError(msg)
raise StructureHandlerNotFoundError(msg)

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

return cl(*conv_obj) # type: ignore

def _structure_attr_from_tuple(self, a, _, value):
def _structure_attribute(
self, a: Union[Attribute, Field], value: Any
) -> Any:
"""Handle an individual attrs attribute."""
type_ = a.type
attrib_converter = getattr(a, "converter", None)
if self._prefer_attrib_converters and attrib_converter:
# A attrib converter is defined on this attribute, and prefer_attrib_converters is set
# to give these priority over registered structure hooks. So, pass through the raw
# value, which attrs will flow into the converter
return value
if type_ is None:
# No type metadata.
return value
return self._structure_func.dispatch(type_)(value, type_)

try:
return self._structure_func.dispatch(type_)(value, type_)
except StructureHandlerNotFoundError:
if attrib_converter:
# Return the original value and fallback to using an attrib converter.
return value
else:
raise

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

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

try:
Expand All @@ -354,9 +373,7 @@ def structure_attrs_fromdict(
if name[0] == "_":
name = name[1:]

conv_obj[name] = (
dispatch(type_)(val, type_) if type_ is not None else val
)
conv_obj[name] = self._structure_attribute(a, val)

return cl(**conv_obj) # type: ignore

Expand Down Expand Up @@ -476,7 +493,7 @@ def _get_dis_func(union):
)

if not all(has(get_origin(e) or e) for e in union_types):
raise ValueError(
raise StructureHandlerNotFoundError(
"Only unions of attr classes supported "
"currently. Register a loads hook manually."
)
Expand All @@ -501,9 +518,12 @@ def __init__(
forbid_extra_keys: bool = False,
type_overrides: Mapping[Type, AttributeOverride] = {},
unstruct_collection_overrides: Mapping[Type, Callable] = {},
prefer_attrib_converters: bool = False,
):
super().__init__(
dict_factory=dict_factory, unstruct_strat=unstruct_strat
dict_factory=dict_factory,
unstruct_strat=unstruct_strat,
prefer_attrib_converters=prefer_attrib_converters,
)
self.omit_if_default = omit_if_default
self.forbid_extra_keys = forbid_extra_keys
Expand Down
6 changes: 5 additions & 1 deletion src/cattr/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

import attr

from .errors import StructureHandlerNotFoundError


@attr.s
class _DispatchNotFound:
Expand Down Expand Up @@ -121,4 +123,6 @@ def dispatch(self, typ):
return handler(typ)
else:
return handler
raise KeyError("unable to find handler for {0}".format(typ))
raise StructureHandlerNotFoundError(
"unable to find handler for {0}".format(typ)
)
2 changes: 2 additions & 0 deletions src/cattr/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class StructureHandlerNotFoundError(Exception):
pass
26 changes: 25 additions & 1 deletion src/cattr/gen.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import functools
import re
from dataclasses import is_dataclass
from typing import Any, Optional, Type, TypeVar
Expand All @@ -6,6 +7,7 @@
from attr import NOTHING, resolve_types

from ._compat import adapted_fields, get_args, get_origin, is_generic
from .errors import StructureHandlerNotFoundError


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

if not converter._prefer_attrib_converters and a.converter is not None:
handler = _fallback_to_passthru(handler)

struct_handler_name = f"__cattr_struct_handler_{an}"
globs[struct_handler_name] = handler

Expand Down Expand Up @@ -201,6 +210,21 @@ def make_dict_structure_fn(
return globs[fn_name]


def _passthru(obj, _):
return obj


def _fallback_to_passthru(func):
@functools.wraps(func)
def invoke(obj, type_):
try:
return func(obj, type_)
except StructureHandlerNotFoundError:
return obj

return invoke


def make_iterable_unstructure_fn(cl: Any, converter, unstructure_to=None):
"""Generate a specialized unstructure function for an iterable."""
handler = converter.unstructure
Expand Down
4 changes: 2 additions & 2 deletions tests/test_function_dispatch.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import pytest

from cattr.dispatch import FunctionDispatch
from cattr.dispatch import FunctionDispatch, StructureHandlerNotFoundError


def test_function_dispatch():
dispatch = FunctionDispatch()

with pytest.raises(KeyError):
with pytest.raises(StructureHandlerNotFoundError):
dispatch.dispatch(float)

test_func = object()
Expand Down
5 changes: 3 additions & 2 deletions tests/test_structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from cattr import Converter
from cattr._compat import is_bare, is_union_type
from cattr.converters import NoneType
from cattr.errors import StructureHandlerNotFoundError

from . import (
dicts_of_primitives,
Expand Down Expand Up @@ -326,9 +327,9 @@ def test_structuring_enums(data, enum):
def test_structuring_unsupported():
"""Loading unsupported classes should throw."""
converter = Converter()
with raises(ValueError):
with raises(StructureHandlerNotFoundError):
converter.structure(1, Converter)
with raises(ValueError):
with raises(StructureHandlerNotFoundError):
converter.structure(1, Union[int, str])


Expand Down
Loading

0 comments on commit 2cf4132

Please sign in to comment.