Skip to content

Commit 2a60d1f

Browse files
authored
refactor: move spec inspection logic to Spec class (#108)
1 parent 39d165b commit 2a60d1f

19 files changed

+924
-712
lines changed

decoy/__init__.py

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Decoy stubbing and spying library."""
2+
from warnings import warn
23
from typing import Any, Callable, Generic, Optional, Union, cast, overload
34

45
from . import errors, matchers, warnings
@@ -15,15 +16,25 @@
1516

1617

1718
class Decoy:
18-
"""Decoy mock factory and state container."""
19+
"""Decoy mock factory and state container.
1920
20-
def __init__(self) -> None:
21-
"""Initialize a new mock factory.
21+
You should create a new Decoy instance before each test and call
22+
[reset][decoy.Decoy.reset] after each test. If you use the
23+
[`decoy` pytest fixture][decoy.pytest_plugin.decoy], this is done
24+
automatically. See the [setup guide](../#setup) for more details.
2225
23-
You should create a new Decoy instance for every test. If you use
24-
the [`decoy` pytest fixture][decoy.pytest_plugin.decoy], this is done
25-
automatically. See the [setup guide](../#setup) for more details.
26-
"""
26+
Example:
27+
```python
28+
decoy = Decoy()
29+
30+
# test your subject
31+
...
32+
33+
decoy.reset()
34+
```
35+
"""
36+
37+
def __init__(self) -> None:
2738
self._core = DecoyCore()
2839

2940
@overload
@@ -47,9 +58,9 @@ def mock(
4758
name: Optional[str] = None,
4859
is_async: bool = False,
4960
) -> Any:
50-
"""Create a mock.
61+
"""Create a mock. See the [mock creation guide] for more details.
5162
52-
See the [mock creation guide](../usage/create/) for more details.
63+
[mock creation guide]: ../usage/create/
5364
5465
Arguments:
5566
cls: A class definition that the mock should imitate.
@@ -83,6 +94,11 @@ def create_decoy(
8394
!!! warning "Deprecated since v1.6.0"
8495
Use [decoy.Decoy.mock][] with the `cls` parameter, instead.
8596
"""
97+
warn(
98+
"decoy.create_decoy is deprecated; use decoy.mock(cls=...) instead.",
99+
DeprecationWarning,
100+
)
101+
86102
spy = self._core.mock(spec=spec, is_async=is_async)
87103
return cast(ClassT, spy)
88104

@@ -97,6 +113,11 @@ def create_decoy_func(
97113
!!! warning "Deprecated since v1.6.0"
98114
Use [decoy.Decoy.mock][] with the `func` parameter, instead.
99115
"""
116+
warn(
117+
"decoy.create_decoy_func is deprecated; use decoy.mock(func=...) instead.",
118+
DeprecationWarning,
119+
)
120+
100121
spy = self._core.mock(spec=spec, is_async=is_async)
101122
return cast(FuncT, spy)
102123

decoy/call_handler.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Spy call handling."""
22
from typing import Any
33

4-
from .call_stack import CallStack
4+
from .spy_log import SpyLog
55
from .context_managers import ContextWrapper
66
from .spy_calls import SpyCall
77
from .stub_store import StubStore
@@ -10,15 +10,18 @@
1010
class CallHandler:
1111
"""An interface to handle calls to spies."""
1212

13-
def __init__(self, call_stack: CallStack, stub_store: StubStore) -> None:
13+
def __init__(self, spy_log: SpyLog, stub_store: StubStore) -> None:
1414
"""Initialize the CallHandler with access to SpyCalls and Stubs."""
15-
self._call_stack = call_stack
15+
self._spy_log = spy_log
1616
self._stub_store = stub_store
1717

1818
def handle(self, call: SpyCall) -> Any:
1919
"""Handle a Spy's call, triggering stub behavior if necessary."""
2020
behavior = self._stub_store.get_by_call(call)
21-
self._call_stack.push(call)
21+
self._spy_log.push(call)
22+
23+
if behavior is None:
24+
return None
2225

2326
if behavior.error:
2427
raise behavior.error

decoy/core.py

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@
22
from typing import Any, Callable, Optional
33

44
from .call_handler import CallHandler
5-
from .call_stack import CallStack
6-
from .spy import SpyConfig, SpyFactory
7-
from .spy import create_spy as default_create_spy
5+
from .spy import SpyCreator
86
from .spy_calls import WhenRehearsal
7+
from .spy_log import SpyLog
98
from .stub_store import StubBehavior, StubStore
109
from .types import ContextValueT, ReturnT
1110
from .verifier import Verifier
@@ -20,23 +19,23 @@ class DecoyCore:
2019

2120
def __init__(
2221
self,
23-
create_spy: Optional[SpyFactory] = None,
2422
verifier: Optional[Verifier] = None,
2523
warning_checker: Optional[WarningChecker] = None,
2624
stub_store: Optional[StubStore] = None,
27-
call_stack: Optional[CallStack] = None,
25+
spy_log: Optional[SpyLog] = None,
2826
call_handler: Optional[CallHandler] = None,
27+
spy_creator: Optional[SpyCreator] = None,
2928
) -> None:
3029
"""Initialize the DecoyCore with its dependencies."""
31-
self._create_spy = create_spy or default_create_spy
3230
self._verifier = verifier or Verifier()
3331
self._warning_checker = warning_checker or WarningChecker()
3432
self._stub_store = stub_store or StubStore()
35-
self._call_stack = call_stack or CallStack()
33+
self._spy_log = spy_log or SpyLog()
3634
self._call_hander = call_handler or CallHandler(
37-
call_stack=self._call_stack,
35+
spy_log=self._spy_log,
3836
stub_store=self._stub_store,
3937
)
38+
self._spy_creator = spy_creator or SpyCreator(call_handler=self._call_hander)
4039

4140
def mock(
4241
self,
@@ -46,17 +45,11 @@ def mock(
4645
is_async: bool = False,
4746
) -> Any:
4847
"""Create and register a new spy."""
49-
config = SpyConfig(
50-
spec=spec,
51-
name=name,
52-
is_async=is_async,
53-
handle_call=self._call_hander.handle,
54-
)
55-
return self._create_spy(config)
48+
return self._spy_creator.create(spec=spec, name=name, is_async=is_async)
5649

5750
def when(self, _rehearsal: ReturnT, *, ignore_extra_args: bool) -> "StubCore":
5851
"""Create a new stub from the last spy rehearsal."""
59-
rehearsal = self._call_stack.consume_when_rehearsal(
52+
rehearsal = self._spy_log.consume_when_rehearsal(
6053
ignore_extra_args=ignore_extra_args
6154
)
6255
return StubCore(rehearsal=rehearsal, stub_store=self._stub_store)
@@ -68,19 +61,19 @@ def verify(
6861
ignore_extra_args: bool,
6962
) -> None:
7063
"""Verify that a Spy or Spies were called."""
71-
rehearsals = self._call_stack.consume_verify_rehearsals(
64+
rehearsals = self._spy_log.consume_verify_rehearsals(
7265
count=len(_rehearsals),
7366
ignore_extra_args=ignore_extra_args,
7467
)
75-
calls = self._call_stack.get_by_rehearsals(rehearsals)
68+
calls = self._spy_log.get_by_rehearsals(rehearsals)
7669

7770
self._verifier.verify(rehearsals=rehearsals, calls=calls, times=times)
7871

7972
def reset(self) -> None:
8073
"""Reset and remove all stored spies and stubs."""
81-
calls = self._call_stack.get_all()
74+
calls = self._spy_log.get_all()
8275
self._warning_checker.check(calls)
83-
self._call_stack.clear()
76+
self._spy_log.clear()
8477
self._stub_store.clear()
8578

8679

decoy/spec.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""Mock specification."""
2+
import inspect
3+
import functools
4+
import warnings
5+
from typing import Any, Dict, NamedTuple, Optional, Tuple, Type, Union, get_type_hints
6+
7+
from .warnings import IncorrectCallWarning
8+
9+
10+
class BoundArgs(NamedTuple):
11+
"""Arguments bound to a spec."""
12+
13+
args: Tuple[Any, ...]
14+
kwargs: Dict[str, Any]
15+
16+
17+
class Spec:
18+
"""Interface defining a Spy's specification.
19+
20+
Arguments:
21+
source: The source object for the specification.
22+
name: The spec's name. If left unspecified, will be derived from
23+
`source`, if possible. Will fallback to a default value.
24+
module_name: The spec's module name. If left unspecified or `True`,
25+
will be derived from `source`, if possible. If explicitly set to `None`
26+
or `False` or it is unable to be derived, a module name will not be used.
27+
28+
"""
29+
30+
_DEFAULT_SPY_NAME = "unnamed"
31+
32+
def __init__(
33+
self,
34+
source: Optional[Any],
35+
name: Optional[str],
36+
module_name: Union[str, bool, None] = True,
37+
) -> None:
38+
self._source = source
39+
40+
if name is not None:
41+
self._name = name
42+
elif source is not None:
43+
self._name = getattr(source, "__name__", self._DEFAULT_SPY_NAME)
44+
else:
45+
self._name = self._DEFAULT_SPY_NAME
46+
47+
if isinstance(module_name, str):
48+
self._module_name: Optional[str] = module_name
49+
elif module_name is True and source is not None:
50+
self._module_name = getattr(source, "__module__", None)
51+
else:
52+
self._module_name = None
53+
54+
def get_name(self) -> str:
55+
"""Get the Spec's human readable name.
56+
57+
Name may be manually specified or derived from the object the Spec
58+
represents.
59+
"""
60+
return self._name
61+
62+
def get_full_name(self) -> str:
63+
"""Get the full name of the spec.
64+
65+
Full name includes the module name of the object the Spec represents,
66+
if available.
67+
"""
68+
name = self._name
69+
module_name = self._module_name
70+
return f"{module_name}.{name}" if module_name else name
71+
72+
def get_signature(self) -> Optional[inspect.Signature]:
73+
"""Get the Spec's signature, if Spec represents a callable."""
74+
try:
75+
return inspect.signature(self._source) # type: ignore[arg-type]
76+
except TypeError:
77+
return None
78+
79+
def get_class_type(self) -> Optional[Type[Any]]:
80+
"""Get the Spec's class type, if Spec represents a class."""
81+
return self._source if inspect.isclass(self._source) else None
82+
83+
def get_is_async(self) -> bool:
84+
"""Get whether the Spec represents an async. callable."""
85+
source = self._source
86+
87+
# `iscoroutinefunction` does not work for `partial` on Python < 3.8
88+
if isinstance(source, functools.partial):
89+
source = source.func
90+
91+
# check if spec source is a class with a __call__ method
92+
elif inspect.isclass(source):
93+
call_method = inspect.getattr_static(source, "__call__", None)
94+
if inspect.isfunction(call_method):
95+
source = call_method
96+
97+
return inspect.iscoroutinefunction(source)
98+
99+
def bind_args(self, *args: Any, **kwargs: Any) -> BoundArgs:
100+
"""Bind given args and kwargs to the Spec's signature, if possible.
101+
102+
If no signature or unable to bind, will simply pass args and kwargs
103+
through without modification.
104+
"""
105+
signature = self.get_signature()
106+
107+
if signature:
108+
try:
109+
bound_args = signature.bind(*args, **kwargs)
110+
except TypeError as e:
111+
# stacklevel: 4 ensures warning is linked to call location
112+
warnings.warn(IncorrectCallWarning(e), stacklevel=4)
113+
else:
114+
args = bound_args.args
115+
kwargs = bound_args.kwargs
116+
117+
return BoundArgs(args=args, kwargs=kwargs)
118+
119+
def get_child_spec(self, name: str) -> "Spec":
120+
"""Get a child attribute, property, or method's Spec from this Spec."""
121+
source = self._source
122+
child_name = f"{self._name}.{name}"
123+
child_source = None
124+
125+
if inspect.isclass(source):
126+
# use type hints to get child spec for class attributes
127+
child_hint = _get_type_hints(source).get(name)
128+
# use inspect to get child spec for methods and properties
129+
child_source = inspect.getattr_static(source, name, child_hint)
130+
131+
if isinstance(child_source, property):
132+
child_source = _get_type_hints(child_source.fget).get("return")
133+
134+
elif isinstance(child_source, staticmethod):
135+
child_source = child_source.__func__
136+
137+
elif inspect.isfunction(child_source):
138+
# consume the `self` argument of the method to ensure proper
139+
# signature reporting by wrapping it in a partial
140+
child_source = functools.partial(child_source, None)
141+
142+
return Spec(source=child_source, name=child_name, module_name=self._module_name)
143+
144+
145+
def _get_type_hints(obj: Any) -> Dict[str, Any]:
146+
"""Get type hints for an object, if possible.
147+
148+
The builtin `typing.get_type_hints` may fail at runtime,
149+
e.g. if a type is subscriptable according to mypy but not
150+
according to Python.
151+
"""
152+
try:
153+
return get_type_hints(obj)
154+
except Exception:
155+
return {}

0 commit comments

Comments
 (0)