From 2fa42661aa9f056778b3ac50ad6213e70251b455 Mon Sep 17 00:00:00 2001 From: Tin Tvrtkovic Date: Tue, 24 Mar 2026 00:23:12 +0100 Subject: [PATCH 1/2] Introduce tuple structuring hook factories --- src/cattr/gen.py | 2 + src/cattrs/converters.py | 11 ++++ src/cattrs/gen/__init__.py | 102 ++++++++++++++++++++++++++++++++++ tests/test_gen_collections.py | 29 +++++++++- 4 files changed, 143 insertions(+), 1 deletion(-) diff --git a/src/cattr/gen.py b/src/cattr/gen.py index b1f63b59..1ab1afc2 100644 --- a/src/cattr/gen.py +++ b/src/cattr/gen.py @@ -2,6 +2,7 @@ from cattrs.gen import ( make_dict_structure_fn, make_dict_unstructure_fn, + make_hetero_tuple_structure_fn, make_hetero_tuple_unstructure_fn, make_mapping_structure_fn, make_mapping_unstructure_fn, @@ -13,6 +14,7 @@ "AttributeOverride", "make_dict_structure_fn", "make_dict_unstructure_fn", + "make_hetero_tuple_structure_fn", "make_hetero_tuple_unstructure_fn", "make_iterable_unstructure_fn", "make_mapping_structure_fn", diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index f787fe74..58e0df61 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -87,11 +87,13 @@ from .fns import Predicate, identity, raise_error from .gen import ( AttributeOverride, + HeteroTupleStructureFn, HeteroTupleUnstructureFn, IterableUnstructureFn, MappingUnstructureFn, make_dict_structure_fn, make_dict_unstructure_fn, + make_hetero_tuple_structure_fn, make_hetero_tuple_unstructure_fn, ) from .gen.typeddicts import make_dict_structure_fn as make_typeddict_dict_struct_fn @@ -902,6 +904,12 @@ def _structure_optional(self, obj, union): # We can't actually have a Union of a Union, so this is safe. return self._structure_func.dispatch(other)(obj, other) + def gen_structure_hetero_tuple(self, cl: Any) -> HeteroTupleStructureFn: + """Generate a heterogeneous tuple structure function.""" + return make_hetero_tuple_structure_fn( + cl, self, detailed_validation=self.detailed_validation + ) + def _structure_tuple(self, obj: Iterable, tup: type[T]) -> T: """Deal with structuring into a tuple.""" tup_params = None if tup in (Tuple, tuple) else tup.__args__ @@ -1183,6 +1191,9 @@ def __init__( ) self.register_structure_hook_factory(is_annotated, self.gen_structure_annotated) + self.register_structure_hook_factory( + is_hetero_tuple, self.gen_structure_hetero_tuple + ) self.register_structure_hook_factory(is_mapping, self.gen_structure_mapping) self.register_structure_hook_factory(is_counter, self.gen_structure_counter) self.register_structure_hook_factory( diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index 3e386fab..e875e252 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -43,6 +43,7 @@ "make_dict_structure_fn_from_attrs", "make_dict_unstructure_fn", "make_dict_unstructure_fn_from_attrs", + "make_hetero_tuple_structure_fn", "make_hetero_tuple_unstructure_fn", "make_iterable_unstructure_fn", "make_mapping_structure_fn", @@ -852,6 +853,107 @@ def make_dict_structure_fn( #: A type alias for heterogeneous tuple unstructure hooks. HeteroTupleUnstructureFn: TypeAlias = Callable[[tuple[Any, ...]], Any] +HeteroTupleStructureFn: TypeAlias = Callable[[Iterable[Any], Any], tuple[Any, ...]] + + +def make_hetero_tuple_structure_fn( + cl: Any, + converter: BaseConverter, + detailed_validation: bool | Literal["from_converter"] = "from_converter", + use_linecache: bool = True, +) -> HeteroTupleStructureFn: + """Generate a specialized structuring function for a heterogenous tuple. + + .. versionadded:: NEXT + """ + fn_name = "structure_tuple" + + if detailed_validation == "from_converter": + detailed_validation = converter.detailed_validation + + type_args = get_args(cl) + globs = {} + lines = [] + internal_arg_parts = {"__cl": cl} + + if detailed_validation: + internal_arg_parts["__c_ive"] = IterableValidationError + internal_arg_parts["__c_ivn"] = IterableValidationNote + lines.extend([" errors = []", " res = []"]) + + for ix, t in enumerate(type_args): + handler = converter.get_structure_hook(t) + struct_handler_name = f"__c_structure_{ix}" + type_name = f"__c_type_{ix}" + internal_arg_parts[struct_handler_name] = handler + internal_arg_parts[type_name] = t + if handler == converter._structure_call: + internal_arg_parts[struct_handler_name] = t + invocation = f"{struct_handler_name}(o[{ix}])" + else: + invocation = f"{struct_handler_name}(o[{ix}], {type_name})" + lines.extend( + [ + f" if len(o) > {ix}:", + " try:", + f" res.append({invocation})", + " except Exception as e:", + ( + f" e.__notes__ = [*getattr(e, '__notes__', []), " + f"__c_ivn('Structuring {cl} @ index {ix}', {ix}, {type_name})]" + ), + " errors.append(e)", + ] + ) + + lines.extend( + [ + f" if len(o) != {len(type_args)}:", + ( + " problem = 'Not enough' if len(o) < " + f"{len(type_args)} else 'Too many'" + ), + ' exc = ValueError(f"{problem} values in {o!r} to structure as {__cl!r}")', + " exc.__notes__ = [f'Structuring {__cl}']", + " errors.append(exc)", + " if errors:", + " raise __c_ive(f'While structuring {__cl!r}', errors, __cl)", + " return tuple(res)", + ] + ) + else: + for ix, t in enumerate(type_args): + handler = converter.get_structure_hook(t) + struct_handler_name = f"__c_structure_{ix}" + type_name = f"__c_type_{ix}" + internal_arg_parts[struct_handler_name] = handler + internal_arg_parts[type_name] = t + if handler == converter._structure_call: + internal_arg_parts[struct_handler_name] = t + invocation = f"{struct_handler_name}(o[{ix}])" + else: + invocation = f"{struct_handler_name}(o[{ix}], {type_name})" + lines.append(f" {invocation},") + + total_len = len(type_args) + lines = [ + f" if len(o) != {total_len}:", + (f" problem = 'Not enough' if len(o) < {total_len} else 'Too many'"), + ' raise ValueError(f"{problem} values in {o!r} to structure as {__cl!r}")', + " return (", + *lines, + " )", + ] + + internal_arg_line = ", ".join([f"{i}={i}" for i in internal_arg_parts]) + globs.update(internal_arg_parts) + script = "\n".join([f"def {fn_name}(o, _=__cl, {internal_arg_line}):", *lines]) + + fname = generate_unique_filename( + cl, "structure", lines=script.splitlines() if use_linecache else [] + ) + eval(compile(script, fname, "exec"), globs) + return globs[fn_name] def make_hetero_tuple_unstructure_fn( diff --git a/tests/test_gen_collections.py b/tests/test_gen_collections.py index 93437891..d81a28d9 100644 --- a/tests/test_gen_collections.py +++ b/tests/test_gen_collections.py @@ -2,8 +2,15 @@ from typing import Generic, Mapping, NewType, Tuple, TypeVar +from pytest import raises + from cattrs import Converter -from cattrs.gen import make_hetero_tuple_unstructure_fn, make_mapping_structure_fn +from cattrs.errors import IterableValidationError +from cattrs.gen import ( + make_hetero_tuple_structure_fn, + make_hetero_tuple_unstructure_fn, + make_mapping_structure_fn, +) def test_structuring_mappings(genconverter: Converter): @@ -30,3 +37,23 @@ def test_unstructure_hetero_tuple_to_tuple(genconverter: Converter): fn = make_hetero_tuple_unstructure_fn(Tuple[int, str, int], genconverter, tuple) assert fn((1, "1", 2)) == (1, "1", 2) + + +def test_structure_hetero_tuple(genconverter: Converter): + """`make_hetero_tuple_structure_fn` structures heterogeneous tuples.""" + fn = make_hetero_tuple_structure_fn(tuple[int, str], genconverter) + + assert fn(["1", 2], tuple[int, str]) == (1, "2") + + +def test_structure_hetero_tuple_validation(): + """`make_hetero_tuple_structure_fn` preserves detailed validation.""" + conv = Converter() + fn = make_hetero_tuple_structure_fn(Tuple[int, int], conv) + + with raises(IterableValidationError) as exc: + fn(["1", "a"], Tuple[int, int]) + + assert exc.value.exceptions[0].__notes__ == [ + "Structuring typing.Tuple[int, int] @ index 1" + ] From 52e553ab43198cdba0b20fa39b23537026ef11a3 Mon Sep 17 00:00:00 2001 From: Tin Tvrtkovic Date: Tue, 24 Mar 2026 00:46:16 +0100 Subject: [PATCH 2/2] Add test for coverage --- tests/test_tuples.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/tests/test_tuples.py b/tests/test_tuples.py index c1a9562c..7ea69072 100644 --- a/tests/test_tuples.py +++ b/tests/test_tuples.py @@ -5,13 +5,31 @@ from attrs import Factory, define from pytest import raises +from cattrs import Converter from cattrs.cols import ( is_namedtuple, namedtuple_dict_structure_factory, namedtuple_dict_unstructure_factory, ) -from cattrs.converters import Converter -from cattrs.errors import ClassValidationError, ForbiddenExtraKeysError +from cattrs.converters import BaseConverter +from cattrs.errors import ( + ClassValidationError, + ForbiddenExtraKeysError, + IterableValidationError, +) + + +def test_structuring_invalid_tuples(converter: BaseConverter): + """Structuring (hetero) tuples raises properly.""" + + if converter.detailed_validation: + with raises(IterableValidationError) as exc_info: + converter.structure(["1", 2, "c"], tuple[int, int, int]) + assert isinstance(exc_info.value.exceptions[0], ValueError) + else: + # `int("c")` raises a ValueError + with raises(ValueError): + converter.structure(["1", 2, "c"], tuple[int, int, int]) def test_simple_hetero_tuples(genconverter: Converter):