onecache 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- onecache/__init__.py +241 -0
- onecache/cache_value.py +34 -0
- onecache/utils.py +11 -0
- onecache-0.7.0.dist-info/METADATA +102 -0
- onecache-0.7.0.dist-info/RECORD +6 -0
- onecache-0.7.0.dist-info/WHEEL +4 -0
onecache/__init__.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Onecache main module."""
|
|
2
|
+
|
|
3
|
+
from collections import OrderedDict
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from sys import getsizeof
|
|
7
|
+
from threading import Lock
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
from onecache.cache_value import CacheValue
|
|
11
|
+
from onecache.utils import IS_PYPY, utcnow
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ExpirableCache(object):
|
|
15
|
+
"""Class used for custom cache decorator, dummy expirable cache based on dict structure.
|
|
16
|
+
|
|
17
|
+
Params:
|
|
18
|
+
* **size (int)**: max items in dict. default=512
|
|
19
|
+
* **timeout (int)**: Timeout in milliseconds, if it is None,
|
|
20
|
+
there is no timeout. default=None
|
|
21
|
+
* **refresh_ttl (int)**: Refresh ttl anytime key is accessed. default=False
|
|
22
|
+
* **thread_safe (bool)**: Tell cache decorator to be thread safe. default=False
|
|
23
|
+
* **max_mem_size (int)**: max mem size inside cache structure. default=None which means no limit. For pypy this value is ignored as the objects can change by the JIT compilation.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
size: int = 512,
|
|
29
|
+
timeout: Optional[int] = None,
|
|
30
|
+
refresh_ttl=False,
|
|
31
|
+
thread_safe=False,
|
|
32
|
+
max_mem_size: Optional[int] = None,
|
|
33
|
+
):
|
|
34
|
+
self.cache: Dict[str, CacheValue] = OrderedDict()
|
|
35
|
+
self.timeout = timeout
|
|
36
|
+
self.size = size
|
|
37
|
+
self.max_mem_size = None if IS_PYPY else max_mem_size
|
|
38
|
+
self.current_size = 0
|
|
39
|
+
self.refresh_ttl = refresh_ttl
|
|
40
|
+
self.lock = None
|
|
41
|
+
if thread_safe:
|
|
42
|
+
self.lock = Lock()
|
|
43
|
+
|
|
44
|
+
def set(self, key, data):
|
|
45
|
+
key_size = None
|
|
46
|
+
value = None
|
|
47
|
+
with self._scoped():
|
|
48
|
+
if len(self.cache) + 1 > self.size and key not in self.cache:
|
|
49
|
+
self._pop_one()
|
|
50
|
+
|
|
51
|
+
if self.timeout:
|
|
52
|
+
expire_at = utcnow() + timedelta(milliseconds=self.timeout)
|
|
53
|
+
value = CacheValue(data, expire_at)
|
|
54
|
+
else:
|
|
55
|
+
value = CacheValue(data)
|
|
56
|
+
|
|
57
|
+
if self.max_mem_size:
|
|
58
|
+
key_size = getsizeof(key)
|
|
59
|
+
# pop data if it will exceed max mem size
|
|
60
|
+
while (
|
|
61
|
+
self.current_size + key_size + value.size > self.max_mem_size
|
|
62
|
+
and len(self.cache) > 1
|
|
63
|
+
):
|
|
64
|
+
self._pop_one()
|
|
65
|
+
self.cache[key] = value
|
|
66
|
+
|
|
67
|
+
if self.max_mem_size:
|
|
68
|
+
self.current_size += key_size + value.size
|
|
69
|
+
|
|
70
|
+
def _pop_one(self):
|
|
71
|
+
key = next(iter(self.cache))
|
|
72
|
+
self._remove_key(key)
|
|
73
|
+
|
|
74
|
+
def get(self, key, fetch_value=True):
|
|
75
|
+
self._check_expired(key)
|
|
76
|
+
cache_value = self.cache.get(key)
|
|
77
|
+
if cache_value:
|
|
78
|
+
if self.refresh_ttl:
|
|
79
|
+
with self._scoped():
|
|
80
|
+
self.refresh_key_ttl(key)
|
|
81
|
+
if fetch_value:
|
|
82
|
+
return cache_value.value
|
|
83
|
+
return cache_value
|
|
84
|
+
|
|
85
|
+
def _check_expired(self, key):
|
|
86
|
+
to_rm = []
|
|
87
|
+
if self.timeout:
|
|
88
|
+
for key, cache_value in self.cache.items():
|
|
89
|
+
if cache_value.expired():
|
|
90
|
+
to_rm.append(key)
|
|
91
|
+
for key in to_rm:
|
|
92
|
+
self._remove_key(key)
|
|
93
|
+
|
|
94
|
+
def _remove_key(self, key):
|
|
95
|
+
item = self.cache[key]
|
|
96
|
+
if self.max_mem_size:
|
|
97
|
+
key_size = getsizeof(key)
|
|
98
|
+
self.current_size -= item.size + key_size
|
|
99
|
+
|
|
100
|
+
del self.cache[key]
|
|
101
|
+
|
|
102
|
+
def __contains__(self, key):
|
|
103
|
+
self._check_expired(key)
|
|
104
|
+
return key in self.cache
|
|
105
|
+
|
|
106
|
+
def refresh_key_ttl(self, key: Any, milliseconds=None):
|
|
107
|
+
"""Do refresh of key ttl, if present."""
|
|
108
|
+
cache_value = self.cache.get(key)
|
|
109
|
+
if cache_value and (milliseconds or self.timeout):
|
|
110
|
+
ms = milliseconds if milliseconds else self.timeout
|
|
111
|
+
expire_at = utcnow() + timedelta(milliseconds=ms)
|
|
112
|
+
cache_value.refresh_ttl(expire_at)
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def get_key(cls, *args, **kwargs):
|
|
116
|
+
"""Helper method to generate keys from *args, **kwargs."""
|
|
117
|
+
sorted_kwargs = sorted(kwargs.items(), key=lambda item: item[0])
|
|
118
|
+
kwargs_list = [f"{key}={cls.serialize_key(val)}" for key, val in sorted_kwargs]
|
|
119
|
+
items = list(args) + kwargs_list
|
|
120
|
+
return "-".join([cls.serialize_key(item) for item in items])
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def serialize_key(cls, data: Any):
|
|
124
|
+
if isinstance(data, dict):
|
|
125
|
+
return ",".join(
|
|
126
|
+
[f"{key}={cls.serialize_key(val)}" for key, val in data.items()]
|
|
127
|
+
)
|
|
128
|
+
elif isinstance(data, list):
|
|
129
|
+
return ",".join([cls.serialize_key(val) for val in data])
|
|
130
|
+
elif isinstance(data, str):
|
|
131
|
+
return data
|
|
132
|
+
else:
|
|
133
|
+
return str(data)
|
|
134
|
+
|
|
135
|
+
def expire(self, *args, **kwargs):
|
|
136
|
+
"""Usefull to expire any combination of *args, **kwargs."""
|
|
137
|
+
key = self.get_key(*args, **kwargs)
|
|
138
|
+
if key in self.cache:
|
|
139
|
+
self._remove_key(key)
|
|
140
|
+
|
|
141
|
+
@contextmanager
|
|
142
|
+
def _scoped(self):
|
|
143
|
+
if self.lock:
|
|
144
|
+
with self.lock:
|
|
145
|
+
yield
|
|
146
|
+
else:
|
|
147
|
+
yield
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class LRUCache(ExpirableCache):
|
|
151
|
+
def get(self, key):
|
|
152
|
+
cache_value = super().get(key, fetch_value=False)
|
|
153
|
+
if cache_value:
|
|
154
|
+
with self._scoped():
|
|
155
|
+
self._increment(cache_value)
|
|
156
|
+
return cache_value.value
|
|
157
|
+
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
def _increment(self, val: CacheValue):
|
|
161
|
+
val.increment()
|
|
162
|
+
|
|
163
|
+
def _pop_one(self):
|
|
164
|
+
ordered_cache = sorted(self.cache.items(), key=lambda item: item[1].access)
|
|
165
|
+
key, _ = ordered_cache[0]
|
|
166
|
+
self._remove_key(key)
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def access(self):
|
|
170
|
+
"""Get accesses as map."""
|
|
171
|
+
return {key: val.access for key, val in self.cache.items()}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# Decorator!
|
|
175
|
+
class CacheDecorator:
|
|
176
|
+
"""Decorator for ExpirableCache"""
|
|
177
|
+
|
|
178
|
+
def __init__(
|
|
179
|
+
self,
|
|
180
|
+
maxsize=512,
|
|
181
|
+
ttl: Optional[int] = None,
|
|
182
|
+
skip_args: bool = False,
|
|
183
|
+
cache_class=LRUCache,
|
|
184
|
+
refresh_ttl: Optional[bool] = False,
|
|
185
|
+
thread_safe: Optional[bool] = False,
|
|
186
|
+
max_mem_size: Optional[int] = None,
|
|
187
|
+
):
|
|
188
|
+
"""
|
|
189
|
+
Args:
|
|
190
|
+
* **maxsize (int)**: Maximun number of items to be cached. default: 512
|
|
191
|
+
* **ttl (int)**: time to expire in milliseconds, if None, it does not expire. default: None
|
|
192
|
+
* **skip_args (bool)**: apply cache as the function doesn't have any arguments, default: False
|
|
193
|
+
* **cache_class (class)**: Class to use for cache instance. default: LRUCache
|
|
194
|
+
* **refresh_ttl (bool)**: if cache with ttl, This flag makes key expiration timestamp to be
|
|
195
|
+
refresh per access. default: False
|
|
196
|
+
* **thread_safe (bool)**: tell decorator to use thread safe lock. default=False
|
|
197
|
+
* **max_mem_size (int)**: max mem size inside cache structure. default=None which means no limit. For pypy this value is ignored as the objects can change by the JIT compilation.
|
|
198
|
+
"""
|
|
199
|
+
self.cache = cache_class(
|
|
200
|
+
maxsize,
|
|
201
|
+
ttl,
|
|
202
|
+
refresh_ttl=refresh_ttl,
|
|
203
|
+
thread_safe=thread_safe,
|
|
204
|
+
max_mem_size=max_mem_size,
|
|
205
|
+
)
|
|
206
|
+
self.maxsize = maxsize
|
|
207
|
+
self.skip_args = skip_args
|
|
208
|
+
|
|
209
|
+
def __call__(self, func):
|
|
210
|
+
def wrapper(*args, **kwargs):
|
|
211
|
+
key = "any" if self.skip_args else self.cache.get_key(*args, **kwargs)
|
|
212
|
+
|
|
213
|
+
if key not in self.cache:
|
|
214
|
+
resp = func(*args, **kwargs)
|
|
215
|
+
self.cache.set(key, resp)
|
|
216
|
+
return resp
|
|
217
|
+
else:
|
|
218
|
+
return self.cache.get(key)
|
|
219
|
+
|
|
220
|
+
wrapper.cache = self.cache
|
|
221
|
+
|
|
222
|
+
return wrapper
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class AsyncCacheDecorator(CacheDecorator):
|
|
226
|
+
"""Async Decorator for ExpirableCache"""
|
|
227
|
+
|
|
228
|
+
def __call__(self, func):
|
|
229
|
+
async def async_wrapper(*args, **kwargs):
|
|
230
|
+
key = "any" if self.skip_args else self.cache.get_key(*args, **kwargs)
|
|
231
|
+
|
|
232
|
+
if key not in self.cache:
|
|
233
|
+
resp = await func(*args, **kwargs)
|
|
234
|
+
self.cache.set(key, resp)
|
|
235
|
+
return resp
|
|
236
|
+
else:
|
|
237
|
+
return self.cache.get(key)
|
|
238
|
+
|
|
239
|
+
async_wrapper.cache = self.cache
|
|
240
|
+
|
|
241
|
+
return async_wrapper
|
onecache/cache_value.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from sys import getsizeof
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
from onecache.utils import IS_PYPY, utcnow
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CacheValue:
|
|
9
|
+
"""Dummy class for handling cache values."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, value: Any, expire_at: Optional[datetime] = None):
|
|
12
|
+
self.size = None if IS_PYPY else getsizeof(value)
|
|
13
|
+
self.access = 0
|
|
14
|
+
|
|
15
|
+
self.value = value
|
|
16
|
+
self.expire_at = expire_at
|
|
17
|
+
|
|
18
|
+
def set_expired(self, data):
|
|
19
|
+
self.expire_at = data
|
|
20
|
+
|
|
21
|
+
def expired(self):
|
|
22
|
+
"""Check if value is expired."""
|
|
23
|
+
if self.expire_at:
|
|
24
|
+
return utcnow() > self.expire_at
|
|
25
|
+
return False # pragma: no cover
|
|
26
|
+
|
|
27
|
+
def refresh_ttl(self, expire_at: datetime):
|
|
28
|
+
self.expire_at = expire_at
|
|
29
|
+
|
|
30
|
+
def increment(self):
|
|
31
|
+
self.access += 1
|
|
32
|
+
|
|
33
|
+
def __eq__(self, otherinstance: "CacheValue"):
|
|
34
|
+
return self.value == otherinstance.value
|
onecache/utils.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import platform
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
IS_PYPY = platform.python_implementation().lower().startswith("pypy")
|
|
6
|
+
|
|
7
|
+
def utcnow():
|
|
8
|
+
if sys.version_info >= (3, 11):
|
|
9
|
+
return datetime.datetime.now(datetime.UTC)
|
|
10
|
+
else:
|
|
11
|
+
return datetime.datetime.utcnow()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: onecache
|
|
3
|
+
Version: 0.7.0
|
|
4
|
+
Summary: Python cache for sync and async code
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Johanderson Mogollon
|
|
7
|
+
Author-email: johander1822@gmail.com
|
|
8
|
+
Requires-Python: >=3.8,<3.13
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
[](https://coveralls.io/github/sonic182/onecache?branch=master)
|
|
24
|
+

|
|
25
|
+
# OneCache
|
|
26
|
+
|
|
27
|
+
Python cache for sync and async code.
|
|
28
|
+
|
|
29
|
+
Cache uses LRU algoritm. Cache can optionally have TTL.
|
|
30
|
+
|
|
31
|
+
Tested in python 3.7, 3.9, 3.11 and pypy3.9 for windows, mac and linux (see github status badge), it should work in versions between them. It may work for python3.6
|
|
32
|
+
|
|
33
|
+
# Usage
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from onecache import CacheDecorator
|
|
37
|
+
from onecache import AsyncCacheDecorator
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Counter:
|
|
41
|
+
def __init__(self, count=0):
|
|
42
|
+
self.count = count
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.mark.asyncio
|
|
46
|
+
async def test_async_cache_counter():
|
|
47
|
+
"""Test async cache, counter case."""
|
|
48
|
+
counter = Counter()
|
|
49
|
+
|
|
50
|
+
@AsyncCacheDecorator()
|
|
51
|
+
async def mycoro(counter: Counter):
|
|
52
|
+
counter.count += 1
|
|
53
|
+
return counter.count
|
|
54
|
+
|
|
55
|
+
assert 1 == (await mycoro(counter))
|
|
56
|
+
assert 1 == (await mycoro(counter))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_cache_counter():
|
|
60
|
+
"""Test async cache, counter case."""
|
|
61
|
+
counter = Counter()
|
|
62
|
+
|
|
63
|
+
@CacheDecorator()
|
|
64
|
+
def sample(counter: Counter):
|
|
65
|
+
counter.count += 1
|
|
66
|
+
return counter.count
|
|
67
|
+
|
|
68
|
+
assert 1 == (sample(counter))
|
|
69
|
+
assert 1 == (sample(counter))
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Decorator classes supports the following arguments
|
|
73
|
+
|
|
74
|
+
* **maxsize (int)**: Maximun number of items to be cached. default: 512
|
|
75
|
+
* **ttl (int)**: time to expire in milliseconds, if None, it does not expire. default: None
|
|
76
|
+
* **skip_args (bool)**: apply cache as the function doesn't have any arguments, default: False
|
|
77
|
+
* **cache_class (class)**: Class to use for cache instance. default: LRUCache
|
|
78
|
+
* **refresh_ttl (bool)**: if cache with ttl, This flag makes key expiration timestamp to be refresh per access. default: False
|
|
79
|
+
* **thread_safe (bool)**: tell decorator to use thread safe lock. default=False
|
|
80
|
+
* **max_mem_size (int)**: max mem size in bytes. Ceil for sum of cache values sizes. default=None which means no limit. For pypy this value is ignored as the objects can change by the JIT compilation.
|
|
81
|
+
|
|
82
|
+
If num of records exceds maxsize, it drops the oldest.
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# Development
|
|
86
|
+
|
|
87
|
+
Install packages with pip-tools:
|
|
88
|
+
```bash
|
|
89
|
+
pip install pip-tools
|
|
90
|
+
pip-compile
|
|
91
|
+
pip-compile test-requirements.in
|
|
92
|
+
pip-sync requirements.txt test-requirements.txt
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
# Contribute
|
|
96
|
+
|
|
97
|
+
1. Fork
|
|
98
|
+
2. create a branch `feature/your_feature`
|
|
99
|
+
3. commit - push - pull request
|
|
100
|
+
|
|
101
|
+
Thanks :)
|
|
102
|
+
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
onecache/__init__.py,sha256=jwfOOuz_wmy9f1iXhIh4XZB4T-602qJOW3ny8TYXTqc,8148
|
|
2
|
+
onecache/cache_value.py,sha256=6IRKg_Pj6kYHcTJMqD0ua9FTs5LBMK0J2XUQogNlk8Q,900
|
|
3
|
+
onecache/utils.py,sha256=E8Sv5-QSS_gdnaFXIN4dIlR4FUqKKURJ64SkhPinVdU,268
|
|
4
|
+
onecache-0.7.0.dist-info/METADATA,sha256=Soyi6rNUTsccCJ_ZCclggCBSASsPTcLYxLw9AyaqY5k,3268
|
|
5
|
+
onecache-0.7.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
|
6
|
+
onecache-0.7.0.dist-info/RECORD,,
|