Skip to content

Commit dc712d2

Browse files
authored
fix: support functools.cached_property (#335)
1 parent 8229d6b commit dc712d2

4 files changed

Lines changed: 81 additions & 11 deletions

File tree

decoy/next/_internal/inspect.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -95,21 +95,25 @@ def get_child_spec(spec: object, child_name: str) -> object:
9595
# falling back to type annotations for attributes
9696
child_hint = _get_type_hints(spec).get(child_name)
9797
child_source = inspect.getattr_static(spec, child_name, child_hint)
98-
unwrapped_child_source = inspect.unwrap(child_source)
98+
99+
if isinstance(child_source, property):
100+
return _unwrap_type_alias(_get_type_hints(child_source.fget).get("return"))
101+
102+
if isinstance(child_source, functools.cached_property):
103+
return _unwrap_type_alias(_get_type_hints(child_source.func).get("return"))
99104

100105
if isinstance(child_source, staticmethod):
101-
return unwrapped_child_source
106+
return child_source.__func__
102107

103-
if isinstance(unwrapped_child_source, property):
104-
return _unwrap_type_alias(
105-
_get_type_hints(unwrapped_child_source.fget).get("return")
106-
)
108+
# consume `cls` argument
109+
if isinstance(child_source, classmethod):
110+
return functools.partial(child_source.__func__, spec)
107111

108-
# consume `self` and `cls` arguments
109-
if inspect.isroutine(unwrapped_child_source):
110-
return functools.partial(unwrapped_child_source, None)
112+
# consume `self` argument
113+
if inspect.isroutine(child_source) and callable(child_source):
114+
return functools.partial(child_source, None)
111115

112-
return _unwrap_type_alias(unwrapped_child_source)
116+
return _unwrap_type_alias(child_source)
113117

114118
return None
115119

decoy/spy_core.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,11 @@ def create_child_core(self, name: str, is_async: bool) -> "SpyCore":
133133
if isinstance(child_source, property):
134134
child_source = _get_type_hints(child_source.fget).get("return")
135135

136+
elif hasattr(functools, "cached_property") and isinstance(
137+
child_source, functools.cached_property
138+
):
139+
child_source = _get_type_hints(child_source.func).get("return")
140+
136141
elif isinstance(child_source, staticmethod):
137142
child_source = child_source.__func__
138143

@@ -142,7 +147,7 @@ def create_child_core(self, name: str, is_async: bool) -> "SpyCore":
142147

143148
child_source = inspect.unwrap(child_source)
144149

145-
if inspect.isroutine(child_source):
150+
if inspect.isroutine(child_source) and callable(child_source):
146151
# consume the `self` argument of the method to ensure proper
147152
# signature reporting by wrapping it in a partial
148153
child_source = functools.partial(child_source, None)

tests/legacy/test_mock.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Smoke and acceptance tests for main Decoy interface."""
22

33
import asyncio
4+
import functools
45
import inspect
56
import sys
67
from typing import Any
@@ -338,3 +339,34 @@ def test_builtin(decoy: Decoy) -> None:
338339

339340
with pytest.warns(IncorrectCallWarning):
340341
subject.cancel(message="oops") # type: ignore[call-arg]
342+
343+
344+
@pytest.mark.skipif(
345+
sys.version_info < (3, 8), reason="functools.cached_property added in 3.8"
346+
)
347+
def test_cached_property(decoy: Decoy) -> None:
348+
"""It can mock a cached property."""
349+
350+
class _Spec:
351+
@functools.cached_property
352+
def child(self) -> fixtures.SomeClass:
353+
raise NotImplementedError()
354+
355+
subject = decoy.mock(cls=_Spec).child
356+
357+
assert isinstance(subject, Spy)
358+
assert isinstance(subject, fixtures.SomeClass)
359+
360+
361+
def test_non_callable_descriptor(decoy: Decoy) -> None:
362+
"""It doesn't choke on non-callable descriptors."""
363+
364+
class _NonCallableDescriptor:
365+
def __get__(self, *args: Any) -> None: ...
366+
367+
class _Spec:
368+
child = _NonCallableDescriptor()
369+
370+
subject = decoy.mock(cls=_Spec).child
371+
372+
assert isinstance(subject, Spy)

tests/test_mock.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import asyncio
6+
import functools
67
import inspect
78
import sys
89
from typing import Any
@@ -331,6 +332,20 @@ def test_create_untyped_property_mock(decoy: Decoy) -> None:
331332
assert repr(subject) == "<Decoy mock `tests.fixtures.SomeClass.mystery_property`>"
332333

333334

335+
def test_create_cached_property_mock(decoy: Decoy) -> None:
336+
"""It can mock a cached property."""
337+
338+
class _Spec:
339+
@functools.cached_property
340+
def child(self) -> fixtures.SomeClass:
341+
raise NotImplementedError()
342+
343+
subject = decoy.mock(cls=_Spec).child
344+
345+
assert isinstance(subject, Mock)
346+
assert isinstance(subject, fixtures.SomeClass)
347+
348+
334349
def test_func_bad_call(decoy: Decoy) -> None:
335350
"""It raises an IncorrectCallWarning if call is bad."""
336351
subject = decoy.mock(func=fixtures.some_func)
@@ -374,3 +389,17 @@ def test_builtin(decoy: Decoy) -> None:
374389

375390
with pytest.raises(errors.SignatureMismatchError):
376391
subject.cancel(message="oops") # type: ignore[call-arg]
392+
393+
394+
def test_non_callable_descriptor(decoy: Decoy) -> None:
395+
"""It doesn't choke on non-callable descriptors."""
396+
397+
class _NonCallableDescriptor:
398+
def __get__(self, *args: Any) -> None: ...
399+
400+
class _Spec:
401+
child = _NonCallableDescriptor()
402+
403+
subject = decoy.mock(cls=_Spec).child
404+
405+
assert isinstance(subject, Mock)

0 commit comments

Comments
 (0)