Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Un/structure fallback factories #441

Merged
merged 3 commits into from
Nov 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
([#405](https://github.com/python-attrs/cattrs/pull/405))
- The `omit` parameter of {py:func}`cattrs.override` is now of type `bool | None` (from `bool`).
`None` is the new default and means to apply default _cattrs_ handling to the attribute, which is to omit the attribute if it's marked as `init=False`, and keep it otherwise.
- Converters can now be initialized with custom fallback hook factories for un/structuring.
([#331](https://github.com/python-attrs/cattrs/issues/311) [#441](https://github.com/python-attrs/cattrs/pull/441))
- Fix {py:func}`format_exception() <cattrs.v.format_exception>` parameter working for recursive calls to {py:func}`transform_error <cattrs.transform_error>`.
([#389](https://github.com/python-attrs/cattrs/issues/389))
- [_attrs_ aliases](https://www.attrs.org/en/stable/init.html#private-attributes-and-aliases) are now supported, although aliased fields still map to their attribute name instead of their alias by default when un/structuring.
Expand Down
8 changes: 8 additions & 0 deletions docs/cattrs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ cattrs.errors module
:undoc-members:
:show-inheritance:

cattrs.fns module
-----------------

.. automodule:: cattrs.fns
:members:
:undoc-members:
:show-inheritance:

cattrs.v module
---------------

Expand Down
2 changes: 2 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@
doctest_global_setup = (
"import attr, cattr, cattrs;"
"from attr import Factory, define, field;"
"from cattrs import Converter;"
"from typing import *;"
"from enum import Enum, unique"
)
Expand All @@ -292,3 +293,4 @@
copybutton_prompt_text = r">>> |\.\.\. "
copybutton_prompt_is_regexp = True
myst_heading_anchors = 3
autoclass_content = "both"
61 changes: 47 additions & 14 deletions docs/converters.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
# Converters

All _cattrs_ functionality is exposed through a {class}`cattrs.Converter` object.
Global _cattrs_ functions, such as {meth}`cattrs.unstructure`, use a single
global converter. Changes done to this global converter, such as registering new
structure and unstructure hooks, affect all code using the global
functions.
Global _cattrs_ functions, such as {meth}`cattrs.unstructure`, use a single global converter.
Changes done to this global converter, such as registering new structure and unstructure hooks, affect all code using the global functions.

## Global Converter

Expand All @@ -18,8 +16,7 @@ The following functions implicitly use this global converter:

Changes made to the global converter will affect the behavior of these functions.

Larger applications are strongly encouraged to create and customize a different,
private instance of {class}`cattrs.Converter`.
Larger applications are strongly encouraged to create and customize a different, private instance of {class}`cattrs.Converter`.

## Converter Objects

Expand All @@ -32,14 +29,52 @@ Currently, a converter contains the following state:
- a reference to an unstructuring strategy (either AS_DICT or AS_TUPLE).
- a `dict_factory` callable, used for creating `dicts` when dumping _attrs_ classes using `AS_DICT`.

Converters may be cloned using the {meth}`cattrs.Converter.copy` method.
Converters may be cloned using the {meth}`Converter.copy() <cattrs.BaseConverter.copy>` method.
The new copy may be changed through the `copy` arguments, but will retain all manually registered hooks from the original.

### Fallback Hook Factories

By default, when a {class}`converter <cattrs.BaseConverter>` cannot handle a type it will:

* when unstructuring, pass the value through unchanged
* when structuring, raise a {class}`cattrs.errors.StructureHandlerNotFoundError` asking the user to add configuration

These behaviors can be customized by providing custom [hook factories](usage.md#using-factory-hooks) when creating the converter.

```python
>>> from pickle import dumps

>>> class Unsupported:
... """An artisinal (non-attrs) class, unsupported by default."""

>>> converter = Converter(unstructure_fallback_factory=lambda _: dumps)
>>> instance = Unsupported()
>>> converter.unstructure(instance)
b'\x80\x04\x95\x18\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x04Test\x94\x93\x94)\x81\x94.'
```

This also enables converters to be chained.

```python
>>> parent = Converter()

>>> child = Converter(
... unstructure_fallback_factory=parent._unstructure_func.dispatch,
... structure_fallback_factory=parent._structure_func.dispatch,
... )
```

```{note}
`Converter._structure_func.dispatch` and `Converter._unstructure_func.dispatch` are slated to become public APIs in a future release.
```

```{versionadded} 23.2.0

```

## `cattrs.Converter`

The {class}`Converter <cattrs.Converter>` is a converter variant that automatically generates,
compiles and caches specialized structuring and unstructuring hooks for _attrs_
classes and dataclasses.
The {class}`Converter <cattrs.Converter>` is a converter variant that automatically generates, compiles and caches specialized structuring and unstructuring hooks for _attrs_ classes, dataclasses and TypedDicts.

`Converter` differs from the {class}`cattrs.BaseConverter` in the following ways:

Expand All @@ -53,7 +88,5 @@ The `Converter` used to be called `GenConverter`, and that alias is still presen

## `cattrs.BaseConverter`

The {class}`BaseConverter <cattrs.BaseConverter>` is a simpler and slower Converter variant. It does no
code generation, so it may be faster on first-use which can be useful
in specific cases, like CLI applications where startup time is more
important than throughput.
The {class}`BaseConverter <cattrs.BaseConverter>` is a simpler and slower `Converter` variant.
It does no code generation, so it may be faster on first-use which can be useful in specific cases, like CLI applications where startup time is more important than throughput.
2 changes: 1 addition & 1 deletion docs/customizing.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This section deals with customizing the unstructuring and structuring processes

The default {class}`Converter <cattrs.Converter>`, upon first encountering an _attrs_ class, will use the generation functions mentioned here to generate the specialized hooks for it, register the hooks and use them.

## Manual un/structuring hooks
## Manual Un/structuring Hooks

You can write your own structuring and unstructuring functions and register
them for types using {meth}`Converter.register_structure_hook() <cattrs.BaseConverter.register_structure_hook>` and
Expand Down
9 changes: 4 additions & 5 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,9 @@ MyRecord(a_string='test', a_datetime=DateTime(2018, 7, 28, 18, 24, 0, tzinfo=Tim
MyRecord(a_string='test', a_datetime=DateTime(2018, 7, 28, 18, 24, 0, tzinfo=Timezone('+02:00')))
```

## Using factory hooks
## Using Factory Hooks

For this example, let's assume you have some attrs classes with snake case attributes, and you want to
un/structure them as camel case.
For this example, let's assume you have some attrs classes with snake case attributes, and you want to un/structure them as camel case.

```{warning}
A simpler and better approach to this problem is to simply make your class attributes camel case.
Expand Down Expand Up @@ -257,7 +256,7 @@ converter.register_structure_hook_factory(
The `converter` instance will now un/structure every attrs class to camel case.
Nothing has been omitted from this final example; it's complete.

## Using fallback key names
## Using Fallback Key Names

Sometimes when structuring data, the input data may be in multiple formats that need to be converted into a common attribute.

Expand Down Expand Up @@ -305,7 +304,7 @@ class MyInternalAttr:

_cattrs_ will now structure both key names into `new_field` on your class.

```
```python
converter.structure({"new_field": "foo"}, MyInternalAttr)
converter.structure({"old_field": "foo"}, MyInternalAttr)
```
85 changes: 51 additions & 34 deletions src/cattrs/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
Dict,
Iterable,
List,
NoReturn,
Optional,
Tuple,
Type,
Expand Down Expand Up @@ -56,12 +55,13 @@
is_union_type,
)
from .disambiguators import create_default_dis_func, is_supported_union
from .dispatch import MultiStrategyDispatch
from .dispatch import HookFactory, MultiStrategyDispatch, StructureHook, UnstructureHook
from .errors import (
IterableValidationError,
IterableValidationNote,
StructureHandlerNotFoundError,
)
from .fns import identity, raise_error
from .gen import (
AttributeOverride,
DictStructureFn,
Expand All @@ -79,6 +79,8 @@
from .gen.typeddicts import make_dict_structure_fn as make_typeddict_dict_struct_fn
from .gen.typeddicts import make_dict_unstructure_fn as make_typeddict_dict_unstruct_fn

__all__ = ["UnstructureStrategy", "BaseConverter", "Converter", "GenConverter"]

NoneType = type(None)
T = TypeVar("T")
V = TypeVar("V")
Expand Down Expand Up @@ -127,7 +129,20 @@ def __init__(
unstruct_strat: UnstructureStrategy = UnstructureStrategy.AS_DICT,
prefer_attrib_converters: bool = False,
detailed_validation: bool = True,
unstructure_fallback_factory: HookFactory[UnstructureHook] = lambda _: identity,
structure_fallback_factory: HookFactory[StructureHook] = lambda _: raise_error,
) -> None:
"""
:param detailed_validation: Whether to use a slightly slower mode for detailed
validation errors.
:param unstructure_fallback_factory: A hook factory to be called when no
registered unstructuring hooks match.
:param structure_fallback_factory: A hook factory to be called when no
registered structuring hooks match.

.. versionadded:: 23.2.0 *unstructure_fallback_factory*
.. versionadded:: 23.2.0 *structure_fallback_factory*
"""
unstruct_strat = UnstructureStrategy(unstruct_strat)
self._prefer_attrib_converters = prefer_attrib_converters

Expand All @@ -143,13 +158,9 @@ def __init__(

self._dis_func_cache = lru_cache()(self._get_dis_func)

self._unstructure_func = MultiStrategyDispatch(self._unstructure_identity)
self._unstructure_func = MultiStrategyDispatch(unstructure_fallback_factory)
self._unstructure_func.register_cls_list(
[
(bytes, self._unstructure_identity),
(str, self._unstructure_identity),
(Path, str),
]
[(bytes, identity), (str, identity), (Path, str)]
)
self._unstructure_func.register_func_list(
[
Expand All @@ -175,7 +186,7 @@ def __init__(
# Per-instance register of to-attrs converters.
# Singledispatch dispatches based on the first argument, so we
# store the function and switch the arguments in self.loads.
self._structure_func = MultiStrategyDispatch(BaseConverter._structure_error)
self._structure_func = MultiStrategyDispatch(structure_fallback_factory)
self._structure_func.register_func_list(
[
(lambda cl: cl is Any or cl is Optional or cl is None, lambda v, _: v),
Expand Down Expand Up @@ -237,7 +248,7 @@ def unstruct_strat(self) -> UnstructureStrategy:
else UnstructureStrategy.AS_TUPLE
)

def register_unstructure_hook(self, cls: Any, func: Callable[[Any], Any]) -> None:
def register_unstructure_hook(self, cls: Any, func: UnstructureHook) -> None:
"""Register a class-to-primitive converter function for a class.

The converter function should take an instance of the class and return
Expand All @@ -254,17 +265,15 @@ def register_unstructure_hook(self, cls: Any, func: Callable[[Any], Any]) -> Non
self._unstructure_func.register_cls_list([(cls, func)])

def register_unstructure_hook_func(
self, check_func: Callable[[Any], bool], func: Callable[[Any], Any]
self, check_func: Callable[[Any], bool], func: UnstructureHook
) -> None:
"""Register a class-to-primitive converter function for a class, using
a function to check if it's a match.
"""
self._unstructure_func.register_func_list([(check_func, func)])

def register_unstructure_hook_factory(
self,
predicate: Callable[[Any], bool],
factory: Callable[[Any], Callable[[Any], Any]],
self, predicate: Callable[[Any], bool], factory: HookFactory[UnstructureHook]
) -> None:
"""
Register a hook factory for a given predicate.
Expand All @@ -276,9 +285,7 @@ def register_unstructure_hook_factory(
"""
self._unstructure_func.register_func_list([(predicate, factory, True)])

def register_structure_hook(
self, cl: Any, func: Callable[[Any, Type[T]], T]
) -> None:
def register_structure_hook(self, cl: Any, func: StructureHook) -> None:
"""Register a primitive-to-class converter function for a type.

The converter function should take two arguments:
Expand All @@ -300,17 +307,15 @@ def register_structure_hook(
self._structure_func.register_cls_list([(cl, func)])

def register_structure_hook_func(
self, check_func: Callable[[Type[T]], bool], func: Callable[[Any, Type[T]], T]
self, check_func: Callable[[Type[T]], bool], func: StructureHook
) -> None:
"""Register a class-to-primitive converter function for a class, using
a function to check if it's a match.
"""
self._structure_func.register_func_list([(check_func, func)])

def register_structure_hook_factory(
self,
predicate: Callable[[Any], bool],
factory: Callable[[Any], Callable[[Any, Any], Any]],
self, predicate: Callable[[Any], bool], factory: HookFactory[StructureHook]
) -> None:
"""
Register a hook factory for a given predicate.
Expand Down Expand Up @@ -353,11 +358,6 @@ def _unstructure_enum(self, obj: Enum) -> Any:
"""Convert an enum to its value."""
return obj.value

@staticmethod
def _unstructure_identity(obj: T) -> T:
"""Just pass it through."""
return obj

def _unstructure_seq(self, seq: Sequence[T]) -> Sequence[T]:
"""Convert a sequence to primitive equivalents."""
# We can reuse the sequence class, so tuples stay tuples.
Expand Down Expand Up @@ -388,12 +388,6 @@ def _unstructure_union(self, obj: Any) -> Any:

# Python primitives to classes.

@staticmethod
def _structure_error(_, cl: Type) -> NoReturn:
"""At the bottom of the condition stack, we explode if we can't handle it."""
msg = f"Unsupported type: {cl!r}. Register a structure hook for it."
raise StructureHandlerNotFoundError(msg, type_=cl)

def _gen_structure_generic(self, cl: Type[T]) -> DictStructureFn[T]:
"""Create and return a hook for structuring generics."""
return make_dict_structure_fn(
Expand Down Expand Up @@ -742,7 +736,11 @@ def copy(
prefer_attrib_converters: Optional[bool] = None,
detailed_validation: Optional[bool] = None,
) -> "BaseConverter":
"""Create a copy of the converter, keeping all existing custom hooks."""
"""Create a copy of the converter, keeping all existing custom hooks.

:param detailed_validation: Whether to use a slightly slower mode for detailed
validation errors.
"""
res = self.__class__(
dict_factory if dict_factory is not None else self._dict_factory,
unstruct_strat
Expand Down Expand Up @@ -786,12 +784,27 @@ def __init__(
unstruct_collection_overrides: Mapping[Type, Callable] = {},
prefer_attrib_converters: bool = False,
detailed_validation: bool = True,
unstructure_fallback_factory: HookFactory[UnstructureHook] = lambda _: identity,
structure_fallback_factory: HookFactory[StructureHook] = lambda _: raise_error,
):
"""
:param detailed_validation: Whether to use a slightly slower mode for detailed
validation errors.
:param unstructure_fallback_factory: A hook factory to be called when no
registered unstructuring hooks match.
:param structure_fallback_factory: A hook factory to be called when no
registered structuring hooks match.

.. versionadded:: 23.2.0 *unstructure_fallback_factory*
.. versionadded:: 23.2.0 *structure_fallback_factory*
"""
super().__init__(
dict_factory=dict_factory,
unstruct_strat=unstruct_strat,
prefer_attrib_converters=prefer_attrib_converters,
detailed_validation=detailed_validation,
unstructure_fallback_factory=unstructure_fallback_factory,
structure_fallback_factory=structure_fallback_factory,
)
self.omit_if_default = omit_if_default
self.forbid_extra_keys = forbid_extra_keys
Expand Down Expand Up @@ -1042,7 +1055,11 @@ def copy(
prefer_attrib_converters: Optional[bool] = None,
detailed_validation: Optional[bool] = None,
) -> "Converter":
"""Create a copy of the converter, keeping all existing custom hooks."""
"""Create a copy of the converter, keeping all existing custom hooks.

:param detailed_validation: Whether to use a slightly slower mode for detailed
validation errors.
"""
res = self.__class__(
dict_factory if dict_factory is not None else self._dict_factory,
unstruct_strat
Expand Down
Loading