Skip to content

Commit

Permalink
Structure using the value of attr.ib converter, if defined (#139)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
natemcmaster authored Jun 18, 2021
1 parent e772725 commit 9f53896
Show file tree
Hide file tree
Showing 10 changed files with 247 additions and 41 deletions.
3 changes: 3 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ History
* Fix ``GenConverter`` mapping structuring for unannotated dicts on Python 3.8.
(`#151 <https://github.com/Tinche/cattrs/issues/151>`_)
* 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.
* Support using the attr converter callback during structure.
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`.
(`#138 <https://github.com/Tinche/cattrs/issues/138>`_)

1.7.1 (2021-05-28)
------------------
Expand Down
54 changes: 46 additions & 8 deletions docs/structuring.rst
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,8 @@ 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)
... a: int = attr.ib()
... b: int = attr.ib()
...
>>> cattr.structure({'a': 1, 'b': '2'}, A)
A(a=1, b=2)
Expand All @@ -298,8 +298,8 @@ Classes like these deconstructed into tuples can be structured using
>>> @attr.s
... class A:
... a = attr.ib()
... b = attr.ib(converter=int)
... a: str = attr.ib()
... b: int = attr.ib()
...
>>> cattr.structure_attrs_fromtuple(['string', '2'], A)
A(a='string', b=2)
Expand All @@ -312,15 +312,53 @@ Loading from tuples can be made the default by creating a new ``Converter`` with
>>> converter = cattr.Converter(unstruct_strat=cattr.UnstructureStrategy.AS_TUPLE)
>>> @attr.s
... class A:
... a = attr.ib()
... b = attr.ib(converter=int)
... a: str = attr.ib()
... b: int = attr.ib()
...
>>> 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: IPv4Address = attr.ib(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: int = attr.ib(converter=lambda v: int(v) + 5)
>>> converter.structure({'a': '10'}, A)
A(a=15)
Complex ``attrs`` classes and dataclasses
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down Expand Up @@ -352,7 +390,7 @@ attributes holding ``attrs`` classes and dataclasses.
...
>>> @attr.s
... class B:
... b = attr.ib(type=A) # Legacy syntax.
... b: A = attr.ib()
...
>>> cattr.structure({'b': {'a': '1'}}, B)
B(b=A(a=1))
Expand All @@ -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
56 changes: 41 additions & 15 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 @@ -34,6 +36,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 @@ -72,14 +75,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 @@ -292,7 +298,11 @@ def _structure_default(self, obj, cl):
return obj

if is_generic(cl):
fn = make_dict_structure_fn(cl, self)
fn = make_dict_structure_fn(
cl,
self,
_cattrs_prefer_attrib_converters=self._prefer_attrib_converters,
)
self.register_structure_hook(cl, fn)
return fn(obj)

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

@staticmethod
def _structure_call(obj, cl):
Expand All @@ -328,18 +338,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 @@ -348,10 +374,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 @@ -362,9 +385,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 @@ -484,9 +505,10 @@ 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."
"currently. Register a loads hook manually.",
type_=union,
)
return create_uniq_field_dis_func(*union_types)

Expand All @@ -509,9 +531,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 Expand Up @@ -662,6 +687,7 @@ def gen_structure_attrs_fromdict(self, cl: Type[T]) -> T:
cl,
self,
_cattrs_forbid_extra_keys=self.forbid_extra_keys,
_cattrs_prefer_attrib_converters=self._prefer_attrib_converters,
**attrib_overrides,
)
self._structure_func.register_cls_list([(cl, h)], direct=True)
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(
f"unable to find handler for {typ}", type_=typ
)
9 changes: 9 additions & 0 deletions src/cattr/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from typing import Type


class StructureHandlerNotFoundError(Exception):
"""Error raised when structuring cannot find a handler for converting inputs into :attr:`type_`."""

def __init__(self, message: str, type_: Type) -> None:
super().__init__(message)
self.type_ = type_
49 changes: 40 additions & 9 deletions src/cattr/gen.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import functools
import linecache
import re
import uuid
from dataclasses import is_dataclass
from typing import Any, Optional, Type, TypeVar
from typing import Any, Optional, TYPE_CHECKING, Type, TypeVar

import attr
from attr import NOTHING, resolve_types

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

if TYPE_CHECKING:
from cattr.converters import Converter


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

def make_dict_structure_fn(
cl: Type,
converter,
converter: "Converter",
_cattrs_forbid_extra_keys: bool = False,
_cattrs_use_linecache: bool = True,
_cattrs_prefer_attrib_converters: bool = False,
**kwargs,
):
"""Generate a specialized dict structuring function for an attrs class."""
Expand Down Expand Up @@ -185,26 +191,40 @@ 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 _cattrs_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 = None
elif type is not None:
handler = converter._structure_func.dispatch(type)
else:
handler = converter.structure

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

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

ian = an if (is_dc or an[0] != "_") else an[1:]
kn = an if override.rename is None else override.rename
globs[f"type_{an}"] = type
if a.default is NOTHING:
lines.append(
f" '{ian}': {struct_handler_name}(o['{kn}'], type_{an}),"
)
if handler:
lines.append(
f" '{ian}': {struct_handler_name}(o['{kn}'], type_{an}),"
)
else:
lines.append(f" '{ian}': o['{kn}'],")
else:
post_lines.append(f" if '{kn}' in o:")
post_lines.append(
f" res['{ian}'] = {struct_handler_name}(o['{kn}'], type_{an})"
)
if handler:
post_lines.append(
f" res['{ian}'] = {struct_handler_name}(o['{kn}'], type_{an})"
)
else:
post_lines.append(f" res['{ian}'] = o['{kn}']")

lines.append(" }")
if _cattrs_forbid_extra_keys:
allowed_fields = {a.name for a in attrs}
Expand Down Expand Up @@ -237,6 +257,17 @@ def make_dict_structure_fn(
return globs[fn_name]


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
Loading

0 comments on commit 9f53896

Please sign in to comment.