Skip to content

Commit c88ed78

Browse files
author
bofm
committed
implemented LRU and LFU policies.
1 parent d6671f7 commit c88ed78

6 files changed

Lines changed: 234 additions & 54 deletions

File tree

README.rst

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ Usage
2828
def long_running_function(a, b, *args, c=None, **kwargs):
2929
pass
3030
31-
# Memory-based cache with limited ttl and maxsize
32-
@Cache(ttl=60, maxsize=128)
31+
# Memory-based cache with limited ttl and maxsize and "least recently used"
32+
# cache replacement policy.
33+
@Cache(ttl=60, maxsize=128, policy='LRU')
3334
def long_running_function(a, b, *args, c=None, **kwargs):
3435
pass
3536
@@ -115,9 +116,9 @@ Features
115116
- [x] TTL and maxsize.
116117
- [x] Works with ``*args``, ``**kwargs``.
117118
- [x] Works with mutable function arguments of the following types: ``dict``, ``list``, ``set``.
118-
- [ ] LRS (least recently stored), LRU and LFU cache.
119-
- [ ] Multiprocessing- and thread-safe.
119+
- [x] FIFO, LRU and LFU cache replacement policies.
120120
- [x] Customizable cache key function.
121+
- [ ] Multiprocessing- and thread-safe.
121122
- [ ] Pluggable external caching backends (see Redis example).
122123

123124
.. |Build Status| image:: https://travis-ci.org/bofm/python-caching.svg?branch=master

caching/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
from .cache import Cache, cache, make_key
1+
from .cache import Cache
22
from .storage import CacheStorageBase, SQLiteStorage
33

44

5-
__version__ = '0.1.dev3'
5+
__version__ = '0.1.dev4'
6+
7+
__all__ = (Cache, CacheStorageBase, SQLiteStorage)

caching/cache.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pickle
22
from collections import OrderedDict
33
from functools import wraps
4+
from typing import Union, Callable
45

56
from .storage import SQLiteStorage
67

@@ -30,20 +31,41 @@ def _function_name(fn):
3031

3132

3233
class Cache:
34+
"""Cache.
35+
36+
Can be used as a function decorator and as a dict-like key-value store.
37+
"""
3338

3439
def __init__(
3540
self,
3641
*,
37-
maxsize=1024,
38-
ttl=-1,
39-
filepath=None,
40-
key=make_key,
42+
maxsize: int=1024,
43+
ttl: Union[float, int]=-1,
44+
filepath: Union[str, None]=None,
45+
policy: str='FIFO',
46+
key: Callable=make_key,
4147
**kwargs
4248
):
49+
"""
50+
Args:
51+
maxsize: maximum number of keys in cache.
52+
ttl: amount of time in seconds since the item is added to cache
53+
before the item is deleted from cache.
54+
filepath: if a string is passed then file path where the cache
55+
is stored on disk. If `None` is passed then the cache is stored
56+
in memory.
57+
policy: one of: FIFO, LRU, LFU.
58+
Cache replacement (or eviction) policy.
59+
key: a function which takes the arguments and keyword arguments of
60+
the decorated by the `Cache` instance function and retuns
61+
something which will be used as a key under which the function's
62+
return value will be stored in cache.
63+
"""
4364
self.params = OrderedDict(
4465
maxsize=maxsize,
4566
ttl=ttl,
4667
filepath=filepath,
68+
policy=policy,
4769
key=key,
4870
**kwargs
4971
)
@@ -52,6 +74,7 @@ def __init__(
5274
filepath=filepath or ':memory:',
5375
ttl=ttl,
5476
maxsize=maxsize,
77+
policy=policy,
5578
)
5679

5780
def __repr__(self):
@@ -135,6 +158,3 @@ def __exit__(self, exc_type, exc_val, exc_tb):
135158

136159
def remove(self):
137160
self.storage.remove()
138-
139-
140-
cache = Cache()

caching/storage.py

Lines changed: 78 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import os
22
import sqlite3
33
from contextlib import suppress
4-
from typing import Generator, Tuple, Union
4+
from typing import Generator, Tuple, Union, ByteString
55

66

77
class CacheStorageBase:
88

9-
def __init__(self, *, maxsize, ttl):
9+
def __init__(self, *, maxsize: int, ttl: Union[int, float], policy: str):
1010
self.maxsize = maxsize
1111
self.ttl = ttl
12+
self.policy = policy
1213

13-
def __setitem__(self, key, value) -> None:
14+
def __setitem__(self, key: ByteString, value: ByteString) -> None:
1415
raise NotImplementedError # pragma: no cover
1516

