Skip to content

Commit

Permalink
NEW: implement purging specific values set by @cached
Browse files Browse the repository at this point in the history
  • Loading branch information
eigenein committed Jul 5, 2023
1 parent 46e21a6 commit 1212ab1
Show file tree
Hide file tree
Showing 16 changed files with 159 additions and 231 deletions.
3 changes: 1 addition & 2 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ jobs:
- '3.9'
- '3.10'
- '3.11'
- 'pypy-3.8'
- 'pypy-3.9'
- 'pypy-3.10'

services:
redis:
Expand Down
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,15 +141,26 @@ another_cache = Cache[int, ...](backend=..., serializer=...)
time_to_live=None, # forwarded to `Cache.set`
if_not_exists=False, # forwarded to `Cache.set`
)
def expensive_function() -> int:
return 42
def expensive_function(x: int) -> int:
return 42 * x
```

There's a few `make_key` functions provided by default:
##### Key functions

There are a few `make_key` functions provided by default:

- `cachetory.decorators.shared.make_default_key` builds a human-readable cache key out of decorated function fully-qualified name and stringified arguments. The length of the key depends on the argument values.
- `cachetory.decorators.shared.make_default_hashed_key` calls `make_default_key` under the hood but hashes the key and returns a hash hex digest – making it a fixed-length key and not human-readable.

##### Purge cache

Specific cached value can be deleted using the added `purge()` function, which accepts the same arguments as the original wrapped callable:

```python
expensive_function(100500)
expensive_function.purge(100500) # purge cached value for this argument
```

## Supported backends

The badges indicate which schemes are supported by a particular backend, and which package extras are required for it – if any:
Expand Down
12 changes: 6 additions & 6 deletions cachetory/backends/async_/dummy.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from datetime import datetime, timedelta
from typing import AsyncIterable, Generic, Iterable, Optional, Tuple
from typing import AsyncIterable, Generic, Iterable

from cachetory.interfaces.backends.async_ import AsyncBackend
from cachetory.interfaces.backends.private import WireT
Expand All @@ -17,27 +17,27 @@ def from_url(cls, url: str) -> DummyBackend:
async def get(self, key: str) -> WireT: # pragma: no cover
raise KeyError(key)

async def get_many(self, *keys: str) -> AsyncIterable[Tuple[str, WireT]]:
async def get_many(self, *keys: str) -> AsyncIterable[tuple[str, WireT]]:
for _ in (): # pragma: no cover
yield () # type: ignore

async def expire_in(self, key: str, time_to_live: Optional[timedelta] = None) -> None: # pragma: no cover
async def expire_in(self, key: str, time_to_live: timedelta | None = None) -> None: # pragma: no cover
return None

async def expire_at(self, key: str, deadline: Optional[datetime]) -> None: # pragma: no cover
async def expire_at(self, key: str, deadline: datetime | None) -> None: # pragma: no cover
return None

async def set( # noqa: A003
self,
key: str,
value: WireT,
*,
time_to_live: Optional[timedelta] = None,
time_to_live: timedelta | None = None,
if_not_exists: bool = False,
) -> bool: # pragma: no cover
return True

async def set_many(self, items: Iterable[Tuple[str, WireT]]) -> None: # pragma: no cover
async def set_many(self, items: Iterable[tuple[str, WireT]]) -> None: # pragma: no cover
return None

async def delete(self, key: str) -> bool: # pragma: no cover
Expand Down
6 changes: 3 additions & 3 deletions cachetory/backends/async_/memory.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from datetime import datetime, timedelta
from typing import Any, Coroutine, Generic, Optional
from typing import Any, Coroutine, Generic

from cachetory.backends.sync.memory import MemoryBackend as SyncMemoryBackend
from cachetory.interfaces.backends.async_ import AsyncBackend
Expand All @@ -25,15 +25,15 @@ def __init__(self) -> None:
def get(self, key: str) -> Coroutine[Any, Any, WireT]:
return postpone(self._inner.get, key)

def expire_at(self, key: str, deadline: Optional[datetime]) -> Coroutine[Any, Any, None]:
def expire_at(self, key: str, deadline: datetime | None) -> Coroutine[Any, Any, None]:
return postpone(self._inner.expire_at, key, deadline)

def set( # noqa: A003
self,
key: str,
value: WireT,
*,
time_to_live: Optional[timedelta] = None,
time_to_live: timedelta | None = None,
if_not_exists: bool = False,
) -> Coroutine[Any, Any, bool]:
return postpone(
Expand Down
12 changes: 6 additions & 6 deletions cachetory/backends/async_/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import itertools
from datetime import datetime, timedelta
from typing import AsyncIterable, Iterable, Optional, Tuple
from typing import AsyncIterable, Iterable

from redis.asyncio import Redis

Expand All @@ -29,20 +29,20 @@ async def get(self, key: str) -> bytes:
return data
raise KeyError(key)

async def get_many(self, *keys: str) -> AsyncIterable[Tuple[str, bytes]]:
async def get_many(self, *keys: str) -> AsyncIterable[tuple[str, bytes]]:
for key, value in zip(keys, await self._client.mget(*keys)):
if value is not None:
yield key, value

async def expire_at(self, key: str, deadline: Optional[datetime]) -> None:
async def expire_at(self, key: str, deadline: datetime | None) -> None:
if deadline:
# One can pass `datetime` directly to `pexpireat`, but the latter
# incorrectly converts datetime into timestamp.
await self._client.pexpireat(key, int(deadline.timestamp() * 1000.0))
else:
await self._client.persist(key)

async def expire_in(self, key: str, time_to_live: Optional[timedelta] = None) -> None:
async def expire_in(self, key: str, time_to_live: timedelta | None = None) -> None:
if time_to_live:
await self._client.pexpire(key, time_to_live)
else:
Expand All @@ -53,12 +53,12 @@ async def set( # noqa: A003
key: str,
value: bytes,
*,
time_to_live: Optional[timedelta] = None,
time_to_live: timedelta | None = None,
if_not_exists: bool = False,
) -> bool:
return bool(await self._client.set(key, value, px=time_to_live, nx=if_not_exists))

async def set_many(self, items: Iterable[Tuple[str, bytes]]) -> None:
async def set_many(self, items: Iterable[tuple[str, bytes]]) -> None:
await self._client.execute_command("MSET", *itertools.chain.from_iterable(items))

async def delete(self, key: str) -> bool:
Expand Down
12 changes: 6 additions & 6 deletions cachetory/backends/sync/dummy.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from datetime import datetime, timedelta
from typing import Generic, Iterable, Optional, Tuple
from typing import Generic, Iterable

from cachetory.interfaces.backends.private import WireT
from cachetory.interfaces.backends.sync import SyncBackend
Expand All @@ -17,26 +17,26 @@ def from_url(cls, url: str) -> DummyBackend:
def get(self, key: str) -> WireT: # pragma: no cover
raise KeyError(key)

def get_many(self, *keys: str) -> Iterable[Tuple[str, WireT]]: # pragma: no cover
def get_many(self, *keys: str) -> Iterable[tuple[str, WireT]]: # pragma: no cover
return []

def expire_in(self, key: str, time_to_live: Optional[timedelta] = None) -> None: # pragma: no cover
def expire_in(self, key: str, time_to_live: timedelta | None = None) -> None: # pragma: no cover
return None

def expire_at(self, key: str, deadline: Optional[datetime]) -> None: # pragma: no cover
def expire_at(self, key: str, deadline: datetime | None) -> None: # pragma: no cover
return None

def set( # noqa: A003
self,
key: str,
value: WireT,
*,
time_to_live: Optional[timedelta] = None,
time_to_live: timedelta | None = None,
if_not_exists: bool = False,
) -> bool: # pragma: no cover
return True

def set_many(self, items: Iterable[Tuple[str, WireT]]) -> None: # pragma: no cover
def set_many(self, items: Iterable[tuple[str, WireT]]) -> None: # pragma: no cover
return None

def delete(self, key: str) -> bool: # pragma: no cover
Expand Down
12 changes: 6 additions & 6 deletions cachetory/backends/sync/memory.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from datetime import datetime, timedelta, timezone
from typing import Dict, Generic, Optional
from typing import Generic

from cachetory.interfaces.backends.private import WireT
from cachetory.interfaces.backends.sync import SyncBackend
Expand All @@ -18,12 +18,12 @@ def from_url(cls, _url: str) -> MemoryBackend:
return MemoryBackend()

def __init__(self) -> None:
self._entries: Dict[str, _Entry[WireT]] = {}
self._entries: dict[str, _Entry[WireT]] = {}

def get(self, key: str) -> WireT:
return self._get_entry(key).value

def expire_at(self, key: str, deadline: Optional[datetime]) -> None:
def expire_at(self, key: str, deadline: datetime | None) -> None:
try:
entry = self._get_entry(key)
except KeyError:
Expand All @@ -36,7 +36,7 @@ def set( # noqa: A003
key: str,
value: WireT,
*,
time_to_live: Optional[timedelta] = None,
time_to_live: timedelta | None = None,
if_not_exists: bool = False,
) -> bool:
entry = _Entry[WireT](value, make_deadline(time_to_live))
Expand Down Expand Up @@ -68,11 +68,11 @@ class _Entry(Generic[WireT]):
"""`mypy` doesn't support generic named tuples, thus defining this little one."""

value: WireT
deadline: Optional[datetime]
deadline: datetime | None

__slots__ = ("value", "deadline")

def __init__(self, value: WireT, deadline: Optional[datetime]) -> None:
def __init__(self, value: WireT, deadline: datetime | None) -> None:
self.value = value
self.deadline = deadline

Expand Down
12 changes: 6 additions & 6 deletions cachetory/backends/sync/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import itertools
from datetime import datetime, timedelta
from typing import Iterable, Optional, Tuple
from typing import Iterable

from redis import Redis

Expand All @@ -27,20 +27,20 @@ def get(self, key: str) -> bytes:
return data
raise KeyError(key)

def get_many(self, *keys: str) -> Iterable[Tuple[str, bytes]]:
def get_many(self, *keys: str) -> Iterable[tuple[str, bytes]]:
for key, value in zip(keys, self._client.mget(*keys)):
if value is not None:
yield key, value

def expire_at(self, key: str, deadline: Optional[datetime]) -> None:
def expire_at(self, key: str, deadline: datetime | None) -> None:
if deadline:
# One can pass `datetime` directly to `pexpireat`, but the latter
# incorrectly converts datetime into timestamp.
self._client.pexpireat(key, int(deadline.timestamp() * 1000.0))
else:
self._client.persist(key)

def expire_in(self, key: str, time_to_live: Optional[timedelta] = None) -> None:
def expire_in(self, key: str, time_to_live: timedelta | None = None) -> None:
if time_to_live:
self._client.pexpire(key, time_to_live)
else:
Expand All @@ -51,12 +51,12 @@ def set( # noqa: A003
key: str,
value: bytes,
*,
time_to_live: Optional[timedelta] = None,
time_to_live: timedelta | None = None,
if_not_exists: bool = False,
) -> bool:
return bool(self._client.set(key, value, px=time_to_live, nx=if_not_exists))

def set_many(self, items: Iterable[Tuple[str, bytes]]) -> None:
def set_many(self, items: Iterable[tuple[str, bytes]]) -> None:
self._client.execute_command("MSET", *itertools.chain.from_iterable(items))

def delete(self, key: str) -> bool:
Expand Down
48 changes: 34 additions & 14 deletions cachetory/decorators/async_.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,28 @@

from datetime import timedelta
from functools import wraps
from typing import Awaitable, Callable, Optional, Union
from typing import Awaitable, Callable, Protocol

from typing_extensions import ParamSpec

from cachetory.caches.async_ import Cache
from cachetory.decorators import shared
from cachetory.interfaces.backends.private import WireT
from cachetory.interfaces.serializers import ValueT
from cachetory.interfaces.serializers import ValueT, ValueT_co
from cachetory.private.functools import into_async_callable

P = ParamSpec("P")
"""
Original wrapped function parameter specification.
"""
"""Original wrapped function parameter specification."""


def cached(
cache: Union[
Cache[ValueT, WireT],
Callable[..., Cache[ValueT, WireT]],
Callable[..., Awaitable[Cache[ValueT, WireT]]],
], # no way to use `P` here
cache: Cache[ValueT, WireT] | Callable[..., Cache[ValueT, WireT]] | Callable[..., Awaitable[Cache[ValueT, WireT]]],
*,
make_key: Callable[..., str] = shared.make_default_key, # no way to use `P` here
time_to_live: Optional[timedelta | Callable[..., timedelta] | Callable[..., Awaitable[timedelta]]] = None,
time_to_live: timedelta | Callable[..., timedelta] | Callable[..., Awaitable[timedelta]] | None = None,
if_not_exists: bool = False,
exclude: Callable[[str, ValueT], bool] | Callable[[str, ValueT], Awaitable[bool]] | None = None,
) -> Callable[[Callable[P, Awaitable[ValueT]]], Callable[P, Awaitable[ValueT]]]:
) -> Callable[[Callable[P, Awaitable[ValueT]]], _CachedCallable[P, Awaitable[ValueT]]]:
"""
Apply memoization to the wrapped callable.
Expand All @@ -47,7 +41,7 @@ def cached(
exclude: Optional callable to prevent a key-value pair from being cached if the callable returns true.
"""

def wrap(callable_: Callable[P, Awaitable[ValueT]]) -> Callable[P, Awaitable[ValueT]]:
def wrap(callable_: Callable[P, Awaitable[ValueT]]) -> _CachedCallable[P, Awaitable[ValueT]]:
get_cache = into_async_callable(cache)
get_time_to_live = into_async_callable(time_to_live)
exclude_ = into_async_callable(exclude) # type: ignore[arg-type]
Expand All @@ -65,6 +59,32 @@ async def cached_callable(*args: P.args, **kwargs: P.kwargs) -> ValueT:
await cache_.set(key_, value, time_to_live=time_to_live_, if_not_exists=if_not_exists)
return value

return cached_callable
async def purge(*args: P.args, **kwargs: P.kwargs) -> bool:
"""
Delete the value that was cached using the same call arguments.
Returns:
whether a cached value existed
"""
key = make_key(callable_, *args, **kwargs)
return await (await get_cache(callable_, *args, **kwargs)).delete(key)

cached_callable.purge = purge # type: ignore[attr-defined]
return cached_callable # type: ignore[return-value]

return wrap


class _CachedCallable(Protocol[P, ValueT_co]):
"""Protocol of the wrapped callable."""

def __call__(self, *args: P.args, **kwargs: P.kwargs) -> ValueT_co:
...

async def purge(self, *args: P.args, **kwargs: P.kwargs) -> bool:
"""
Delete the value that was cached using the same call arguments.
Returns:
whether a cached value existed
"""
Loading

0 comments on commit 1212ab1

Please sign in to comment.