Skip to content

Commit aece8f9

Browse files
authored
Merge pull request #122 from kaste/nicer-matcher-reprs
2 parents 5980331 + 23a4740 commit aece8f9

File tree

2 files changed

+312
-8
lines changed

2 files changed

+312
-8
lines changed

mockito/matchers.py

Lines changed: 143 additions & 8 deletions
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

@@ -137,15 +138,46 @@ def matches(self, arg):
137138
return True
138139

139140
def __repr__(self):
140-
return "<Any: %s>" % self.wanted_type
141+
return "<Any: %s>" % _any_wanted_type_label(self.wanted_type)
142+
143+
144+
def _any_wanted_type_label(wanted_type):
145+
if isinstance(wanted_type, type):
146+
return _type_label(wanted_type)
147+
148+
if (
149+
isinstance(wanted_type, tuple)
150+
and all(isinstance(t, type) for t in wanted_type)
151+
):
152+
items = [_type_label(t) for t in wanted_type]
153+
if len(items) == 1:
154+
return '(%s,)' % items[0]
155+
return '(%s)' % ', '.join(items)
156+
157+
return _safe_repr(wanted_type)
158+
159+
160+
def _type_label(type_):
161+
module = _safe_getattr(type_, '__module__')
162+
qualname = _safe_getattr(type_, '__qualname__') or _safe_getattr(type_, '__name__')
163+
if qualname is None:
164+
return _safe_repr(type_)
165+
166+
if module is None or module == 'builtins':
167+
return qualname
168+
169+
return '%s.%s' % (module, qualname)
141170

142171

143172
class ValueMatcher(Matcher):
144173
def __init__(self, value):
145174
self.value = value
146175

147176
def __repr__(self):
148-
return "<%s: %s>" % (self.__class__.__name__, self.value)
177+
return "<%s: %s>" % (
178+
self.__class__.__name__,
179+
_safe_repr(self.value),
180+
)
149181

150182

151183
class Eq(ValueMatcher):
@@ -223,7 +255,93 @@ def matches(self, arg):
223255
return self.predicate(arg)
224256

225257
def __repr__(self):
226-
return "<ArgThat>"
258+
return "<ArgThat: %s>" % _arg_that_predicate_label(self.predicate)
259+
260+
261+
def _arg_that_predicate_label(predicate):
262+
try:
263+
return _arg_that_predicate_label_unchecked(predicate)
264+
except Exception:
265+
predicate_class = _safe_getattr(
266+
_safe_getattr(predicate, '__class__'),
267+
'__name__',
268+
)
269+
if predicate_class is None:
270+
return 'callable'
271+
272+
return 'callable %s' % predicate_class
273+
274+
275+
def _arg_that_predicate_label_unchecked(predicate):
276+
if isinstance(predicate, functools.partial):
277+
return _arg_that_partial_label(predicate)
278+
279+
function_line = _line_of_callable(predicate)
280+
function_name = _safe_getattr(predicate, '__name__')
281+
if function_name is not None:
282+
if function_name == '<lambda>':
283+
return _label_with_line('lambda', function_line)
284+
return _label_with_line('def %s' % function_name, function_line)
285+
286+
predicate_class = _safe_getattr(
287+
_safe_getattr(predicate, '__class__'),
288+
'__name__',
289+
)
290+
if predicate_class is None:
291+
predicate_class = 'object'
292+
293+
call = _safe_getattr(predicate, '__call__')
294+
call_line = _line_of_callable(call)
295+
return _label_with_line(
296+
'callable %s.__call__' % predicate_class,
297+
call_line,
298+
)
299+
300+
301+
def _arg_that_partial_label(predicate):
302+
partial_func = _safe_getattr(predicate, 'func')
303+
partial_name = _safe_getattr(partial_func, '__name__')
304+
305+
if partial_name is not None:
306+
return 'partial %s' % partial_name
307+
308+
return 'partial'
309+
310+
311+
def _line_of_callable(value):
312+
if value is None:
313+
return None
314+
315+
func = _safe_getattr(value, '__func__', value)
316+
code = _safe_getattr(func, '__code__')
317+
if code is None:
318+
return None
319+
320+
return _safe_getattr(code, 'co_firstlineno')
321+
322+
323+
def _safe_getattr(value, name, default=None):
324+
try:
325+
return getattr(value, name)
326+
except Exception:
327+
return default
328+
329+
330+
def _safe_repr(value):
331+
try:
332+
return repr(value)
333+
except Exception:
334+
try:
335+
return object.__repr__(value)
336+
except Exception:
337+
return '<unrepresentable>'
338+
339+
340+
def _label_with_line(label, line_number):
341+
if line_number is None:
342+
return label
343+
344+
return '%s at line %s' % (label, line_number)
227345

