From 81072ede2ff0c11b8e93aa418b7088b59377558c Mon Sep 17 00:00:00 2001 From: Mike Cousins Date: Wed, 21 Jul 2021 08:35:02 -0700 Subject: [PATCH] fix(spy): gracefully degrade when a class's type hints can't be resolved at runtime (#47) Fixes #46 --- decoy/spy.py | 33 +++++++++++++++++++-------------- tests/test_spy.py | 10 ++++++++++ 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/decoy/spy.py b/decoy/spy.py index 92f974e..8ca2db4 100644 --- a/decoy/spy.py +++ b/decoy/spy.py @@ -23,6 +23,19 @@ class SpyConfig(NamedTuple): is_async: bool = False +def _get_type_hints(obj: Any) -> Dict[str, Any]: + """Get type hints for an object, if possible. + + The builtin `typing.get_type_hints` may fail at runtime, + e.g. if a type is subscriptable according to mypy but not + according to Python. + """ + try: + return get_type_hints(obj) + except Exception: + return {} + + class BaseSpy: """Spy object base class. @@ -88,23 +101,15 @@ def __getattr__(self, name: str) -> Any: if isclass(self._spec): try: - # NOTE: `get_type_hints` may fail at runtime, - # e.g. if a type is subscriptable according to mypy but not - # according to Python, `get_type_hints` will raise. - # Rather than fail to create a spy with an inscrutable error, - # gracefully fallback to a specification-less spy. - hints = get_type_hints(self._spec) - child_spec = getattr( - self._spec, - name, - hints.get(name), - ) + child_hint = _get_type_hints(self._spec).get(name) except Exception: - pass + child_hint = None + + child_spec = getattr(self._spec, name, child_hint) if isinstance(child_spec, property): - hints = get_type_hints(child_spec.fget) - child_spec = hints.get("return") + child_spec = _get_type_hints(child_spec.fget).get("return") + elif isclass(self._spec) and isfunction(child_spec): # `iscoroutinefunction` does not work for `partial` on Python < 3.8 # check before we wrap it diff --git a/tests/test_spy.py b/tests/test_spy.py index f3b4c94..69a62ab 100644 --- a/tests/test_spy.py +++ b/tests/test_spy.py @@ -265,9 +265,13 @@ async def test_create_nested_spy_using_non_runtime_type_hints() -> None: class _SomeClass: _property: "None[str]" + async def _do_something_async(self) -> None: + pass + calls = [] spy = create_spy(SpyConfig(spec=_SomeClass, handle_call=lambda c: calls.append(c))) spy._property.do_something(7, eight=8, nine=9) + await spy._do_something_async() assert calls == [ SpyCall( @@ -276,6 +280,12 @@ class _SomeClass: args=(7,), kwargs={"eight": 8, "nine": 9}, ), + SpyCall( + spy_id=id(spy._do_something_async), + spy_name="_SomeClass._do_something_async", + args=(), + kwargs={}, + ), ]