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
4 changes: 3 additions & 1 deletion src/shared_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PyAny>,
pickle_dumps: Py<PyAny>,
Expand Down
5 changes: 4 additions & 1 deletion src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PyAny>,
shards: Box<[ShardLock]>,
Expand Down
97 changes: 97 additions & 0 deletions tests/test_introspection.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions warp_cache/_decorator.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Loading