Skip to content

Commit 6751caa

Browse files
Adds caching utilities
1 parent 166a2b1 commit 6751caa

File tree

4 files changed

+488
-2
lines changed

4 files changed

+488
-2
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ $ pip install essentials
1616
* [common decorator to support retries](https://github.com/RobertoPrevato/essentials/wiki/Retry-decorator)
1717
* [common decorator to support logging function calls](https://github.com/RobertoPrevato/essentials/wiki/Logs-decorator)
1818
* [common decorator to control raised exceptions](https://github.com/RobertoPrevato/essentials/wiki/Exception-handle-decorator)
19+
* [caching functions](https://github.com/RobertoPrevato/essentials/wiki/Caching)
1920

2021
## Documentation
2122
Please refer to documentation in the project wiki: [https://github.com/RobertoPrevato/essentials/wiki](https://github.com/RobertoPrevato/essentials/wiki).

essentials/caching.py

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import functools
2+
import time
3+
from collections import OrderedDict
4+
from typing import (Any, Callable, Generic, Iterable, Iterator, Tuple, TypeVar)
5+
6+
7+
T = TypeVar("T")
8+
9+
10+
class Cache(Generic[T]):
11+
"""In-memory LRU cache implementation."""
12+
13+
def __init__(
14+
self,
15+
max_size: int = 500
16+
):
17+
self._bag: OrderedDict[Any, Any] = OrderedDict()
18+
self._max_size = -1
19+
self.max_size = max_size
20+
21+
@property
22+
def max_size(self) -> int:
23+
return self._max_size
24+
25+
@max_size.setter
26+
def max_size(self, value: int) -> None:
27+
assert value > 0
28+
self._max_size = int(value)
29+
30+
@property
31+
def is_empty(self) -> bool:
32+
return len(self._bag) == 0
33+
34+
def values(self) -> Iterable[T]:
35+
for _, value in self:
36+
yield value
37+
38+
def keys(self) -> Iterable[Any]:
39+
for key, _ in self:
40+
yield key
41+
42+
def __repr__(self) -> str:
43+
return f"<Cache {len(self)} at {id(self)}>"
44+
45+
def __len__(self) -> int:
46+
return len(self._bag)
47+
48+
def get(self, key, default=None) -> T:
49+
try:
50+
return self[key]
51+
except KeyError:
52+
return default
53+
54+
def set(self, key, value) -> None:
55+
self[key] = value
56+
57+
def _check_size(self):
58+
while len(self._bag) > self.max_size:
59+
self._bag.popitem(last=False)
60+
61+
def __getitem__(self, key) -> T:
62+
value = self._bag[key]
63+
self._bag.move_to_end(key, last=True)
64+
return value
65+
66+
def __setitem__(self, key, value: T) -> None:
67+
if key in self._bag:
68+
self._bag[key] = value
69+
self._bag.move_to_end(key, last=True)
70+
else:
71+
self._bag[key] = value
72+
self._check_size()
73+
74+
def __delitem__(self, key) -> None:
75+
del self._bag[key]
76+
77+
def __contains__(self, key) -> bool:
78+
return key in self._bag
79+
80+
def __iter__(self) -> Iterator[Tuple[Any, T]]:
81+
return iter(self._bag.items())
82+
83+
def clear(self) -> None:
84+
self._bag.clear()
85+
86+
87+
class CachedItem(Generic[T]):
88+
"""Container for cached items with update timestamp."""
89+
90+
__slots__ = (
91+
'_value',
92+
'_time'
93+
)
94+
95+
def __init__(self, value: T):
96+
self._value = value
97+
self._time = time.time()
98+
99+
@property
100+
def value(self) -> T:
101+
return self._value
102+
103+
@value.setter
104+
def value(self, value: T):
105+
self._value = value
106+
self._time = time.time()
107+
108+
@property
109+
def time(self) -> float:
110+
return self._time
111+
112+
113+
class ExpiringCache(Cache[T]):
114+
"""A cache whose items can expire by a given function."""
115+
116+
def __init__(
117+
self,
118+
expiration_policy: Callable[[CachedItem[T]], bool],
119+
max_size: int = 500
120+
):
121+
super().__init__(max_size)
122+
assert expiration_policy is not None
123+
self.expiration_policy = expiration_policy
124+
125+
@property
126+
def full(self) -> bool:
127+
return self.max_size <= len(self._bag)
128+
129+
def expired(self, item: CachedItem) -> bool:
130+
return self.expiration_policy(item)
131+
132+
def _remove_expired_items(self):
133+
for key, item in list(self._bag.items()):
134+
if self.expired(item):
135+
del self[key]
136+
137+
def _check_size(self):
138+
if self.full:
139+
self._remove_expired_items()
140+
super()._check_size()
141+
142+
def __getitem__(self, key) -> Any:
143+
item = self._bag[key]
144+
if self.expired(item):
145+
del self._bag[key]
146+
raise KeyError(key)
147+
148+
self._bag.move_to_end(key, last=True)
149+
return item.value
150+
151+
def __setitem__(self, key, value: T) -> None:
152+
if key in self._bag:
153+
self._bag[key].value = value
154+
self._bag.move_to_end(key, last=True)
155+
else:
156+
self._bag[key] = CachedItem(value)
157+
self._check_size()
158+
159+
@classmethod
160+
def with_max_age(
161+
cls,
162+
max_age: float,
163+
max_size: int = 500
164+
):
165+
"""
166+
Returns an instance of ExpiringCache whose items are invalidated
167+
when they were set more than a given number of seconds ago.
168+
"""
169+
return cls(lambda item: time.time() - item.time > max_age,
170+
max_size)
171+
172+
def __contains__(self, key) -> bool:
173+
if key not in self._bag:
174+
return False
175+
# remove if expired
176+
try:
177+
self[key]
178+
except KeyError:
179+
return False
180+
return True
181+
182+
def __iter__(self) -> Iterator[Tuple[Any, T]]:
183+
"""Iterates through cached items, discarding and removing expired ones."""
184+
for key, item in list(self._bag.items()):
185+
if self.expired(item):
186+
del self[key]
187+
else:
188+
yield (key, item.value)
189+
190+
191+
def lazy(
192+
max_seconds: int = 1,
193+
cache=None
194+
):
195+
"""
196+
Wraps a function so that it is called up to once
197+
every max_seconds, by input arguments.
198+
Results are stored in a cache, by default a LRU cache of max size 500.
199+
200+
To have a cache without size limit, use a dictionary: @lazy(1, {})
201+
"""
202+
assert max_seconds > 0
203+
if cache is None:
204+
cache = Cache(500)
205+
206+
def lazy_decorator(fn):
207+
setattr(fn, "cache", cache)
208+
209+
@functools.wraps(fn)
210+
def wrapper(*args):
211+
now = time.time()
212+
try:
213+
value, updated_at = cache[args]
214+
if now - updated_at > max_seconds:
215+
raise AttributeError
216+
except (KeyError, AttributeError):
217+
value = fn(*args)
218+
cache[args] = (value, now)
219+
return value
220+
return wrapper
221+
222+
return lazy_decorator

setup.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ def readme():
99
setup(
1010
name="essentials",
1111
version="1.1.3",
12-
description="Core classes and functions, "
12+
description="General purpose classes and functions, "
1313
"reusable in any kind of Python application",
1414
long_description=readme(),
1515
long_description_content_type="text/markdown",
@@ -31,5 +31,4 @@ def readme():
3131
],
3232
install_requires=[],
3333
include_package_data=True,
34-
zip_safe=False,
3534
)

0 commit comments

Comments
 (0)