Skip to content

Commit 306a57a

Browse files
authored
Merge pull request #4694 from HypothesisWorks/DRMacIver/better-reprs-for-all
Various improvements in printing test cases
2 parents 7c23635 + 56e2eed commit 306a57a

7 files changed

Lines changed: 353 additions & 34 deletions

File tree

hypothesis-python/RELEASE.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
RELEASE_TYPE: patch
2+
3+
This release further improves printing of generated values, building on the changes
4+
in :version:`6.151.11`.
5+
6+
Principle changes:
7+
8+
* In many cases where we would have printed a complex expression
9+
producing a value, we now print the repr (or a pretty-printed version of it).
10+
* Additionally, in some cases where we would print a complex expression that involved
11+
a lambda, we are now able to simplify that expression into a more readable one.

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,14 @@ def __next__(self):
455455
def __repr__(self) -> str:
456456
return f"iter({self._values!r})"
457457

458+
def _repr_pretty_(self, printer, cycle):
459+
if cycle:
460+
printer.text("iter(...)")
461+
else:
462+
printer.text("iter(")
463+
printer.pretty(self._values)
464+
printer.text(")")
465+
458466

459467
@defines_strategy()
460468
def iterables(

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

Lines changed: 113 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,6 @@ def _repr_pretty_(self, p, cycle):
9393
"pretty",
9494
]
9595

96-
PRIMITIVE_TYPES_ALWAYS_USE_REPR = (
97-
int,
98-
float,
99-
str,
100-
bytes,
101-
bool,
102-
type(None),
103-
)
104-
10596