1617
def __getitem__(self, key) -> bytes:
@@ -19,7 +20,7 @@ def __getitem__(self, key) -> bytes:
1920
def __delitem__(self, key) -> None:
2021
raise NotImplementedError # pragma: no cover
2122

22-
def get(self, key, default=None) -> Union[bytes, None]:
23+
def get(self, key: ByteString, default=None) -> Union[bytes, None]:
2324
raise NotImplementedError # pragma: no cover
2425

2526
def clear(self) -> None:
@@ -34,9 +35,32 @@ def items(self) -> Generator[Tuple[bytes, bytes], None, None]:
3435

3536
class SQLiteStorage(CacheStorageBase):
3637
SQLITE_TIMESTAMP = "(julianday('now') - 2440587.5)*86400.0"
37-
38-
def __init__(self, *, filepath, ttl, maxsize):
39-
super(SQLiteStorage, self).__init__(ttl=ttl, maxsize=maxsize)
38+
POLICIES = {
39+
'FIFO': {
40+
'additional_columns': [],
41+
'after_get_ok': None,
42+
'additional_indexes': [],
43+
'delete_order_by': 'ts',
44+
},
45+
'LRU': {
46+
f'additional_columns': [f"used INT NOT NULL DEFAULT 0"],
47+
f'additional_indexes': ['used, ts'],
48+
f'after_get_ok': f"UPDATE cache SET used = (SELECT max(used) FROM cache) + 1",
49+
f'delete_order_by': 'used, ts',
50+
},
51+
'LFU': {
52+
'additional_columns': ['used INT NOT NULL DEFAULT 0'],
53+
'additional_indexes': ['used, ts'],
54+
'after_get_ok': "UPDATE cache SET used = used + 1",
55+
f'delete_order_by': 'used, ts',
56+
},
57+
}
58+
59+
def __init__(self, *, filepath, ttl, maxsize, policy='FIFO'):
60+
if policy not in self.POLICIES:
61+
raise ValueError(f'Invalid policy: {policy}')
62+
super(SQLiteStorage, self).__init__(
63+
ttl=ttl, maxsize=maxsize, policy=policy)
4064
self.filepath = filepath
4165
self.db = sqlite3.connect(filepath, isolation_level='DEFERRED')
4266
self.init_db()
@@ -77,8 +101,7 @@ def __exit__(self, exc_type, exc_val, exc_tb):
77101
def __setitem__(self, key, value):
78102
with self.db as db:
79103
db.execute(
80-
"INSERT OR REPLACE INTO cache VALUES "
81-
f"(?, {self.SQLITE_TIMESTAMP}, ?)",
104+
"INSERT OR REPLACE INTO cache (key, value) VALUES (?, ?)",
82105
(key, value)
83106
)
84107

@@ -95,44 +118,61 @@ def __delitem__(self, key):
95118
raise KeyError('Not found')
96119

97120
def get(self, key, default=None):
98-
rows = self.db.execute(
99-
self.sql_select,
100-
(key,),
101-
).fetchall()
102-
return rows[0][0] if rows else default
121+
with self.db:
122+
rows = self.db.execute(
123+
self.sql_select,
124+
(key,),
125+
).fetchall()
126+
after_get_ok = self.POLICIES[self.policy]['after_get_ok']
127+
if rows:
128+
if after_get_ok:
129+
self.db.execute(
130+
f'{after_get_ok} WHERE key = ?',
131+
(key,),
132+
)
133+
return rows[0][0]
134+
else:
135+
return default
103136

104137
def init_db(self):
138+
policy_stuff = self.POLICIES[self.policy]
139+
105140
after_insert_actions = []
106141
if self.ttl > 0:
107-
after_insert_actions.append(
108-
' DELETE FROM cache WHERE '
109-
f'({self.SQLITE_TIMESTAMP} - ts) > {self.ttl};'
110-
)
142+
after_insert_actions.append(f'''
143+
DELETE FROM cache WHERE
144+
({self.SQLITE_TIMESTAMP} - ts) > {self.ttl};
145+
''')
111146
if self.maxsize > 0:
112-
after_insert_actions.append(
113-
' DELETE FROM cache WHERE key in ('
114-
'SELECT key FROM cache '
115-
'ORDER BY ts LIMIT '
116-
f'max(0, (SELECT COUNT(key) FROM cache) - {self.maxsize}));'
117-
)
147+
after_insert_actions.append(f'''
148+
DELETE FROM cache WHERE key in (
149+
SELECT key FROM cache
150+
ORDER BY {policy_stuff['delete_order_by']}
151+
LIMIT max(0, (SELECT COUNT(key) FROM cache) - {self.maxsize})
152+
);
153+
''')
118154

