diff --git a/HISTORY.md b/HISTORY.md index ed2c54ff..164dada5 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -33,6 +33,8 @@ can now be used as decorators and have gained new features when used this way. ([#426](https://github.com/python-attrs/cattrs/issues/426) [#477](https://github.com/python-attrs/cattrs/pull/477)) - Add support for [PEP 695](https://peps.python.org/pep-0695/) type aliases. ([#452](https://github.com/python-attrs/cattrs/pull/452)) +- Add support for named tuples with type metadata ([`typing.NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)). + ([#425](https://github.com/python-attrs/cattrs/issues/425) [#491](https://github.com/python-attrs/cattrs/pull/491)) - The `include_subclasses` strategy now fetches the member hooks from the converter (making use of converter defaults) if overrides are not provided, instead of generating new hooks with no overrides. ([#429](https://github.com/python-attrs/cattrs/issues/429) [#472](https://github.com/python-attrs/cattrs/pull/472)) - The preconf `make_converter` factories are now correctly typed. diff --git a/docs/defaulthooks.md b/docs/defaulthooks.md index 86f77f48..c2f72b36 100644 --- a/docs/defaulthooks.md +++ b/docs/defaulthooks.md @@ -196,6 +196,10 @@ Any type parameters set to `typing.Any` will be passed through unconverted. When unstructuring, heterogeneous tuples unstructure into tuples since it's faster and virtually all serialization libraries support tuples natively. +```{note} +Structuring heterogenous tuples are not supported by the BaseConverter. +``` + ### Deques Deques can be structured from any iterable object. @@ -490,6 +494,13 @@ When unstructuring, literals are passed through. ``` +### `typing.NamedTuple` + +Named tuples with type hints (created from [`typing.NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)) are supported. + +```{versionadded} 24.1.0 + +``` ### `typing.Final` diff --git a/docs/preconf.md b/docs/preconf.md index 95f47aa9..4a3038a9 100644 --- a/docs/preconf.md +++ b/docs/preconf.md @@ -13,7 +13,9 @@ For example, to get a converter configured for BSON: Converters obtained this way can be customized further, just like any other converter. -These converters support the following additional classes and type annotations, both for structuring and unstructuring: +These converters support all [default hooks](defaulthooks.md) +and the following additional classes and type annotations, +both for structuring and unstructuring: - `datetime.datetime`, `datetime.date` @@ -66,6 +68,7 @@ Found at {mod}`cattrs.preconf.orjson`. Bytes are un/structured as base 85 strings. Sets are unstructured into lists, and structured back into sets. `datetime` s and `date` s are passed through to be unstructured into RFC 3339 by _orjson_ itself. +Typed named tuples are unstructured into ordinary tuples, and then into JSON arrays by _orjson_. _orjson_ doesn't support integers less than -9223372036854775808, and greater than 9223372036854775807. _orjson_ only supports mappings with string keys so mappings will have their keys stringified before serialization, and destringified during deserialization. @@ -180,8 +183,9 @@ When encoding and decoding, the library needs to be passed `codec_options=bson.C Found at {mod}`cattrs.preconf.pyyaml`. -Frozensets are serialized as lists, and deserialized back into frozensets. `date` s are serialized as ISO 8601 strings. - +Frozensets are serialized as lists, and deserialized back into frozensets. +`date` s are serialized as ISO 8601 strings. +Typed named tuples are unstructured into ordinary tuples, and then into YAML arrays by _pyyaml_. ## _tomlkit_ diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 7df20632..e05d5e7f 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -5,6 +5,7 @@ from dataclasses import Field from enum import Enum from inspect import Signature +from inspect import signature as inspect_signature from pathlib import Path from typing import Any, Callable, Iterable, Optional, Tuple, TypeVar, overload @@ -81,6 +82,11 @@ ) 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 +from .tuples import ( + is_namedtuple, + namedtuple_structure_factory, + namedtuple_unstructure_factory, +) __all__ = ["UnstructureStrategy", "BaseConverter", "Converter", "GenConverter"] @@ -224,6 +230,7 @@ def __init__( (is_mutable_set, self._structure_set), (is_frozenset, self._structure_frozenset), (is_tuple, self._structure_tuple), + (is_namedtuple, namedtuple_structure_factory, "extended"), (is_mapping, self._structure_dict), (is_supported_union, self._gen_attrs_union_structure, True), ( @@ -365,7 +372,9 @@ def register_unstructure_hook_factory( def decorator(factory): # Is this an extended factory (takes a converter too)? - sig = signature(factory) + # We use the original `inspect.signature` to not evaluate string + # annotations. + sig = inspect_signature(factory) if ( len(sig.parameters) >= 2 and (list(sig.parameters.values())[1]).default is Signature.empty @@ -1095,6 +1104,9 @@ def __init__( self.register_unstructure_hook_factory( is_hetero_tuple, self.gen_unstructure_hetero_tuple ) + self.register_unstructure_hook_factory(is_namedtuple)( + namedtuple_unstructure_factory + ) self.register_unstructure_hook_factory( is_sequence, self.gen_unstructure_iterable ) diff --git a/src/cattrs/dispatch.py b/src/cattrs/dispatch.py index e72f8bb9..792b613f 100644 --- a/src/cattrs/dispatch.py +++ b/src/cattrs/dispatch.py @@ -5,7 +5,7 @@ from attrs import Factory, define -from cattrs._compat import TypeAlias +from ._compat import TypeAlias if TYPE_CHECKING: from .converters import BaseConverter @@ -36,6 +36,12 @@ class FunctionDispatch: first argument in the method, and return True or False. objects that help determine dispatch should be instantiated objects. + + :param converter: A converter to be used for factories that require converters. + + .. versionchanged:: 24.1.0 + Support for factories that require converters, hence this requires a + converter when creating. """ _converter: BaseConverter @@ -86,11 +92,15 @@ class MultiStrategyDispatch(Generic[Hook]): MultiStrategyDispatch uses a combination of exact-match dispatch, singledispatch, and FunctionDispatch. + :param converter: A converter to be used for factories that require converters. :param fallback_factory: A hook factory to be called when a hook cannot be produced. - .. versionchanged:: 23.2.0 + .. versionchanged:: 23.2.0 Fallbacks are now factories. + .. versionchanged:: 24.1.0 + Support for factories that require converters, hence this requires a + converter when creating. """ _fallback_factory: HookFactory[Hook] @@ -150,6 +160,10 @@ def register_func_list( """ Register a predicate function to determine if the handler should be used for the type. + + :param pred_and_handler: The list of predicates and their associated + handlers. If a handler is registered in `extended` mode, it's a + factory that requires a converter. """ for tup in pred_and_handler: if len(tup) == 2: diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index b2277e53..846903d1 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -34,7 +34,7 @@ if TYPE_CHECKING: # pragma: no cover from typing_extensions import Literal - from cattr.converters import BaseConverter + from ..converters import BaseConverter __all__ = [ "make_dict_unstructure_fn", @@ -698,18 +698,21 @@ def make_iterable_unstructure_fn( def make_hetero_tuple_unstructure_fn( - cl: Any, converter: BaseConverter, unstructure_to: Any = None + cl: Any, + converter: BaseConverter, + unstructure_to: Any = None, + type_args: tuple | None = None, ) -> HeteroTupleUnstructureFn: - """Generate a specialized unstructure function for a heterogenous tuple.""" + """Generate a specialized unstructure function for a heterogenous tuple. + + :param type_args: If provided, override the type arguments. + """ fn_name = "unstructure_tuple" - type_args = get_args(cl) + type_args = get_args(cl) if type_args is None else type_args # We can do the dispatch here and now. - handlers = [ - converter.get_unstructure_hook(type_arg, cache_result=False) - for type_arg in type_args - ] + handlers = [converter.get_unstructure_hook(type_arg) for type_arg in type_args] globs = {f"__cattr_u_{i}": h for i, h in enumerate(handlers)} if unstructure_to is not tuple: diff --git a/src/cattrs/preconf/msgspec.py b/src/cattrs/preconf/msgspec.py index a9225970..f4cc7752 100644 --- a/src/cattrs/preconf/msgspec.py +++ b/src/cattrs/preconf/msgspec.py @@ -12,11 +12,13 @@ from msgspec.json import Encoder, decode from cattrs._compat import fields, get_origin, has, is_bare, is_mapping, is_sequence -from cattrs.dispatch import HookFactory, UnstructureHook +from cattrs.dispatch import UnstructureHook from cattrs.fns import identity -from ..converters import Converter +from ..converters import BaseConverter, Converter +from ..gen import make_hetero_tuple_unstructure_fn from ..strategies import configure_union_passthrough +from ..tuples import is_namedtuple from . import wrap T = TypeVar("T") @@ -85,86 +87,89 @@ def configure_passthroughs(converter: Converter) -> None: A passthrough is when we let msgspec handle something automatically. """ converter.register_unstructure_hook(bytes, to_builtins) - converter.register_unstructure_hook_factory( - is_mapping, make_unstructure_mapping_factory(converter) - ) - converter.register_unstructure_hook_factory( - is_sequence, make_unstructure_seq_factory(converter) - ) - converter.register_unstructure_hook_factory( - has, make_attrs_unstruct_factory(converter) + converter.register_unstructure_hook_factory(is_mapping)(mapping_unstructure_factory) + converter.register_unstructure_hook_factory(is_sequence)(seq_unstructure_factory) + converter.register_unstructure_hook_factory(has)(attrs_unstructure_factory) + converter.register_unstructure_hook_factory(is_namedtuple)( + namedtuple_unstructure_factory ) -def make_unstructure_seq_factory(converter: Converter) -> HookFactory[UnstructureHook]: - def unstructure_seq_factory(type) -> UnstructureHook: - if is_bare(type): - type_arg = Any - handler = converter.get_unstructure_hook(type_arg, cache_result=False) - elif getattr(type, "__args__", None) not in (None, ()): - type_arg = type.__args__[0] - handler = converter.get_unstructure_hook(type_arg, cache_result=False) - else: - handler = None - - if handler in (identity, to_builtins): - return handler - return converter.gen_unstructure_iterable(type) - - return unstructure_seq_factory - - -def make_unstructure_mapping_factory( - converter: Converter, -) -> HookFactory[UnstructureHook]: - def unstructure_mapping_factory(type) -> UnstructureHook: - if is_bare(type): - key_arg = Any - val_arg = Any - key_handler = converter.get_unstructure_hook(key_arg, cache_result=False) - value_handler = converter.get_unstructure_hook(val_arg, cache_result=False) - elif (args := getattr(type, "__args__", None)) not in (None, ()): - if len(args) == 2: - key_arg, val_arg = args - else: - # Probably a Counter - key_arg, val_arg = args, Any - key_handler = converter.get_unstructure_hook(key_arg, cache_result=False) - value_handler = converter.get_unstructure_hook(val_arg, cache_result=False) +def seq_unstructure_factory(type, converter: BaseConverter) -> UnstructureHook: + if is_bare(type): + type_arg = Any + handler = converter.get_unstructure_hook(type_arg, cache_result=False) + elif getattr(type, "__args__", None) not in (None, ()): + type_arg = type.__args__[0] + handler = converter.get_unstructure_hook(type_arg, cache_result=False) + else: + handler = None + + if handler in (identity, to_builtins): + return handler + return converter.gen_unstructure_iterable(type) + + +def mapping_unstructure_factory(type, converter: BaseConverter) -> UnstructureHook: + if is_bare(type): + key_arg = Any + val_arg = Any + key_handler = converter.get_unstructure_hook(key_arg, cache_result=False) + value_handler = converter.get_unstructure_hook(val_arg, cache_result=False) + elif (args := getattr(type, "__args__", None)) not in (None, ()): + if len(args) == 2: + key_arg, val_arg = args else: - key_handler = value_handler = None + # Probably a Counter + key_arg, val_arg = args, Any + key_handler = converter.get_unstructure_hook(key_arg, cache_result=False) + value_handler = converter.get_unstructure_hook(val_arg, cache_result=False) + else: + key_handler = value_handler = None + + if key_handler in (identity, to_builtins) and value_handler in ( + identity, + to_builtins, + ): + return to_builtins + return converter.gen_unstructure_mapping(type) + - if key_handler in (identity, to_builtins) and value_handler in ( - identity, - to_builtins, - ): - return to_builtins - return converter.gen_unstructure_mapping(type) +def attrs_unstructure_factory(type: Any, converter: BaseConverter) -> UnstructureHook: + """Choose whether to use msgspec handling or our own.""" + origin = get_origin(type) + attribs = fields(origin or type) + if attrs_has(type) and any(isinstance(a.type, str) for a in attribs): + resolve_types(type) + attribs = fields(origin or type) - return unstructure_mapping_factory + if any( + attr.name.startswith("_") + or ( + converter.get_unstructure_hook(attr.type, cache_result=False) + not in (identity, to_builtins) + ) + for attr in attribs + ): + return converter.gen_unstructure_attrs_fromdict(type) + return to_builtins -def make_attrs_unstruct_factory(converter: Converter) -> HookFactory[UnstructureHook]: - """Short-circuit attrs and dataclass handling if it matches msgspec.""" - def attrs_factory(type: Any) -> UnstructureHook: - """Choose whether to use msgspec handling or our own.""" - origin = get_origin(type) - attribs = fields(origin or type) - if attrs_has(type) and any(isinstance(a.type, str) for a in attribs): - resolve_types(type) - attribs = fields(origin or type) - - if any( - attr.name.startswith("_") - or ( - converter.get_unstructure_hook(attr.type, cache_result=False) - not in (identity, to_builtins) - ) - for attr in attribs - ): - return converter.gen_unstructure_attrs_fromdict(type) +def namedtuple_unstructure_factory( + type: type[tuple], converter: BaseConverter +) -> UnstructureHook: + """A hook factory for unstructuring namedtuples, modified for msgspec.""" - return to_builtins + if all( + converter.get_unstructure_hook(t) in (identity, to_builtins) + for t in type.__annotations__.values() + ): + return identity - return attrs_factory + return make_hetero_tuple_unstructure_fn( + type, + converter, + unstructure_to=tuple, + type_args=tuple(type.__annotations__.values()), + ) diff --git a/src/cattrs/preconf/orjson.py b/src/cattrs/preconf/orjson.py index f913dd8f..bcad43bf 100644 --- a/src/cattrs/preconf/orjson.py +++ b/src/cattrs/preconf/orjson.py @@ -2,15 +2,16 @@ from base64 import b85decode, b85encode from datetime import date, datetime from enum import Enum +from functools import partial from typing import Any, Type, TypeVar, Union from orjson import dumps, loads -from cattrs._compat import AbstractSet, is_mapping - +from .._compat import AbstractSet, is_mapping from ..converters import BaseConverter, Converter from ..fns import identity from ..strategies import configure_union_passthrough +from ..tuples import is_namedtuple, namedtuple_unstructure_factory from . import wrap T = TypeVar("T") @@ -30,9 +31,13 @@ def configure_converter(converter: BaseConverter): * bytes are serialized as base85 strings * datetimes and dates are passed through to be serialized as RFC 3339 by orjson + * typed namedtuples are serialized as lists * sets are serialized as lists * string enum mapping keys have special handling * mapping keys are coerced into strings when unstructuring + + .. versionchanged: 24.1.0 + Add support for typed namedtuples. """ converter.register_unstructure_hook( bytes, lambda v: (b85encode(v) if v else b"").decode("utf8") @@ -65,7 +70,14 @@ def key_handler(v): ) converter._unstructure_func.register_func_list( - [(is_mapping, gen_unstructure_mapping, True)] + [ + (is_mapping, gen_unstructure_mapping, True), + ( + is_namedtuple, + partial(namedtuple_unstructure_factory, unstructure_to=tuple), + "extended", + ), + ] ) configure_union_passthrough(Union[str, bool, int, float, None], converter) diff --git a/src/cattrs/preconf/pyyaml.py b/src/cattrs/preconf/pyyaml.py index 19314ee1..9b479113 100644 --- a/src/cattrs/preconf/pyyaml.py +++ b/src/cattrs/preconf/pyyaml.py @@ -1,13 +1,14 @@ """Preconfigured converters for pyyaml.""" from datetime import date, datetime +from functools import partial from typing import Any, Type, TypeVar, Union from yaml import safe_dump, safe_load -from cattrs._compat import FrozenSetSubscriptable - +from .._compat import FrozenSetSubscriptable from ..converters import BaseConverter, Converter from ..strategies import configure_union_passthrough +from ..tuples import is_namedtuple, namedtuple_unstructure_factory from . import validate_datetime, wrap T = TypeVar("T") @@ -34,6 +35,10 @@ def configure_converter(converter: BaseConverter): * frozensets are serialized as lists * string enums are converted into strings explicitly * datetimes and dates are validated + * typed namedtuples are serialized as lists + + .. versionchanged: 24.1.0 + Add support for typed namedtuples. """ converter.register_unstructure_hook( str, lambda v: v if v.__class__ is str else v.value @@ -44,6 +49,11 @@ def configure_converter(converter: BaseConverter): converter.register_unstructure_hook(datetime, lambda v: v) converter.register_structure_hook(datetime, validate_datetime) converter.register_structure_hook(date, validate_date) + + converter.register_unstructure_hook_factory(is_namedtuple)( + partial(namedtuple_unstructure_factory, unstructure_to=tuple) + ) + configure_union_passthrough( Union[str, bool, int, float, None, bytes, datetime, date], converter ) diff --git a/src/cattrs/tuples.py b/src/cattrs/tuples.py new file mode 100644 index 00000000..1cddd67c --- /dev/null +++ b/src/cattrs/tuples.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from sys import version_info +from typing import TYPE_CHECKING, Any, NamedTuple, Tuple + +from ._compat import is_subclass +from .dispatch import StructureHook, UnstructureHook +from .fns import identity +from .gen import make_hetero_tuple_unstructure_fn + +if TYPE_CHECKING: + from .converters import BaseConverter + +if version_info[:2] >= (3, 9): + + def is_namedtuple(type: Any) -> bool: + """A predicate function for named tuples.""" + + if is_subclass(type, tuple): + for cl in type.mro(): + orig_bases = cl.__dict__.get("__orig_bases__", ()) + if NamedTuple in orig_bases: + return True + return False + +else: + + def is_namedtuple(type: Any) -> bool: + """A predicate function for named tuples.""" + # This is tricky. It may not be possible for this function to be 100% + # accurate, since it doesn't seem like we can distinguish between tuple + # subclasses and named tuples reliably. + + if is_subclass(type, tuple): + for cl in type.mro(): + if cl is tuple: + # No point going further. + break + if "_fields" in cl.__dict__: + return True + return False + + +def is_passthrough(type: type[tuple], converter: BaseConverter) -> bool: + """If all fields would be passed through, this class should not be processed + either. + """ + return all( + converter.get_unstructure_hook(t) == identity + for t in type.__annotations__.values() + ) + + +def namedtuple_unstructure_factory( + type: type[tuple], converter: BaseConverter, unstructure_to: Any = None +) -> UnstructureHook: + """A hook factory for unstructuring namedtuples. + + :param unstructure_to: Force unstructuring to this type, if provided. + """ + + if unstructure_to is None and is_passthrough(type, converter): + return identity + + return make_hetero_tuple_unstructure_fn( + type, + converter, + unstructure_to=tuple if unstructure_to is None else unstructure_to, + type_args=tuple(type.__annotations__.values()), + ) + + +def namedtuple_structure_factory( + type: type[tuple], converter: BaseConverter +) -> StructureHook: + """A hook factory for structuring namedtuples.""" + # We delegate to the existing infrastructure for heterogenous tuples. + hetero_tuple_type = Tuple[tuple(type.__annotations__.values())] + base_hook = converter.get_structure_hook(hetero_tuple_type) + return lambda v, _: type(*base_hook(v, hetero_tuple_type)) diff --git a/tests/preconf/test_msgspec_cpython.py b/tests/preconf/test_msgspec_cpython.py index c4ba29d4..e9e8cb54 100644 --- a/tests/preconf/test_msgspec_cpython.py +++ b/tests/preconf/test_msgspec_cpython.py @@ -7,6 +7,7 @@ Mapping, MutableMapping, MutableSequence, + NamedTuple, Sequence, ) @@ -37,11 +38,27 @@ class B: @define class C: - """This class should not be passed through to msgspec.""" + """This class should not be passed through due to a private attribute.""" _a: int +class N(NamedTuple): + a: int + + +class NA(NamedTuple): + """A complex namedtuple.""" + + a: A + + +class NC(NamedTuple): + """A complex namedtuple.""" + + a: C + + @fixture def converter() -> Conv: return make_converter() @@ -68,12 +85,16 @@ def test_unstructure_passthrough(converter: Conv): assert is_passthrough(converter.get_unstructure_hook(MutableSequence[int])) -def test_unstructure_pt_attrs(converter: Conv): - """Passthrough for attrs works.""" +def test_unstructure_pt_product_types(converter: Conv): + """Passthrough for product types (attrs, dataclasses...) works.""" assert is_passthrough(converter.get_unstructure_hook(A)) assert not is_passthrough(converter.get_unstructure_hook(B)) assert not is_passthrough(converter.get_unstructure_hook(C)) + assert is_passthrough(converter.get_unstructure_hook(N)) + assert is_passthrough(converter.get_unstructure_hook(NA)) + assert not is_passthrough(converter.get_unstructure_hook(NC)) + def test_unstructure_pt_mappings(converter: Conv): """Mapping are passed through for unstructuring.""" diff --git a/tests/test_preconf.py b/tests/test_preconf.py index 2f43873a..3d7ad0f6 100644 --- a/tests/test_preconf.py +++ b/tests/test_preconf.py @@ -4,7 +4,7 @@ from json import dumps as json_dumps from json import loads as json_loads from platform import python_implementation -from typing import Any, Dict, List, NewType, Tuple, Union +from typing import Any, Dict, List, NamedTuple, NewType, Tuple, Union import pytest from attrs import define @@ -63,6 +63,10 @@ class B: b: str +class C(NamedTuple): + c: float + + @define class Everything: @unique @@ -98,6 +102,7 @@ class AStringEnum(str, Enum): native_union: Union[int, float, str] native_union_with_spillover: Union[int, str, Set[str]] native_union_with_union_spillover: Union[int, str, A, B] + a_namedtuple: C @composite @@ -166,6 +171,7 @@ def everythings( draw(one_of(ints, fs, strings)), draw(one_of(ints, strings, sets(strings))), draw(one_of(ints, strings, ints.map(A), strings.map(B))), + draw(fs.map(C)), ) diff --git a/tests/test_tuples.py b/tests/test_tuples.py new file mode 100644 index 00000000..bf0ace5e --- /dev/null +++ b/tests/test_tuples.py @@ -0,0 +1,57 @@ +"""Tests for tuples of all kinds.""" +from typing import NamedTuple, Tuple + +from cattrs.converters import Converter +from cattrs.tuples import is_namedtuple + + +def test_simple_hetero_tuples(genconverter: Converter): + """Simple heterogenous tuples work. + + Only supported for the Converter (not the BaseConverter). + """ + + genconverter.register_unstructure_hook(int, lambda v: v + 1) + + assert genconverter.unstructure((1, "2"), unstructure_as=Tuple[int, str]) == ( + 2, + "2", + ) + + genconverter.register_structure_hook(int, lambda v, _: v - 1) + + assert genconverter.structure([2, "2"], Tuple[int, str]) == (1, "2") + + +def test_named_tuple_predicate(): + """The NamedTuple predicate works.""" + + assert not is_namedtuple(tuple) + assert not is_namedtuple(Tuple[int, ...]) + assert not is_namedtuple(Tuple[int]) + + class Test(NamedTuple): + a: int + + assert is_namedtuple(Test) + + class Test2(Tuple[int, int]): + pass + + assert not is_namedtuple(Test2) + + +def test_simple_typed_namedtuples(genconverter: Converter): + """Simple typed namedtuples work.""" + + class Test(NamedTuple): + a: int + + assert genconverter.unstructure(Test(1)) == Test(1) + assert genconverter.structure([1], Test) == Test(1) + + genconverter.register_unstructure_hook(int, lambda v: v + 1) + genconverter.register_structure_hook(int, lambda v, _: v - 1) + + assert genconverter.unstructure(Test(1)) == (2,) + assert genconverter.structure([2], Test) == Test(1)