Skip to content

Commit 77e3d4a

Browse files
committed
Improve pprint/to_jsonable symbolics
1 parent 755486a commit 77e3d4a

File tree

7 files changed

+60
-47
lines changed

7 files changed

+60
-47
lines changed

hypothesis-python/RELEASE.rst

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
RELEASE_TYPE: patch
2+
3+
This patch improves the interaction between the :pypi:`hypothesis-crosshair`
4+
:ref:`backend <alternative-backends>` and :ref:`our observability tools <observability>`.

hypothesis-python/src/hypothesis/core.py

+17-23
Original file line numberDiff line numberDiff line change
@@ -955,27 +955,20 @@ def run(data):
955955
printer.text("Trying example:")
956956

957957
if self.print_given_args:
958-
if data.provider.avoid_realization and not print_example:
959-
# we can do better here by adding
960-
# avoid_realization: bool = False to repr_call, which
961-
# maintains args/kwargs structure (and comments) but shows
962-
# <symbolic> in place of values. For now, this at least
963-
# avoids realization with verbosity <= verbose.
964-
printer.text(" <symbolics>")
965-
else:
966-
printer.text(" ")
967-
printer.repr_call(
968-
test.__name__,
969-
args,
970-
kwargs,
971-
force_split=True,
972-
arg_slices=argslices,
973-
leading_comment=(
974-
"# " + context.data.slice_comments[(0, 0)]
975-
if (0, 0) in context.data.slice_comments
976-
else None
977-
),
978-
)
958+
printer.text(" ")
959+
printer.repr_call(
960+
test.__name__,
961+
args,
962+
kwargs,
963+
force_split=True,
964+
arg_slices=argslices,
965+
leading_comment=(
966+
"# " + context.data.slice_comments[(0, 0)]
967+
if (0, 0) in context.data.slice_comments
968+
else None
969+
),
970+
avoid_realization=data.provider.avoid_realization,
971+
)
979972
report(printer.getvalue())
980973

981974
if TESTCASE_CALLBACKS:
@@ -991,11 +984,12 @@ def run(data):
991984
if (0, 0) in context.data.slice_comments
992985
else None
993986
),
987+
avoid_realization=data.provider.avoid_realization,
994988
)
995989
self._string_repr = printer.getvalue()
996990
data._observability_arguments = {
997-
**dict(enumerate(map(to_jsonable, args))),
998-
**{k: to_jsonable(v) for k, v in kwargs.items()},
991+
k: to_jsonable(v, avoid_realization=data.provider.avoid_realization)
992+
for k, v in [*enumerate(args), *kwargs.items()]
999993
}
1000994

1001995
try:

hypothesis-python/src/hypothesis/internal/conjecture/data.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -1136,7 +1136,8 @@ def draw(
11361136
)
11371137
raise
11381138
if TESTCASE_CALLBACKS:
1139-
self._observability_args[key] = to_jsonable(v)
1139+
avoid = self.provider.avoid_realization
1140+
self._observability_args[key] = to_jsonable(v, avoid_realization=avoid)
11401141
return v
11411142
finally:
11421143
self.stop_span()

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

+16-11
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import sys
1212
import threading
13+
from functools import partial
1314
from inspect import signature
1415
from typing import TYPE_CHECKING, Callable
1516

@@ -156,7 +157,7 @@ def accept(*args, **kwargs):
156157
return decorator
157158

158159

159-
def to_jsonable(obj: object) -> object:
160+
def to_jsonable(obj: object, *, avoid_realization: bool) -> object:
160161
"""Recursively convert an object to json-encodable form.
161162
162163
This is not intended to round-trip, but rather provide an analysis-ready
@@ -165,26 +166,30 @@ def to_jsonable(obj: object) -> object:
165166
"""
166167
if isinstance(obj, (str, int, float, bool, type(None))):
167168
if isinstance(obj, int) and abs(obj) >= 2**63:
168-
# Silently clamp very large ints to max_float, to avoid
169-
# OverflowError when casting to float.
169+
# Silently clamp very large ints to max_float, to avoid OverflowError when
170+
# casting to float. (but avoid adding more constraints to symbolic values)
171+
if avoid_realization:
172+
return "<symbolic>"
170173
obj = clamp(-sys.float_info.max, obj, sys.float_info.max)
171174
return float(obj)
172175
return obj
176+
if avoid_realization:
177+
return "<symbolic>"
178+
recur = partial(to_jsonable, avoid_realization=avoid_realization)
173179
if isinstance(obj, (list, tuple, set, frozenset)):
174180
if isinstance(obj, tuple) and hasattr(obj, "_asdict"):
175-
return to_jsonable(obj._asdict()) # treat namedtuples as dicts
176-
return [to_jsonable(x) for x in obj]
181+
return recur(obj._asdict()) # treat namedtuples as dicts
182+
return [recur(x) for x in obj]
177183
if isinstance(obj, dict):
178184
return {
179-
k if isinstance(k, str) else pretty(k): to_jsonable(v)
180-
for k, v in obj.items()
185+
k if isinstance(k, str) else pretty(k): recur(v) for k, v in obj.items()
181186
}
182187

