Skip to content

Commit c4d239d

Browse files
committed
Improve ArgThat repr with resilient predicate labeling
Make ArgThat repr informative by labeling predicate kind and optional source line, e.g. "def is_positive at line N", "lambda at line N", and callable instance labels. This improves diagnostics without requiring custom ArgThat subclasses. Add defensive introspection fallbacks so odd/broken callables do not break repr generation. Handle functools.partial explicitly, and add regression tests covering builtins, numpy ufuncs, partial numpy functions, and broken __name__ introspection.
1 parent e02b97c commit c4d239d

File tree

2 files changed

+162
-2
lines changed

2 files changed

+162
-2
lines changed

mockito/matchers.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
"""
6161

6262
from abc import ABC, abstractmethod
63+
import functools
6364
import re
6465
builtin_any = any
6566

@@ -223,7 +224,83 @@ def matches(self, arg):
223224
return self.predicate(arg)
224225

225226
def __repr__(self):
226-
return "<ArgThat>"
227+
return "<ArgThat: %s>" % _arg_that_predicate_label(self.predicate)
228+
229+
230+
def _arg_that_predicate_label(predicate):
231+
try:
232+
return _arg_that_predicate_label_unchecked(predicate)
233+
except Exception:
234+
predicate_class = _safe_getattr(
235+
_safe_getattr(predicate, '__class__'),
236+
'__name__',
237+
)
238+
if predicate_class is None:
239+
return 'callable'
240+
241+
return 'callable %s' % predicate_class
242+
243+
244+
def _arg_that_predicate_label_unchecked(predicate):
245+
if isinstance(predicate, functools.partial):
246+
return _arg_that_partial_label(predicate)
247+
248+
function_line = _line_of_callable(predicate)
249+
function_name = _safe_getattr(predicate, '__name__')
250+
if function_name is not None:
251+
if function_name == '<lambda>':
252+
return _label_with_line('lambda', function_line)
253+
return _label_with_line('def %s' % function_name, function_line)
254+
255+
predicate_class = _safe_getattr(
256+
_safe_getattr(predicate, '__class__'),
257+
'__name__',
258+
)
259+
if predicate_class is None:
260+
predicate_class = 'object'
261+
262+
call = _safe_getattr(predicate, '__call__')
263+
call_line = _line_of_callable(call)
264+
return _label_with_line(
265+
'callable %s.__call__' % predicate_class,
266+
call_line,
267+
)
268+
269+
270+
def _arg_that_partial_label(predicate):
271+
partial_func = _safe_getattr(predicate, 'func')
272+
partial_name = _safe_getattr(partial_func, '__name__')
273+
274+
if partial_name is not None:
275+
return 'partial %s' % partial_name
276+
277+
return 'partial'
278+
279+
280+
def _line_of_callable(value):
281+
if value is None:
282+
return None
283+
284+
func = _safe_getattr(value, '__func__', value)
285+
code = _safe_getattr(func, '__code__')
286+
if code is None:
287+
return None
288+
289+
return _safe_getattr(code, 'co_firstlineno')
290+
291+
292+
def _safe_getattr(value, name, default=None):
293+
try:
294+
return getattr(value, name)
295+
except Exception:
296+
return default
297+
298+
299+
def _label_with_line(label, line_number):
300+
if line_number is None:
301+
return label
302+
303+
return '%s at line %s' % (label, line_number)
227304

228305

229306
class Contains(Matcher):

tests/matcher_repr_test.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
from functools import partial
12
import re
23

3-
from mockito import and_, any as any_, contains, eq, gt, matches, not_, or_
4+
import numpy as np
5+
6+
from mockito import and_, any as any_, arg_that, contains, eq, gt, matches, not_, or_
47

58

69
def test_value_matchers_use_repr_for_string_values():
@@ -26,3 +29,83 @@ def test_matches_repr_shows_only_explicit_flags():
2629
assert repr(matches("f..", re.IGNORECASE)) == (
2730
f"<Matches: 'f..' flags={int(re.IGNORECASE)}>"
2831
)
32+
33+
34+
def test_arg_that_repr_includes_named_function_name():
35+
# Predicate display name: "def is_positive"
36+
def is_positive(value):
37+
return value > 0
38+
39+
matcher = arg_that(is_positive)
40+
41+
assert repr(matcher) == (
42+
f"<ArgThat: def is_positive at line {is_positive.__code__.co_firstlineno}>"
43+
)
44+
45+
46+
def test_arg_that_repr_includes_lambda_name():
47+
# Predicate display name: "lambda"
48+
predicate = lambda value: value > 0
49+
matcher = arg_that(predicate)
50+
51+
assert repr(matcher) == (
52+
f"<ArgThat: lambda at line {predicate.__code__.co_firstlineno}>"
53+
)
54+
55+
56+
def test_arg_that_repr_for_callable_instance_includes_class_name():
57+
# Predicate display name: "callable IsPositive.__call__"
58+
class IsPositive:
59+
def __call__(self, value):
60+
return value > 0
61+
62+
predicate = IsPositive()
63+
matcher = arg_that(predicate)
64+
65+
assert repr(matcher) == (
66+
"<ArgThat: callable IsPositive.__call__ at line "
67+
f"{predicate.__call__.__func__.__code__.co_firstlineno}>"
68+
)
69+
70+
71+
def test_arg_that_repr_for_builtin_callable_has_no_line_number():
72+
matcher = arg_that(len)
73+
74+
assert repr(matcher) == "<ArgThat: def len>"
75+
76+
77+
def test_arg_that_repr_for_partial_uses_underlying_function_name():
78+
predicate = partial(pow, exp=2)
79+
matcher = arg_that(predicate)
80+
81+
assert repr(matcher) == "<ArgThat: partial pow>"
82+
83+
84+
def test_arg_that_repr_for_numpy_ufunc_uses_function_name_without_line():
85+
matcher = arg_that(np.isfinite)
86+
87+
assert repr(matcher) == "<ArgThat: def isfinite>"
88+
89+
90+
def test_arg_that_repr_for_partial_numpy_function_uses_wrapped_name():
91+
predicate = partial(np.allclose, b=0.0)
92+
matcher = arg_that(predicate)
93+
94+
assert repr(matcher) == "<ArgThat: partial allclose>"
95+
96+
97+
def test_arg_that_repr_handles_callables_with_broken_name_introspection():
98+
class BrokenNameCallable:
99+
def __getattribute__(self, name):
100+
if name == '__name__':
101+
raise RuntimeError("boom")
102+
return super().__getattribute__(name)
103+
104+
def __call__(self, value):
105+
return value > 0
106+
107+
matcher = arg_that(BrokenNameCallable())
108+
109+
matcher_repr = repr(matcher)
110+
assert matcher_repr.startswith("<ArgThat: callable BrokenNameCallable")
111+
assert "__name__" not in matcher_repr

0 commit comments

Comments
 (0)