diff --git a/HISTORY.md b/HISTORY.md index f88ecc06..ed2c54ff 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -19,6 +19,11 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ([#486](https://github.com/python-attrs/cattrs/pull/486)) - Introduce {meth}`BaseConverter.get_structure_hook` and {meth}`BaseConverter.get_unstructure_hook` methods. ([#432](https://github.com/python-attrs/cattrs/issues/432) [#472](https://github.com/python-attrs/cattrs/pull/472)) +- {meth}`BaseConverter.register_structure_hook`, {meth}`BaseConverter.register_unstructure_hook`, +{meth}`BaseConverter.register_unstructure_hook_factory` and {meth}`BaseConverter.register_structure_hook_factory` +can now be used as decorators and have gained new features when used this way. + See [here](https://catt.rs/en/latest/customizing.html#use-as-decorators) and [here](https://catt.rs/en/latest/customizing.html#id1) for more details. + ([#487](https://github.com/python-attrs/cattrs/pull/487)) - Introduce the [_msgspec_](https://jcristharif.com/msgspec/) {mod}`preconf converter `. Only JSON is supported for now, with other formats supported by _msgspec_ to come later. ([#481](https://github.com/python-attrs/cattrs/pull/481)) diff --git a/docs/basics.md b/docs/basics.md index cf978de6..f40559ab 100644 --- a/docs/basics.md +++ b/docs/basics.md @@ -63,7 +63,7 @@ A base hook can be obtained from a converter and then be subjected to the very r ... return result ``` -(`cattrs.structure({}, Model)` is shorthand for `cattrs.get_structure_hook(Model)({}, Model)`.) +(`cattrs.structure({}, Model)` is equivalent to `cattrs.get_structure_hook(Model)({}, Model)`.) This new hook can be used directly or registered to a converter (the original instance, or a different one): @@ -72,7 +72,7 @@ This new hook can be used directly or registered to a converter (the original in ``` -Now if we use this hook to structure a `Model`, through the ✨magic of function composition✨ that hook will use our old `int_hook`. +Now if we use this hook to structure a `Model`, through ✨the magic of function composition✨ that hook will use our old `int_hook`. ```python >>> converter.structure({"a": "1"}, Model) diff --git a/docs/conf.py b/docs/conf.py index 5badbac3..9643037a 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -40,7 +40,6 @@ "sphinx.ext.autodoc", "sphinx.ext.viewcode", "sphinx.ext.doctest", - "sphinx.ext.autosectionlabel", "sphinx_copybutton", "myst_parser", ] diff --git a/docs/customizing.md b/docs/customizing.md index e10a9743..475acaa8 100644 --- a/docs/customizing.md +++ b/docs/customizing.md @@ -17,12 +17,44 @@ Some examples of this are: * protocols, unless they are `runtime_checkable` * various modifiers, such as `Final` and `NotRequired` * newtypes and 3.12 type aliases +* `typing.Annotated` ... and many others. In these cases, predicate functions should be used instead. +### Use as Decorators + +{meth}`register_structure_hook() ` and {meth}`register_unstructure_hook() ` can also be used as _decorators_. +When used this way they behave a little differently. + +{meth}`register_structure_hook() ` will inspect the return type of the hook and register the hook for that type. + +```python +@converter.register_structure_hook +def my_int_hook(val: Any, _) -> int: + """This hook will be registered for `int`s.""" + return int(val) +``` + +{meth}`register_unstructure_hook() ` will inspect the type of the first argument and register the hook for that type. + +```python +from datetime import datetime + +@converter.register_unstructure_hook +def my_datetime_hook(val: datetime) -> str: + """This hook will be registered for `datetime`s.""" + return val.isoformat() +``` + +The non-decorator approach is still recommended when dealing with lambdas, hooks produced elsewhere, unannotated hooks and situations where type introspection doesn't work. + +```{versionadded} 24.1.0 +``` + ### Predicate Hooks -A predicate is a function that takes a type and returns true or false, depending on whether the associated hook can handle the given type. +A _predicate_ is a function that takes a type and returns true or false +depending on whether the associated hook can handle the given type. The {meth}`register_unstructure_hook_func() ` and {meth}`register_structure_hook_func() ` are used to link un/structuring hooks to arbitrary types. These hooks are then called _predicate hooks_, and are very powerful. @@ -64,9 +96,11 @@ Here's an example showing how to use hook factories to apply the `forbid_extra_k ```python >>> from attrs import define, has +>>> from cattrs import Converter >>> from cattrs.gen import make_dict_structure_fn ->>> c = cattrs.Converter() +>>> c = Converter() + >>> c.register_structure_hook_factory( ... has, ... lambda cl: make_dict_structure_fn(cl, c, _cattrs_forbid_extra_keys=True) @@ -82,8 +116,44 @@ Traceback (most recent call last): cattrs.errors.ForbiddenExtraKeysError: Extra fields in constructor for E: else ``` -A complex use case for hook factories is described over at {ref}`usage:Using factory hooks`. +A complex use case for hook factories is described over at [](usage.md#using-factory-hooks). + +#### Use as Decorators + +{meth}`register_unstructure_hook_factory() ` and +{meth}`register_structure_hook_factory() ` can also be used as decorators. + +When registered via decorators, hook factories can receive the current converter by exposing an additional required parameter. + +Here's an example of using an unstructure hook factory to handle unstructuring [queues](https://docs.python.org/3/library/queue.html#queue.Queue). + +```{doctest} +>>> from queue import Queue +>>> from typing import get_origin +>>> from cattrs import Converter + +>>> c = Converter() +>>> @c.register_unstructure_hook_factory(lambda t: get_origin(t) is Queue) +... def queue_hook_factory(cl: Any, converter: Converter) -> Callable: +... type_arg = get_args(cl)[0] +... elem_handler = converter.get_unstructure_hook(type_arg) +... +... def unstructure_hook(v: Queue) -> list: +... res = [] +... while not v.empty(): +... res.append(elem_handler(v.get_nowait())) +... return res +... +... return unstructure_hook + +>>> q = Queue() +>>> q.put(1) +>>> q.put(2) + +>>> c.unstructure(q, unstructure_as=Queue[int]) +[1, 2] +``` ## Using `cattrs.gen` Generators diff --git a/pyproject.toml b/pyproject.toml index 9f3530ab..682d179f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,6 +112,11 @@ source = [ ".tox/pypy*/site-packages", ] +[tool.coverage.report] +exclude_also = [ + "@overload", +] + [tool.ruff] src = ["src", "tests"] select = [ diff --git a/src/cattrs/_compat.py b/src/cattrs/_compat.py index 0e010eda..8493f0c9 100644 --- a/src/cattrs/_compat.py +++ b/src/cattrs/_compat.py @@ -4,6 +4,8 @@ from collections.abc import Set as AbcSet from dataclasses import MISSING, Field, is_dataclass from dataclasses import fields as dataclass_fields +from functools import partial +from inspect import signature as _signature from typing import AbstractSet as TypingAbstractSet from typing import ( Any, @@ -211,6 +213,11 @@ def get_final_base(type) -> Optional[type]: OriginAbstractSet = AbcSet OriginMutableSet = AbcMutableSet +signature = _signature + +if sys.version_info >= (3, 10): + signature = partial(_signature, eval_str=True) + if sys.version_info >= (3, 9): from collections import Counter from collections.abc import Mapping as AbcMapping diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 5e7dcc61..7df20632 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -4,8 +4,9 @@ from collections.abc import MutableSet as AbcMutableSet from dataclasses import Field from enum import Enum +from inspect import Signature from pathlib import Path -from typing import Any, Callable, Iterable, Optional, Tuple, TypeVar +from typing import Any, Callable, Iterable, Optional, Tuple, TypeVar, overload from attrs import Attribute, resolve_types from attrs import has as attrs_has @@ -46,6 +47,7 @@ is_type_alias, is_typeddict, is_union_type, + signature, ) from .disambiguators import create_default_dis_func, is_supported_union from .dispatch import ( @@ -53,6 +55,7 @@ MultiStrategyDispatch, StructuredValue, StructureHook, + TargetType, UnstructuredValue, UnstructureHook, ) @@ -84,6 +87,24 @@ T = TypeVar("T") V = TypeVar("V") +UnstructureHookFactory = TypeVar( + "UnstructureHookFactory", bound=HookFactory[UnstructureHook] +) + +# The Extended factory also takes a converter. +ExtendedUnstructureHookFactory = TypeVar( + "ExtendedUnstructureHookFactory", + bound=Callable[[TargetType, "BaseConverter"], UnstructureHook], +) + +StructureHookFactory = TypeVar("StructureHookFactory", bound=HookFactory[StructureHook]) + +# The Extended factory also takes a converter. +ExtendedStructureHookFactory = TypeVar( + "ExtendedStructureHookFactory", + bound=Callable[[TargetType, "BaseConverter"], StructureHook], +) + class UnstructureStrategy(Enum): """`attrs` classes unstructuring strategies.""" @@ -145,7 +166,9 @@ def __init__( self._unstructure_attrs = self.unstructure_attrs_astuple self._structure_attrs = self.structure_attrs_fromtuple - self._unstructure_func = MultiStrategyDispatch(unstructure_fallback_factory) + self._unstructure_func = MultiStrategyDispatch( + unstructure_fallback_factory, self + ) self._unstructure_func.register_cls_list( [(bytes, identity), (str, identity), (Path, str)] ) @@ -157,12 +180,12 @@ def __init__( ), ( lambda t: get_final_base(t) is not None, - lambda t: self._unstructure_func.dispatch(get_final_base(t)), + lambda t: self.get_unstructure_hook(get_final_base(t)), True, ), ( is_type_alias, - lambda t: self._unstructure_func.dispatch(get_type_alias_base(t)), + lambda t: self.get_unstructure_hook(get_type_alias_base(t)), True, ), (is_mapping, self._unstructure_mapping), @@ -179,7 +202,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(structure_fallback_factory) + self._structure_func = MultiStrategyDispatch(structure_fallback_factory, self) self._structure_func.register_func_list( [ ( @@ -245,12 +268,38 @@ def unstruct_strat(self) -> UnstructureStrategy: else UnstructureStrategy.AS_TUPLE ) + @overload + def register_unstructure_hook(self) -> Callable[[UnstructureHook], None]: + ... + + @overload def register_unstructure_hook(self, cls: Any, func: UnstructureHook) -> None: + ... + + def register_unstructure_hook( + self, cls: Any = None, func: UnstructureHook | None = None + ) -> Callable[[UnstructureHook]] | None: """Register a class-to-primitive converter function for a class. The converter function should take an instance of the class and return its Python equivalent. + + May also be used as a decorator. When used as a decorator, the first + argument annotation from the decorated function will be used as the + type to register the hook for. + + .. versionchanged:: 24.1.0 + This method may now be used as a decorator. """ + if func is None: + # Autodetecting decorator. + func = cls + sig = signature(func) + cls = next(iter(sig.parameters.values())).annotation + self.register_unstructure_hook(cls, func) + + return None + if attrs_has(cls): resolve_types(cls) if is_union_type(cls): @@ -260,6 +309,7 @@ def register_unstructure_hook(self, cls: Any, func: UnstructureHook) -> None: self._unstructure_func.register_func_list([(lambda t: t is cls, func)]) else: self._unstructure_func.register_cls_list([(cls, func)]) + return None def register_unstructure_hook_func( self, check_func: Callable[[Any], bool], func: UnstructureHook @@ -269,18 +319,68 @@ def register_unstructure_hook_func( """ self._unstructure_func.register_func_list([(check_func, func)]) + @overload def register_unstructure_hook_factory( - self, predicate: Callable[[Any], bool], factory: HookFactory[UnstructureHook] - ) -> None: + self, predicate: Callable[[Any], bool] + ) -> Callable[[UnstructureHookFactory], UnstructureHookFactory]: + ... + + @overload + def register_unstructure_hook_factory( + self, predicate: Callable[[Any], bool] + ) -> Callable[[ExtendedUnstructureHookFactory], ExtendedUnstructureHookFactory]: + ... + + @overload + def register_unstructure_hook_factory( + self, predicate: Callable[[Any], bool], factory: UnstructureHookFactory + ) -> UnstructureHookFactory: + ... + + def register_unstructure_hook_factory( + self, + predicate: Callable[[Any], bool], + factory: UnstructureHookFactory | None = None, + ) -> ( + Callable[[UnstructureHookFactory], UnstructureHookFactory] + | UnstructureHookFactory + ): """ Register a hook factory for a given predicate. + May also be used as a decorator. When used as a decorator, the hook + factory may expose an additional required parameter. In this case, + the current converter will be provided to the hook factory as that + parameter. + :param predicate: A function that, given a type, returns whether the factory can produce a hook for that type. :param factory: A callable that, given a type, produces an unstructuring hook for that type. This unstructuring hook will be cached. + + .. versionchanged:: 24.1.0 + This method may now be used as a decorator. """ + if factory is None: + + def decorator(factory): + # Is this an extended factory (takes a converter too)? + sig = signature(factory) + if ( + len(sig.parameters) >= 2 + and (list(sig.parameters.values())[1]).default is Signature.empty + ): + self._unstructure_func.register_func_list( + [(predicate, factory, "extended")] + ) + else: + self._unstructure_func.register_func_list( + [(predicate, factory, True)] + ) + + return decorator self._unstructure_func.register_func_list([(predicate, factory, True)]) + return factory def get_unstructure_hook( self, type: Any, cache_result: bool = True @@ -303,7 +403,17 @@ def get_unstructure_hook( else self._unstructure_func.dispatch_without_caching(type) ) - def register_structure_hook(self, cl: Any, func: StructureHook) -> None: + @overload + def register_structure_hook(self) -> Callable[[StructureHook], None]: + ... + + @overload + def register_structure_hook(self, cl: Any, func: StructuredValue) -> None: + ... + + def register_structure_hook( + self, cl: Any, func: StructureHook | None = None + ) -> None: """Register a primitive-to-class converter function for a type. The converter function should take two arguments: @@ -312,7 +422,21 @@ def register_structure_hook(self, cl: Any, func: StructureHook) -> None: and return the instance of the class. The type may seem redundant, but is sometimes needed (for example, when dealing with generic classes). + + This method may be used as a decorator. In this case, the decorated + hook must have a return type annotation, and this annotation will be used + as the type for the hook. + + .. versionchanged:: 24.1.0 + This method may now be used as a decorator. """ + if func is None: + # The autodetecting decorator. + func = cl + sig = signature(func) + self.register_structure_hook(sig.return_annotation, func) + return + if attrs_has(cl): resolve_types(cl) if is_union_type(cl): @@ -332,18 +456,65 @@ def register_structure_hook_func( """ self._structure_func.register_func_list([(check_func, func)]) + @overload def register_structure_hook_factory( - self, predicate: Callable[[Any], bool], factory: HookFactory[StructureHook] - ) -> None: + self, predicate: Callable[[Any, bool]] + ) -> Callable[[StructureHookFactory, StructureHookFactory]]: + ... + + @overload + def register_structure_hook_factory( + self, predicate: Callable[[Any, bool]] + ) -> Callable[[ExtendedStructureHookFactory, ExtendedStructureHookFactory]]: + ... + + @overload + def register_structure_hook_factory( + self, predicate: Callable[[Any], bool], factory: StructureHookFactory + ) -> StructureHookFactory: + ... + + def register_structure_hook_factory( + self, + predicate: Callable[[Any], bool], + factory: HookFactory[StructureHook] | None = None, + ) -> Callable[[StructureHookFactory, StructureHookFactory]] | StructureHookFactory: """ Register a hook factory for a given predicate. + May also be used as a decorator. When used as a decorator, the hook + factory may expose an additional required parameter. In this case, + the current converter will be provided to the hook factory as that + parameter. + :param predicate: A function that, given a type, returns whether the factory can produce a hook for that type. :param factory: A callable that, given a type, produces a structuring hook for that type. This structuring hook will be cached. + + .. versionchanged:: 24.1.0 + This method may now be used as a decorator. """ + if factory is None: + # Decorator use. + def decorator(factory): + # Is this an extended factory (takes a converter too)? + sig = signature(factory) + if ( + len(sig.parameters) >= 2 + and (list(sig.parameters.values())[1]).default is Signature.empty + ): + self._structure_func.register_func_list( + [(predicate, factory, "extended")] + ) + else: + self._structure_func.register_func_list( + [(predicate, factory, True)] + ) + + return decorator self._structure_func.register_func_list([(predicate, factory, True)]) + return factory def structure(self, obj: UnstructuredValue, cl: type[T]) -> T: """Convert unstructured Python data structures to structured data.""" @@ -580,7 +751,7 @@ def _structure_list(self, obj: Iterable[T], cl: Any) -> list[T]: def _structure_deque(self, obj: Iterable[T], cl: Any) -> deque[T]: """Convert an iterable to a potentially generic deque.""" if is_bare(cl) or cl.__args__[0] in ANIES: - res = deque(e for e in obj) + res = deque(obj) else: elem_type = cl.__args__[0] handler = self._structure_func.dispatch(elem_type) @@ -944,7 +1115,7 @@ def __init__( ) self.register_unstructure_hook_factory( lambda t: get_newtype_base(t) is not None, - lambda t: self._unstructure_func.dispatch(get_newtype_base(t)), + lambda t: self.get_unstructure_hook(get_newtype_base(t)), ) self.register_structure_hook_factory(is_annotated, self.gen_structure_annotated) @@ -966,7 +1137,7 @@ def get_structure_newtype(self, type: type[T]) -> Callable[[Any, Any], T]: def gen_unstructure_annotated(self, type): origin = type.__origin__ - return self._unstructure_func.dispatch(origin) + return self.get_unstructure_hook(origin) def gen_structure_annotated(self, type) -> Callable: """A hook factory for annotated types.""" @@ -1007,7 +1178,7 @@ def gen_unstructure_optional(self, cl: type[T]) -> Callable[[T], Any]: if isinstance(other, TypeVar): handler = self.unstructure else: - handler = self._unstructure_func.dispatch(other) + handler = self.get_unstructure_hook(other) def unstructure_optional(val, _handler=handler): return None if val is None else _handler(val) diff --git a/src/cattrs/dispatch.py b/src/cattrs/dispatch.py index fe3ceba8..e72f8bb9 100644 --- a/src/cattrs/dispatch.py +++ b/src/cattrs/dispatch.py @@ -1,10 +1,15 @@ -from functools import lru_cache, partial, singledispatch -from typing import Any, Callable, Dict, Generic, List, Optional, Tuple, TypeVar, Union +from __future__ import annotations -from attrs import Factory, define, field +from functools import lru_cache, singledispatch +from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, TypeVar + +from attrs import Factory, define from cattrs._compat import TypeAlias +if TYPE_CHECKING: + from .converters import BaseConverter + T = TypeVar("T") TargetType: TypeAlias = Any @@ -33,23 +38,25 @@ class FunctionDispatch: objects that help determine dispatch should be instantiated objects. """ - _handler_pairs: List[ - Tuple[Callable[[Any], bool], Callable[[Any, Any], Any], bool] + _converter: BaseConverter + _handler_pairs: list[ + tuple[Callable[[Any], bool], Callable[[Any, Any], Any], bool, bool] ] = Factory(list) def register( self, - can_handle: Callable[[Any], bool], + predicate: Callable[[Any], bool], func: Callable[..., Any], is_generator=False, + takes_converter=False, ) -> None: - self._handler_pairs.insert(0, (can_handle, func, is_generator)) + self._handler_pairs.insert(0, (predicate, func, is_generator, takes_converter)) - def dispatch(self, typ: Any) -> Optional[Callable[..., Any]]: + def dispatch(self, typ: Any) -> Callable[..., Any] | None: """ Return the appropriate handler for the object passed. """ - for can_handle, handler, is_generator in self._handler_pairs: + for can_handle, handler, is_generator, takes_converter in self._handler_pairs: # can handle could raise an exception here # such as issubclass being called on an instance. # it's easier to just ignore that case. @@ -59,6 +66,8 @@ def dispatch(self, typ: Any) -> Optional[Callable[..., Any]]: continue if ch: if is_generator: + if takes_converter: + return handler(typ, self._converter) return handler(typ) return handler @@ -67,11 +76,11 @@ def dispatch(self, typ: Any) -> Optional[Callable[..., Any]]: def get_num_fns(self) -> int: return len(self._handler_pairs) - def copy_to(self, other: "FunctionDispatch", skip: int = 0) -> None: + def copy_to(self, other: FunctionDispatch, skip: int = 0) -> None: other._handler_pairs = self._handler_pairs[:-skip] + other._handler_pairs -@define +@define(init=False) class MultiStrategyDispatch(Generic[Hook]): """ MultiStrategyDispatch uses a combination of exact-match dispatch, @@ -85,18 +94,20 @@ class MultiStrategyDispatch(Generic[Hook]): """ _fallback_factory: HookFactory[Hook] - _direct_dispatch: Dict[TargetType, Hook] = field(init=False, factory=dict) - _function_dispatch: FunctionDispatch = field(init=False, factory=FunctionDispatch) - _single_dispatch: Any = field( - init=False, factory=partial(singledispatch, _DispatchNotFound) - ) - dispatch: Callable[[TargetType], Hook] = field( - init=False, - default=Factory( - lambda self: lru_cache(maxsize=None)(self.dispatch_without_caching), - takes_self=True, - ), - ) + _converter: BaseConverter + _direct_dispatch: dict[TargetType, Hook] + _function_dispatch: FunctionDispatch + _single_dispatch: Any + dispatch: Callable[[TargetType, BaseConverter], Hook] + + def __init__( + self, fallback_factory: HookFactory[Hook], converter: BaseConverter + ) -> None: + self._fallback_factory = fallback_factory + self._direct_dispatch = {} + self._function_dispatch = FunctionDispatch(converter) + self._single_dispatch = singledispatch(_DispatchNotFound) + self.dispatch = lru_cache(maxsize=None)(self.dispatch_without_caching) def dispatch_without_caching(self, typ: TargetType) -> Hook: """Dispatch on the type but without caching the result.""" @@ -126,15 +137,18 @@ def register_cls_list(self, cls_and_handler, direct: bool = False) -> None: def register_func_list( self, - pred_and_handler: List[ - Union[ - Tuple[Callable[[Any], bool], Any], - Tuple[Callable[[Any], bool], Any, bool], + pred_and_handler: list[ + tuple[Callable[[Any], bool], Any] + | tuple[Callable[[Any], bool], Any, bool] + | tuple[ + Callable[[Any], bool], + Callable[[Any, BaseConverter], Any], + Literal["extended"], ] ], ): """ - Register a predicate function to determine if the handle + Register a predicate function to determine if the handler should be used for the type. """ for tup in pred_and_handler: @@ -143,7 +157,12 @@ def register_func_list( self._function_dispatch.register(func, handler) else: func, handler, is_gen = tup - self._function_dispatch.register(func, handler, is_generator=is_gen) + if is_gen == "extended": + self._function_dispatch.register( + func, handler, is_generator=is_gen, takes_converter=True + ) + else: + self._function_dispatch.register(func, handler, is_generator=is_gen) self.clear_direct() self.dispatch.cache_clear() @@ -159,7 +178,7 @@ def clear_cache(self) -> None: def get_num_fns(self) -> int: return self._function_dispatch.get_num_fns() - def copy_to(self, other: "MultiStrategyDispatch", skip: int = 0) -> None: + def copy_to(self, other: MultiStrategyDispatch, skip: int = 0) -> None: self._function_dispatch.copy_to(other._function_dispatch, skip=skip) for cls, fn in self._single_dispatch.registry.items(): other._single_dispatch.register(cls, fn) diff --git a/src/cattrs/gen/typeddicts.py b/src/cattrs/gen/typeddicts.py index f77c0a86..99bc786d 100644 --- a/src/cattrs/gen/typeddicts.py +++ b/src/cattrs/gen/typeddicts.py @@ -139,7 +139,7 @@ def make_dict_unstructure_fn( if nrb is not NOTHING: t = nrb try: - handler = converter._unstructure_func.dispatch(t) + handler = converter.get_unstructure_hook(t) except RecursionError: # There's a circular reference somewhere down the line handler = converter.unstructure @@ -185,7 +185,7 @@ def make_dict_unstructure_fn( if nrb is not NOTHING: t = nrb try: - handler = converter._unstructure_func.dispatch(t) + handler = converter.get_unstructure_hook(t) except RecursionError: # There's a circular reference somewhere down the line handler = converter.unstructure diff --git a/src/cattrs/preconf/orjson.py b/src/cattrs/preconf/orjson.py index 8df76a78..f913dd8f 100644 --- a/src/cattrs/preconf/orjson.py +++ b/src/cattrs/preconf/orjson.py @@ -56,7 +56,7 @@ def key_handler(v): # (For example base85 encoding for bytes.) # In that case, we want to use the override. - kh = converter._unstructure_func.dispatch(args[0]) + kh = converter.get_unstructure_hook(args[0]) if kh != identity: key_handler = kh diff --git a/tests/strategies/test_native_unions.py b/tests/strategies/test_native_unions.py index a7c5ca61..cbe0b7e8 100644 --- a/tests/strategies/test_native_unions.py +++ b/tests/strategies/test_native_unions.py @@ -58,7 +58,7 @@ def test_skip_optionals() -> None: configure_union_passthrough(Union[int, str, None], c) - h = c._structure_func.dispatch(Optional[int]) + h = c.get_structure_hook(Optional[int]) assert h.__name__ != "structure_native_union" diff --git a/tests/test_converter.py b/tests/test_converter.py index 65ee8496..526464bd 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -1,6 +1,7 @@ """Test both structuring and unstructuring.""" from collections import deque from typing import ( + Any, Deque, FrozenSet, List, @@ -14,11 +15,12 @@ ) import pytest -from attrs import Factory, define, fields, make_class +from attrs import Factory, define, fields, has, make_class from hypothesis import HealthCheck, assume, given, settings from hypothesis.strategies import booleans, just, lists, one_of, sampled_from from cattrs import BaseConverter, Converter, UnstructureStrategy +from cattrs.dispatch import StructureHook, UnstructureHook from cattrs.errors import ( ClassValidationError, ForbiddenExtraKeysError, @@ -783,3 +785,74 @@ class Test: structure = converter.get_structure_hook(Test) assert structure({"a": 1}, Test) == Test(1) + + +def test_decorators(converter: BaseConverter): + """The decorator versions work.""" + + @define + class Test: + a: int + + @converter.register_unstructure_hook + def my_hook(value: Test) -> dict: + res = {"a": value.a} + + res["a"] += 1 + + return res + + assert converter.unstructure(Test(1)) == {"a": 2} + + @converter.register_structure_hook + def my_structure_hook(value, _) -> Test: + value["a"] += 1 + return Test(**value) + + assert converter.structure({"a": 5}, Test) == Test(6) + + +def test_hook_factory_decorators(converter: BaseConverter): + """Hook factory decorators work.""" + + @define + class Test: + a: int + + @converter.register_unstructure_hook_factory(has) + def my_hook_factory(type: Any) -> UnstructureHook: + return lambda v: v.a + + assert converter.unstructure(Test(1)) == 1 + + @converter.register_structure_hook_factory(has) + def my_structure_hook_factory(type: Any) -> StructureHook: + return lambda v, _: Test(v) + + assert converter.structure(1, Test) == Test(1) + + +def test_hook_factory_decorators_with_converters(converter: BaseConverter): + """Hook factory decorators with converters work.""" + + @define + class Test: + a: int + + converter.register_unstructure_hook(int, lambda v: v + 1) + + @converter.register_unstructure_hook_factory(has) + def my_hook_factory(type: Any, converter: BaseConverter) -> UnstructureHook: + int_handler = converter.get_unstructure_hook(int) + return lambda v: (int_handler(v.a),) + + assert converter.unstructure(Test(1)) == (2,) + + converter.register_structure_hook(int, lambda v: v - 1) + + @converter.register_structure_hook_factory(has) + def my_structure_hook_factory(type: Any, converter: BaseConverter) -> StructureHook: + int_handler = converter.get_structure_hook(int) + return lambda v, _: Test(int_handler(v[0])) + + assert converter.structure((2,), Test) == Test(1) diff --git a/tests/test_function_dispatch.py b/tests/test_function_dispatch.py index 7485d726..4641443d 100644 --- a/tests/test_function_dispatch.py +++ b/tests/test_function_dispatch.py @@ -1,8 +1,9 @@ +from cattrs import BaseConverter from cattrs.dispatch import FunctionDispatch def test_function_dispatch(): - dispatch = FunctionDispatch() + dispatch = FunctionDispatch(BaseConverter()) assert dispatch.dispatch(float) is None @@ -14,7 +15,7 @@ def test_function_dispatch(): def test_function_clears_cache_after_function_added(): - dispatch = FunctionDispatch() + dispatch = FunctionDispatch(BaseConverter()) class Foo: pass diff --git a/tests/test_multistrategy_dispatch.py b/tests/test_multistrategy_dispatch.py index 6d9c3499..f36f4445 100644 --- a/tests/test_multistrategy_dispatch.py +++ b/tests/test_multistrategy_dispatch.py @@ -1,3 +1,4 @@ +from cattrs import BaseConverter from cattrs.dispatch import MultiStrategyDispatch @@ -17,18 +18,21 @@ def _foo_cls(): pass +c = BaseConverter() + + def test_multistrategy_dispatch_register_cls(): _fallback() _foo_func() _foo_cls() - dispatch = MultiStrategyDispatch(lambda _: _fallback) + dispatch = MultiStrategyDispatch(lambda _: _fallback, c) assert dispatch.dispatch(Foo) == _fallback dispatch.register_cls_list([(Foo, _foo_cls)]) assert dispatch.dispatch(Foo) == _foo_cls def test_multistrategy_dispatch_register_func(): - dispatch = MultiStrategyDispatch(lambda _: _fallback) + dispatch = MultiStrategyDispatch(lambda _: _fallback, c) assert dispatch.dispatch(Foo) == _fallback dispatch.register_func_list([(lambda cls: issubclass(cls, Foo), _foo_func)]) assert dispatch.dispatch(Foo) == _foo_func @@ -40,7 +44,7 @@ def test_multistrategy_dispatch_conflict_class_wins(): are registered which handle the same type, the class dispatch should return. """ - dispatch = MultiStrategyDispatch(lambda _: _fallback) + dispatch = MultiStrategyDispatch(lambda _: _fallback, c) dispatch.register_func_list([(lambda cls: issubclass(cls, Foo), _foo_func)]) dispatch.register_cls_list([(Foo, _foo_cls)]) assert dispatch.dispatch(Foo) == _foo_cls