diff --git a/HISTORY.rst b/HISTORY.rst index 8b876de0..93de9e65 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -19,6 +19,8 @@ History * Fix `Converter.register_structure_hook_factory` and `cattrs.gen.make_dict_unstructure_fn` type annotations. (`#281 `_) * Expose all error classes in the `cattr.errors` namespace. Note that it is deprecated, just use `cattrs.errors`. (`#252 `_) +* ``cattrs.Converter`` and ``cattrs.BaseConverter`` can now copy themselves using the ``copy`` method. + (`#284 `_) 22.1.0 (2022-04-03) ------------------- diff --git a/docs/converters.rst b/docs/converters.rst index b87e1c89..47d522fc 100644 --- a/docs/converters.rst +++ b/docs/converters.rst @@ -2,7 +2,7 @@ Converters ========== -All ``cattrs`` functionality is exposed through a ``cattrs.Converter`` object. +All ``cattrs`` functionality is exposed through a :py:class:`cattrs.Converter` object. Global ``cattrs`` functions, such as ``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 @@ -38,6 +38,9 @@ Currently, a converter contains the following state: * a ``dict_factory`` callable, used for creating ``dicts`` when dumping ``attrs`` classes using ``AS_DICT``. +Converters may be cloned using the :py:attr:`cattrs.Converter.copy` method. +The new copy may be changed through the `copy` arguments, but will retain all manually registered hooks from the original. + ``cattrs.Converter`` -------------------- diff --git a/docs/index.rst b/docs/index.rst index 1160093c..6eda12a4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -13,8 +13,8 @@ Contents: readme installation - usage converters + usage structuring unstructuring validation diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 6dd4d3e9..17454032 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -146,7 +146,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(self._structure_error) + self._structure_func = MultiStrategyDispatch(BaseConverter._structure_error) self._structure_func.register_func_list( [ (lambda cl: cl is Any or cl is Optional or cl is None, lambda v, _: v), @@ -309,7 +309,8 @@ def _unstructure_enum(self, obj): """Convert an enum to its value.""" return obj.value - def _unstructure_identity(self, obj): + @staticmethod + def _unstructure_identity(obj): """Just pass it through.""" return obj @@ -340,7 +341,8 @@ def _unstructure_union(self, obj): # Python primitives to classes. - def _structure_error(self, _, cl): + @staticmethod + def _structure_error(_, cl): """At the bottom of the condition stack, we explode if we can't handle it.""" msg = "Unsupported type: {0!r}. Register a structure hook for " "it.".format(cl) raise StructureHandlerNotFoundError(msg, type_=cl) @@ -615,6 +617,38 @@ def _get_dis_func(union) -> Callable[..., Type]: ) return create_uniq_field_dis_func(*union_types) + def __deepcopy__(self, _) -> "BaseConverter": + return self.copy() + + def copy( + self, + dict_factory: Optional[Callable[[], Any]] = None, + unstruct_strat: Optional[UnstructureStrategy] = None, + prefer_attrib_converters: Optional[bool] = None, + detailed_validation: Optional[bool] = None, + ) -> "BaseConverter": + res = self.__class__( + dict_factory if dict_factory is not None else self._dict_factory, + unstruct_strat + if unstruct_strat is not None + else ( + UnstructureStrategy.AS_DICT + if self._unstructure_attrs == self.unstructure_attrs_asdict + else UnstructureStrategy.AS_TUPLE + ), + prefer_attrib_converters + if prefer_attrib_converters is not None + else self._prefer_attrib_converters, + detailed_validation + if detailed_validation is not None + else self.detailed_validation, + ) + + self._unstructure_func.copy_to(res._unstructure_func) + self._structure_func.copy_to(res._structure_func) + + return res + class Converter(BaseConverter): """A converter which generates specialized un/structuring functions.""" @@ -624,6 +658,8 @@ class Converter(BaseConverter): "forbid_extra_keys", "type_overrides", "_unstruct_collection_overrides", + "_struct_copy_skip", + "_unstruct_copy_skip", ) def __init__( @@ -736,6 +772,10 @@ def __init__( lambda t: get_newtype_base(t) is not None, self.get_structure_newtype ) + # We keep these so we can more correctly copy the hooks. + self._struct_copy_skip = self._structure_func.get_num_fns() + self._unstruct_copy_skip = self._unstructure_func.get_num_fns() + def get_structure_newtype(self, type: Type[T]) -> Callable[[Any, Any], T]: base = get_newtype_base(type) return self._structure_func.dispatch(base) @@ -836,5 +876,48 @@ def gen_structure_mapping(self, cl: Any): self._structure_func.register_cls_list([(cl, h)], direct=True) return h + def copy( + self, + dict_factory: Optional[Callable[[], Any]] = None, + unstruct_strat: Optional[UnstructureStrategy] = None, + omit_if_default: Optional[bool] = None, + forbid_extra_keys: Optional[bool] = None, + type_overrides: Optional[Mapping[Type, AttributeOverride]] = None, + unstruct_collection_overrides: Optional[Mapping[Type, Callable]] = None, + prefer_attrib_converters: Optional[bool] = None, + detailed_validation: Optional[bool] = None, + ) -> "Converter": + res = self.__class__( + dict_factory if dict_factory is not None else self._dict_factory, + unstruct_strat + if unstruct_strat is not None + else ( + UnstructureStrategy.AS_DICT + if self._unstructure_attrs == self.unstructure_attrs_asdict + else UnstructureStrategy.AS_TUPLE + ), + omit_if_default if omit_if_default is not None else self.omit_if_default, + forbid_extra_keys + if forbid_extra_keys is not None + else self.forbid_extra_keys, + type_overrides if type_overrides is not None else self.type_overrides, + unstruct_collection_overrides + if unstruct_collection_overrides is not None + else self._unstruct_collection_overrides, + prefer_attrib_converters + if prefer_attrib_converters is not None + else self._prefer_attrib_converters, + detailed_validation + if detailed_validation is not None + else self.detailed_validation, + ) + + self._unstructure_func.copy_to( + res._unstructure_func, skip=self._unstruct_copy_skip + ) + self._structure_func.copy_to(res._structure_func, skip=self._struct_copy_skip) + + return res + GenConverter = Converter diff --git a/src/cattrs/dispatch.py b/src/cattrs/dispatch.py index 42e6719e..b131b63b 100644 --- a/src/cattrs/dispatch.py +++ b/src/cattrs/dispatch.py @@ -48,7 +48,7 @@ def _dispatch(self, cl): return self._function_dispatch.dispatch(cl) - def register_cls_list(self, cls_and_handler, direct: bool = False): + def register_cls_list(self, cls_and_handler, direct: bool = False) -> None: """Register a class to direct or singledispatch.""" for cls, handler in cls_and_handler: if direct: @@ -89,6 +89,14 @@ def clear_cache(self): self._direct_dispatch.clear() self.dispatch.cache_clear() + def get_num_fns(self) -> int: + return self._function_dispatch.get_num_fns() + + def copy_to(self, other: "MultiStrategyDispatch", skip: int = 0): + 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) + @attr.s(slots=True) class FunctionDispatch: @@ -125,3 +133,9 @@ def dispatch(self, typ): raise StructureHandlerNotFoundError( f"unable to find handler for {typ}", type_=typ ) + + def get_num_fns(self) -> int: + return len(self._handler_pairs) + + def copy_to(self, other: "FunctionDispatch", skip: int = 0): + other._handler_pairs.extend(self._handler_pairs[skip:]) diff --git a/tests/__init__.py b/tests/__init__.py index 20e6dbd0..9d678465 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,6 +1,9 @@ import os from hypothesis import HealthCheck, settings +from hypothesis.strategies import just, one_of + +from cattrs import UnstructureStrategy settings.register_profile( "CI", settings(suppress_health_check=[HealthCheck.too_slow]), deadline=None @@ -8,3 +11,5 @@ if "CI" in os.environ: settings.load_profile("CI") + +unstructure_strats = one_of(just(s) for s in UnstructureStrategy) diff --git a/tests/test_copy.py b/tests/test_copy.py new file mode 100644 index 00000000..7868e686 --- /dev/null +++ b/tests/test_copy.py @@ -0,0 +1,201 @@ +from collections import OrderedDict +from copy import deepcopy +from typing import Callable, Type + +from attr import define +from hypothesis import given +from hypothesis.strategies import just, one_of +from pytest import raises + +from cattrs import BaseConverter, Converter, UnstructureStrategy +from cattrs.errors import ClassValidationError + +from . import unstructure_strats + + +@define +class Simple: + a: int + + +@given(strat=unstructure_strats, detailed_validation=..., prefer_attrib=...) +def test_deepcopy( + converter_cls: Type[BaseConverter], + strat: UnstructureStrategy, + prefer_attrib: bool, + detailed_validation: bool, +): + c = converter_cls( + unstruct_strat=strat, + prefer_attrib_converters=prefer_attrib, + detailed_validation=detailed_validation, + ) + + copy = deepcopy(c) + + assert c is not copy + + assert c.unstructure(Simple(1)) == copy.unstructure(Simple(1)) + assert c.detailed_validation == copy.detailed_validation + assert c._prefer_attrib_converters == copy._prefer_attrib_converters + + +@given( + strat=unstructure_strats, + detailed_validation=..., + prefer_attrib=..., + dict_factory=one_of(just(dict), just(OrderedDict)), +) +def test_copy( + converter_cls: Type[BaseConverter], + strat: UnstructureStrategy, + prefer_attrib: bool, + detailed_validation: bool, + dict_factory: Callable, +): + c = converter_cls( + unstruct_strat=strat, + prefer_attrib_converters=prefer_attrib, + detailed_validation=detailed_validation, + dict_factory=dict_factory, + ) + + copy = c.copy() + + assert c is not copy + + assert c.unstructure(Simple(1)) == copy.unstructure(Simple(1)) + assert c.detailed_validation == copy.detailed_validation + assert c._prefer_attrib_converters == copy._prefer_attrib_converters + assert c._dict_factory == copy._dict_factory + + +@given( + strat=unstructure_strats, + detailed_validation=..., + prefer_attrib=..., + dict_factory=one_of(just(dict), just(OrderedDict)), + omit_if_default=..., +) +def test_copy_converter( + strat: UnstructureStrategy, + prefer_attrib: bool, + detailed_validation: bool, + dict_factory: Callable, + omit_if_default: bool, +): + """cattrs.Converter can be copied, and keeps its attributs.""" + c = Converter( + unstruct_strat=strat, + prefer_attrib_converters=prefer_attrib, + detailed_validation=detailed_validation, + dict_factory=dict_factory, + omit_if_default=omit_if_default, + ) + + copy = c.copy() + + assert c is not copy + + assert c.unstructure(Simple(1)) == copy.unstructure(Simple(1)) + assert c.detailed_validation == copy.detailed_validation + assert c._prefer_attrib_converters == copy._prefer_attrib_converters + assert c._dict_factory == copy._dict_factory + assert c.omit_if_default == copy.omit_if_default + + another_copy = c.copy(omit_if_default=not omit_if_default) + assert c.omit_if_default != another_copy.omit_if_default + + +@given( + strat=unstructure_strats, + detailed_validation=..., + prefer_attrib=..., + dict_factory=one_of(just(dict), just(OrderedDict)), +) +def test_copy_hooks( + converter_cls: Type[BaseConverter], + strat: UnstructureStrategy, + prefer_attrib: bool, + detailed_validation: bool, + dict_factory: Callable, +): + """Un/structure hooks are copied over.""" + c = converter_cls( + unstruct_strat=strat, + prefer_attrib_converters=prefer_attrib, + detailed_validation=detailed_validation, + dict_factory=dict_factory, + ) + + c.register_unstructure_hook(Simple, lambda s: s.a) + c.register_structure_hook(Simple, lambda v, t: Simple(v)) + + assert c.unstructure(Simple(1)) == 1 + assert c.structure(1, Simple) == Simple(1) + + copy = c.copy() + + assert c is not copy + + assert c.unstructure(Simple(1)) == copy.unstructure(Simple(1)) + assert copy.structure(copy.unstructure(Simple(1)), Simple) == Simple(1) + assert c.detailed_validation == copy.detailed_validation + assert c._prefer_attrib_converters == copy._prefer_attrib_converters + assert c._dict_factory == copy._dict_factory + + +@given(prefer_attrib=..., dict_factory=one_of(just(dict), just(OrderedDict))) +def test_detailed_validation(prefer_attrib: bool, dict_factory: Callable): + """Copies with different detailed validation work correctly.""" + c = Converter( + prefer_attrib_converters=prefer_attrib, + detailed_validation=True, + dict_factory=dict_factory, + ) + + # So the converter gets generated. + c.structure({"a": 1}, Simple) + + copy = c.copy(detailed_validation=False) + + assert c is not copy + assert copy.detailed_validation is False + + with raises(ClassValidationError): + c.structure({}, Simple) + + with raises(KeyError): + copy.structure({}, Simple) + + +@given( + prefer_attrib=..., + dict_factory=one_of(just(dict), just(OrderedDict)), + detailed_validation=..., +) +def test_col_overrides( + prefer_attrib: bool, dict_factory: Callable, detailed_validation: bool +): + """Copies with different sequence overrides work correctly.""" + c = Converter( + prefer_attrib_converters=prefer_attrib, + detailed_validation=detailed_validation, + dict_factory=dict_factory, + unstruct_collection_overrides={list: tuple}, + ) + + # So the converter gets generated. + assert c.unstructure([1, 2, 3]) == (1, 2, 3) + # We also stick a manual hook on there so it gets copied too. + c.register_unstructure_hook(Simple, lambda s: s.a) + + copy = c.copy(unstruct_collection_overrides={}) + + assert c is not copy + + assert c.unstructure([1, 2, 3]) == (1, 2, 3) + assert copy.unstructure([1, 2, 3]) == [1, 2, 3] + + assert c.unstructure(Simple(1)) == 1 + assert copy.unstructure(Simple(1)) == 1 diff --git a/tests/test_factory_hooks.py b/tests/test_factory_hooks.py index 6175be03..2dc85cbd 100644 --- a/tests/test_factory_hooks.py +++ b/tests/test_factory_hooks.py @@ -1,8 +1,6 @@ """Tests for the factory hooks documentation.""" -import pytest from attr import define, fields, has -from cattrs import BaseConverter, Converter from cattrs.gen import make_dict_structure_fn, make_dict_unstructure_fn, override @@ -11,7 +9,6 @@ def to_camel_case(snake_str): return components[0] + "".join(x.title() for x in components[1:]) -@pytest.mark.parametrize("converter_cls", [BaseConverter, Converter]) def test_snake_to_camel(converter_cls): @define class Inner: