Skip to content

Commit

Permalink
Eviction callback (#31)
Browse files Browse the repository at this point in the history
* cache.py: item_ttl

* cache.py: del expired item

* Update src/cacheout/cache.py

Co-authored-by: Derrick Gilland <dgilland@gmail.com>

* test_cache.py: add tests for get_ttl()

* refactor and tests

* format code

* feat: eviction callback

* style: improve code style

* style: add comments

* style: Fix spelling err

* fix: add an arg for _popitem()

* rewrite EvictionCause

* style: improve comments

---------

Co-authored-by: Derrick Gilland <dgilland@gmail.com>
  • Loading branch information
uncle-lv and dgilland authored Sep 19, 2023
1 parent 11ba7fd commit 51c88ba
Show file tree
Hide file tree
Showing 4 changed files with 75 additions and 15 deletions.
2 changes: 1 addition & 1 deletion src/cacheout/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

__version__ = "0.14.1"

from .cache import Cache
from .cache import Cache, EvictionCause
from .fifo import FIFOCache
from .lfu import LFUCache
from .lifo import LIFOCache
Expand Down
46 changes: 36 additions & 10 deletions src/cacheout/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from collections import OrderedDict
from collections.abc import Mapping
from decimal import Decimal
from enum import Enum, auto
import fnmatch
from functools import wraps
import hashlib
Expand All @@ -23,6 +24,25 @@
UNSET = object()


class EvictionCause(Enum):
"""
An enum to represent the cause for the eviction of a cache entry.
- DELETE: indicates that the cache entry was deleted by delete() or delete_many() explicitly.
- SET: indicates that the cache entry was replaced with a new value by set() or set_many().
- EXPIRED: indicates that the cache entry was removed because it expired.
- FULL: indicates that the cache entry was removed because cache has been full
(reached the maximum size limit).
- POPITEM: indicates that the cache entry was deleted by popitem().
"""

DELETE = auto()
SET = auto()
EXPIRED = auto()
FULL = auto()
POPITEM = auto()


class Cache:
"""
An in-memory, FIFO cache object.
Expand Down Expand Up @@ -51,6 +71,7 @@ class Cache:
default: Default value or function to use in :meth:`get` when key is not found. If callable,
it will be passed a single argument, ``key``, and its return value will be set for that
cache key.
on_delete: Callback which will be excuted when a cache entry is evicted.
"""

_cache: OrderedDict
Expand All @@ -63,11 +84,13 @@ def __init__(
ttl: T_TTL = 0,
timer: t.Callable[[], T_TTL] = time.time,
default: t.Any = None,
on_delete: t.Optional[t.Callable[[t.Hashable, t.Any, EvictionCause], None]] = None,
):
self.maxsize = maxsize
self.ttl = ttl
self.timer = timer
self.default = default
self.on_delete = on_delete

self.setup()
self.configure(maxsize=maxsize, ttl=ttl, timer=timer, default=default)
Expand Down Expand Up @@ -216,7 +239,7 @@ def _get(self, key: t.Hashable, default: t.Any = None) -> t.Any:
value = self._cache[key]

if self.expired(key):
self._delete(key)
self._delete(key, EvictionCause.EXPIRED)
raise KeyError
except KeyError:
if default is None:
Expand Down Expand Up @@ -311,7 +334,7 @@ def _set(self, key: t.Hashable, value: t.Any, ttl: t.Optional[T_TTL] = None) ->
if key not in self._cache:
self.evict()

self._delete(key)
self._delete(key, EvictionCause.SET)
self._cache[key] = value

if ttl and ttl > 0:
Expand Down Expand Up @@ -344,13 +367,16 @@ def delete(self, key: t.Hashable) -> int:
int: ``1`` if key was deleted, ``0`` if key didn't exist.
"""
with self._lock:
return self._delete(key)
return self._delete(key, EvictionCause.DELETE)

def _delete(self, key: t.Hashable) -> int:
def _delete(self, key: t.Hashable, cause: EvictionCause) -> int:
count = 0

try:
value = self._cache[key]
del self._cache[key]
if self.on_delete:
self.on_delete(key, value, cause)
count = 1
except KeyError:
pass
Expand Down Expand Up @@ -388,7 +414,7 @@ def _delete_many(self, iteratee: T_FILTER) -> int:
with self._lock:
keys = self._filter_keys(iteratee)
for key in keys:
count += self._delete(key)
count += self._delete(key, EvictionCause.DELETE)
return count

def delete_expired(self) -> int:
Expand All @@ -413,7 +439,7 @@ def _delete_expired(self) -> int:

for key, expiration in expire_times.items():
if expiration <= expires_on:
count += self._delete(key)
count += self._delete(key, EvictionCause.EXPIRED)
return count

def expired(self, key: t.Hashable, expires_on: t.Optional[T_TTL] = None) -> bool:
Expand Down Expand Up @@ -486,7 +512,7 @@ def evict(self) -> int:
with self._lock:
while self.full():
try:
self._popitem()
self._popitem(EvictionCause.FULL)
except KeyError: # pragma: no cover
break
count += 1
Expand All @@ -504,16 +530,16 @@ def popitem(self) -> t.Tuple[t.Hashable, t.Any]:
"""
with self._lock:
self._delete_expired()
return self._popitem()
return self._popitem(EvictionCause.POPITEM)

def _popitem(self):
def _popitem(self, cause: EvictionCause):
try:
key = next(self)
except StopIteration:
raise KeyError("popitem(): cache is empty")

value = self._cache[key]
self._delete(key)
self._delete(key, cause)

return key, value

Expand Down
6 changes: 3 additions & 3 deletions src/cacheout/lfu.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from collections import Counter
import typing as t

from .cache import T_TTL, Cache
from .cache import T_TTL, Cache, EvictionCause


class LFUCache(Cache):
Expand Down Expand Up @@ -59,8 +59,8 @@ def add(self, key: t.Hashable, value: t.Any, ttl: t.Optional[T_TTL] = None) -> N

add.__doc__ = Cache.add.__doc__

def _delete(self, key: t.Hashable) -> int:
count = super()._delete(key)
def _delete(self, key: t.Hashable, cause: EvictionCause) -> int:
count = super()._delete(key, cause)

try:
del self._access_counts[key]
Expand Down
36 changes: 35 additions & 1 deletion tests/test_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import pytest

from cacheout import Cache
from cacheout import Cache, EvictionCause


parametrize = pytest.mark.parametrize
Expand Down Expand Up @@ -709,3 +709,37 @@ def test_cache_repr(cache: Cache):
cache.set("c", 3)

assert repr(cache) == "Cache([('a', 1), ('b', 2), ('c', 3)])"


def test_cache_on_delete(cache: Cache, timer: Timer):
"""Test that on_delete(cache) callback."""
log = ""

def on_delete(key, value, cause):
nonlocal log
log = f"{key}:{value} {cause.value}"

cache.on_delete = on_delete
cache.set("DELETE", 1)
cache.delete("DELETE")
assert log == f"DELETE:1 {EvictionCause.DELETE.value}"

cache.set("SET", 1)
cache.set("SET", 2)
assert log == f"SET:1 {EvictionCause.SET.value}"

cache.clear()
cache.set("POPITEM", 1)
cache.popitem()
assert log == f"POPITEM:1 {EvictionCause.POPITEM.value}"

cache.set("EXPIRED", 1, ttl=1)
timer.time = 1
cache.delete_expired()
assert log == f"EXPIRED:1 {EvictionCause.EXPIRED.value}"

cache.clear()
cache.maxsize = 1
cache.set("FULL", 1)
cache.set("OVERFLOW", 2)
assert log == f"FULL:1 {EvictionCause.FULL.value}"

0 comments on commit 51c88ba

Please sign in to comment.