Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 9 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,15 @@ The Cachier wrapper adds a ``clear_cache()`` function to each wrapped function.

foo.clear_cache()

To clear only the cache entry for a specific call, pass the same arguments to ``clear_cache()`` that you would pass to the wrapped function:

.. code-block:: python

foo.clear_cache(arg1, arg2)
foo.clear_cache(arg1, arg2=arg2)

The asynchronous ``aclear_cache()`` helper supports the same argument-specific form.

General Configuration
----------------------

Expand Down
33 changes: 25 additions & 8 deletions src/cachier/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@ class _CachierWrappedFunc(Protocol[_P, _R_co]):

def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _R_co: ... # pragma: no cover

clear_cache: Callable[[], Any]
clear_cache: Callable[..., Any]
clear_being_calculated: Callable[[], Any]
aclear_cache: Callable[[], Any]
aclear_cache: Callable[..., Any]
aclear_being_calculated: Callable[[], Any]
cache_dpath: Callable[[], Optional[str]]
precache_value: Callable[..., Any]
Expand Down Expand Up @@ -219,6 +219,13 @@ def _is_async_redis_client(client: Any) -> bool:
return all(inspect.iscoroutinefunction(getattr(client, name, None)) for name in method_names)


def _convert_public_cache_args(func, _is_method: bool, args: tuple, kwds: dict) -> dict:
"""Convert cache-management arguments to canonical cache-key kwargs."""
if _is_method:
args = (None, *args)
return _convert_args_kwargs(func, _is_method=_is_method, args=args, kwds=kwds)


