Skip to content

Commit 867e56a

Browse files
authored
Merge pull request #3813 from tybug/json-defaultdict
Vendor `dataclasses.asdict`
2 parents fdfa7c3 + 3ee5f1a commit 867e56a

File tree

6 files changed

+107
-4
lines changed

6 files changed

+107
-4
lines changed

hypothesis-python/RELEASE.rst

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
RELEASE_TYPE: patch
2+
3+
This patch fixes a bug introduced in :ref:`version 6.92.0 <v6.92.0>`, where using :func:`~python:dataclasses.dataclass` with a :class:`~python:collections.defaultdict` field as a strategy argument would error.

hypothesis-python/src/hypothesis/internal/compat.py

+45
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
# obtain one at https://mozilla.org/MPL/2.0/.
1010

1111
import codecs
12+
import copy
13+
import dataclasses
1214
import inspect
1315
import platform
1416
import sys
@@ -188,3 +190,46 @@ def bad_django_TestCase(runner):
188190
from hypothesis.extra.django._impl import HypothesisTestCase
189191

190192
return not isinstance(runner, HypothesisTestCase)
193+
194+
195+
# see issue #3812
196+
if sys.version_info[:2] < (3, 12):
197+
198+
def dataclass_asdict(obj, *, dict_factory=dict):
199+
"""
200+
A vendored variant of dataclasses.asdict. Includes the bugfix for
201+
defaultdicts (cpython/32056) for all versions. See also issues/3812.
202+
203+
This should be removed whenever we drop support for 3.11. We can use the
204+
standard dataclasses.asdict after that point.
205+
"""
206+
if not dataclasses._is_dataclass_instance(obj): # pragma: no cover
207+
raise TypeError("asdict() should be called on dataclass instances")
208+
return _asdict_inner(obj, dict_factory)
209+
210+
else: # pragma: no cover
211+
dataclass_asdict = dataclasses.asdict
212+
213+
214+
def _asdict_inner(obj, dict_factory):
215+
if dataclasses._is_dataclass_instance(obj):
216+
return dict_factory(
217+
(f.name, _asdict_inner(getattr(obj, f.name), dict_factory))
218+
for f in dataclasses.fields(obj)
219+
)
220+
elif isinstance(obj, tuple) and hasattr(obj, "_fields"):
221+
return type(obj)(*[_asdict_inner(v, dict_factory) for v in obj])
222+
elif isinstance(obj, (list, tuple)):
223+
return type(obj)(_asdict_inner(v, dict_factory) for v in obj)
224+
elif isinstance(obj, dict):
225+
if hasattr(type(obj), "default_factory"):
226+
result = type(obj)(obj.default_factory)
227+
for k, v in obj.items():
228+
result[_asdict_inner(k, dict_factory)] = _asdict_inner(v, dict_factory)
229+
return result
230+
return type(obj)(
231+
(_asdict_inner(k, dict_factory), _asdict_inner(v, dict_factory))
232+
for k, v in obj.items()
233+
)
234+
else:
235+
return copy.deepcopy(obj)