10697
def _safe_getattr(obj: object, attr: str, default: Any | None = None) -> Any:
10798
"""Safe version of getattr.
@@ -134,6 +125,98 @@ def __eq__(self, __o: object) -> bool:
134125
return isinstance(__o, type(self)) and id(self.value) == id(__o.value)
135126

136127

128+
def _try_inline_lambda(
129+
func_name: str,
130+
args: Sequence[object],
131+
kwargs: dict[str, object],
132+
printer: "RepresentationPrinter",
133+
) -> bool:
134+
"""Try to inline single-use lambda arguments into the body expression.
135+
136+
Given e.g. func_name="lambda b: hashlib.sha256(b).hexdigest()" with
137+
args=(b'',), returns the printer output for "hashlib.sha256(b'').hexdigest()"
138+
by substituting the argument repr into the AST.
139+
140+
Returns True if inlining succeeded (the printer has been written to),
141+
False if inlining is not possible (parse failure, multi-use params, etc).
142+
"""
143+
try:
144+
tree = ast.parse(func_name, mode="eval")
145+
except Exception:
146+
return False
147+
lam = tree.body
148+
if not isinstance(lam, ast.Lambda):
149+
return False
150+
151+
# Build param name -> argument repr mapping, matching Python call semantics
152+
params = lam.args
153+
if params.vararg or params.kwonlyargs or params.kw_defaults or params.kwarg:
154+
return False
155+
156+
param_names = [p.arg for p in params.args]
157+
# params.defaults are right-aligned: if there are 3 params and 1 default,
158+
# params.defaults applies to the last param only.
159+
n_defaults = len(params.defaults)
160+
has_default = (
161+
set(param_names[len(param_names) - n_defaults :]) if n_defaults else set()
162+
)
163+
164+
# Bail if there are more positional args than parameters, or if any
165+
# kwarg doesn't match a parameter name — these can't be inlined.
166+
if len(args) > len(param_names):
167+
return False
168+
if any(k not in param_names for k in kwargs):
169+
return False
170+
171+
arg_reprs: dict[str, str] = {}
172+
for i, name in enumerate(param_names):
173+
if i < len(args):
174+
arg_reprs[name] = pretty(args[i])
175+
elif name in kwargs:
176+
arg_reprs[name] = pretty(kwargs[name])
177+
elif name in has_default:
178+
pass # not passed, will use its default — just skip
179+
else:
180+
return False
181+
182+
# Bail if any repr is not valid Python (e.g. "HypothesisRandom(generated data)")
183+
for repr_str in arg_reprs.values():
184+
try:
185+
ast.parse(repr_str, mode="eval")
186+
except Exception:
187+
return False
188+
189+
use_counts = dict.fromkeys(param_names, 0)
190+
for node in ast.walk(lam.body):
191+
if isinstance(node, ast.Name) and node.id in use_counts:
192+
use_counts[node.id] += 1
193+
194+
# Bail if any parameter is used more than once (avoid duplicating expressions)
195+
if any(count > 1 for count in use_counts.values()):
196+
return False
197+
198+
# Substitute argument reprs into the body AST
199+
class _Inliner(ast.NodeTransformer):
200+
def visit_Name(self, node: ast.Name) -> ast.AST:
201+
if node.id in arg_reprs:
202+
# Parse the repr as an expression and splice it in.
203+
# Wrap in parens to preserve precedence in all contexts.
204+
replacement = ast.parse(arg_reprs[node.id], mode="eval").body
205+
return ast.copy_location(replacement, node)
206+
return node
207+
208+
new_body = _Inliner().visit(lam.body)
209+
ast.fix_missing_locations(new_body)
210+
211+
try:
212+
result = ast.unparse(new_body)
213+
except Exception:
214+
return False
215+
216+
printer.text(result)
217+
return True
218+
219+
137220
class RepresentationPrinter:
138221
"""Special pretty printer that has a `pretty` method that calls the pretty
139222
printer for a python object.
@@ -428,11 +511,12 @@ def maybe_repr_known_object_as_call(
428511
kwargs: dict[str, object],
429512
arg_labels: ArgLabelsT | None = None,
430513
) -> None:
431-
if isinstance(obj, PRIMITIVE_TYPES_ALWAYS_USE_REPR):
432-
return _repr_pprint(obj, self, cycle)
433-
434-
# pprint this object as a call, _unless_ the call would be invalid syntax
435-
# and the repr would be valid and there are not comments on arguments.
514+
# pprint this object as a call if it seems like a good idea to do so,
515+
# otherwise pprint as repr.
516+
# Rules:
517+
# 1. If there are comments, we *must* print as a call.
518+
# 2. Prefer valid syntax to invalid syntax.
519+
# 3. Prefer shorter expressions.
436520
if cycle:
437521
return self.text("<...>")
438522
# Look up comments from slice_comments if we have arg_labels
@@ -452,6 +536,8 @@ def maybe_repr_known_object_as_call(
452536
p.known_object_printers = self.known_object_printers
453537
p.repr_call(name, args, kwargs)
454538
# If the call is not valid syntax, use the repr
539+
if len(repr(obj)) < len(p.getvalue()):
540+
return _repr_pprint(obj, self, cycle)
455541
try:
456542
ast.parse(p.getvalue())
457543
except Exception:
@@ -479,6 +565,19 @@ def repr_call(
479565
"""
480566
assert isinstance(func_name, str)
481567
if func_name.startswith(("lambda:", "lambda ")):
568+
# Before wrapping the lambda in parens for a call, try to inline
569+
# arguments that are used exactly once in the body. If all args
570+
# get inlined, we can emit just the body expression with no call.
571+
# Skip inlining only when there are actual comments on arguments,
572+
# since comments need the call-style repr to attach to.
573+
has_comments = arg_slices and any(
574+
sr in self.slice_comments and sr not in self._commented_slices
575+
for sr in arg_slices.values()
576+
)
577+
if not has_comments:
578+
inlined = _try_inline_lambda(func_name, args, kwargs, self)
579+
if inlined:
580+
return
482581
func_name = f"({func_name})"
483582
self.text(func_name)
484583
# Build list of (label, value) pairs. Labels are "arg[i]" for positional

hypothesis-python/tests/cover/__snapshots__/test_custom_reprs.ambr

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
# serializer version: 1
2+
# name: test_invalid_call_syntax_falls_back_to_repr
3+
'''
4+
Falsifying example: inner(
5+
x='hello world',
6+
)
7+
'''
8+
# ---
29
# name: test_map_to_bytes_prints_as_repr
310
'''
411
Falsifying example: inner(
5-
b=b"\xe3\xb0\xc4B\x98\xfc\x1c\x14\x9a\xfb\xf4\xc8\x99o\xb9$'\xaeA\xe4d\x9b\x93L\xa4\x95\x99\x1bxR\xb8U",
12+
b=hashlib.sha256(b'').digest(),
613
)
714
'''
815
# ---

hypothesis-python/tests/cover/test_custom_reprs.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,20 @@ def inner(b):
192192
raise AssertionError
193193

194194
assert _get_output(inner) == snapshot
195+
196+
197+
def test_invalid_call_syntax_falls_back_to_repr(snapshot):
198+
# When the call-style repr (e.g. "a b()") is invalid Python syntax but
199+
# repr(obj) is valid, we should fall back to using repr(obj).
200+
_BadName = type(
201+
"a b",
202+
(),
203+
{"__repr__": lambda self: "'hello world'"},
204+
)
205+
206+
@given(x=st.builds(_BadName))
207+
@settings(phases=[Phase.generate, Phase.shrink], print_blob=False)
208+
def inner(x):
209+
raise AssertionError
210+
211+
assert _get_output(inner) == snapshot

0 commit comments

Comments
 (0)