def cachier(
hash_func: Optional[HashFunc] = None,
hash_params: Optional[HashFunc] = None,
Expand Down Expand Up @@ -733,9 +740,14 @@ async def func_wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
def func_wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
return _call(*args, **kwargs) # type: ignore[arg-type]

def _clear_cache():
"""Clear the cache."""
core.clear_cache()
def _clear_cache(*args, **kwds):
"""Clear the cache, or only the entry matching the provided arguments."""
if args or kwds:
kwargs = _convert_public_cache_args(func, core.func_is_method, args, kwds)
key = core.get_key((), kwargs)
core.clear_cache_entry(key)
else:
core.clear_cache()
if is_coroutine:
return _ImmediateAwaitable()
return None
Expand All @@ -747,9 +759,14 @@ def _clear_being_calculated():
return _ImmediateAwaitable()
return None

async def _aclear_cache():
"""Clear the cache asynchronously."""
await core.aclear_cache()
async def _aclear_cache(*args, **kwds):
"""Clear the cache asynchronously, or only the entry matching the provided arguments."""
if args or kwds:
kwargs = _convert_public_cache_args(func, core.func_is_method, args, kwds)
key = core.get_key((), kwargs)
await core.aclear_cache_entry(key)
else:
await core.aclear_cache()

async def _aclear_being_calculated():
"""Mark all entries in this cache as not being calculated asynchronously."""
Expand Down
12 changes: 12 additions & 0 deletions src/cachier/cores/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,18 @@ async def aclear_cache(self) -> None:
"""
await asyncio.to_thread(self.clear_cache)

@abc.abstractmethod
def clear_cache_entry(self, key: str) -> None:
"""Clear the cache entry mapped by the given key."""

async def aclear_cache_entry(self, key: str) -> None:
"""Async-compatible variant of :meth:`clear_cache_entry`.

By default this runs in a thread to avoid blocking the event loop.

"""
await asyncio.to_thread(self.clear_cache_entry, key)

@abc.abstractmethod
def clear_being_calculated(self) -> None:
"""Mark all entries in this cache as not being calculated."""
Expand Down
5 changes: 5 additions & 0 deletions src/cachier/cores/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ def clear_cache(self) -> None:
# Update size metrics after clearing
self._update_size_metrics()

def clear_cache_entry(self, key: str) -> None:
with self.lock:
self.cache.pop(self._hash_func_key(key), None)
self._update_size_metrics()

def clear_being_calculated(self) -> None:
with self.lock:
for entry in self.cache.values():
Expand Down
8 changes: 8 additions & 0 deletions src/cachier/cores/mongo.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,14 @@ async def aclear_cache(self) -> None:
mongo_collection = await self._ensure_collection_async()
await mongo_collection.delete_many(filter={"func": self._func_str})

def clear_cache_entry(self, key: str) -> None:
mongo_collection = self._ensure_collection()
mongo_collection.delete_one(filter={"func": self._func_str, "key": key})

async def aclear_cache_entry(self, key: str) -> None:
mongo_collection = await self._ensure_collection_async()
await mongo_collection.delete_one(filter={"func": self._func_str, "key": key})

def clear_being_calculated(self) -> None:
mongo_collection = self._ensure_collection()
mongo_collection.update_many(
Expand Down
11 changes: 11 additions & 0 deletions src/cachier/cores/pickle.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,17 @@ def clear_cache(self) -> None:
else:
self._save_cache({})

def clear_cache_entry(self, key: str) -> None:
if self.separate_files:
with suppress(FileNotFoundError):
os.remove(f"{self.cache_fpath}_{key}")
return
Comment thread
shaypal5 marked this conversation as resolved.

with self.lock:
cache = self.get_cache_dict()
cache.pop(key, None)
self._save_cache(cache)

def clear_being_calculated(self) -> None:
if self.separate_files:
self._clear_being_calculated_all_cache_files()
Expand Down
20 changes: 20 additions & 0 deletions src/cachier/cores/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,16 @@ def clear_cache(self) -> None:
except Exception as e:
warnings.warn(f"Redis clear_cache failed: {e}", stacklevel=2)

def clear_cache_entry(self, key: str) -> None:
"""Clear the cache entry mapped by the given key."""
redis_client = self._resolve_redis_client()
redis_key = self._get_redis_key(key)

try:
redis_client.delete(redis_key)
except Exception as e:
warnings.warn(f"Redis clear_cache_entry failed: {e}", stacklevel=2)

async def aclear_cache(self) -> None:
"""Clear the cache of this core asynchronously."""
redis_client = await self._resolve_redis_client_async()
Expand All @@ -365,6 +375,16 @@ async def aclear_cache(self) -> None:
except Exception as e:
warnings.warn(f"Redis clear_cache failed: {e}", stacklevel=2)

async def aclear_cache_entry(self, key: str) -> None:
"""Clear the cache entry mapped by the given key asynchronously."""
redis_client = await self._resolve_redis_client_async()
redis_key = self._get_redis_key(key)

try:
await redis_client.delete(redis_key)
except Exception as e:
warnings.warn(f"Redis clear_cache_entry failed: {e}", stacklevel=2)

def clear_being_calculated(self) -> None:
"""Mark all entries in this cache as not being calculated."""
redis_client = self._resolve_redis_client()
Expand Down
9 changes: 9 additions & 0 deletions src/cachier/cores/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,15 @@ def clear_cache(self) -> None:
except Exception as exc:
_safe_warn(f"S3 clear_cache failed: {exc}")

def clear_cache_entry(self, key: str) -> None:
"""Delete the cache entry mapped by the given key from S3."""
client = self._get_s3_client()
s3_key = self._get_s3_key(key)
try:
client.delete_object(Bucket=self.s3_bucket, Key=s3_key)
except Exception as exc:
_safe_warn(f"S3 clear_cache_entry failed: {exc}")

def clear_being_calculated(self) -> None:
"""Reset the ``_processing`` flag on all entries for this function in S3."""
client = self._get_s3_client()
Expand Down
16 changes: 16 additions & 0 deletions src/cachier/cores/sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,12 +434,28 @@ def clear_cache(self) -> None:
session.execute(delete(CacheTable).where(CacheTable.function_id == self._func_str))
session.commit()

def clear_cache_entry(self, key: str) -> None:
session_factory = self._get_sync_session()
with self._lock, session_factory() as session:
session.execute(
delete(CacheTable).where(and_(CacheTable.function_id == self._func_str, CacheTable.key == key))
)
session.commit()

async def aclear_cache(self) -> None:
session_factory = await self._get_async_session()
async with session_factory() as session:
await session.execute(delete(CacheTable).where(CacheTable.function_id == self._func_str))
await session.commit()

async def aclear_cache_entry(self, key: str) -> None:
session_factory = await self._get_async_session()
async with session_factory() as session:
await session.execute(
delete(CacheTable).where(and_(CacheTable.function_id == self._func_str, CacheTable.key == key))
)
await session.commit()

def clear_being_calculated(self) -> None:
session_factory = self._get_sync_session()
with self._lock, session_factory() as session:
Expand Down
22 changes: 22 additions & 0 deletions tests/mongo_tests/test_mongo_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,28 @@ def _test_mongo_caching(arg_1, arg_2):
assert val6 == val5


@pytest.mark.mongo
def test_mongo_clear_cache_for_specific_arguments():
"""clear_cache can remove one Mongo cache entry by function arguments."""

@cachier(mongetter=_test_mongetter)
def _test_mongo_caching(arg_1, arg_2):
"""Some function."""
return random() + arg_1 + arg_2

_test_mongo_caching.clear_cache()
val1 = _test_mongo_caching(1, arg_2=2)
val2 = _test_mongo_caching(3, arg_2=4)
assert _test_mongo_caching(1, arg_2=2) == val1
assert _test_mongo_caching(3, arg_2=4) == val2

_test_mongo_caching.clear_cache(1, arg_2=2)

assert _test_mongo_caching(1, arg_2=2) != val1
assert _test_mongo_caching(3, arg_2=4) == val2
_test_mongo_caching.clear_cache()


@pytest.mark.mongo
def test_mongo_stale_after():
"""Testing MongoDB core stale_after functionality."""
Expand Down
22 changes: 22 additions & 0 deletions tests/redis_tests/test_redis_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,28 @@ def _test_redis_caching(arg_1, arg_2):
assert val6 == val5


@pytest.mark.redis
def test_redis_clear_cache_for_specific_arguments():
"""clear_cache can remove one Redis cache entry by function arguments."""

@cachier(backend="redis", redis_client=_test_redis_getter)
def _test_redis_caching(arg_1, arg_2):
"""Some function."""
return random() + arg_1 + arg_2

_test_redis_caching.clear_cache()
val1 = _test_redis_caching(1, arg_2=2)
val2 = _test_redis_caching(3, arg_2=4)
assert _test_redis_caching(1, arg_2=2) == val1
assert _test_redis_caching(3, arg_2=4) == val2

_test_redis_caching.clear_cache(1, arg_2=2)

assert _test_redis_caching(1, arg_2=2) != val1
assert _test_redis_caching(3, arg_2=4) == val2
_test_redis_caching.clear_cache()


@pytest.mark.redis
def test_redis_stale_after():
"""Testing Redis core stale_after functionality."""
Expand Down
21 changes: 21 additions & 0 deletions tests/s3_tests/test_s3_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,27 @@ def _cached(x):
assert val1 != val2


@pytest.mark.s3
def test_s3_clear_cache_for_specific_arguments(s3_bucket):
"""clear_cache can remove one S3 cache entry by function arguments."""

@cachier(backend="s3", s3_bucket=s3_bucket, s3_region=TEST_REGION)
def _cached(x, y=1):
return random() + x + y

_cached.clear_cache()
val1 = _cached(1, y=2)
val2 = _cached(3, y=4)
assert _cached(1, y=2) == val1
assert _cached(3, y=4) == val2

_cached.clear_cache(1, y=2)

assert _cached(1, y=2) != val1
assert _cached(3, y=4) == val2
_cached.clear_cache()


@pytest.mark.s3
def test_s3_core_skip_cache(s3_bucket):
"""cachier__skip_cache bypasses the cache."""
Expand Down
21 changes: 21 additions & 0 deletions tests/sql_tests/test_sql_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,27 @@ def f(x, y):
f.clear_cache()


@pytest.mark.sql
def test_sql_clear_cache_for_specific_arguments():
"""clear_cache can remove one SQL cache entry by function arguments."""

@cachier(backend="sql", sql_engine=SQL_CONN_STR)
def f(x, y):
return random() + x + y

f.clear_cache()
v1 = f(1, y=2)
v2 = f(3, y=4)
assert f(1, y=2) == v1
assert f(3, y=4) == v2

f.clear_cache(1, y=2)

assert f(1, y=2) != v1
assert f(3, y=4) == v2
f.clear_cache()


@pytest.mark.sql
def test_sql_stale_after():
@cachier(
Expand Down
25 changes: 25 additions & 0 deletions tests/test_async_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,31 @@ async def async_func(x):

async_func.clear_cache()

@pytest.mark.memory
@pytest.mark.asyncio
async def test_clear_cache_for_specific_arguments(self):
"""Test async wrappers can clear one cached argument set."""
call_count = 0

@cachier(backend="memory")
async def async_func(x, y=1):
nonlocal call_count
call_count += 1
return f"{x}:{y}:{call_count}"

async_func.clear_cache()

first = await async_func(1, y=2)
second = await async_func(3, y=4)
assert await async_func(1, y=2) == first
assert await async_func(3, y=4) == second

await async_func.aclear_cache(1, y=2)

assert await async_func(1, y=2) != first
assert await async_func(3, y=4) == second
await async_func.aclear_cache()

@pytest.mark.pickle
@pytest.mark.asyncio
async def test_pickle(self):
Expand Down
8 changes: 8 additions & 0 deletions tests/test_base_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def __init__(self, hash_func, wait_for_calc_timeout, entry_size_limit=None):
self.last_mark_not_calc = None
self.last_wait_key = None
self.clear_cache_called = False
self.last_cleared_key = None
self.clear_being_calculated_called = False
self.last_deleted_stale_after = None

Expand Down Expand Up @@ -48,6 +49,10 @@ def clear_cache(self):
"""Clear the cache."""
self.clear_cache_called = True

def clear_cache_entry(self, key):
"""Clear one cache entry."""
self.last_cleared_key = key

def clear_being_calculated(self):
"""Clear entries that are being calculated."""
self.clear_being_calculated_called = True
Expand Down Expand Up @@ -112,6 +117,9 @@ async def fake_aset_entry(key, value):
await core.aclear_cache()
assert core.clear_cache_called is True

await core.aclear_cache_entry("one-key")
assert core.last_cleared_key == "one-key"

await core.aclear_being_calculated()
assert core.clear_being_calculated_called is True

Expand Down
Loading
Loading