diff --git a/src/shared_store.rs b/src/shared_store.rs index 5078ae8..ce6e9fe 100644 --- a/src/shared_store.rs +++ b/src/shared_store.rs @@ -37,7 +37,9 @@ impl SharedCacheInfo { /// /// Parallel to `CachedFunction` but stores serialized bytes in shared /// memory accessible across processes. -#[pyclass(frozen)] +// `dict` gives instances a __dict__ so the Python decorator can apply +// functools.wraps for introspection (#43). See store.rs CachedFunction. +#[pyclass(frozen, dict)] pub struct SharedCachedFunction { fn_obj: Py, pickle_dumps: Py, diff --git a/src/store.rs b/src/store.rs index f3334e5..9f2256b 100644 --- a/src/store.rs +++ b/src/store.rs @@ -239,7 +239,10 @@ impl CacheInfo { } } -#[pyclass(frozen)] +// `dict` gives instances a __dict__ so the Python decorator can apply +// functools.wraps (copies __name__/__qualname__/__module__/__doc__/__wrapped__) +// for introspection (#43). Only touched at decoration time, never on the hot path. +#[pyclass(frozen, dict)] pub struct CachedFunction { fn_obj: Py, shards: Box<[ShardLock]>, diff --git a/tests/test_introspection.py b/tests/test_introspection.py new file mode 100644 index 0000000..0f22af1 --- /dev/null +++ b/tests/test_introspection.py @@ -0,0 +1,97 @@ +"""Regression tests for #43 and #44. + +#43: sync decorated functions used to return the raw Rust object with no +metadata — `__name__`/`__doc__`/`__wrapped__` missing and `inspect.signature` +collapsing to `(*args, **kwargs)`, breaking logging/doc tools/FastAPI/click. + +#44: an async decorated function was not detected as a coroutine function, so +frameworks that branch on `iscoroutinefunction` (FastAPI/Starlette, anyio, +pytest-asyncio) treated it as sync and dropped the returned coroutine. +""" + +import asyncio +import contextlib +import glob +import inspect +import os +import sys +import tempfile + +import pytest + +from warp_cache import cache + + +def _cleanup_shm(): + shm_dir = os.path.join(tempfile.gettempdir(), "warp_cache") + if os.path.isdir(shm_dir): + for f in glob.glob(os.path.join(shm_dir, "*")): + with contextlib.suppress(OSError): + os.unlink(f) + + +@pytest.mark.parametrize( + "backend", + [ + "memory", + pytest.param( + "shared", + marks=pytest.mark.skipif( + sys.platform == "win32", reason="shared memory is Unix-only" + ), + ), + ], +) +def test_sync_preserves_introspection(backend): + """#43: name/qualname/module/doc/__wrapped__ and a resolvable signature.""" + _cleanup_shm() + try: + + @cache(max_size=128, backend=backend) + def add(a, b=2): + """add docstring""" + return a + b + + assert add.__name__ == "add" + assert add.__qualname__.endswith("add") + assert add.__module__ == __name__ + assert add.__doc__ == "add docstring" + assert add.__wrapped__ is not None + assert str(inspect.signature(add)) == "(a, b=2)" + + # Wrapping must not break caching. + assert add(1) == 3 + assert add(1) == 3 + assert add.cache_info().hits == 1 + finally: + _cleanup_shm() + + +def test_async_preserves_introspection(): + """#43 (async path): name/doc/__wrapped__/signature stay intact.""" + + @cache(max_size=128) + async def fetch(a, b=2): + """fetch docstring""" + return a + b + + assert fetch.__name__ == "fetch" + assert fetch.__doc__ == "fetch docstring" + assert fetch.__wrapped__ is not None + assert str(inspect.signature(fetch)) == "(a, b=2)" + + +def test_async_is_detected_as_coroutine_function(): + """#44: iscoroutinefunction must report True so frameworks await it.""" + + @cache(max_size=128) + async def fetch(x): + return x + + if hasattr(inspect, "markcoroutinefunction"): # Python 3.12+ + assert inspect.iscoroutinefunction(fetch) + else: # 3.10 / 3.11: asyncio's sentinel check (not deprecated there) + assert asyncio.iscoroutinefunction(fetch) + + # And it still returns an awaitable yielding the right value. + assert asyncio.run(fetch(7)) == 7 diff --git a/warp_cache/_decorator.py b/warp_cache/_decorator.py index 189d516..aa61e53 100644 --- a/warp_cache/_decorator.py +++ b/warp_cache/_decorator.py @@ -1,6 +1,8 @@ from __future__ import annotations import asyncio +import functools +import inspect import warnings from collections.abc import Callable from typing import Any, ParamSpec, Protocol, TypeVar, runtime_checkable @@ -62,6 +64,13 @@ def __init__( self.__qualname__ = getattr(fn, "__qualname__", self.__name__) self.__module__ = getattr(fn, "__module__", __name__) self.__doc__ = getattr(fn, "__doc__", None) + # Mark the wrapper as a coroutine function so frameworks that branch on + # iscoroutinefunction (FastAPI/Starlette, anyio, pytest-asyncio) await it + # instead of treating it as sync and dropping the returned coroutine (#44). + if hasattr(inspect, "markcoroutinefunction"): # Python 3.12+ + inspect.markcoroutinefunction(self) + else: # 3.10 / 3.11: set the sentinel asyncio.iscoroutinefunction checks + self._is_coroutine = asyncio.coroutines._is_coroutine # ty: ignore[unresolved-attribute] @staticmethod def _make_inflight_key(args: tuple[Any, ...], kwargs: dict[str, Any] | None) -> Any: @@ -183,6 +192,11 @@ def decorator(fn: Callable[P, R]) -> CachedCallable[P, R]: if asyncio.iscoroutinefunction(fn): return AsyncCachedFunction(fn, inner) # type: ignore[return-value] + # Sync path returns the Rust object directly (no Python wrapper, so __call__ + # stays a single FFI crossing). Copy introspection metadata onto it — + # __name__/__qualname__/__module__/__doc__ and __wrapped__ so inspect.signature + # resolves to the original (#43). The pyclass carries a __dict__ for this. + functools.wraps(fn)(inner) return inner return decorator