From b02f13a45ae03d8a519e97906731d5b38150148a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Wed, 3 Sep 2025 15:30:10 +0200 Subject: [PATCH 1/2] Improve Hypothesis test robustness --- HISTORY.md | 7 +++++++ src/cattrs/converters.py | 4 ++++ tests/asserts.py | 20 ++++++++++++++++++++ tests/test_converter.py | 7 ++++--- tests/test_gen_dict.py | 6 +++--- 5 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 tests/asserts.py diff --git a/HISTORY.md b/HISTORY.md index 0ff3e56e..c9daa47b 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -11,6 +11,13 @@ The third number is for emergencies when we need to start branches for older rel Our backwards-compatibility policy can be found [here](https://github.com/python-attrs/cattrs/blob/main/.github/SECURITY.md). +## NEXT (UNRELEASED) + +- Fix unstructuring NewTypes with the {class}`BaseConverter`. + ([#684](https://github.com/python-attrs/cattrs/pull/684)) +- Make some Hypothesis tests more robust. + ([#684](https://github.com/python-attrs/cattrs/pull/684)) + ## 25.2.0 (2025-08-31) - **Potentially breaking**: Sequences are now structured into tuples. diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 864a8b9d..43772d60 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -227,6 +227,10 @@ def __init__( ) self._unstructure_func.register_func_list( [ + ( + lambda t: get_newtype_base(t) is not None, + lambda o: self.unstructure(o, unstructure_as=o.__class__), + ), ( is_protocol, lambda o: self.unstructure(o, unstructure_as=o.__class__), diff --git a/tests/asserts.py b/tests/asserts.py new file mode 100644 index 00000000..736a2348 --- /dev/null +++ b/tests/asserts.py @@ -0,0 +1,20 @@ +"""Helpers for assertions.""" + +from typing import Any + + +def assert_only_unstructured(obj: Any): + """Assert the object is comprised of only unstructured data: + + * dicts, lists, tuples + * strings, ints, floats, bools, None + """ + if isinstance(obj, dict): + for k, v in obj.items(): + assert_only_unstructured(k) + assert_only_unstructured(v) + elif isinstance(obj, (list, tuple, frozenset, set)): + for e in obj: + assert_only_unstructured(e) + else: + assert isinstance(obj, (int, float, str, bool, type(None))) diff --git a/tests/test_converter.py b/tests/test_converter.py index 76162aab..6076042f 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -32,6 +32,7 @@ from cattrs.gen import make_dict_structure_fn, override from ._compat import is_py310_plus +from .asserts import assert_only_unstructured from .typed import ( nested_typed_classes, simple_typed_attrs, @@ -54,7 +55,7 @@ def test_simple_roundtrip(cls_and_vals, detailed_validation): cl, vals, kwargs = cls_and_vals inst = cl(*vals, **kwargs) unstructured = converter.unstructure(inst) - assert "Hyp" not in repr(unstructured) + assert_only_unstructured(unstructured) assert inst == converter.structure(unstructured, cl) @@ -73,7 +74,7 @@ def test_simple_roundtrip_tuple(cls_and_vals, dv: bool): cl, vals, _ = cls_and_vals inst = cl(*vals) unstructured = converter.unstructure(inst) - assert "Hyp" not in repr(unstructured) + assert_only_unstructured(unstructured) assert inst == converter.structure(unstructured, cl) @@ -125,7 +126,7 @@ def test_simple_roundtrip_with_extra_keys_forbidden(cls_and_vals, strat): assume(strat is UnstructureStrategy.AS_DICT or not kwargs) inst = cl(*vals, **kwargs) unstructured = converter.unstructure(inst) - assert "Hyp" not in repr(unstructured) + assert_only_unstructured(unstructured) assert inst == converter.structure(unstructured, cl) diff --git a/tests/test_gen_dict.py b/tests/test_gen_dict.py index ef1e5e62..f4143b81 100644 --- a/tests/test_gen_dict.py +++ b/tests/test_gen_dict.py @@ -13,6 +13,7 @@ from cattrs.errors import ClassValidationError, ForbiddenExtraKeysError from cattrs.gen import make_dict_structure_fn, make_dict_unstructure_fn, override +from .asserts import assert_only_unstructured from .typed import nested_typed_classes, simple_typed_classes, simple_typed_dataclasses from .untyped import nested_classes, simple_classes @@ -143,8 +144,7 @@ def test_individual_overrides(converter_cls, cl_and_vals): inst = cl(*vals, **kwargs) res = converter.unstructure(inst) - assert "Hyp" not in repr(res) - assert "Factory" not in repr(res) + assert_only_unstructured(res) for attr, val in zip(fields, vals): if attr.name == chosen_name: @@ -181,7 +181,7 @@ def test_unmodified_generated_structuring(cl_and_vals, dv: bool): unstructured = converter.unstructure(inst) - assert "Hyp" not in repr(unstructured) + assert_only_unstructured(unstructured) converter.register_structure_hook(cl, fn) From a674a30b081bef2eea865887c0b913c7001c5ac5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Wed, 3 Sep 2025 15:37:50 +0200 Subject: [PATCH 2/2] Add tests for `test_assert_only_unstructured_passes_for_primitives` --- tests/{asserts.py => helpers.py} | 2 +- tests/test_converter.py | 2 +- tests/test_gen_dict.py | 2 +- tests/test_helpers.py | 52 ++++++++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 3 deletions(-) rename tests/{asserts.py => helpers.py} (94%) create mode 100644 tests/test_helpers.py diff --git a/tests/asserts.py b/tests/helpers.py similarity index 94% rename from tests/asserts.py rename to tests/helpers.py index 736a2348..d21fb0e5 100644 --- a/tests/asserts.py +++ b/tests/helpers.py @@ -1,4 +1,4 @@ -"""Helpers for assertions.""" +"""Helpers for tests.""" from typing import Any diff --git a/tests/test_converter.py b/tests/test_converter.py index 6076042f..3c5cbe25 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -32,7 +32,7 @@ from cattrs.gen import make_dict_structure_fn, override from ._compat import is_py310_plus -from .asserts import assert_only_unstructured +from .helpers import assert_only_unstructured from .typed import ( nested_typed_classes, simple_typed_attrs, diff --git a/tests/test_gen_dict.py b/tests/test_gen_dict.py index f4143b81..393fcc0f 100644 --- a/tests/test_gen_dict.py +++ b/tests/test_gen_dict.py @@ -13,7 +13,7 @@ from cattrs.errors import ClassValidationError, ForbiddenExtraKeysError from cattrs.gen import make_dict_structure_fn, make_dict_unstructure_fn, override -from .asserts import assert_only_unstructured +from .helpers import assert_only_unstructured from .typed import nested_typed_classes, simple_typed_classes, simple_typed_dataclasses from .untyped import nested_classes, simple_classes diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 00000000..95db7a7c --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,52 @@ +"""Tests for test helpers.""" + +import pytest +from attrs import define + +from .helpers import assert_only_unstructured + + +def test_assert_only_unstructured_passes_for_primitives(): + """assert_only_unstructured should pass for basic Python data types.""" + # Test primitives + assert_only_unstructured(42) + assert_only_unstructured("hello") + assert_only_unstructured(3.14) + assert_only_unstructured(True) + assert_only_unstructured(None) + + # Test collections of primitives + assert_only_unstructured([1, 2, 3]) + assert_only_unstructured({"key": "value", "number": 42}) + assert_only_unstructured((1, "two", 3.0)) + assert_only_unstructured({1, 2, 3}) + assert_only_unstructured(frozenset([1, 2, 3])) + + # Test nested structures + assert_only_unstructured( + {"list": [1, 2, {"nested": "dict"}], "tuple": (True, None), "number": 42} + ) + + +def test_assert_only_unstructured_fails_for_attrs_classes(): + """assert_only_unstructured should fail for attrs classes.""" + + @define + class SimpleAttrsClass: + value: int + + instance = SimpleAttrsClass(42) + + # Should raise AssertionError for attrs class instance + with pytest.raises(AssertionError): + assert_only_unstructured(instance) + + # Should also fail when attrs instance is nested in collections + with pytest.raises(AssertionError): + assert_only_unstructured([instance]) + + with pytest.raises(AssertionError): + assert_only_unstructured({"key": instance}) + + with pytest.raises(AssertionError): + assert_only_unstructured((1, instance, 3))