# 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)