From 17cf587435108feacc9ca3a46b21d0196eea4b28 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Fri, 24 Jan 2020 15:41:19 +0200 Subject: [PATCH] caching: added support for cache-hints via timecache.return_value --- easypy/caching.py | 69 +++++++++++++++++++++++++++++++++++++++---- tests/test_caching.py | 31 +++++++++++++++++-- 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/easypy/caching.py b/easypy/caching.py index 6acfdd0b..2ee8b548 100644 --- a/easypy/caching.py +++ b/easypy/caching.py @@ -215,6 +215,20 @@ def _apply_defaults(bound_arguments): class _TimeCache(DecoratingDescriptor): + + class CacheHint(BaseException): + # inheriting from BaseException so we don't get accidentally caught by `try/except Exception` + + def __init__(self, value, **kwargs): + self.value = value + self.__dict__.update(kwargs) + + def __getattr__(self, name): + # this help keeping the list of hint parameters 'DRY', in 'return_value' + if name.startswith("_"): + raise AttributeError(name) + return None + def __init__(self, func, **kwargs): update_wrapper(self, func) # this needs to be first to avoid overriding attributes we set super().__init__(func=func, cached=True) @@ -267,7 +281,7 @@ def __call__(self, *args, **kwargs): pass # nothing to fuss with, cache does not expire elif result is self.NOT_FOUND: pass # cache is empty - elif self.get_ts_func() - ts >= self.expiration: + elif self.get_ts_func() >= ts: # cache expired result = self.NOT_FOUND del self.cache[key] @@ -275,11 +289,46 @@ def __call__(self, *args, **kwargs): if result is self.NOT_FOUND: if self.log_recalculation: _logger.debug('time cache expired, calculating new value for %s', self.__name__) - result = self.func(*args, **kwargs) - self.cache[key] = result, self.get_ts_func() + try: + hint = self.func(*args, **kwargs) + except self.CacheHint as exc: + hint = exc + else: + if not isinstance(hint, self.CacheHint): + hint = self.CacheHint(hint) + if hint.clear_cache: + self.cache_clear() + result = hint.value + if not hint.skip_cache: + ts = self.get_ts_func() + (self.expiration if hint.expiration is None else hint.expiration) + self.cache[key] = result, ts + elif hint.expiration: + raise Exception("Can't both 'skip_cache' AND set 'expiration'") return result + @classmethod + def return_value(cls, value, skip_cache=False, expiration=None, clear_cache=False): + """ + Provide hints to the caching mechanism: + + :param value: The value to return + :param skip_cache: If True, skip caching this value + :param expiration: Set an alternate expiration for this value + :param clear_cache: If True, clears the cache before caching this value + + This method raises a 'CacheHint' exception in order to mimic the behavior of `return`:: + + @timecache(5) + def fib(n): + if n <= 1: + fib.return_value(n, skip_cache=True) + skip_cache = (n % 10) != 0 # cache every 10 + fib.return_value(fib(n-2) + fib(n-1), skip_cache=skip_cache) + print("This line will not be executed") + """ + raise cls.CacheHint(value, skip_cache=skip_cache, expiration=expiration, clear_cache=clear_cache) + def cache_clear(self): with self.main_lock: for key, lock in dict(self.keyed_locks).items(): @@ -311,6 +360,17 @@ def timecache(expiration=0, typed=False, get_ts_func=time.time, log_recalculatio :type ignored_keywords: iterable, optional :param key_func: The function to use in order to create the item key, defaults to functools._make_key :type key_func: callable, optional + + The decorated function may use the 'func.return_value(...)' to provide some hints to the caching mechanism:: + + @timecache(5) + def fib(n): + if n <= 1: + fib.return_value(n, skip_cache=True) + skip_cache = (n % 10) != 0 # cache every 10 + fib.return_value(fib(n-2) + fib(n-1), skip_cache=skip_cache) + print("This line will not be executed") + """ def deco(func): @@ -326,9 +386,6 @@ def deco(func): return deco -timecache.__doc__ = _TimeCache.__doc__ - - @parametrizeable_decorator def locking_cache(func=None, typed=False, log_recalculation=False, ignored_keywords=False): """ diff --git a/tests/test_caching.py b/tests/test_caching.py index acb61c53..af958f91 100644 --- a/tests/test_caching.py +++ b/tests/test_caching.py @@ -5,12 +5,12 @@ import random import weakref import gc +from collections import Counter import pytest from easypy.bunch import Bunch -from easypy.caching import timecache, PersistentCache, cached_property, locking_cache -from easypy.units import DAY +from easypy.caching import timecache, PersistentCache, cached_property from easypy.resilience import resilient _logger = getLogger(__name__) @@ -273,3 +273,30 @@ def foo(): raise ExceptionLeakedThroughResilient() assert foo() == 'default' + + +def test_cache_hints(): + ts = 0 + counter = Counter() + + @timecache(1, get_ts_func=lambda: ts) + def foo(a): + counter[a] += 1 + foo.return_value(a, skip_cache=a == 0, expiration=a, clear_cache=a < 0) + + assert list(range(5)) == list(map(foo, range(5))) + assert list(range(5)) == list(map(foo, range(5))) + assert counter.pop(0) == 2 + assert set(counter.values()) == {1} # make sure we reached the cache on all but '0' + counter.clear() + + ts = 1 + assert list(range(5)) == list(map(foo, range(5))) + assert counter.pop(0) == 1 + assert counter.pop(1) == 1 + assert not counter + + assert set(foo.cache.keys()) == {1, 2, 3, 4} + + foo(-1) + assert set(foo.cache.keys()) == {-1}