From 39e698f0f9b0fe0bd4692b747c1e7c12bfab95ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Wed, 6 Mar 2024 10:06:28 +0100 Subject: [PATCH] Simplify and optimize iterable unstructuring (#516) * Simplify and optimize iterable unstructuring * Handle TypeVars after all * Add test --- src/cattrs/gen/__init__.py | 25 +++++++------------ src/cattrs/preconf/msgspec.py | 5 ++-- .../{test_pep_695.py => test_generics_695.py} | 0 tests/test_generics_696.py | 14 ++++++++++- 4 files changed, 24 insertions(+), 20 deletions(-) rename tests/{test_pep_695.py => test_generics_695.py} (100%) diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index cf1ceb3f..e7a93fd7 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -675,29 +675,22 @@ def make_iterable_unstructure_fn( """Generate a specialized unstructure function for an iterable.""" handler = converter.unstructure - fn_name = "unstructure_iterable" - # Let's try fishing out the type args # Unspecified tuples have `__args__` as empty tuples, so guard # against IndexError. if getattr(cl, "__args__", None) not in (None, ()): type_arg = cl.__args__[0] - # We don't know how to handle the TypeVar on this level, - # so we skip doing the dispatch here. - if not isinstance(type_arg, TypeVar): - handler = converter.get_unstructure_hook(type_arg, cache_result=False) - - globs = {"__cattr_seq_cl": unstructure_to or cl, "__cattr_u": handler} - lines = [] - - lines.append(f"def {fn_name}(iterable):") - lines.append(" res = __cattr_seq_cl(__cattr_u(i) for i in iterable)") + if isinstance(type_arg, TypeVar): + type_arg = getattr(type_arg, "__default__", Any) + handler = converter.get_unstructure_hook(type_arg, cache_result=False) + if handler == identity: + # Save ourselves the trouble of iterating over it all. + return unstructure_to or cl - total_lines = [*lines, " return res"] - - eval(compile("\n".join(total_lines), "", "exec"), globs) + def unstructure_iterable(iterable, _seq_cl=unstructure_to or cl, _hook=handler): + return _seq_cl(_hook(i) for i in iterable) - return globs[fn_name] + return unstructure_iterable #: A type alias for heterogeneous tuple unstructure hooks. diff --git a/src/cattrs/preconf/msgspec.py b/src/cattrs/preconf/msgspec.py index 9c1f1164..b087047f 100644 --- a/src/cattrs/preconf/msgspec.py +++ b/src/cattrs/preconf/msgspec.py @@ -106,15 +106,14 @@ def configure_passthroughs(converter: Converter) -> None: ) -def seq_unstructure_factory(type, converter: BaseConverter) -> UnstructureHook: +def seq_unstructure_factory(type, converter: Converter) -> UnstructureHook: """The msgspec unstructure hook factory for sequences.""" if is_bare(type): type_arg = Any - handler = converter.get_unstructure_hook(type_arg, cache_result=False) else: args = get_args(type) type_arg = args[0] - handler = converter.get_unstructure_hook(type_arg, cache_result=False) + handler = converter.get_unstructure_hook(type_arg, cache_result=False) if handler in (identity, to_builtins): return handler diff --git a/tests/test_pep_695.py b/tests/test_generics_695.py similarity index 100% rename from tests/test_pep_695.py rename to tests/test_generics_695.py diff --git a/tests/test_generics_696.py b/tests/test_generics_696.py index c4643321..2e0680eb 100644 --- a/tests/test_generics_696.py +++ b/tests/test_generics_696.py @@ -1,5 +1,5 @@ """Tests for generics under PEP 696 (type defaults).""" -from typing import Generic +from typing import Generic, List import pytest from attrs import define, fields @@ -48,3 +48,15 @@ class D(Generic[TD]): # But allows other types assert genconverter.structure({"a": "1"}, D[str]) == D("1") assert genconverter.structure({"a": 1}, D[int]) == D(1) + + +def test_unstructure_iterable(genconverter): + """Unstructuring iterables with defaults works.""" + genconverter.register_unstructure_hook(str, lambda v: v + "_str") + + @define + class C(Generic[TD]): + a: List[TD] + + assert genconverter.unstructure(C(["a"])) == {"a": ["a_str"]} + assert genconverter.unstructure(["a"], List[TD]) == ["a_str"]