Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ inputs:
default: "3.12"
poetry-version:
description: "Poetry version to install"
default: "1.7.0"
default: "2.1.1"
cache:
description: "Cache directory"
default: "${{ runner.temp }}/cache"
Expand All @@ -17,19 +17,19 @@ runs:
steps:
- name: "Set up Python"
id: setup-python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
update-environment: false

- name: "Set up Python 3.12 for Poetry"
id: setup-poetry-python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: 3.12

- name: "Set up dependency cache"
uses: actions/cache@v3
uses: actions/cache@v4
with:
key: ${{ runner.os }}-${{ steps.setup-poetry-python.outputs.python-version }}-${{ steps.setup-python.outputs.python-version }}-${{ inputs.poetry-version }}-${{ hashFiles('poetry.lock') }}
path: ${{ inputs.cache }}
Expand Down
21 changes: 17 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,22 @@ on:
jobs:
test:
name: "Test Python ${{ matrix.python-version }} on ${{ matrix.os }}"
runs-on: ${{ matrix.os }}-latest
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [Ubuntu, Windows, macOS]
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
include:
- os: ubuntu-22.04
python-version: "3.7"
- os: windows-latest
python-version: "3.7"
- os: macos-13
python-version: "3.7"
permissions:
id-token: write

steps:
- name: "Check out repository"
uses: actions/checkout@v4
Expand All @@ -28,7 +39,9 @@ jobs:
run: poetry run poe test-ci

- name: "Upload coverage report"
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v5
with:
use_oidc: true

check:
name: "Lint and type checks"
Expand Down
40 changes: 38 additions & 2 deletions decoy/spy_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,17 @@
import inspect
import functools
import warnings
from typing import Any, Dict, NamedTuple, Optional, Tuple, Type, Union, get_type_hints
from typing import (
Any,
Dict,
NamedTuple,
Optional,
Tuple,
Type,
Union,
Sequence,
get_type_hints,
)

from .spy_events import SpyInfo
from .warnings import IncorrectCallWarning, MissingSpecAttributeWarning
Expand Down Expand Up @@ -112,12 +122,15 @@ def create_child_core(self, name: str, is_async: bool) -> "SpyCore":
source = self._source
child_name = f"{self._name}.{name}"
child_source = None
child_found = False

if inspect.isclass(source):
# use type hints to get child spec for class attributes
child_hint = _get_type_hints(source).get(name)
# use inspect to get child spec for methods and properties
child_source = inspect.getattr_static(source, name, child_hint)
# record whether a child was found before we make modifications
child_found = child_source is not None

if isinstance(child_source, property):
child_source = _get_type_hints(child_source.fget).get("return")
Expand All @@ -136,7 +149,9 @@ def create_child_core(self, name: str, is_async: bool) -> "SpyCore":
# signature reporting by wrapping it in a partial
child_source = functools.partial(child_source, None)

if child_source is None and source is not None:
child_source = _unwrap_optional(child_source)

if source is not None and child_found is False:
# stacklevel: 4 ensures warning is linked to call location
warnings.warn(
MissingSpecAttributeWarning(f"{self._name} has no attribute '{name}'"),
Expand Down Expand Up @@ -215,3 +230,24 @@ def _get_type_hints(obj: Any) -> Dict[str, Any]:
return get_type_hints(obj)
except Exception:
return {}


def _unwrap_optional(source: Any) -> Any:
"""Return the source's base type if it's a optional.

If the type is a union of more than just T | None,
bail out and return None to avoid potentially false warnings.
"""
origin = getattr(source, "__origin__", None)
args: Sequence[Any] = getattr(source, "__args__", ())

# TODO(mc, 2025-03-19): support larger unions? might be a lot of work for little payoff
if origin is Union:
if len(args) == 2 and args[0] is type(None):
return args[1]
if len(args) == 2 and args[1] is type(None):
return args[0]

return None

return source
22 changes: 21 additions & 1 deletion tests/fixtures.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Common test fixtures."""
from functools import lru_cache
from typing import Any, Generic, TypeVar
from typing import Any, Generic, TypeVar, Optional, Union


class SomeClass:
Expand Down Expand Up @@ -28,6 +28,11 @@ def primitive_property(self) -> str:
"""Get a primitive computed property."""
raise NotImplementedError()

@property
def mystery_property(self): # type: ignore[no-untyped-def] # noqa: ANN201
"""Get a property without type annotations."""
raise NotImplementedError()

@lru_cache(maxsize=None) # noqa: B019
def some_wrapped_method(self, val: str) -> str:
"""Get a thing through a wrapped method."""
Expand All @@ -48,6 +53,21 @@ def child(self) -> SomeClass:
"""Get the child instance."""
raise NotImplementedError()

@property
def optional_child(self) -> Optional[SomeClass]:
"""Get the child instance."""
raise NotImplementedError()

@property
def union_none_child(self) -> Union[None, SomeClass]:
"""Get the child instance."""
raise NotImplementedError()

@property
def union_child(self) -> Union[SomeClass, "SomeAsyncClass"]:
"""Get the child instance."""
raise NotImplementedError()


class SomeAsyncClass:
"""Async testing class."""
Expand Down
38 changes: 38 additions & 0 deletions tests/test_spy_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ def test_warn_if_called_incorrectly() -> None:
def test_warn_if_spec_does_not_have_method() -> None:
"""It should trigger a warning if bound_args is called incorrectly."""
class_subject = SpyCore(source=SomeClass, name=None)
nested_class_subject = SpyCore(source=SomeNestedClass, name=None)
func_subject = SpyCore(source=some_func, name=None)
specless_subject = SpyCore(source=None, name="anonymous")

Expand All @@ -458,6 +459,29 @@ def test_warn_if_spec_does_not_have_method() -> None:
warnings.simplefilter("error")
class_subject.create_child_core("foo", False)

# property access without types should not warn
with warnings.catch_warnings():
warnings.simplefilter("error")
class_subject.create_child_core("mystery_property", False)

# property access should be allowed through optionals
with warnings.catch_warnings():
warnings.simplefilter("error")
parent = nested_class_subject.create_child_core("optional_child", False)
parent.create_child_core("primitive_property", False)

# property access should be allowed through None unions
with warnings.catch_warnings():
warnings.simplefilter("error")
parent = nested_class_subject.create_child_core("union_none_child", False)
parent.create_child_core("primitive_property", False)

# property access should not be checked through unions
with warnings.catch_warnings():
warnings.simplefilter("error")
parent = nested_class_subject.create_child_core("union_child", False)
parent.create_child_core("who_knows", False)

# incorrect class usage should warn
with pytest.warns(
MissingSpecAttributeWarning, match="has no attribute 'this_is_wrong'"
Expand All @@ -469,3 +493,17 @@ def test_warn_if_spec_does_not_have_method() -> None:
MissingSpecAttributeWarning, match="has no attribute 'this_is_wrong'"
):
func_subject.create_child_core("this_is_wrong", False)

# incorrect nested property usage should warn
with pytest.warns(
MissingSpecAttributeWarning, match="has no attribute 'this_is_wrong'"
):
parent = nested_class_subject.create_child_core("optional_child", False)
parent.create_child_core("this_is_wrong", False)

# incorrect nested property usage should warn
with pytest.warns(
MissingSpecAttributeWarning, match="has no attribute 'this_is_wrong'"
):
parent = nested_class_subject.create_child_core("union_none_child", False)
parent.create_child_core("this_is_wrong", False)
Loading