add cache invalidation
[leap_pycommon.git] / src / leap / common / decorators.py
1 # -*- coding: utf-8 -*-
2 # decorators.py
3 # Copyright (C) 2013 LEAP
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation, either version 3 of the License, or
8 # (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
17 """
18 Useful decorators.
19 """
20 import collections
21 import datetime
22 import functools
23 import logging
24
25 logger = logging.getLogger(__name__)
26
27
28 class _memoized(object):
29     """
30     Decorator.
31
32     Caches a function's return value each time it is called.
33     If called later with the same arguments, the cached value is returned
34     (not reevaluated).
35     """
36
37     # cache invalidation time, in seconds
38     CACHE_INVALIDATION_DELTA = 1800
39
40     def __init__(self, func, ignore_kwargs=None, is_method=False,
41                  invalidation=None):
42
43         """
44         :param ignore_kwargs: If True, ignore all kwargs.
45                               If tuple, ignore those kwargs.
46         :type ignore_kwargs: bool, tuple or None
47         :param is_method: whether the decorated function is a method.
48                           (ignores the self argument if so).
49         :type is_method: True
50         """
51         self.ignore_kwargs = ignore_kwargs if ignore_kwargs else []
52         self.is_method = is_method
53         self.func = func
54
55         if invalidation:
56             self.CACHE_INVALIDATION_DELTA = invalidation
57
58         # TODO should put bounds to the cache dict so we do not
59         # consume a huge amount of memory.
60         self.cache = {}
61         self.cache_ts = {}
62
63     def __call__(self, *args, **kwargs):
64         """
65         Executes the call.
66
67         :tyoe args: tuple
68         :type kwargs: dict
69         """
70         def ret_or_raise(value):
71             """
72             Returns the value except if it is an exception,
73             in which case it's raised.
74             """
75             if isinstance(value, Exception):
76                 raise value
77             return value
78
79         if self.is_method:
80             # forget about `self` as key
81             key_args = args[1:]
82         else:
83             key_args = args
84
85         if self.ignore_kwargs is True:
86             key = key_args
87         else:
88             key = (key_args, frozenset(
89                 [(k, v) for k, v in kwargs.items()
90                  if k not in self.ignore_kwargs]))
91
92         if not isinstance(key, collections.Hashable):
93             # uncacheable. a list, for instance.
94             # better to not cache than blow up.
95             logger.warning("Key is not hashable, bailing out!")
96             return self.func(*args, **kwargs)
97
98         if key in self.cache:
99             if self._is_cache_still_valid(key):
100                 value = self.cache[key]
101                 logger.debug("Got value from cache...")
102                 return ret_or_raise(value)
103             else:
104                 logger.debug("Cache is invalid, evaluating again...")
105
106         # no cache, or cache invalid
107         try:
108             value = self.func(*args, **kwargs)
109         except Exception as exc:
110             logger.error("Exception while calling function: %r" % (exc,))
111             value = exc
112         self.cache[key] = value
113         self.cache_ts[key] = datetime.datetime.now()
114         return ret_or_raise(value)
115
116     def _is_cache_still_valid(self, key, now=datetime.datetime.now):
117         """
118         Returns True if the cache value is still valid, False otherwise.
119
120         For now, this happen if less than CACHE_INVALIDATION_DELTA seconds
121         have passed from the time in which we recorded the cached value.
122
123         :param key: the key to lookup in the cache
124         :type key: hashable
125         :param now: a callable that returns a datetime object. override
126                     for dependency injection during testing.
127         :type now: callable
128         :rtype: bool
129         """
130         cached_ts = self.cache_ts[key]
131         delta = datetime.timedelta(seconds=self.CACHE_INVALIDATION_DELTA)
132         return (now() - cached_ts) < delta
133
134     def __repr__(self):
135         """
136         Return the function's docstring.
137         """
138         return self.func.__doc__
139
140     def __get__(self, obj, objtype):
141         """
142         Support instance methods.
143         """
144         return functools.partial(self.__call__, obj)
145
146
147 def memoized_method(function=None, ignore_kwargs=None):
148     """
149     Wrap _memoized to allow for deferred calling
150
151     :type function: callable, or None.
152     :type ignore_kwargs: None, True or tuple.
153     """
154     if function:
155         return _memoized(function, is_method=True)
156     else:
157         def wrapper(function):
158             return _memoized(
159                 function, ignore_kwargs=ignore_kwargs, is_method=True)
160         return wrapper