326 lines
9.8 KiB
Python
326 lines
9.8 KiB
Python
# coding: utf-8
|
|
|
|
from __future__ import absolute_import, division, print_function, unicode_literals
|
|
|
|
import time
|
|
from collections import OrderedDict
|
|
from threading import RLock
|
|
|
|
_NOTSET = object()
|
|
|
|
|
|
class _Cache(object):
|
|
"""
|
|
An in-memory, FIFO cache object that supports:
|
|
- Maximum number of cache entries
|
|
- Global TTL default
|
|
- Per cache entry TTL
|
|
- TTL first/non-TTL FIFO cache eviction policy
|
|
Cache entries are stored in an ``OrderedDict`` so that key ordering based
|
|
on the cache type can be maintained without the need for additional
|
|
list(s). Essentially, the key order of the ``OrderedDict`` is treated as an
|
|
"eviction queue" with the convention that entries at the beginning of the
|
|
queue are "newer" while the entries at the end are "older" (the exact
|
|
meaning of "newer" and "older" will vary between different cache types).
|
|
When cache entries need to be evicted, expired entries are removed first
|
|
followed by the "older" entries (i.e. the ones at the end of the queue).
|
|
Attributes:
|
|
maxsize (int, optional): Maximum size of cache dictionary. Defaults to ``256``.
|
|
ttl (int, optional): Default TTL for all cache entries. Defaults to ``0`` which
|
|
means that entries do not expire.
|
|
timer (callable, optional): Timer function to use to calculate TTL expiration.
|
|
Defaults to ``time.time``.
|
|
default (mixed, optional): 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.
|
|
"""
|
|
|
|
def __init__(self, maxsize=None, ttl=None, timer=None, default=None):
|
|
if maxsize is None:
|
|
maxsize = 256
|
|
|
|
if ttl is None:
|
|
ttl = 0
|
|
|
|
if timer is None:
|
|
timer = time.time
|
|
|
|
self.setup()
|
|
self.configure(maxsize=maxsize, ttl=ttl, timer=timer, default=default)
|
|
|
|
def setup(self):
|
|
self._cache = OrderedDict()
|
|
self._expire_times = {}
|
|
self._lock = RLock()
|
|
|
|
def configure(self, maxsize=None, ttl=None, timer=None, default=None):
|
|
"""
|
|
Configure cache settings. This method is meant to support runtime level
|
|
configurations for global level cache objects.
|
|
"""
|
|
if maxsize is not None:
|
|
if not isinstance(maxsize, int):
|
|
raise TypeError("maxsize must be an integer")
|
|
|
|
if not maxsize >= 0:
|
|
raise ValueError("maxsize must be greater than or equal to 0")
|
|
|
|
self.maxsize = maxsize
|
|
|
|
if ttl is not None:
|
|
if not isinstance(ttl, (int, float)):
|
|
raise TypeError("ttl must be a number")
|
|
|
|
if not ttl >= 0:
|
|
raise ValueError("ttl must be greater than or equal to 0")
|
|
|
|
self.ttl = ttl
|
|
|
|
if timer is not None:
|
|
if not callable(timer):
|
|
raise TypeError("timer must be a callable")
|
|
|
|
self.timer = timer
|
|
|
|
self.default = default
|
|
|
|
def __repr__(self):
|
|
return "{}({})".format(self.__class__.__name__, list(self.copy().items()))
|
|
|
|
def __len__(self):
|
|
with self._lock:
|
|
return len(self._cache)
|
|
|
|
def __contains__(self, key):
|
|
with self._lock:
|
|
return key in self._cache
|
|
|
|
def __iter__(self):
|
|
for i in self.keys():
|
|
yield i
|
|
|
|
def __next__(self):
|
|
return next(iter(self._cache))
|
|
|
|
def next(self):
|
|
return next(iter(self._cache))
|
|
|
|
def copy(self):
|
|
"""
|
|
Return a copy of the cache.
|
|
Returns:
|
|
OrderedDict
|
|
"""
|
|
with self._lock:
|
|
return self._cache.copy()
|
|
|
|
def keys(self):
|
|
"""
|
|
Return ``dict_keys`` view of all cache keys.
|
|
Note:
|
|
Cache is copied from the underlying cache storage before returning.
|
|
Returns:
|
|
dict_keys
|
|
"""
|
|
return self.copy().keys()
|
|
|
|
def _has(self, key):
|
|
# Use get method since it will take care of evicting expired keys.
|
|
return self._get(key, default=_NOTSET) is not _NOTSET
|
|
|
|
def size(self):
|
|
"""Return number of cache entries."""
|
|
return len(self)
|
|
|
|
def full(self):
|
|
"""
|
|
Return whether the cache is full or not.
|
|
Returns:
|
|
bool
|
|
"""
|
|
if self.maxsize == 0:
|
|
return False
|
|
return len(self) >= self.maxsize
|
|
|
|
def get(self, key, default=None):
|
|
"""
|
|
Return the cache value for `key` or `default` or ``missing(key)`` if it doesn't
|
|
exist or has expired.
|
|
Args:
|
|
key (mixed): Cache key.
|
|
default (mixed, optional): Value to return if `key` doesn't exist. If any
|
|
value other than ``None``, then it will take precendence over
|
|
:attr:`missing` and be used as the return value. If `default` is
|
|
callable, it will function like :attr:`missing` and its return value
|
|
will be set for the cache `key`. Defaults to ``None``.
|
|
Returns:
|
|
mixed: The cached value.
|
|
"""
|
|
with self._lock:
|
|
return self._get(key, default=default)
|
|
|
|
def _get(self, key, default=None):
|
|
try:
|
|
value = self._cache[key]
|
|
|
|
if self.expired(key):
|
|
self._delete(key)
|
|
raise KeyError
|
|
except KeyError:
|
|
if default is None:
|
|
default = self.default
|
|
|
|
if callable(default):
|
|
value = default(key)
|
|
self._set(key, value)
|
|
else:
|
|
value = default
|
|
|
|
return value
|
|
|
|
def add(self, key, value, ttl=None):
|
|
"""
|
|
Add cache key/value if it doesn't already exist. Essentially, this method
|
|
ignores keys that exist which leaves the original TTL in tact.
|
|
Note:
|
|
Cache key must be hashable.
|
|
Args:
|
|
key (mixed): Cache key to add.
|
|
value (mixed): Cache value.
|
|
ttl (int, optional): TTL value. Defaults to ``None`` which uses :attr:`ttl`.
|
|
"""
|
|
with self._lock:
|
|
self._add(key, value, ttl=ttl)
|
|
|
|
def _add(self, key, value, ttl=None):
|
|
if self._has(key):
|
|
return
|
|
self._set(key, value, ttl=ttl)
|
|
|
|
def set(self, key, value, ttl=None):
|
|
"""
|
|
Set cache key/value and replace any previously set cache key. If the cache key
|
|
previous existed, setting it will move it to the end of the cache stack which
|
|
means it would be evicted last.
|
|
Note:
|
|
Cache key must be hashable.
|
|
Args:
|
|
key (mixed): Cache key to set.
|
|
value (mixed): Cache value.
|
|
ttl (int, optional): TTL value. Defaults to ``None`` which uses :attr:`ttl`.
|
|
"""
|
|
with self._lock:
|
|
self._set(key, value, ttl=ttl)
|
|
|
|
def _set(self, key, value, ttl=None):
|
|
if ttl is None:
|
|
ttl = self.ttl
|
|
|
|
if key not in self:
|
|
self.evict()
|
|
|
|
self._delete(key)
|
|
self._cache[key] = value
|
|
|
|
if ttl and ttl > 0:
|
|
self._expire_times[key] = self.timer() + ttl
|
|
|
|
def _delete(self, key):
|
|
count = 0
|
|
|
|
try:
|
|
del self._cache[key]
|
|
count = 1
|
|
except KeyError:
|
|
pass
|
|
|
|
try:
|
|
del self._expire_times[key]
|
|
except KeyError:
|
|
pass
|
|
|
|
return count
|
|
|
|
def delete_expired(self):
|
|
"""
|
|
Delete expired cache keys and return number of entries deleted.
|
|
Returns:
|
|
int: Number of entries deleted.
|
|
"""
|
|
with self._lock:
|
|
return self._delete_expired()
|
|
|
|
def _delete_expired(self):
|
|
count = 0
|
|
|
|
if not self._expire_times:
|
|
return count
|
|
|
|
# Use a static expiration time for each key for better consistency as opposed to
|
|
# a newly computed timestamp on each iteration.
|
|
expires_on = self.timer()
|
|
expire_times = self._expire_times.copy()
|
|
|
|
for key, expiration in expire_times.items():
|
|
if expiration <= expires_on:
|
|
count += self._delete(key)
|
|
|
|
return count
|
|
|
|
def expired(self, key, expires_on=None):
|
|
"""
|
|
Return whether cache key is expired or not.
|
|
Args:
|
|
key (mixed): Cache key.
|
|
expires_on (float, optional): Timestamp of when the key is considered
|
|
expired. Defaults to ``None`` which uses the current value returned from
|
|
:meth:`timer`.
|
|
Returns:
|
|
bool
|
|
"""
|
|
if not expires_on:
|
|
expires_on = self.timer()
|
|
|
|
try:
|
|
return self._expire_times[key] <= expires_on
|
|
except KeyError:
|
|
return key not in self
|
|
|
|
def evict(self):
|
|
"""
|
|
Perform cache eviction per the cache replacement policy:
|
|
- First, remove **all** expired entries.
|
|
- Then, remove non-TTL entries using the cache replacement policy.
|
|
When removing non-TTL entries, this method will only remove the minimum number
|
|
of entries to reduce the number of entries below :attr:`maxsize`. If
|
|
:attr:`maxsize` is ``0``, then only expired entries will be removed.
|
|
Returns:
|
|
int: Number of cache entries evicted.
|
|
"""
|
|
count = self.delete_expired()
|
|
|
|
if not self.full():
|
|
return count
|
|
|
|
with self._lock:
|
|
while self.full():
|
|
try:
|
|
self._popitem()
|
|
except KeyError: # pragma: no cover
|
|
break
|
|
count += 1
|
|
|
|
return count
|
|
|
|
def _popitem(self):
|
|
try:
|
|
key = next(self)
|
|
except StopIteration:
|
|
raise KeyError("popitem(): cache is empty")
|
|
|
|
value = self._cache[key]
|
|
|
|
self._delete(key)
|
|
|
|
return (key, value)
|