228346

229347
class Contains(Matcher):
@@ -236,24 +354,41 @@ def matches(self, arg):
236354
return self.sub and len(self.sub) > 0 and arg.find(self.sub) > -1
237355

238356
def __repr__(self):
239-
return "<Contains: '%s'>" % self.sub
357+
return "<Contains: %s>" % _safe_repr(self.sub)
240358

241359

242360
class Matches(Matcher):
243361
def __init__(self, regex, flags=0):
244362
self.regex = re.compile(regex, flags)
363+
self.flags = _explicit_regex_flags(regex, flags)
245364

246365
def matches(self, arg):
247366
if not isinstance(arg, str):
248367
return
249368
return self.regex.match(arg) is not None
250369

251370
def __repr__(self):
252-
if self.regex.flags:
253-
return "<Matches: %s flags=%d>" % (self.regex.pattern,
254-
self.regex.flags)
371+
if self.flags:
372+
return "<Matches: %r flags=%d>" % (self.regex.pattern, self.flags)
255373
else:
256-
return "<Matches: %s>" % self.regex.pattern
374+
return "<Matches: %r>" % self.regex.pattern
375+
376+
377+
def _explicit_regex_flags(regex, flags):
378+
if flags:
379+
return flags
380+
381+
compiled_flags = _safe_getattr(regex, 'flags')
382+
pattern = _safe_getattr(regex, 'pattern')
383+
if compiled_flags is None or pattern is None:
384+
return 0
385+
386+
try:
387+
baseline_flags = re.compile(pattern).flags
388+
except Exception:
389+
return compiled_flags
390+
391+
return compiled_flags & ~baseline_flags
257392

258393

259394
class ArgumentCaptor(Matcher, Capturing):

