From 6626c0b53c471555d5399d58cfceb76718757e67 Mon Sep 17 00:00:00 2001 From: Tin Tvrtkovic Date: Sat, 10 Jun 2023 23:48:29 +0200 Subject: [PATCH 1/2] Newtypes fix --- HISTORY.md | 2 ++ src/cattrs/converters.py | 15 +++++++++++++++ tests/test_newtypes.py | 6 +++--- tests/test_optionals.py | 20 ++++++++++++++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 tests/test_optionals.py diff --git a/HISTORY.md b/HISTORY.md index d515e541..cc7cfd6f 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -6,6 +6,8 @@ - _cattrs_ is now linted with [Ruff](https://beta.ruff.rs/docs/). - Fix TypedDicts with periods in their field names. ([#376](https://github.com/python-attrs/cattrs/issues/376) [#377](https://github.com/python-attrs/cattrs/pull/377)) +- Optimize and improve unstructuring of `Optional` (unions of one type and `None`). + ([#380](https://github.com/python-attrs/cattrs/issues/380)) - Fix `format_exception` and `transform_error` type annotations. ## 23.1.2 (2023-06-02) diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 664c6428..b5a5a815 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -881,6 +881,9 @@ def __init__( is_frozenset, lambda cl: self.gen_unstructure_iterable(cl, unstructure_to=frozenset), ) + self.register_unstructure_hook_factory( + is_optional, self.gen_unstructure_optional + ) self.register_unstructure_hook_factory( is_typeddict, self.gen_unstructure_typeddict ) @@ -888,6 +891,7 @@ def __init__( lambda t: get_newtype_base(t) is not None, lambda t: self._unstructure_func.dispatch(get_newtype_base(t)), ) + self.register_structure_hook_factory(is_annotated, self.gen_structure_annotated) self.register_structure_hook_factory(is_mapping, self.gen_structure_mapping) self.register_structure_hook_factory(is_counter, self.gen_structure_counter) @@ -938,6 +942,17 @@ def gen_unstructure_attrs_fromdict( cl, self, _cattrs_omit_if_default=self.omit_if_default, **attrib_overrides ) + def gen_unstructure_optional(self, cl: Type[T]) -> Callable[[T], Any]: + """Generate an unstructuring hook for optional types.""" + union_params = cl.__args__ + other = union_params[0] if union_params[1] is NoneType else union_params[1] + handler = self._unstructure_func.dispatch(other) + + def unstructure_optional(val, _handler=handler): + return None if val is None else _handler(val) + + return unstructure_optional + def gen_structure_typeddict(self, cl: Any) -> Callable[[Dict], Dict]: """Generate a TypedDict structure function. diff --git a/tests/test_newtypes.py b/tests/test_newtypes.py index 2203d5a7..6b27f6dd 100644 --- a/tests/test_newtypes.py +++ b/tests/test_newtypes.py @@ -3,13 +3,13 @@ import pytest -from cattrs import BaseConverter +from cattrs import Converter PositiveIntNewType = NewType("PositiveIntNewType", int) BigPositiveIntNewType = NewType("BigPositiveIntNewType", PositiveIntNewType) -def test_newtype_structure_hooks(genconverter: BaseConverter): +def test_newtype_structure_hooks(genconverter: Converter): """NewTypes should work with `register_structure_hook`.""" assert genconverter.structure("0", int) == 0 @@ -39,7 +39,7 @@ def test_newtype_structure_hooks(genconverter: BaseConverter): assert genconverter.structure("51", BigPositiveIntNewType) == 51 -def test_newtype_unstructure_hooks(genconverter: BaseConverter): +def test_newtype_unstructure_hooks(genconverter: Converter): """NewTypes should work with `register_unstructure_hook`.""" assert genconverter.unstructure(0, int) == 0 diff --git a/tests/test_optionals.py b/tests/test_optionals.py new file mode 100644 index 00000000..2147ea60 --- /dev/null +++ b/tests/test_optionals.py @@ -0,0 +1,20 @@ +from typing import NewType + +from attrs import define + + +def test_newtype_optionals(genconverter): + """Newtype optionals should work.""" + Foo = NewType("Foo", str) + + genconverter.register_unstructure_hook(Foo, lambda v: v.replace("foo", "bar")) + + @define + class ModelWithFoo: + total_foo: Foo + maybe_foo: Foo | None + + assert genconverter.unstructure(ModelWithFoo(Foo("foo"), Foo("is it a foo?"))) == { + "total_foo": "bar", + "maybe_foo": "is it a bar?", + } From 0e3995e51e9c028192c4c6d5cd16a41ab3c2ab0d Mon Sep 17 00:00:00 2001 From: Tin Tvrtkovic Date: Sun, 11 Jun 2023 00:18:43 +0200 Subject: [PATCH 2/2] Rework test --- HISTORY.md | 2 +- tests/test_optionals.py | 23 ++++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index cc7cfd6f..da44cbbd 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -7,7 +7,7 @@ - Fix TypedDicts with periods in their field names. ([#376](https://github.com/python-attrs/cattrs/issues/376) [#377](https://github.com/python-attrs/cattrs/pull/377)) - Optimize and improve unstructuring of `Optional` (unions of one type and `None`). - ([#380](https://github.com/python-attrs/cattrs/issues/380)) + ([#380](https://github.com/python-attrs/cattrs/issues/380) [#381](https://github.com/python-attrs/cattrs/pull/381)) - Fix `format_exception` and `transform_error` type annotations. ## 23.1.2 (2023-06-02) diff --git a/tests/test_optionals.py b/tests/test_optionals.py index 2147ea60..483b6299 100644 --- a/tests/test_optionals.py +++ b/tests/test_optionals.py @@ -1,7 +1,10 @@ -from typing import NewType +from typing import NewType, Optional +import pytest from attrs import define +from cattrs._compat import is_py310_plus + def test_newtype_optionals(genconverter): """Newtype optionals should work.""" @@ -9,6 +12,24 @@ def test_newtype_optionals(genconverter): genconverter.register_unstructure_hook(Foo, lambda v: v.replace("foo", "bar")) + @define + class ModelWithFoo: + total_foo: Foo + maybe_foo: Optional[Foo] + + assert genconverter.unstructure(ModelWithFoo(Foo("foo"), Foo("is it a foo?"))) == { + "total_foo": "bar", + "maybe_foo": "is it a bar?", + } + + +@pytest.mark.skipif(not is_py310_plus, reason="3.10+ union syntax") +def test_newtype_modern_optionals(genconverter): + """Newtype optionals should work.""" + Foo = NewType("Foo", str) + + genconverter.register_unstructure_hook(Foo, lambda v: v.replace("foo", "bar")) + @define class ModelWithFoo: total_foo: Foo