hypothesis-python/src/hypothesis/strategies/_internal/core.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
from hypothesis.internal.conjecture.utils import calc_label_from_cls, check_sample
7878
from hypothesis.internal.entropy import get_seeder_and_restorer
7979
from hypothesis.internal.floats import float_of
80+
from hypothesis.internal.observability import TESTCASE_CALLBACKS
8081
from hypothesis.internal.reflection import (
8182
define_function_signature,
8283
get_pretty_function_description,
@@ -2103,7 +2104,9 @@ def draw(self, strategy: SearchStrategy[Ex], label: Any = None) -> Ex:
21032104
self.count += 1
21042105
printer = RepresentationPrinter(context=current_build_context())
21052106
desc = f"Draw {self.count}{'' if label is None else f' ({label})'}: "
2106-
self.conjecture_data._observability_args[desc] = to_jsonable(result)
2107+
if TESTCASE_CALLBACKS:
2108+
self.conjecture_data._observability_args[desc] = to_jsonable(result)
2109+
21072110
printer.text(desc)
21082111
printer.pretty(result)
21092112
note(printer.getvalue())

hypothesis-python/src/hypothesis/strategies/_internal/utils.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import attr
1717

1818
from hypothesis.internal.cache import LRUReusedCache
19+
from hypothesis.internal.compat import dataclass_asdict
1920
from hypothesis.internal.floats import float_to_int
2021
from hypothesis.internal.reflection import proxies
2122
from hypothesis.vendor.pretty import pretty
@@ -177,7 +178,7 @@ def to_jsonable(obj: object) -> object:
177178
and dcs.is_dataclass(obj)
178179
and not isinstance(obj, type)
179180
):
180-
return to_jsonable(dcs.asdict(obj))
181+
return to_jsonable(dataclass_asdict(obj))
181182
if attr.has(type(obj)):
182183
return to_jsonable(attr.asdict(obj, recurse=False)) # type: ignore
183184
if (pyd := sys.modules.get("pydantic")) and isinstance(obj, pyd.BaseModel):

hypothesis-python/tests/cover/test_compat.py

+23-1
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@
99
# obtain one at https://mozilla.org/MPL/2.0/.
1010

1111
import math
12+
from collections import defaultdict, namedtuple
1213
from dataclasses import dataclass
1314
from functools import partial
1415
from inspect import Parameter, Signature, signature
1516
from typing import ForwardRef, Optional, Union
1617

1718
import pytest
1819

19-
from hypothesis.internal.compat import ceil, floor, get_type_hints
20+
from hypothesis.internal.compat import ceil, dataclass_asdict, floor, get_type_hints
2021

2122
floor_ceil_values = [
2223
-10.7,
@@ -106,3 +107,24 @@ def func(a, b: int, *c: str, d: Optional[int] = None):
106107
)
107108
def test_get_hints_through_partial(pf, names):
108109
assert set(get_type_hints(pf)) == set(names.split())
110+
111+
112+
@dataclass
113+
class FilledWithStuff:
114+
a: list
115+
b: tuple
116+
c: namedtuple
117+
d: dict
118+
e: defaultdict
119+
120+
121+
def test_dataclass_asdict():
122+
ANamedTuple = namedtuple("ANamedTuple", ("with_some_field"))
123+
obj = FilledWithStuff(a=[1], b=(2), c=ANamedTuple(3), d={4: 5}, e=defaultdict(list))
124+
assert dataclass_asdict(obj) == {
125+
"a": [1],
126+
"b": (2),
127+
"c": ANamedTuple(3),
128+
"d": {4: 5},
129+
"e": {},
130+
}

hypothesis-python/tests/cover/test_searchstrategy.py

+30-1
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
99
# obtain one at https://mozilla.org/MPL/2.0/.
1010

11+
import dataclasses
1112
import functools
12-
from collections import namedtuple
13+
from collections import defaultdict, namedtuple
1314

15+
import attr
1416
import pytest
1517

1618
from hypothesis.errors import InvalidArgument
@@ -90,3 +92,30 @@ def test_flatmap_with_invalid_expand():
9092

9193
def test_jsonable():
9294
assert isinstance(to_jsonable(object()), str)
95+
96+
97+
@dataclasses.dataclass()
98+
class HasDefaultDict:
99+
x: defaultdict
100+
101+
102+
@attr.s
103+
class AttrsClass:
104+
n = attr.ib()
105+
106+
107+
def test_jsonable_defaultdict():
108+
obj = HasDefaultDict(defaultdict(list))
109+
obj.x["a"] = [42]
110+
assert to_jsonable(obj) == {"x": {"a": [42]}}
111+
112+
113+
def test_jsonable_attrs():
114+
obj = AttrsClass(n=10)
115+
assert to_jsonable(obj) == {"n": 10}
116+
117+
118+
def test_jsonable_namedtuple():
119+
Obj = namedtuple("Obj", ("x"))
120+
obj = Obj(10)
121+
assert to_jsonable(obj) == {"x": 10}

0 commit comments

Comments
 (0)