tests/matcher_repr_test.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
from functools import partial
2+
import re
3+
4+
import numpy as np
5+
6+
from mockito import and_, any as any_, arg_that, contains, eq, gt, matches, not_, or_
7+
8+
9+
def test_value_matchers_use_repr_for_string_values():
10+
assert repr(eq("foo")) == "<Eq: 'foo'>"
11+
12+
13+
def test_composed_matchers_include_quoted_nested_values():
14+
assert repr(not_(eq("foo"))) == "<Not: <Eq: 'foo'>>"
15+
assert repr(and_(eq("foo"), gt(1))) == "<And: [<Eq: 'foo'>, <Gt: 1>]>"
16+
assert repr(or_(eq("foo"), gt(1))) == "<Or: [<Eq: 'foo'>, <Gt: 1>]>"
17+
18+
19+
def test_any_repr_uses_pretty_names_for_types():
20+
assert repr(any_(int)) == "<Any: int>"
21+
assert repr(any_((int, str))) == "<Any: (int, str)>"
22+
23+
24+
def test_any_repr_quotes_non_type_values():
25+
assert repr(any_("foo")) == "<Any: 'foo'>"
26+
27+
28+
def test_any_repr_handles_types_with_broken_introspection():
29+
class EvilMeta(type):
30+
def __getattribute__(cls, name):
31+
if name in {'__module__', '__qualname__', '__name__'}:
32+
raise RuntimeError('boom')
33+
return super().__getattribute__(name)
34+
35+
class Evil(metaclass=EvilMeta):
36+
pass
37+
38+
matcher_repr = repr(any_(Evil))
39+
assert matcher_repr.startswith("<Any: <class '")
40+
assert "Evil" in matcher_repr
41+
42+
43+
def test_any_repr_handles_values_with_broken_repr():
44+
class BrokenRepr:
45+
def __repr__(self):
46+
raise RuntimeError('boom')
47+
48+
matcher_repr = repr(any_(BrokenRepr()))
49+
assert matcher_repr.startswith('<Any: <')
50+
assert 'BrokenRepr object' in matcher_repr
51+
52+
53+
def test_value_matcher_repr_handles_values_with_broken_repr():
54+
class BrokenRepr:
55+
def __repr__(self):
56+
raise RuntimeError('boom')
57+
58+
matcher_repr = repr(eq(BrokenRepr()))
59+
assert matcher_repr.startswith('<Eq: <')
60+
assert 'BrokenRepr object' in matcher_repr
61+
62+
63+
def test_contains_repr_handles_values_with_broken_repr():
64+
class BrokenRepr:
65+
def __repr__(self):
66+
raise RuntimeError('boom')
67+
68+
matcher_repr = repr(contains(BrokenRepr()))
69+
assert matcher_repr.startswith('<Contains: <')
70+
assert 'BrokenRepr object' in matcher_repr
71+
72+
73+
def test_contains_repr_uses_safe_quoted_substring():
74+
assert repr(contains("a'b")) == "<Contains: \"a'b\">"
75+
76+
77+
def test_matches_repr_shows_only_explicit_flags():
78+
assert repr(matches("f..")) == "<Matches: 'f..'>"
79+
assert repr(matches("f..", re.IGNORECASE)) == (
80+
f"<Matches: 'f..' flags={int(re.IGNORECASE)}>"
81+
)
82+
83+
84+
def test_matches_repr_shows_flags_for_compiled_patterns():
85+
compiled = re.compile('f..', re.IGNORECASE)
86+
87+
assert repr(matches(compiled)) == (
88+
f"<Matches: 'f..' flags={int(re.IGNORECASE)}>"
89+
)
90+
91+
92+
def test_arg_that_repr_includes_named_function_name():
93+
# Predicate display name: "def is_positive"
94+
def is_positive(value):
95+
return value > 0
96+
97+
matcher = arg_that(is_positive)
98+
99+
assert repr(matcher) == (
100+
f"<ArgThat: def is_positive at line {is_positive.__code__.co_firstlineno}>"
101+
)
102+
103+
104+
def test_arg_that_repr_includes_lambda_name():
105+
# Predicate display name: "lambda"
106+
predicate = lambda value: value > 0
107+
matcher = arg_that(predicate)
108+
109+
assert repr(matcher) == (
110+
f"<ArgThat: lambda at line {predicate.__code__.co_firstlineno}>"
111+
)
112+
113+
114+
def test_arg_that_repr_for_callable_instance_includes_class_name():
115+
# Predicate display name: "callable IsPositive.__call__"
116+
class IsPositive:
117+
def __call__(self, value):
118+
return value > 0
119+
120+
predicate = IsPositive()
121+
matcher = arg_that(predicate)
122+
123+
assert repr(matcher) == (
124+
"<ArgThat: callable IsPositive.__call__ at line "
125+
f"{predicate.__call__.__func__.__code__.co_firstlineno}>"
126+
)
127+
128+
129+
def test_arg_that_repr_for_builtin_callable_has_no_line_number():
130+
matcher = arg_that(len)
131+
132+
assert repr(matcher) == "<ArgThat: def len>"
133+
134+
135+
def test_arg_that_repr_for_partial_uses_underlying_function_name():
136+
predicate = partial(pow, exp=2)
137+
matcher = arg_that(predicate)
138+
139+
assert repr(matcher) == "<ArgThat: partial pow>"
140+
141+
142+
def test_arg_that_repr_for_numpy_ufunc_uses_function_name_without_line():
143+
matcher = arg_that(np.isfinite)
144+
145+
assert repr(matcher) == "<ArgThat: def isfinite>"
146+
147+
148+
def test_arg_that_repr_for_partial_numpy_function_uses_wrapped_name():
149+
predicate = partial(np.allclose, b=0.0)
150+
matcher = arg_that(predicate)
151+
152+
assert repr(matcher) == "<ArgThat: partial allclose>"
153+
154+
155+
def test_arg_that_repr_handles_callables_with_broken_name_introspection():
156+
class BrokenNameCallable:
157+
def __getattribute__(self, name):
158+
if name == '__name__':
159+
raise RuntimeError("boom")
160+
return super().__getattribute__(name)
161+
162+
def __call__(self, value):
163+
return value > 0
164+
165+
matcher = arg_that(BrokenNameCallable())
166+
167+
matcher_repr = repr(matcher)
168+
assert matcher_repr.startswith("<ArgThat: callable BrokenNameCallable")
169+
assert "__name__" not in matcher_repr

0 commit comments

Comments
 (0)