119155
with self.db as db:
120-
db.execute(
121-
'CREATE TABLE IF NOT EXISTS cache ('
122-
' key BINARY PRIMARY KEY,'
123-
' ts REAL,'
124-
' value BLOB'
125-
') WITHOUT ROWID'
126-
)
156+
db.execute(f'''
157+
CREATE TABLE IF NOT EXISTS cache (
158+
key BINARY PRIMARY KEY,
159+
ts REAL NOT NULL DEFAULT ({self.SQLITE_TIMESTAMP}),
160+
{''.join(f"{c}, " for c in policy_stuff['additional_columns'])}
161+
value BLOB NOT NULL
162+
) WITHOUT ROWID
163+
''')
127164
db.execute('CREATE INDEX IF NOT EXISTS i_cache_ts ON cache (ts)')
165+
166+
for i, columns in enumerate(policy_stuff['additional_indexes']):
167+
db.execute(f'CREATE INDEX IF NOT EXISTS i_cache_{i} ON cache ({columns})')
168+
128169
if after_insert_actions:
129-
trigger_ddl = (
130-
'CREATE TRIGGER IF NOT EXISTS t_cache_cleanup\n'
131-
'AFTER INSERT ON cache FOR EACH ROW BEGIN\n'
132-
'%s\n'
133-
'END'
134-
) % '\n'.join(after_insert_actions)
135-
db.execute(trigger_ddl)
170+
db.execute(f'''
171+
CREATE TRIGGER IF NOT EXISTS t_cache_cleanup
172+
AFTER INSERT ON cache FOR EACH ROW BEGIN
173+
%s
174+
END
175+
''' % '\n'.join(after_insert_actions))
136176

137177
def clear(self):
138178
with self.db as db:

tests/test_cache.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ def cache(tempdirpath, request):
1515

1616

1717
def test_repr():
18-
c = Cache(maxsize=1, ttl=1, filepath=None, x='y')
19-
expected = ("Cache(maxsize=1, ttl=1, filepath=None, "
18+
c = Cache(maxsize=1, ttl=1, filepath=None, policy='FIFO', x='y')
19+
expected = ("Cache(maxsize=1, ttl=1, filepath=None, policy='FIFO', "
2020
f"key={make_key}, x='y')")
2121
assert repr(c) == expected
2222

@@ -263,3 +263,58 @@ def test_items(cache):
263263
time.sleep(0.001)
264264
cache[2] = 'two'
265265
assert [(k, v) for k, v in cache.items()] == [(1, 'one'), (2, 'two')]
266+
267+
268+
@pytest.mark.parametrize('storage', ['file', 'memory'])
269+
def test_lru(tempdirpath, storage):
270+
filepath = None if storage == 'memory' else f'{tempdirpath}/cache'
271+
cache = Cache(filepath=filepath, maxsize=2, ttl=-1, policy='LRU')
272+
273+
@cache
274+
def func(a):
275+
return a
276+
277+
def keys():
278+
return [arg for (fn_name, arg), v in cache.items()]
279+
280+
assert func(1) == 1
281+
assert func(2) == 2
282+
the_keys = keys()
283+
assert len(the_keys) == 2
284+
assert 1 in the_keys and 2 in the_keys
285+
286+
assert func(1) == 1
287+
assert func(3) == 3
288+
the_keys = keys()
289+
assert len(the_keys) == 2
290+
assert 1 in the_keys and 3 in the_keys
291+
assert 2 not in the_keys
292+
293+
294+
@pytest.mark.parametrize('storage', ['file', 'memory'])
295+
def test_lfu(tempdirpath, storage):
296+
filepath = None if storage == 'memory' else f'{tempdirpath}/cache'
297+
cache = Cache(filepath=filepath, maxsize=2, ttl=-1, policy='LFU')
298+
299+
@cache
300+
def func(a):
301+
return a
302+
303+
def keys():
304+
return [arg for (fn_name, arg), v in cache.items()]
305+
306+
assert func(1) == 1
307+
assert func(2) == 2
308+
the_keys = keys()
309+
assert len(the_keys) == 2
310+
assert 1 in the_keys and 2 in the_keys
311+
312+
assert func(1) == 1
313+
assert func(1) == 1
314+
assert func(2) == 2
315+
assert func(2) == 2
316+
assert func(3) == 3
317+
the_keys = keys()
318+
assert len(the_keys) == 2
319+
assert 1 in the_keys and 2 in the_keys
320+
assert 3 not in the_keys

0 commit comments

Comments
 (0)