Skip to content

Commit a82fe54

Browse files
CopilotBordapre-commit-ci[bot]Copilot
authored
Add asyncio/coroutine support for caching async functions (#319)
* Add asyncio support for caching coroutines * Fix async concurrent call handling and update tests/examples * Fix linting issues in async implementation * Add comment explaining next_time mark/unmark pattern * add pytest-asyncio * Address PR review feedback: fix test assertions, add allow_none test, improve error handling, document async limitations * Refactor async caching tests: introduce test classes and improve organization * Fix redundant test assertions and add edge case tests for async functionality * Simplify line breaks in async_example.py for 120 char line length * Fix redundant test assertion in test_uses_cache_before_expiry * Add comprehensive tests for missing async code coverage * Apply suggestions from code review * Add tests for missing code coverage: exception handling and stale processing * Add tests to cover remaining uncovered async code paths * Remove duplicate tests and consolidate stale entry processing tests * Remove unreachable async code path for stale entry processing * Move common pytest marks to class level for cleaner test code --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Borda <6035284+Borda@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent a683275 commit a82fe54

7 files changed

Lines changed: 1285 additions & 14 deletions

File tree

examples/async_example.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"""Example demonstrating async/coroutine support in Cachier.
2+
3+
This example shows how to use the @cachier decorator with async functions to cache the results of HTTP requests or other
4+
async operations.
5+
6+
"""
7+
8+
import asyncio
9+
import time
10+
from datetime import timedelta
11+
12+
from cachier import cachier
13+
14+
15+
# Example 1: Basic async function caching
16+
@cachier(backend="pickle", stale_after=timedelta(hours=1))
17+
async def fetch_user_data(user_id: int) -> dict:
18+
"""Simulate fetching user data from an API."""
19+
print(f" Fetching user {user_id} from API...")
20+
await asyncio.sleep(1) # Simulate network delay
21+
return {"id": user_id, "name": f"User{user_id}", "email": f"user{user_id}@example.com"}
22+
23+
24+
# Example 2: Async function with memory backend (faster, but not persistent)
25+
@cachier(backend="memory")
26+
async def calculate_complex_result(x: int, y: int) -> int:
27+
"""Simulate a complex calculation."""
28+
print(f" Computing {x} ** {y}...")
29+
await asyncio.sleep(0.5) # Simulate computation time
30+
return x**y
31+
32+
33+
# Example 3: Async function with stale_after (without next_time for simplicity)
34+
@cachier(backend="memory", stale_after=timedelta(seconds=3), next_time=False)
35+
async def get_weather_data(city: str) -> dict:
36+
"""Simulate fetching weather data with automatic refresh when stale."""
37+
print(f" Fetching weather for {city}...")
38+
await asyncio.sleep(0.5)
39+
return {"city": city, "temp": 72, "condition": "sunny", "timestamp": time.time()}
40+
41+
42+
# Example 4: Real-world HTTP request caching (requires httpx)
43+
async def demo_http_caching():
44+
"""Demonstrate caching actual HTTP requests."""
45+
print("\n=== HTTP Request Caching Example ===")
46+
try:
47+
import httpx
48+
49+
@cachier(backend="pickle", stale_after=timedelta(minutes=5))
50+
async def fetch_github_user(username: str) -> dict:
51+
"""Fetch GitHub user data with caching."""
52+
print(f" Making API request for {username}...")
53+
async with httpx.AsyncClient() as client:
54+
response = await client.get(f"https://api.github.com/users/{username}")
55+
return response.json()
56+
57+
# First call - makes actual HTTP request
58+
start = time.time()
59+
user1 = await fetch_github_user("torvalds")
60+
duration1 = time.time() - start
61+
print(f" First call took {duration1:.2f}s")
62+
user_name = user1.get("name", "N/A")
63+
user_repos = user1.get("public_repos", "N/A")
64+
print(f" User: {user_name}, Repos: {user_repos}")
65+
66+
# Second call - uses cache (much faster)
67+
start = time.time()
68+
await fetch_github_user("torvalds")
69+
duration2 = time.time() - start
70+
print(f" Second call took {duration2:.2f}s (from cache)")
71+
if duration2 > 0:
72+
print(f" Cache speedup: {duration1 / duration2:.1f}x")
73+
else:
74+
print(" Cache speedup: instantaneous (duration too small to measure)")
75+
76+
except ImportError:
77+
print(" (Skipping - httpx not installed. Install with: pip install httpx)")
78+
79+
80+
async def main():
81+
"""Run all async caching examples."""
82+
print("=" * 60)
83+
print("Cachier Async/Coroutine Support Examples")
84+
print("=" * 60)
85+
86+
# Example 1: Basic async caching
87+
print("\n=== Example 1: Basic Async Caching ===")
88+
start = time.time()
89+
user = await fetch_user_data(42)
90+
duration1 = time.time() - start
91+
print(f"First call: {user} (took {duration1:.2f}s)")
92+
93+
start = time.time()
94+
user = await fetch_user_data(42)
95+
duration2 = time.time() - start
96+
print(f"Second call: {user} (took {duration2:.2f}s)")
97+
if duration2 > 0:
98+
print(f"Speedup: {duration1 / duration2:.1f}x faster!")
99+
else:
100+
print("Speedup: instantaneous (duration too small to measure)")
101+
102+
# Example 2: Memory backend
103+
print("\n=== Example 2: Memory Backend (Fast, Non-Persistent) ===")
104+
start = time.time()
105+
result = await calculate_complex_result(2, 20)
106+
duration1 = time.time() - start
107+
print(f"First call: 2^20 = {result} (took {duration1:.2f}s)")
108+
109+
start = time.time()
110+
result = await calculate_complex_result(2, 20)
111+
duration2 = time.time() - start
112+
print(f"Second call: 2^20 = {result} (took {duration2:.2f}s)")
113+
114+
# Example 3: Stale-after
115+
print("\n=== Example 3: Stale-After ===")
116+
weather = await get_weather_data("San Francisco")
117+
print(f"First call: {weather}")
118+
119+
weather = await get_weather_data("San Francisco")
120+
print(f"Second call (cached): {weather}")
121+
122+
print("Waiting 4 seconds for cache to become stale...")
123+
await asyncio.sleep(4)
124+
125+
weather = await get_weather_data("San Francisco")
126+
print(f"Third call (recalculates because stale): {weather}")
127+
128+
# Example 4: Concurrent requests
129+
print("\n=== Example 4: Concurrent Async Requests ===")
130+
print("Making 5 concurrent requests...")
131+
print("(First 3 are unique and will execute, last 2 are duplicates)")
132+
start = time.time()
133+
await asyncio.gather(
134+
fetch_user_data(1),
135+
fetch_user_data(2),
136+
fetch_user_data(3),
137+
fetch_user_data(1), # Duplicate - will execute in parallel with first
138+
fetch_user_data(2), # Duplicate - will execute in parallel with second
139+
)
140+
duration = time.time() - start
141+
print(f"All requests completed in {duration:.2f}s")
142+
143+
# Now test that subsequent calls use cache
144+
print("\nMaking the same requests again (should use cache):")
145+
start = time.time()
146+
await asyncio.gather(
147+
fetch_user_data(1),
148+
fetch_user_data(2),
149+
fetch_user_data(3),
150+
)
151+
duration2 = time.time() - start
152+
print(f"Completed in {duration2:.2f}s - much faster!")
153+
154+
# Example 5: HTTP caching (if httpx is available)
155+
await demo_http_caching()
156+
157+
# Clean up
158+
print("\n=== Cleanup ===")
159+
fetch_user_data.clear_cache()
160+
calculate_complex_result.clear_cache()
161+
get_weather_data.clear_cache()
162+
print("All caches cleared!")
163+
164+
print("\n" + "=" * 60)
165+
print("Key Features Demonstrated:")
166+
print(" - Async function caching with @cachier decorator")
167+
print(" - Multiple backends (pickle, memory)")
168+
print(" - Automatic cache invalidation (stale_after)")
169+
print(" - Concurrent request handling")
170+
print(" - Significant performance improvements")
171+
print("\nNote: For async functions, concurrent calls with the same")
172+
print("arguments will execute in parallel initially. Subsequent calls")
173+
print("will use the cached result for significant speedup.")
174+
print("=" * 60)
175+
176+
177+
if __name__ == "__main__":
178+
asyncio.run(main())

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,8 @@ lint.mccabe.max-complexity = 10
147147
[tool.docformatter]
148148
recursive = true
149149
# some docstring start with r"""
150-
wrap-summaries = 79
151-
wrap-descriptions = 79
150+
wrap-summaries = 120
151+
wrap-descriptions = 120
152152
blank = true
153153

154154
# === Testing ===
@@ -178,6 +178,7 @@ markers = [
178178
"redis: test the Redis core",
179179
"sql: test the SQL core",
180180
"maxage: test the max_age functionality",
181+
"asyncio: marks tests as async",
181182
]
182183

183184
# --- coverage ---

src/cachier/core.py

Lines changed: 119 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
# http://www.opensource.org/licenses/MIT-license
88
# Copyright (c) 2016, Shay Palachy <shaypal5@gmail.com>
99

10+
import asyncio
1011
import inspect
1112
import os
1213
import threading
@@ -56,6 +57,14 @@ def _function_thread(core, key, func, args, kwds):
5657
print(f"Function call failed with the following exception:\n{exc}")
5758

5859

60+
async def _function_thread_async(core, key, func, args, kwds):
61+
try:
62+
func_res = await func(*args, **kwds)
63+
core.set_entry(key, func_res)
64+
except BaseException as exc:
65+
print(f"Function call failed with the following exception:\n{exc}")
66+
67+
5968
def _calc_entry(core, key, func, args, kwds, printer=lambda *_: None) -> Optional[Any]:
6069
core.mark_entry_being_calculated(key)
6170
try:
@@ -68,6 +77,18 @@ def _calc_entry(core, key, func, args, kwds, printer=lambda *_: None) -> Optiona
6877
core.mark_entry_not_calculated(key)
6978

7079

80+
async def _calc_entry_async(core, key, func, args, kwds, printer=lambda *_: None) -> Optional[Any]:
81+
core.mark_entry_being_calculated(key)
82+
try:
83+
func_res = await func(*args, **kwds)
84+
stored = core.set_entry(key, func_res)
85+
if not stored:
86+
printer("Result exceeds entry_size_limit; not cached")
87+
return func_res
88+
finally:
89+
core.mark_entry_not_calculated(key)
90+
91+
7192
def _convert_args_kwargs(func, _is_method: bool, args: tuple, kwds: dict) -> dict:
7293
"""Convert mix of positional and keyword arguments to aggregated kwargs."""
7394
# unwrap if the function is functools.partial
@@ -390,13 +411,108 @@ def _call(*args, max_age: Optional[timedelta] = None, **kwds):
390411
_print("No entry found. No current calc. Calling like a boss.")
391412
return _calc_entry(core, key, func, args, kwds, _print)
392413

414+
async def _call_async(*args, max_age: Optional[timedelta] = None, **kwds):
415+
# NOTE: For async functions, wait_for_calc_timeout is not honored.
416+
# Instead of blocking the event loop waiting for concurrent
417+
# calculations, async functions will recalculate in parallel.
418+
# This avoids deadlocks and maintains async efficiency.
419+
nonlocal allow_none, last_cleanup
420+
_allow_none = _update_with_defaults(allow_none, "allow_none", kwds)
421+
# print('Inside async wrapper for {}.'.format(func.__name__))
422+
ignore_cache = _pop_kwds_with_deprecation(kwds, "ignore_cache", False)
423+
overwrite_cache = _pop_kwds_with_deprecation(kwds, "overwrite_cache", False)
424+
verbose = _pop_kwds_with_deprecation(kwds, "verbose_cache", False)
425+
ignore_cache = kwds.pop("cachier__skip_cache", ignore_cache)
426+
overwrite_cache = kwds.pop("cachier__overwrite_cache", overwrite_cache)
427+
verbose = kwds.pop("cachier__verbose", verbose)
428+
_stale_after = _update_with_defaults(stale_after, "stale_after", kwds)
429+
_next_time = _update_with_defaults(next_time, "next_time", kwds)
430+
_cleanup_flag = _update_with_defaults(cleanup_stale, "cleanup_stale", kwds)
431+
_cleanup_interval_val = _update_with_defaults(cleanup_interval, "cleanup_interval", kwds)
432+
# merge args expanded as kwargs and the original kwds
433+
kwargs = _convert_args_kwargs(func, _is_method=core.func_is_method, args=args, kwds=kwds)
434+
435+
if _cleanup_flag:
436+
now = datetime.now()
437+
with cleanup_lock:
438+
if now - last_cleanup >= _cleanup_interval_val:
439+
last_cleanup = now
440+
_get_executor().submit(core.delete_stale_entries, _stale_after)
441+
442+
_print = print if verbose else lambda x: None
443+
444+
# Check current global caching state dynamically
445+
from .config import _global_params
446+
447+
if ignore_cache or not _global_params.caching_enabled:
448+
return await func(args[0], **kwargs) if core.func_is_method else await func(**kwargs)
449+
key, entry = core.get_entry((), kwargs)
450+
if overwrite_cache:
451+
result = await _calc_entry_async(core, key, func, args, kwds, _print)
452+
return result
453+
if entry is None or (not entry._completed and not entry._processing):
454+
_print("No entry found. No current calc. Calling like a boss.")
455+
result = await _calc_entry_async(core, key, func, args, kwds, _print)
456+
return result
457+
_print("Entry found.")
458+
if _allow_none or entry.value is not None:
459+
_print("Cached result found.")
460+
now = datetime.now()
461+
max_allowed_age = _stale_after
462+
nonneg_max_age = True
463+
if max_age is not None:
464+
if max_age < ZERO_TIMEDELTA:
465+
_print("max_age is negative. Cached result considered stale.")
466+
nonneg_max_age = False
467+
else:
468+
assert max_age is not None # noqa: S101
469+
max_allowed_age = min(_stale_after, max_age)
470+
# note: if max_age < 0, we always consider a value stale
471+
if nonneg_max_age and (now - entry.time <= max_allowed_age):
472+
_print("And it is fresh!")
473+
return entry.value
474+
_print("But it is stale... :(")
475+
if _next_time:
476+
_print("Async calc and return stale")
477+
# Mark entry as being calculated then immediately unmark
478+
# This matches sync behavior and ensures entry exists
479+
# Background task will update cache when complete
480+
core.mark_entry_being_calculated(key)
481+
# Use asyncio.create_task for background execution
482+
asyncio.create_task(_function_thread_async(core, key, func, args, kwds))
483+
core.mark_entry_not_calculated(key)
484+
return entry.value
485+
_print("Calling decorated function and waiting")
486+
result = await _calc_entry_async(core, key, func, args, kwds, _print)
487+
return result
488+
if entry._processing:
489+
msg = "No value but being calculated. Recalculating"
490+
_print(f"{msg} (async - no wait).")
491+
# For async, don't wait - just recalculate
492+
# This avoids blocking the event loop
493+
result = await _calc_entry_async(core, key, func, args, kwds, _print)
494+
return result
495+
_print("No entry found. No current calc. Calling like a boss.")
496+
return await _calc_entry_async(core, key, func, args, kwds, _print)
497+
393498
# MAINTAINER NOTE: The main function wrapper is now a standard function
394499
# that passes *args and **kwargs to _call. This ensures that user
395500
# arguments are not shifted, and max_age is only settable via keyword
396501
# argument.
397-
@wraps(func)
398-
def func_wrapper(*args, **kwargs):
399-
return _call(*args, **kwargs)
502+
# For async functions, we create an async wrapper that calls
503+
# _call_async.
504+
is_coroutine = inspect.iscoroutinefunction(func)
505+
506+
if is_coroutine:
507+
508+
@wraps(func)
509+
async def func_wrapper(*args, **kwargs):
510+
return await _call_async(*args, **kwargs)
511+
else:
512+
513+
@wraps(func)
514+
def func_wrapper(*args, **kwargs):
515+
return _call(*args, **kwargs)
400516

401517
def _clear_cache():
402518
"""Clear the cache."""

src/cachier/cores/base.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,8 @@ class RecalculationNeeded(Exception):
2929
def _get_func_str(func: Callable) -> str:
3030
"""Return a string identifier for the function (module + name).
3131
32-
We accept Any here because static analysis can't always prove that the
33-
runtime object will have __module__ and __name__, but at runtime the
34-
decorated functions always do.
32+
We accept Any here because static analysis can't always prove that the runtime object will have __module__ and
33+
__name__, but at runtime the decorated functions always do.
3534
3635
"""
3736
return f".{func.__module__}.{func.__name__}"
@@ -52,8 +51,7 @@ def __init__(
5251
def set_func(self, func):
5352
"""Set the function this core will use.
5453
55-
This has to be set before any method is called. Also determine if the
56-
function is an object method.
54+
This has to be set before any method is called. Also determine if the function is an object method.
5755
5856
"""
5957
# unwrap if the function is functools.partial
@@ -70,8 +68,7 @@ def get_key(self, args, kwds):
7068
def get_entry(self, args, kwds) -> Tuple[str, Optional[CacheEntry]]:
7169
"""Get entry based on given arguments.
7270
73-
Return the result mapped to the given arguments in this core's cache,
74-
if such a mapping exists.
71+
Return the result mapped to the given arguments in this core's cache, if such a mapping exists.
7572
7673
"""
7774
key = self.get_key(args, kwds)

tests/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
pytest
44
coverage
55
pytest-cov
6+
pytest-asyncio
67
birch
78
# to be able to run `python setup.py checkdocs`
89
collective.checkdocs

0 commit comments

Comments
 (0)