Skip to content

Commit 0b12237

Browse files
authored
feat(spy): add inspect.signature and repr support (#35)
Closes #29, closes #30
1 parent fb83fd8 commit 0b12237

File tree

3 files changed

+87
-5
lines changed

3 files changed

+87
-5
lines changed

decoy/spy.py

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
[unittest.mock library](https://docs.python.org/3/library/unittest.mock.html).
55
"""
66
from __future__ import annotations
7-
from inspect import isclass, iscoroutinefunction
7+
from inspect import isclass, iscoroutinefunction, isfunction, signature
8+
from functools import partial
89
from typing import get_type_hints, Any, Callable, Dict, NamedTuple, Optional, Tuple
910

1011

@@ -39,6 +40,7 @@ class SpyConfig(NamedTuple):
3940
handle_call: CallHandler
4041
spec: Optional[Any] = None
4142
name: Optional[str] = None
43+
module_name: Optional[str] = None
4244
is_async: bool = False
4345

4446

@@ -54,13 +56,25 @@ def __init__(
5456
handle_call: CallHandler,
5557
spec: Optional[Any] = None,
5658
name: Optional[str] = None,
59+
module_name: Optional[str] = None,
5760
) -> None:
5861
"""Initialize a BaseSpy from a call handler and an optional spec object."""
59-
self._name = name or (spec.__name__ if spec is not None else "spy")
6062
self._spec = spec
6163
self._handle_call: CallHandler = handle_call
6264
self._spy_children: Dict[str, BaseSpy] = {}
6365

66+
self._name = name or (spec.__name__ if spec is not None else "spy")
67+
self._module_name = module_name
68+
69+
if module_name is None and spec is not None and hasattr(spec, "__module__"):
70+
self._module_name = spec.__module__
71+
72+
# ensure spy can pass inspect.signature checks
73+
try:
74+
self.__signature__ = signature(spec) # type: ignore[arg-type]
75+
except Exception:
76+
pass
77+
6478
@property # type: ignore[misc]
6579
def __class__(self) -> Any:
6680
"""Ensure Spy can pass `instanceof` checks."""
@@ -69,15 +83,29 @@ def __class__(self) -> Any:
6983

7084
return type(self)
7185

86+
def __repr__(self) -> str:
87+
"""Get a helpful string representation of the spy."""
88+
name = self._name
89+
if self._module_name:
90+
name = f"{self._module_name}.{name}"
91+
92+
return f"<Decoy mock of {name}>" if self._spec else "<Decoy spy function>"
93+
7294
def __getattr__(self, name: str) -> Any:
7395
"""Get a property of the spy.
7496
7597
Lazily constructs child spies, basing them on type hints if available.
7698
"""
99+
# do not attempt to mock magic methods
100+
if name.startswith("__") and name.endswith("__"):
101+
return super().__getattribute__(name)
102+
103+
# return previously constructed (and cached) child spies
77104
if name in self._spy_children:
78105
return self._spy_children[name]
79106

80107
child_spec = None
108+
child_is_async = False
81109

82110
if isclass(self._spec):
83111
try:
@@ -98,12 +126,21 @@ def __getattr__(self, name: str) -> Any:
98126
if isinstance(child_spec, property):
99127
hints = get_type_hints(child_spec.fget)
100128
child_spec = hints.get("return")
129+
elif isclass(self._spec) and isfunction(child_spec):
130+
# `iscoroutinefunction` does not work for `partial` on Python < 3.8
131+
# check before we wrap it
132+
child_is_async = iscoroutinefunction(child_spec)
133+
# consume the `self` argument of the method to ensure proper
134+
# signature reporting by wrapping it in a partial
135+
child_spec = partial(child_spec, None) # type: ignore[arg-type]
101136

102137
spy = create_spy(
103138
config=SpyConfig(
104139
handle_call=self._handle_call,
105140
spec=child_spec,
106141
name=f"{self._name}.{name}",
142+
module_name=self._module_name,
143+
is_async=child_is_async,
107144
),
108145
)
109146

@@ -137,7 +174,12 @@ def create_spy(config: SpyConfig) -> Any:
137174
Functions and classes passed to `spec` will be inspected (and have any type
138175
annotations inspected) to ensure `AsyncSpy`'s are returned where necessary.
139176
"""
140-
handle_call, spec, name, is_async = config
177+
handle_call, spec, name, module_name, is_async = config
141178
_SpyCls = AsyncSpy if iscoroutinefunction(spec) or is_async is True else Spy
142179

143-
return _SpyCls(handle_call=handle_call, spec=spec, name=name)
180+
return _SpyCls(
181+
handle_call=handle_call,
182+
spec=spec,
183+
name=name,
184+
module_name=module_name,
185+
)

tests/common.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ async def do_the_thing(self, flag: bool) -> None:
4747
...
4848

4949

50-
def noop(*args: Any, **kwargs: Any) -> Any:
50+
# NOTE: these `Any`s are forward references for call signature testing purposes
51+
def noop(*args: "Any", **kwargs: "Any") -> "Any":
5152
"""No-op."""
5253
pass
5354

tests/test_spy.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
"""Tests for spy creation."""
22
import pytest
3+
import inspect
34
from typing import Any
45

56
from decoy.spy import create_spy, AsyncSpy, SpyConfig, SpyCall
67

78
from .common import (
9+
noop,
810
some_func,
911
some_async_func,
1012
SomeClass,
@@ -294,3 +296,40 @@ def _handle_call(call: Any) -> int:
294296
sync_spy(),
295297
await async_spy(),
296298
] == [1, 2, 3, 4]
299+
300+
301+
def test_spy_passes_instance_of() -> None:
302+
"""A spy should pass instanceof checks."""
303+
spy = create_spy(SpyConfig(spec=SomeClass, handle_call=noop))
304+
305+
assert isinstance(spy, SomeClass)
306+
307+
308+
def test_spy_matches_signature() -> None:
309+
"""It should pass `inspect.signature` checks."""
310+
class_spy = create_spy(SpyConfig(spec=SomeClass, handle_call=noop))
311+
actual_instance = SomeClass()
312+
assert inspect.signature(class_spy) == inspect.signature(SomeClass)
313+
assert inspect.signature(class_spy.foo) == inspect.signature(actual_instance.foo)
314+
assert inspect.signature(class_spy.bar) == inspect.signature(actual_instance.bar)
315+
assert inspect.signature(class_spy.do_the_thing) == inspect.signature(
316+
actual_instance.do_the_thing
317+
)
318+
319+
func_spy = create_spy(SpyConfig(spec=some_func, handle_call=noop))
320+
assert inspect.signature(func_spy) == inspect.signature(some_func)
321+
322+
spy = create_spy(SpyConfig(handle_call=noop))
323+
assert inspect.signature(spy) == inspect.signature(noop)
324+
325+
326+
def test_spy_repr() -> None:
327+
"""It should have an informative repr."""
328+
class_spy = create_spy(SpyConfig(spec=SomeClass, handle_call=noop))
329+
func_spy = create_spy(SpyConfig(spec=some_func, handle_call=noop))
330+
spy = create_spy(SpyConfig(handle_call=noop))
331+
332+
assert repr(class_spy) == "<Decoy mock of tests.common.SomeClass>"
333+
assert repr(class_spy.foo) == "<Decoy mock of tests.common.SomeClass.foo>"
334+
assert repr(func_spy) == "<Decoy mock of tests.common.some_func>"
335+
assert repr(spy) == "<Decoy spy function>"

0 commit comments

Comments
 (0)