Skip to content

Commit 85539dc

Browse files
authored
Restrict decorating async methods with sync enignes of Redis, Mongo and SQL cores (#336)
* feat: Restrict decorating async methods with sync enignes of Redis, Mongo and SQL cores * feat: Add compatibility unit tests against the async mode keyword; Increase tests coverage * fix: Add conftest.py and clients.py files in core testing packages * fix: Disable engine disposal * fix: Remove imports from conftest.py files, simplify the test files * fix: Increase tests coverage * fix: Increase tests coverage * fix: Increase tests coverage * fix: Increase tests coverage * feat: Update README.rst * feat: Restrict decorating async methods with sync enignes of Redis, Mongo and SQL cores * feat: Add compatibility unit tests against the async mode keyword; Increase tests coverage * fix: Add conftest.py and clients.py files in core testing packages * fix: Disable engine disposal * fix: Remove imports from conftest.py files, simplify the test files * fix: Increase tests coverage * fix: Increase tests coverage * fix: Increase tests coverage * fix: Increase tests coverage * feat: Update README.rst * fix: Remove root tests conftest.py file * fix: Extra tests of `_is_async_redis_client` * fix: Use the actual async engine from fixtures in SQL tests * fix: Add type hinting allowing Callable[[], "AsyncEngine"] as sql_engine parameter
1 parent fdd521c commit 85539dc

25 files changed

+1405
-679
lines changed

README.rst

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -328,9 +328,12 @@ By default, ``cachier`` does not cache ``None`` values. You can override this be
328328
Cachier Cores
329329
=============
330330

331+
331332
Pickle Core
332333
-----------
333334

335+
**Sync/Async Support:** Both sync and async functions are supported with no additional setup. Async operations are internally delegated to the sync implementation, so no async-specific configuration is needed.
336+
334337
The default core for Cachier is pickle based, meaning each function will store its cache in a separate pickle file in the ``~/.cachier`` directory. Naturally, this kind of cache is both machine-specific and user-specific.
335338

336339
You can configure ``cachier`` to use another directory by providing the ``cache_dir`` parameter with the path to that directory:
@@ -369,6 +372,12 @@ You can get the fully qualified path to the directory of cache files used by ``c
369372
370373
MongoDB Core
371374
------------
375+
376+
**Sync/Async Support:** Both sync and async functions are supported, but the ``mongetter`` callable type must match the decorated function:
377+
378+
- **Sync functions** require a sync ``mongetter`` (a regular callable returning a ``pymongo.Collection``).
379+
- **Async functions** require an async ``mongetter`` (a coroutine callable returning an async collection, e.g. via ``motor`` or ``pymongo.asynchronous``). Passing a sync ``mongetter`` to an async function raises ``TypeError``.
380+
372381
You can set a MongoDB-based cache by assigning ``mongetter`` with a callable that returns a ``pymongo.Collection`` object with writing permissions:
373382

374383
**Usage Example (MongoDB sync):**
@@ -404,8 +413,6 @@ You can set a MongoDB-based cache by assigning ``mongetter`` with a callable tha
404413
await asyncio.sleep(0.01)
405414
return x * 2
406415
407-
**Note:** An async ``mongetter`` callable is supported only for async cached functions.
408-
409416
This allows you to have a cross-machine, albeit slower, cache. This functionality requires that the installation of the ``pymongo`` python package.
410417

411418
In certain cases the MongoDB backend might leave a deadlock behind, blocking all subsequent requests from being processed. If you encounter this issue, supply the ``wait_for_calc_timeout`` with a reasonable number of seconds; calls will then wait at most this number of seconds before triggering a recalculation.
@@ -418,6 +425,8 @@ In certain cases the MongoDB backend might leave a deadlock behind, blocking all
418425
Memory Core
419426
-----------
420427

428+
**Sync/Async Support:** Both sync and async functions are supported with no additional setup. Async operations are internally delegated to the sync implementation, so no async-specific configuration is needed.
429+
421430
You can set an in-memory cache by assigning the ``backend`` parameter with ``'memory'``:
422431

423432
.. code-block:: python
@@ -429,6 +438,11 @@ Note, however, that ``cachier``'s in-memory core is simple, and has no monitorin
429438
SQLAlchemy (SQL) Core
430439
---------------------
431440

441+
**Sync/Async Support:** Both sync and async functions are supported, but the ``sql_engine`` type must match the decorated function:
442+
443+
- **Sync functions** require a sync ``Engine`` (or a connection string / callable that resolves to one).
444+
- **Async functions** require a SQLAlchemy ``AsyncEngine`` (e.g. created with ``create_async_engine``). Passing a sync engine to an async function raises ``TypeError``, and passing an async engine to a sync function also raises ``TypeError``.
445+
432446
**Note:** The SQL core requires SQLAlchemy to be installed. It is not installed by default with cachier. To use the SQL backend, run::
433447

434448
pip install SQLAlchemy
@@ -476,6 +490,11 @@ Cachier supports a generic SQL backend via SQLAlchemy, allowing you to use SQLit
476490
Redis Core
477491
----------
478492

493+
**Sync/Async Support:** Both sync and async functions are supported, but the ``redis_client`` callable type must match the decorated function:
494+
495+
- **Sync functions** require a sync ``redis.Redis`` client or a sync callable returning one.
496+
- **Async functions** require an async callable returning a ``redis.asyncio.Redis`` client. Passing a sync callable to an async function raises ``TypeError``.
497+
479498
**Note:** The Redis core requires the redis package to be installed. It is not installed by default with cachier. To use the Redis backend, run::
480499

481500
pip install redis
@@ -546,8 +565,6 @@ Cachier supports Redis-based caching for high-performance scenarios. Redis provi
546565
547566
asyncio.run(main())
548567
549-
**Note:** An async ``redis_client`` callable is supported only for async cached functions.
550-
551568
**Configuration Options:**
552569

553570
- ``sql_engine``: SQLAlchemy connection string, Engine, or callable returning an Engine.
@@ -572,6 +589,50 @@ Cachier supports Redis-based caching for high-performance scenarios. Redis provi
572589
- For best performance, ensure your DB supports row-level locking
573590

574591

592+
Core Sync/Async Compatibility
593+
------------------------------
594+
595+
The table below summarises sync and async function support across all cachier cores.
596+
Cores marked as *delegated* run async operations on top of the sync implementation
597+
(no event loop or async driver is required). Cores marked as *native* use dedicated
598+
async drivers and require the client or engine type to match the decorated function.
599+
600+
.. list-table::
601+
:header-rows: 1
602+
:widths: 15 12 12 50
603+
604+
* - Core
605+
- Sync
606+
- Async
607+
- Constraint
608+
* - **Pickle**
609+
- Yes
610+
- Yes (delegated)
611+
- None. No special configuration needed for async functions.
612+
* - **Memory**
613+
- Yes
614+
- Yes (delegated)
615+
- None. No special configuration needed for async functions.
616+
* - **MongoDB**
617+
- Yes
618+
- Yes (native)
619+
- ``mongetter`` must be a sync callable for sync functions and an async callable
620+
for async functions. Passing a sync ``mongetter`` to an async function raises
621+
``TypeError``.
622+
* - **SQL**
623+
- Yes
624+
- Yes (native)
625+
- ``sql_engine`` must be a sync ``Engine`` (or connection string) for sync
626+
functions and a SQLAlchemy ``AsyncEngine`` for async functions. A type mismatch
627+
in either direction raises ``TypeError``.
628+
* - **Redis**
629+
- Yes
630+
- Yes (native)
631+
- ``redis_client`` must be a sync client or sync callable for sync functions and
632+
an async callable returning a ``redis.asyncio.Redis`` client for async
633+
functions. Passing a sync callable to an async function raises ``TypeError``.
634+
635+
575636
Contributing
576637
============
577638

src/cachier/core.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,13 @@ def _pop_kwds_with_deprecation(kwds, name: str, default_value: bool):
156156
return kwds.pop(name, default_value)
157157

158158

159+
def _is_async_redis_client(client: Any) -> bool:
160+
if client is None:
161+
return False
162+
method_names = ("hgetall", "hset", "keys", "delete", "hget")
163+
return all(inspect.iscoroutinefunction(getattr(client, name, None)) for name in method_names)
164+
165+
159166
def cachier(
160167
hash_func: Optional[HashFunc] = None,
161168
hash_params: Optional[HashFunc] = None,
@@ -300,6 +307,42 @@ def cachier(
300307

301308
def _cachier_decorator(func):
302309
core.set_func(func)
310+
is_coroutine = inspect.iscoroutinefunction(func)
311+
312+
if backend == "mongo":
313+
if is_coroutine and not inspect.iscoroutinefunction(mongetter):
314+
msg = "Async cached functions with Mongo backend require an async mongetter."
315+
raise TypeError(msg)
316+
if (not is_coroutine) and inspect.iscoroutinefunction(mongetter):
317+
msg = "Async mongetter requires an async cached function."
318+
raise TypeError(msg)
319+
320+
if backend == "redis":
321+
if is_coroutine:
322+
if callable(redis_client):
323+
if not inspect.iscoroutinefunction(redis_client):
324+
msg = "Async cached functions with Redis backend require an async redis_client callable."
325+
raise TypeError(msg)
326+
elif not _is_async_redis_client(redis_client):
327+
msg = "Async cached functions with Redis backend require an async Redis client."
328+
raise TypeError(msg)
329+
else:
330+
if callable(redis_client) and inspect.iscoroutinefunction(redis_client):
331+
msg = "Async redis_client callable requires an async cached function."
332+
raise TypeError(msg)
333+
if _is_async_redis_client(redis_client):
334+
msg = "Async Redis client requires an async cached function."
335+
raise TypeError(msg)
336+
337+
if backend == "sql":
338+
sql_core = core
339+
assert isinstance(sql_core, _SQLCore) # noqa: S101
340+
if is_coroutine and not sql_core.has_async_engine():
341+
msg = "Async cached functions with SQL backend require an AsyncEngine sql_engine."
342+
raise TypeError(msg)
343+
if (not is_coroutine) and sql_core.has_async_engine():
344+
msg = "Async SQL engines require an async cached function."
345+
raise TypeError(msg)
303346

304347
last_cleanup = datetime.min
305348
cleanup_lock = threading.Lock()
@@ -501,8 +544,6 @@ async def _call_async(*args, max_age: Optional[timedelta] = None, **kwds):
501544
# argument.
502545
# For async functions, we create an async wrapper that calls
503546
# _call_async.
504-
is_coroutine = inspect.iscoroutinefunction(func)
505-
506547
if is_coroutine:
507548

508549
@wraps(func)
@@ -522,6 +563,14 @@ def _clear_being_calculated():
522563
"""Mark all entries in this cache as not being calculated."""
523564
core.clear_being_calculated()
524565

566+
async def _aclear_cache():
567+
"""Clear the cache asynchronously."""
568+
await core.aclear_cache()
569+
570+
async def _aclear_being_calculated():
571+
"""Mark all entries in this cache as not being calculated asynchronously."""
572+
await core.aclear_being_calculated()
573+
525574
def _cache_dpath():
526575
"""Return the path to the cache dir, if exists; None if not."""
527576
return getattr(core, "cache_dir", None)
@@ -541,6 +590,8 @@ def _precache_value(*args, value_to_cache, **kwds): # noqa: D417
541590

542591
func_wrapper.clear_cache = _clear_cache
543592
func_wrapper.clear_being_calculated = _clear_being_calculated
593+
func_wrapper.aclear_cache = _aclear_cache
594+
func_wrapper.aclear_being_calculated = _aclear_being_calculated
544595
func_wrapper.cache_dpath = _cache_dpath
545596
func_wrapper.precache_value = _precache_value
546597
return func_wrapper

src/cachier/cores/mongo.py

Lines changed: 11 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import warnings # to warn if pymongo is missing
1414
from contextlib import suppress
1515
from datetime import datetime, timedelta
16-
from inspect import isawaitable
1716
from typing import Any, Optional, Tuple
1817

1918
from .._types import HashFunc, Mongetter
@@ -68,16 +67,7 @@ def _ensure_collection(self) -> Any:
6867

6968
with self.lock:
7069
if self.mongo_collection is None:
71-
coll = self.mongetter()
72-
if isawaitable(coll):
73-
# Avoid "coroutine was never awaited" warnings.
74-
close = getattr(coll, "close", None)
75-
if callable(close):
76-
with suppress(Exception):
77-
close()
78-
msg = "async mongetter is only supported for async cached functions"
79-
raise TypeError(msg)
80-
self.mongo_collection = coll
70+
self.mongo_collection = self.mongetter()
8171

8272
if not self._index_verified:
8373
index_inf = self.mongo_collection.index_information()
@@ -96,23 +86,17 @@ async def _ensure_collection_async(self) -> Any:
9686
if self.mongo_collection is not None and self._index_verified:
9787
return self.mongo_collection
9888

99-
coll = self.mongetter()
100-
if isawaitable(coll):
101-
coll = await coll
89+
coll = await self.mongetter()
10290
self.mongo_collection = coll
10391

10492
if not self._index_verified:
105-
index_inf = self.mongo_collection.index_information()
106-
if isawaitable(index_inf):
107-
index_inf = await index_inf
93+
index_inf = await self.mongo_collection.index_information()
10894
if _MongoCore._INDEX_NAME not in index_inf:
10995
func1key1 = IndexModel(
11096
keys=[("func", ASCENDING), ("key", ASCENDING)],
11197
name=_MongoCore._INDEX_NAME,
11298
)
113-
res = self.mongo_collection.create_indexes([func1key1])
114-
if isawaitable(res):
115-
await res
99+
await self.mongo_collection.create_indexes([func1key1])
116100
self._index_verified = True
117101

118102
return self.mongo_collection
@@ -144,9 +128,7 @@ async def aget_entry(self, args, kwds) -> Tuple[str, Optional[CacheEntry]]:
144128

145129
async def aget_entry_by_key(self, key: str) -> Tuple[str, Optional[CacheEntry]]:
146130
mongo_collection = await self._ensure_collection_async()
147-
res = mongo_collection.find_one({"func": self._func_str, "key": key})
148-
if isawaitable(res):
149-
res = await res
131+
res = await mongo_collection.find_one({"func": self._func_str, "key": key})
150132
if not res:
151133
return key, None
152134
val = None
@@ -188,7 +170,7 @@ async def aset_entry(self, key: str, func_res: Any) -> bool:
188170
return False
189171
mongo_collection = await self._ensure_collection_async()
190172
thebytes = pickle.dumps(func_res)
191-
res = mongo_collection.update_one(
173+
await mongo_collection.update_one(
192174
filter={"func": self._func_str, "key": key},
193175
update={
194176
"$set": {
@@ -203,8 +185,6 @@ async def aset_entry(self, key: str, func_res: Any) -> bool:
203185
},
204186
upsert=True,
205187
)
206-
if isawaitable(res):
207-
await res
208188
return True
209189

210190
def mark_entry_being_calculated(self, key: str) -> None:
@@ -217,13 +197,11 @@ def mark_entry_being_calculated(self, key: str) -> None:
217197

218198
async def amark_entry_being_calculated(self, key: str) -> None:
219199
mongo_collection = await self._ensure_collection_async()
220-
res = mongo_collection.update_one(
200+
await mongo_collection.update_one(
221201
filter={"func": self._func_str, "key": key},
222202
update={"$set": {"processing": True}},
223203
upsert=True,
224204
)
225-
if isawaitable(res):
226-
await res
227205

228206
def mark_entry_not_calculated(self, key: str) -> None:
229207
mongo_collection = self._ensure_collection()
@@ -240,13 +218,11 @@ def mark_entry_not_calculated(self, key: str) -> None:
240218
async def amark_entry_not_calculated(self, key: str) -> None:
241219
mongo_collection = await self._ensure_collection_async()
242220
with suppress(OperationFailure):
243-
res = mongo_collection.update_one(
221+
await mongo_collection.update_one(
244222
filter={"func": self._func_str, "key": key},
245223
update={"$set": {"processing": False}},
246224
upsert=False,
247225
)
248-
if isawaitable(res):
249-
await res
250226

251227
def wait_on_entry_calc(self, key: str) -> Any:
252228
time_spent = 0
@@ -266,9 +242,7 @@ def clear_cache(self) -> None:
266242

267243
async def aclear_cache(self) -> None:
268244
mongo_collection = await self._ensure_collection_async()
269-
res = mongo_collection.delete_many(filter={"func": self._func_str})
270-
if isawaitable(res):
271-
await res
245+
await mongo_collection.delete_many(filter={"func": self._func_str})
272246

273247
def clear_being_calculated(self) -> None:
274248
mongo_collection = self._ensure_collection()
@@ -279,12 +253,10 @@ def clear_being_calculated(self) -> None:
279253

280254
async def aclear_being_calculated(self) -> None:
281255
mongo_collection = await self._ensure_collection_async()
282-
res = mongo_collection.update_many(
256+
await mongo_collection.update_many(
283257
filter={"func": self._func_str, "processing": True},
284258
update={"$set": {"processing": False}},
285259
)
286-
if isawaitable(res):
287-
await res
288260

289261
def delete_stale_entries(self, stale_after: timedelta) -> None:
290262
"""Delete stale entries from the MongoDB cache."""
@@ -296,6 +268,4 @@ async def adelete_stale_entries(self, stale_after: timedelta) -> None:
296268
"""Delete stale entries from the MongoDB cache."""
297269
mongo_collection = await self._ensure_collection_async()
298270
threshold = datetime.now() - stale_after
299-
res = mongo_collection.delete_many(filter={"func": self._func_str, "time": {"$lt": threshold}})
300-
if isawaitable(res):
301-
await res
271+
await mongo_collection.delete_many(filter={"func": self._func_str, "time": {"$lt": threshold}})

0 commit comments

Comments
 (0)