diff --git a/HISTORY.md b/HISTORY.md index e2e023d1..dac85e3e 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -13,6 +13,7 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ## NEXT +- Fix structuring heterogeneous tuples from iterators and other non-indexable iterables; exact-length validation is preserved. - The msgspec converter now applies the same unstructuring hook to nested instances of recursive _attrs_ classes, so registered hooks and private attributes are no longer skipped below the top level. ([#803](https://github.com/python-attrs/cattrs/pull/803)) - Dict unstructuring hook factories now inherit the converter's `omit_if_default` setting when `_cattrs_omit_if_default` is omitted. Explicit `True` or `False` values and per-attribute overrides retain their precedence. diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index 79d318e1..4f23b304 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -952,6 +952,10 @@ def make_hetero_tuple_structure_fn( " )", ] + # The generated code needs random access and a length check, but the public + # contract accepts any iterable. Keep the common list/tuple case zero-copy. + lines[:0] = [" if not isinstance(o, (list, tuple)):", " o = tuple(o)"] + 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]) diff --git a/tests/test_tuples.py b/tests/test_tuples.py index fcd57306..ae91adb0 100644 --- a/tests/test_tuples.py +++ b/tests/test_tuples.py @@ -32,6 +32,46 @@ def test_structuring_invalid_tuples(converter: BaseConverter): converter.structure(["1", 2, "c"], tuple[int, int, int]) +def test_structure_hetero_tuple_from_iterable(genconverter: Converter): + """Heterogeneous tuples accept finite iterables, not only indexed inputs.""" + assert genconverter.structure(iter(["1", 2]), tuple[int, str]) == (1, "2") + assert genconverter.structure((item for item in ["1", 2]), tuple[int, str]) == ( + 1, + "2", + ) + assert genconverter.structure(iter([]), tuple[()]) == () + + +def test_structure_hetero_tuple_iterable_element_validation(genconverter: Converter): + """Iterable inputs retain element conversion validation.""" + error = IterableValidationError if genconverter.detailed_validation else ValueError + + with raises(error): + genconverter.structure((item for item in ["bad", 2]), tuple[int, str]) + + +def test_structure_hetero_tuple_iterable_length_validation(genconverter: Converter): + """Iterable inputs retain exact-length validation.""" + error = IterableValidationError if genconverter.detailed_validation else ValueError + + with raises(error): + genconverter.structure(iter(["1"]), tuple[int, str]) + + with raises(error): + genconverter.structure(iter(["1", 2, 3]), tuple[int, str]) + + +def test_structure_hetero_tuple_does_not_swallow_iteration_errors(): + """Errors raised while consuming an iterable propagate unchanged.""" + + def failing_iterable(): + yield 1 + raise RuntimeError("iteration failed") + + with raises(RuntimeError, match="iteration failed"): + Converter().structure(failing_iterable(), tuple[int, str]) + + def test_type_names_with_quotes(): """Types with quote characters in their reprs should work.