diff --git a/hypothesis-python/RELEASE.rst b/hypothesis-python/RELEASE.rst new file mode 100644 index 0000000000..4d00de0ece --- /dev/null +++ b/hypothesis-python/RELEASE.rst @@ -0,0 +1,3 @@ +RELEASE_TYPE: patch + +This patch fixes a bug introduced in :ref:`version 6.92.0 `, where using :func:`~python:dataclasses.dataclass` with a :class:`~python:collections.defaultdict` field as a strategy argument would error. diff --git a/hypothesis-python/src/hypothesis/internal/compat.py b/hypothesis-python/src/hypothesis/internal/compat.py index 3eaed1eba1..41e8ce61d1 100644 --- a/hypothesis-python/src/hypothesis/internal/compat.py +++ b/hypothesis-python/src/hypothesis/internal/compat.py @@ -9,6 +9,8 @@ # obtain one at https://mozilla.org/MPL/2.0/. import codecs +import copy +import dataclasses import inspect import platform import sys @@ -188,3 +190,46 @@ def bad_django_TestCase(runner): from hypothesis.extra.django._impl import HypothesisTestCase return not isinstance(runner, HypothesisTestCase) + + +# see issue #3812 +if sys.version_info[:2] < (3, 12): + + def dataclass_asdict(obj, *, dict_factory=dict): + """ + A vendored variant of dataclasses.asdict. Includes the bugfix for + defaultdicts (cpython/32056) for all versions. See also issues/3812. + + This should be removed whenever we drop support for 3.11. We can use the + standard dataclasses.asdict after that point. + """ + if not dataclasses._is_dataclass_instance(obj): # pragma: no cover + raise TypeError("asdict() should be called on dataclass instances") + return _asdict_inner(obj, dict_factory) + +else: # pragma: no cover + dataclass_asdict = dataclasses.asdict + + +def _asdict_inner(obj, dict_factory): + if dataclasses._is_dataclass_instance(obj): + return dict_factory( + (f.name, _asdict_inner(getattr(obj, f.name), dict_factory)) + for f in dataclasses.fields(obj) + ) + elif isinstance(obj, tuple) and hasattr(obj, "_fields"): + return type(obj)(*[_asdict_inner(v, dict_factory) for v in obj]) + elif isinstance(obj, (list, tuple)): + return type(obj)(_asdict_inner(v, dict_factory) for v in obj) + elif isinstance(obj, dict): + if hasattr(type(obj), "default_factory"): + result = type(obj)(obj.default_factory) + for k, v in obj.items(): + result[_asdict_inner(k, dict_factory)] = _asdict_inner(v, dict_factory) + return result + return type(obj)( + (_asdict_inner(k, dict_factory), _asdict_inner(v, dict_factory)) + for k, v in obj.items() + ) + else: + return copy.deepcopy(obj) diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/core.py b/hypothesis-python/src/hypothesis/strategies/_internal/core.py index a5a862635a..03653c61e1 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/core.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/core.py @@ -77,6 +77,7 @@ from hypothesis.internal.conjecture.utils import calc_label_from_cls, check_sample from hypothesis.internal.entropy import get_seeder_and_restorer from hypothesis.internal.floats import float_of +from hypothesis.internal.observability import TESTCASE_CALLBACKS from hypothesis.internal.reflection import ( define_function_signature, get_pretty_function_description, @@ -2103,7 +2104,9 @@ def draw(self, strategy: SearchStrategy[Ex], label: Any = None) -> Ex: self.count += 1 printer = RepresentationPrinter(context=current_build_context()) desc = f"Draw {self.count}{'' if label is None else f' ({label})'}: " - self.conjecture_data._observability_args[desc] = to_jsonable(result) + if TESTCASE_CALLBACKS: + self.conjecture_data._observability_args[desc] = to_jsonable(result) + printer.text(desc) printer.pretty(result) note(printer.getvalue()) diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/utils.py b/hypothesis-python/src/hypothesis/strategies/_internal/utils.py index 995b179b40..b2a7661cd6 100644 --- a/hypothesis-python/src/hypothesis/strategies/_internal/utils.py +++ b/hypothesis-python/src/hypothesis/strategies/_internal/utils.py @@ -16,6 +16,7 @@ import attr from hypothesis.internal.cache import LRUReusedCache +from hypothesis.internal.compat import dataclass_asdict from hypothesis.internal.floats import float_to_int from hypothesis.internal.reflection import proxies from hypothesis.vendor.pretty import pretty @@ -177,7 +178,7 @@ def to_jsonable(obj: object) -> object: and dcs.is_dataclass(obj) and not isinstance(obj, type) ): - return to_jsonable(dcs.asdict(obj)) + return to_jsonable(dataclass_asdict(obj)) if attr.has(type(obj)): return to_jsonable(attr.asdict(obj, recurse=False)) # type: ignore if (pyd := sys.modules.get("pydantic")) and isinstance(obj, pyd.BaseModel): diff --git a/hypothesis-python/tests/cover/test_compat.py b/hypothesis-python/tests/cover/test_compat.py index 17e0a73469..23612a516c 100644 --- a/hypothesis-python/tests/cover/test_compat.py +++ b/hypothesis-python/tests/cover/test_compat.py @@ -9,6 +9,7 @@ # obtain one at https://mozilla.org/MPL/2.0/. import math +from collections import defaultdict, namedtuple from dataclasses import dataclass from functools import partial from inspect import Parameter, Signature, signature @@ -16,7 +17,7 @@ import pytest -from hypothesis.internal.compat import ceil, floor, get_type_hints +from hypothesis.internal.compat import ceil, dataclass_asdict, floor, get_type_hints floor_ceil_values = [ -10.7, @@ -106,3 +107,24 @@ def func(a, b: int, *c: str, d: Optional[int] = None): ) def test_get_hints_through_partial(pf, names): assert set(get_type_hints(pf)) == set(names.split()) + + +@dataclass +class FilledWithStuff: + a: list + b: tuple + c: namedtuple + d: dict + e: defaultdict + + +def test_dataclass_asdict(): + ANamedTuple = namedtuple("ANamedTuple", ("with_some_field")) + obj = FilledWithStuff(a=[1], b=(2), c=ANamedTuple(3), d={4: 5}, e=defaultdict(list)) + assert dataclass_asdict(obj) == { + "a": [1], + "b": (2), + "c": ANamedTuple(3), + "d": {4: 5}, + "e": {}, + } diff --git a/hypothesis-python/tests/cover/test_searchstrategy.py b/hypothesis-python/tests/cover/test_searchstrategy.py index b6b6c498e9..b0f49c2520 100644 --- a/hypothesis-python/tests/cover/test_searchstrategy.py +++ b/hypothesis-python/tests/cover/test_searchstrategy.py @@ -8,9 +8,11 @@ # v. 2.0. If a copy of the MPL was not distributed with this file, You can # obtain one at https://mozilla.org/MPL/2.0/. +import dataclasses import functools -from collections import namedtuple +from collections import defaultdict, namedtuple +import attr import pytest from hypothesis.errors import InvalidArgument @@ -90,3 +92,30 @@ def test_flatmap_with_invalid_expand(): def test_jsonable(): assert isinstance(to_jsonable(object()), str) + + +@dataclasses.dataclass() +class HasDefaultDict: + x: defaultdict + + +@attr.s +class AttrsClass: + n = attr.ib() + + +def test_jsonable_defaultdict(): + obj = HasDefaultDict(defaultdict(list)) + obj.x["a"] = [42] + assert to_jsonable(obj) == {"x": {"a": [42]}} + + +def test_jsonable_attrs(): + obj = AttrsClass(n=10) + assert to_jsonable(obj) == {"n": 10} + + +def test_jsonable_namedtuple(): + Obj = namedtuple("Obj", ("x")) + obj = Obj(10) + assert to_jsonable(obj) == {"x": 10}