183188
# Hey, might as well try calling a .to_json() method - it works for Pandas!
184189
# We try this before the below general-purpose handlers to give folks a
185190
# chance to control this behavior on their custom classes.
186191
try:
187-
return to_jsonable(obj.to_json()) # type: ignore
192+
return recur(obj.to_json()) # type: ignore
188193
except Exception:
189194
pass
190195

@@ -194,11 +199,11 @@ def to_jsonable(obj: object) -> object:
194199
and dcs.is_dataclass(obj)
195200
and not isinstance(obj, type)
196201
):
197-
return to_jsonable(dataclass_asdict(obj))
202+
return recur(dataclass_asdict(obj))
198203
if attr.has(type(obj)):
199-
return to_jsonable(attr.asdict(obj, recurse=False)) # type: ignore
204+
return recur(attr.asdict(obj, recurse=False)) # type: ignore
200205
if (pyd := sys.modules.get("pydantic")) and isinstance(obj, pyd.BaseModel):
201-
return to_jsonable(obj.model_dump())
206+
return recur(obj.model_dump())
202207

203208
# If all else fails, we'll just pretty-print as a string.
204209
return pretty(obj)

hypothesis-python/src/hypothesis/vendor/pretty.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,7 @@ def repr_call(
447447
force_split: Optional[bool] = None,
448448
arg_slices: Optional[dict[str, tuple[int, int]]] = None,
449449
leading_comment: Optional[str] = None,
450+
avoid_realization: bool = False,
450451
) -> None:
451452
"""Helper function to represent a function call.
452453
@@ -494,7 +495,10 @@ def repr_call(
494495
self.breakable(" " if i else "")
495496
if k:
496497
self.text(f"{k}=")
497-
self.pretty(v)
498+
if avoid_realization:
499+
self.text("<symbolic>")
500+
else:
501+
self.pretty(v)
498502
if force_split or i + 1 < len(all_args):
499503
self.text(",")
500504
comment = None

hypothesis-python/tests/conjecture/test_alt_backend.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -444,7 +444,7 @@ def test_function(f):
444444

445445
with capture_out() as out:
446446
test_function()
447-
assert "Trying example: <symbolics>" in out.getvalue()
447+
assert "Trying example: test_function(\n f=<symbolic>,\n)" in out.getvalue()
448448

449449

450450
@pytest.mark.parametrize("verbosity", [Verbosity.verbose, Verbosity.debug])

hypothesis-python/tests/cover/test_searchstrategy.py

+15-10
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@ def inner(d):
114114

115115

116116
def test_jsonable():
117-
assert isinstance(to_jsonable(object()), str)
117+
assert to_jsonable(object(), avoid_realization=True) == "<symbolic>"
118+
assert isinstance(to_jsonable(object(), avoid_realization=False), str)
118119

119120

120121
@dataclasses.dataclass()
@@ -130,36 +131,39 @@ class AttrsClass:
130131
def test_jsonable_defaultdict():
131132
obj = HasDefaultDict(defaultdict(list))
132133
obj.x["a"] = [42]
133-
assert to_jsonable(obj) == {"x": {"a": [42]}}
134+
assert to_jsonable(obj, avoid_realization=False) == {"x": {"a": [42]}}
134135

135136

136137
def test_jsonable_attrs():
137138
obj = AttrsClass(n=10)
138-
assert to_jsonable(obj) == {"n": 10}
139+
assert to_jsonable(obj, avoid_realization=False) == {"n": 10}
139140

140141

141142
def test_jsonable_namedtuple():
142143
Obj = namedtuple("Obj", ("x"))
143144
obj = Obj(10)
144-
assert to_jsonable(obj) == {"x": 10}
145+
assert to_jsonable(obj, avoid_realization=False) == {"x": 10}
145146

146147

147148
def test_jsonable_small_ints_are_ints():
148149
n = 2**62
149-
assert isinstance(to_jsonable(n), int)
150-
assert to_jsonable(n) == n
150+
for avoid in (True, False):
151+
assert isinstance(to_jsonable(n, avoid_realization=avoid), int)
152+
assert to_jsonable(n, avoid_realization=avoid) == n
151153

152154

153155
def test_jsonable_large_ints_are_floats():
154156
n = 2**63
155-
assert isinstance(to_jsonable(n), float)
156-
assert to_jsonable(n) == float(n)
157+
assert isinstance(to_jsonable(n, avoid_realization=False), float)
158+
assert to_jsonable(n, avoid_realization=False) == float(n)
159+
assert to_jsonable(n, avoid_realization=True) == "<symbolic>"
157160

158161

159162
def test_jsonable_very_large_ints():
160163
# previously caused OverflowError when casting to float.
161164
n = 2**1024
162-
assert to_jsonable(n) == sys.float_info.max
165+
assert to_jsonable(n, avoid_realization=False) == sys.float_info.max
166+
assert to_jsonable(n, avoid_realization=True) == "<symbolic>"
163167

164168

165169
@dataclasses.dataclass()
@@ -172,4 +176,5 @@ def to_json(self):
172176

173177
def test_jsonable_override():
174178
obj = HasCustomJsonFormat("expected")
175-
assert to_jsonable(obj) == "surprise!"
179+
assert to_jsonable(obj, avoid_realization=False) == "surprise!"
180+
assert to_jsonable(obj, avoid_realization=True) == "<symbolic>"

0 commit comments

Comments
 (0)