Repository: HypothesisWorks/hypothesis

Issue:
`TypeError` when drawing from a strategy that produces `dataclasses` with a `defaultdict` attribute
Since Hypothesis `6.92.0`, some [tests](https://github.com/schemathesis/schemathesis/actions/runs/7162876716/job/19500510033) in Schematesis started to fail with a traceback like this:

```python-traceback
@given(data=st.data())
  >   @settings(deadline=None)
  
  test/test_hypothesis.py:450: 
  _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
  test/test_hypothesis.py:476: in test_date_format
      case = data.draw(strategy)
  .tox/py38/lib/python3.8/site-packages/hypothesis/strategies/_internal/core.py:2106: in draw
      self.conjecture_data._observability_args[desc] = to_jsonable(result)
  .tox/py38/lib/python3.8/site-packages/hypothesis/strategies/_internal/utils.py:180: in to_jsonable
      return to_jsonable(dcs.asdict(obj))
  /opt/hostedtoolcache/Python/3.8.18/x64/lib/python3.8/dataclasses.py:10[73](https://github.com/schemathesis/schemathesis/actions/runs/7162876716/job/19500510033#step:6:74): in asdict
      return _asdict_inner(obj, dict_factory)
  /opt/hostedtoolcache/Python/3.8.18/x64/lib/python3.8/dataclasses.py:10[80](https://github.com/schemathesis/schemathesis/actions/runs/7162876716/job/19500510033#step:6:81): in _asdict_inner
      value = _asdict_inner(getattr(obj, f.name), dict_factory)
  /opt/hostedtoolcache/Python/3.8.18/x64/lib/python3.8/dataclasses.py:1080: in _asdict_inner
      value = _asdict_inner(getattr(obj, f.name), dict_factory)
  /opt/hostedtoolcache/Python/3.8.18/x64/lib/python3.8/dataclasses.py:1080: in _asdict_inner
      value = _asdict_inner(getattr(obj, f.name), dict_factory)
  /opt/hostedtoolcache/Python/3.8.18/x64/lib/python3.8/dataclasses.py:1080: in _asdict_inner
      value = _asdict_inner(getattr(obj, f.name), dict_factory)
  _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
  
  obj = defaultdict(<class 'list'>, {}), dict_factory = <class 'dict'>
  
      def _asdict_inner(obj, dict_factory):
          if _is_dataclass_instance(obj):
              result = []
              for f in fields(obj):
                  value = _asdict_inner(getattr(obj, f.name), dict_factory)
                  result.append((f.name, value))
              return dict_factory(result)
          elif isinstance(obj, tuple) and hasattr(obj, '_fields'):
              # obj is a namedtuple.  Recurse into it, but the returned
              # object is another namedtuple of the same type.  This is
              # similar to how other list- or tuple-derived classes are
              # treated (see below), but we just need to create them
              # differently because a namedtuple's __init__ needs to be
              # called differently (see bpo-34363).
      
              # I'm not using namedtuple's _asdict()
              # method, because:
              # - it does not recurse in to the namedtuple fields and
              #   convert them to dicts (using dict_factory).
              # - I don't actually want to return a dict here.  The main
              #   use case here is json.dumps, and it handles converting
              #   namedtuples to lists.  Admittedly we're losing some
              #   information here when we produce a json list instead of a
              #   dict.  Note that if we returned dicts here instead of
              #   namedtuples, we could no longer call asdict() on a data
              #   structure where a namedtuple was used as a dict key.
      
              return type(obj)(*[_asdict_inner(v, dict_factory) for v in obj])
          elif isinstance(obj, (list, tuple)):
              # Assume we can create an object of this type by passing in a
              # generator (which is not true for namedtuples, handled
              # above).
              return type(obj)(_asdict_inner(v, dict_factory) for v in obj)
          elif isinstance(obj, dict):
  >           return type(obj)((_asdict_inner(k, dict_factory),
                                _asdict_inner(v, dict_factory))
                               for k, v in obj.items())
  E           TypeError: first argument must be callable or None
  E           Falsifying example: test_date_format(
  E               data=data(...),
  E           )
```

I am not sure if it is an issue on the Python or Hypothesis side, but it could be reproduced with the following snippet (running under `pytest`):


```python
from collections import defaultdict
from dataclasses import dataclass, field
from hypothesis import strategies as st, given


@dataclass
class T:
    key: defaultdict = field(default_factory=lambda: defaultdict(list))
    

@given(st.data())
def test_t(data):
    data.draw(st.builds(T))
```

Alternatively, I can try to work around this on the Schemathesis side. Let me know what you think! :)


Discussion:
Looks like this is an issue with `dataclasses.asdict` not supporting `defaultdict`. stdlib recently added support for it: https://github.com/python/cpython/pull/32056. I'm not sure which versions that patch landed in, but I can reproduce with `3.8.18`, `3.10.13`, `3.11.5`, but not `3.12.0`, so it seems safe to assume this was fixed only in 3.12.

We could try/catch `dcs.asdict` and fall back to the pretty printer here, or try and vendor the fixed `asdict` method. @Zac-HD what do you think?
I'd vote to vendor the fixed `asdict()` function, and use it on Python < 3.12 - we have many such cases in `compat.py`, even after dropping py2 erased the most annoying cases.  It's only a little more work for us, and a noticeably better experience for users 🙂 

We should IMO _also_ guard [this line](https://github.com/anthropics/anthropic/blob/master/flake8-anthropic/flake8_anthropic/__init__.py) with an `if TESTCASE_CALLBACKS:` for a (very small) perf / stability win in the common case where nobody is going to look at the observability data.  My fault for forgetting to do that originally 😅 

Resolving patch:
diff --git a/hypothesis-python/src/hypothesis/internal/compat.py b/hypothesis-python/src/hypothesis/internal/compat.py
--- 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
--- 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
--- 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):


Test patch:
diff --git a/hypothesis-python/tests/cover/test_compat.py b/hypothesis-python/tests/cover/test_compat.py
--- 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
--- 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}