onecache 0.9.0__cp38-cp38-win_amd64.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 ADDED
@@ -0,0 +1,122 @@
1
+ """Onecache main module."""
2
+
3
+ import os
4
+ from functools import update_wrapper
5
+ from typing import Optional
6
+
7
+ if os.environ.get("ONECACHE_NO_EXTENSIONS"):
8
+ from onecache._core import CacheValue, ExpirableCache, LRUCache, monotonic
9
+
10
+ COMPILED = False
11
+ else:
12
+ try:
13
+ from onecache._ccore import CacheValue, ExpirableCache, LRUCache, monotonic
14
+
15
+ COMPILED = True
16
+ except ImportError:
17
+ from onecache._core import CacheValue, ExpirableCache, LRUCache, monotonic
18
+
19
+ COMPILED = False
20
+
21
+ __all__ = [
22
+ "AsyncCacheDecorator",
23
+ "CacheDecorator",
24
+ "CacheValue",
25
+ "COMPILED",
26
+ "ExpirableCache",
27
+ "LRUCache",
28
+ "monotonic",
29
+ ]
30
+
31
+ _MISSING = object()
32
+
33
+
34
+ class CacheDecorator:
35
+ """Decorator for ExpirableCache"""
36
+
37
+ def __init__(
38
+ self,
39
+ maxsize=512,
40
+ ttl: Optional[int] = None,
41
+ skip_args: bool = False,
42
+ cache_class=LRUCache,
43
+ refresh_ttl: Optional[bool] = False,
44
+ thread_safe: Optional[bool] = False,
45
+ max_mem_size: Optional[int] = None,
46
+ ):
47
+ """
48
+ Args:
49
+ * **maxsize (int)**: Maximun number of items to be cached. default: 512
50
+ * **ttl (int)**: time to expire in milliseconds, if None, it does not expire. default: None
51
+ * **skip_args (bool)**: apply cache as the function doesn't have any arguments, default: False
52
+ * **cache_class (class)**: Class to use for cache instance. default: LRUCache
53
+ * **refresh_ttl (bool)**: if cache with ttl, This flag makes key expiration timestamp to be
54
+ refresh per access. default: False
55
+ * **thread_safe (bool)**: tell decorator to use thread safe lock. default=False
56
+ * **max_mem_size (int)**: max mem size inside cache structure. default=None which means no limit.
57
+ For pypy this value is ignored as the objects can change by the JIT compilation.
58
+ """
59
+ self.cache = cache_class(
60
+ maxsize,
61
+ ttl,
62
+ refresh_ttl=refresh_ttl,
63
+ thread_safe=thread_safe,
64
+ max_mem_size=max_mem_size,
65
+ )
66
+ self.maxsize = maxsize
67
+ self.skip_args = skip_args
68
+ self._fast = type(self.cache) in (ExpirableCache, LRUCache)
69
+
70
+ def __call__(self, func):
71
+ cache = self.cache
72
+ skip_args = self.skip_args
73
+
74
+ if self._fast:
75
+ return update_wrapper(cache._wrap(func, skip_args), func, updated=())
76
+
77
+ def wrapper(*args, **kwargs):
78
+ key = "any" if skip_args else cache.get_key(*args, **kwargs)
79
+ if key not in cache:
80
+ resp = func(*args, **kwargs)
81
+ cache.set(key, resp)
82
+ return resp
83
+ return cache.get(key)
84
+
85
+ wrapper.cache = cache
86
+
87
+ return update_wrapper(wrapper, func, updated=())
88
+
89
+
90
+ class AsyncCacheDecorator(CacheDecorator):
91
+ """Async Decorator for ExpirableCache"""
92
+
93
+ def __call__(self, func):
94
+ cache = self.cache
95
+ skip_args = self.skip_args
96
+
97
+ if self._fast:
98
+ make_key = cache._make_key
99
+ lookup = cache._lookup
100
+ set_value = cache.set
101
+
102
+ async def async_wrapper(*args, **kwargs):
103
+ key = "any" if skip_args else make_key(args, kwargs)
104
+ value = lookup(key, _MISSING)
105
+ if value is _MISSING:
106
+ value = await func(*args, **kwargs)
107
+ set_value(key, value)
108
+ return value
109
+
110
+ else:
111
+
112
+ async def async_wrapper(*args, **kwargs):
113
+ key = "any" if skip_args else cache.get_key(*args, **kwargs)
114
+ if key not in cache:
115
+ resp = await func(*args, **kwargs)
116
+ cache.set(key, resp)
117
+ return resp
118
+ return cache.get(key)
119
+
120
+ async_wrapper.cache = cache
121
+
122
+ return update_wrapper(async_wrapper, func, updated=())
Binary file
onecache/_core.py ADDED
@@ -0,0 +1,310 @@
1
+ """Pure Python cache implementation, used on PyPy or when the extension is unavailable."""
2
+
3
+ from collections import OrderedDict
4
+ from contextlib import nullcontext
5
+ from sys import getsizeof
6
+ from threading import RLock
7
+ from time import monotonic
8
+ from types import MappingProxyType
9
+ from typing import Any, Dict, Optional
10
+
11
+ from onecache.utils import IS_PYPY
12
+
13
+ INF = float("inf")
14
+ _MISSING = object()
15
+
16
+
17
+ class CacheValue:
18
+ """Cache entry holding the value, its expiration (monotonic seconds) and access count."""
19
+
20
+ __slots__ = ("value", "expire_at", "size", "access")
21
+
22
+ def __init__(self, value: Any, expire_at: Optional[float] = None):
23
+ self.value = value
24
+ self.expire_at = INF if expire_at is None else expire_at
25
+ self.size = 0
26
+ self.access = 0
27
+
28
+ def expired(self, now: Optional[float] = None) -> bool:
29
+ """Check if value is expired."""
30
+ return (monotonic() if now is None else now) > self.expire_at
31
+
32
+ def __eq__(self, other):
33
+ if not isinstance(other, CacheValue):
34
+ return NotImplemented
35
+ return self.value == other.value
36
+
37
+ __hash__ = None
38
+
39
+ def __reduce__(self):
40
+ return (_restore_value, (self.value, self.expire_at, self.size, self.access))
41
+
42
+
43
+ def _restore_value(value, expire_at, size, access):
44
+ item = CacheValue(value, expire_at)
45
+ item.size = size
46
+ item.access = access
47
+ return item
48
+
49
+
50
+ def _serialize(data: Any):
51
+ if type(data) is str:
52
+ return data
53
+ if isinstance(data, dict):
54
+ return ",".join([f"{key}={_serialize(val)}" for key, val in data.items()])
55
+ if isinstance(data, list):
56
+ return ",".join([_serialize(val) for val in data])
57
+ if isinstance(data, str):
58
+ return data
59
+ return str(data)
60
+
61
+
62
+ def _make_key(args: tuple, kwargs: dict) -> str:
63
+ if not kwargs and len(args) == 1:
64
+ return _serialize(args[0])
65
+ parts = [_serialize(arg) for arg in args]
66
+ if kwargs:
67
+ parts.extend(f"{key}={_serialize(kwargs[key])}" for key in sorted(kwargs))
68
+ return "-".join(parts)
69
+
70
+
71
+ class ExpirableCache:
72
+ """Cache with optional ttl, evicting the oldest inserted entry when full.
73
+
74
+ Params:
75
+ * **size (int)**: max items in dict. default=512
76
+ * **timeout (int)**: Timeout in milliseconds, if it is None,
77
+ there is no timeout. default=None
78
+ * **refresh_ttl (int)**: Refresh ttl anytime key is accessed. default=False
79
+ * **thread_safe (bool)**: Tell cache decorator to be thread safe. default=False
80
+ * **max_mem_size (int)**: max mem size inside cache structure. default=None which means no limit.
81
+ For pypy this value is ignored as the objects can change by the JIT compilation.
82
+ """
83
+
84
+ _lru = False
85
+
86
+ def __init__(
87
+ self,
88
+ size: int = 512,
89
+ timeout: Optional[int] = None,
90
+ refresh_ttl=False,
91
+ thread_safe=False,
92
+ max_mem_size: Optional[int] = None,
93
+ ):
94
+ self._data: Dict[Any, CacheValue] = OrderedDict()
95
+ self._expiry: Dict[Any, None] = OrderedDict()
96
+ self._timeout = timeout
97
+ self._ttl = timeout / 1000 if timeout and timeout > 0 else None
98
+ if size < 1:
99
+ raise ValueError("size must be at least 1")
100
+ self.size = size
101
+ self._max_mem_size = None if IS_PYPY else max_mem_size
102
+ self._current_size = 0
103
+ self.refresh_ttl = refresh_ttl
104
+ self._thread_safe = bool(thread_safe)
105
+ self._guard = RLock() if thread_safe else nullcontext()
106
+
107
+ @property
108
+ def cache(self):
109
+ """Read-only view of the cached entries."""
110
+ return MappingProxyType(self._data)
111
+
112
+ @property
113
+ def timeout(self):
114
+ """Ttl in milliseconds, None when entries do not expire."""
115
+ return self._timeout
116
+
117
+ @property
118
+ def max_mem_size(self):
119
+ """Max memory size of the cached entries, None when unlimited."""
120
+ return self._max_mem_size
121
+
122
+ @property
123
+ def current_size(self):
124
+ """Memory size of the cached entries, tracked only with max_mem_size."""
125
+ return self._current_size
126
+
127
+ @property
128
+ def thread_safe(self):
129
+ """Whether cache operations are guarded by a lock."""
130
+ return self._thread_safe
131
+
132
+ def get(self, key):
133
+ """Get value of key, None if missing or expired."""
134
+ with self._guard:
135
+ return self._lookup_unlocked(key, None)
136
+
137
+ def _lookup(self, key, default=None):
138
+ with self._guard:
139
+ return self._lookup_unlocked(key, default)
140
+
141
+ def _lookup_unlocked(self, key, default):
142
+ if self._expiry:
143
+ self._purge_expired(monotonic())
144
+ item = self._data.get(key)
145
+ if item is None:
146
+ return default
147
+ if item.expire_at != INF:
148
+ now = monotonic()
149
+ if now > item.expire_at:
150
+ self._remove_key(key)
151
+ return default
152
+ if self.refresh_ttl and self._ttl:
153
+ item.expire_at = now + self._ttl
154
+ try:
155
+ self._expiry.move_to_end(key)
156
+ except KeyError:
157
+ pass
158
+ if self._lru:
159
+ try:
160
+ self._data.move_to_end(key)
161
+ except KeyError:
162
+ pass
163
+ item.access += 1
164
+ return item.value
165
+
166
+ def set(self, key, data):
167
+ """Set value for key, evicting entries if needed.
168
+
169
+ With max_mem_size, a value bigger than the limit is not cached.
170
+ """
171
+ with self._guard:
172
+ self._set_unlocked(key, data)
173
+
174
+ def _set_unlocked(self, key, data):
175
+ now = monotonic() if self._ttl or self._expiry else 0.0
176
+ if self._expiry:
177
+ self._purge_expired(now)
178
+ size = 0
179
+ if self._max_mem_size:
180
+ size = getsizeof(key) + getsizeof(data)
181
+ if size > self._max_mem_size:
182
+ if key in self._data:
183
+ self._remove_key(key)
184
+ return
185
+ if key in self._data:
186
+ self._remove_key(key)
187
+ elif self._data and len(self._data) >= self.size:
188
+ self._remove_key(next(iter(self._data)))
189
+
190
+ item = CacheValue(data)
191
+ if self._max_mem_size:
192
+ item.size = size
193
+ while self._data and self._current_size + size > self._max_mem_size:
194
+ self._remove_key(next(iter(self._data)))
195
+ self._current_size += size
196
+ self._data[key] = item
197
+ if self._ttl:
198
+ item.expire_at = now + self._ttl
199
+ self._expiry[key] = None
200
+
201
+ def _wrap(self, func, skip_args: bool = False):
202
+ lookup = self._lookup
203
+ set_value = self.set
204
+
205
+ def wrapper(*args, **kwargs):
206
+ key = "any" if skip_args else _make_key(args, kwargs)
207
+ value = lookup(key, _MISSING)
208
+ if value is _MISSING:
209
+ value = func(*args, **kwargs)
210
+ set_value(key, value)
211
+ return value
212
+
213
+ wrapper.cache = self
214
+ return wrapper
215
+
216
+ def _make_key(self, args: tuple, kwargs: dict) -> str:
217
+ return _make_key(args, kwargs)
218
+
219
+ def _purge_expired(self, now: float):
220
+ expiry = self._expiry
221
+ data = self._data
222
+ while expiry:
223
+ key = next(iter(expiry))
224
+ item = data.get(key)
225
+ if item is not None and now <= item.expire_at:
226
+ break
227
+ self._remove_key(key)
228
+
229
+ def _remove_key(self, key):
230
+ item = self._data.pop(key, None)
231
+ self._expiry.pop(key, None)
232
+ if item is not None and self._max_mem_size:
233
+ self._current_size -= item.size
234
+
235
+ def __contains__(self, key):
236
+ with self._guard:
237
+ item = self._data.get(key)
238
+ if item is None:
239
+ return False
240
+ if item.expire_at != INF and monotonic() > item.expire_at:
241
+ self._remove_key(key)
242
+ return False
243
+ return True
244
+
245
+ def __len__(self):
246
+ return len(self._data)
247
+
248
+ def __reduce__(self):
249
+ raise TypeError(f"cannot pickle {type(self).__name__!r} object")
250
+
251
+ def clear(self):
252
+ """Remove every entry."""
253
+ with self._guard:
254
+ entries = list(self._data.values())
255
+ self._data.clear()
256
+ self._expiry.clear()
257
+ self._current_size = 0
258
+ entries.clear()
259
+
260
+ def refresh_key_ttl(self, key: Any, milliseconds=None):
261
+ """Do refresh of key ttl, if present."""
262
+ ms = milliseconds or (self._timeout if self._ttl else None)
263
+ if not ms:
264
+ return
265
+ with self._guard:
266
+ item = self._data.get(key)
267
+ if item is None:
268
+ return
269
+ item.expire_at = monotonic() + ms / 1000
270
+ self._expiry[key] = None
271
+ self._expiry.move_to_end(key)
272
+
273
+ @classmethod
274
+ def get_key(cls, *args, **kwargs):
275
+ """Helper method to generate keys from *args, **kwargs."""
276
+ serialize = cls.serialize_key
277
+ kwargs_list = [f"{key}={serialize(kwargs[key])}" for key in sorted(kwargs)]
278
+ return "-".join([serialize(item) for item in list(args) + kwargs_list])
279
+
280
+ @classmethod
281
+ def serialize_key(cls, data: Any):
282
+ """Serialize a single argument into its key representation."""
283
+ if type(data) is str:
284
+ return data
285
+ if isinstance(data, dict):
286
+ return ",".join([f"{key}={cls.serialize_key(val)}" for key, val in data.items()])
287
+ if isinstance(data, list):
288
+ return ",".join([cls.serialize_key(val) for val in data])
289
+ if isinstance(data, str):
290
+ return data
291
+ return str(data)
292
+
293
+ def expire(self, *args, **kwargs):
294
+ """Usefull to expire any combination of *args, **kwargs."""
295
+ key = self.get_key(*args, **kwargs)
296
+ with self._guard:
297
+ if key in self._data:
298
+ self._remove_key(key)
299
+
300
+
301
+ class LRUCache(ExpirableCache):
302
+ """Cache with optional ttl, evicting the least recently used entry when full."""
303
+
304
+ _lru = True
305
+
306
+ @property
307
+ def access(self):
308
+ """Get accesses as map."""
309
+ with self._guard:
310
+ return {key: val.access for key, val in self._data.items()}
@@ -0,0 +1,3 @@
1
+ from onecache import CacheValue
2
+
3
+ __all__ = ["CacheValue"]
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,131 @@
1
+ Metadata-Version: 2.1
2
+ Name: onecache
3
+ Version: 0.9.0
4
+ Summary: Python cache for sync and async code
5
+ Author-email: Johanderson Mogollon <johander1822@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/sonic182/onecache
8
+ Classifier: Development Status :: 5 - Production/Stable
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Cython
12
+ Classifier: Programming Language :: Python :: 3.8
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Programming Language :: Python :: Implementation :: CPython
20
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
21
+ Requires-Python: >=3.8
22
+ Description-Content-Type: text/markdown
23
+
24
+
25
+ [![Coverage Status](https://coveralls.io/repos/github/sonic182/onecache/badge.svg?branch=master)](https://coveralls.io/github/sonic182/onecache?branch=master)
26
+ ![github status](https://github.com/sonic182/onecache/actions/workflows/python.yml/badge.svg)
27
+ # OneCache
28
+
29
+ Python cache for sync and async code.
30
+
31
+ Cache uses an LRU algorithm and can optionally set TTLs per entry.
32
+
33
+ On CPython the cache core is a compiled Cython extension (prebuilt wheels for Linux, macOS and Windows on x86_64 and ARM64). On PyPy, or on platforms without a prebuilt wheel, it falls back to an equivalent pure Python implementation. `onecache.COMPILED` tells which one is in use.
34
+
35
+ Tested automatically on CPython 3.10 and 3.14 and PyPy 3.11 across Linux, macOS, and Windows on x86_64 and ARM64 (see the workflow badge). Python 3.8 and 3.9 are still installable and get prebuilt wheels, but are no longer part of the test matrix.
36
+
37
+ # Usage
38
+
39
+ ```python
40
+ from onecache import CacheDecorator
41
+ from onecache import AsyncCacheDecorator
42
+
43
+
44
+ class Counter:
45
+ def __init__(self, count=0):
46
+ self.count = count
47
+
48
+
49
+ @pytest.mark.asyncio
50
+ async def test_async_cache_counter():
51
+ """Test async cache, counter case."""
52
+ counter = Counter()
53
+
54
+ @AsyncCacheDecorator()
55
+ async def mycoro(counter: Counter):
56
+ counter.count += 1
57
+ return counter.count
58
+
59
+ assert 1 == (await mycoro(counter))
60
+ assert 1 == (await mycoro(counter))
61
+
62
+
63
+ def test_cache_counter():
64
+ """Test async cache, counter case."""
65
+ counter = Counter()
66
+
67
+ @CacheDecorator()
68
+ def sample(counter: Counter):
69
+ counter.count += 1
70
+ return counter.count
71
+
72
+ assert 1 == (sample(counter))
73
+ assert 1 == (sample(counter))
74
+ ```
75
+
76
+ Decorator classes supports the following arguments
77
+
78
+ * **maxsize (int)**: Maximun number of items to be cached. default: 512
79
+ * **ttl (int)**: time to expire in milliseconds, if None, it does not expire. default: None
80
+ * **skip_args (bool)**: apply cache as the function doesn't have any arguments, default: False
81
+ * **cache_class (class)**: Class to use for cache instance. default: LRUCache
82
+ * **refresh_ttl (bool)**: if cache with ttl, This flag makes key expiration timestamp to be refresh per access. default: False
83
+ * **thread_safe (bool)**: tell decorator to use thread safe lock. default=False
84
+ * **max_mem_size (int)**: max mem size in bytes, as the sum of `sys.getsizeof(key) + sys.getsizeof(value)` of every entry (shallow sizes). default=None which means no limit. For pypy this value is ignored as the objects can change by the JIT compilation.
85
+
86
+ If num of records exceds maxsize, it drops the least recently used one. With `max_mem_size`, a value bigger than the limit is not cached.
87
+
88
+ Use `thread_safe=True` when the decorated function is called from several threads.
89
+
90
+ The decorated function exposes its cache as `.cache`: `.cache.expire(*args, **kwargs)` drops one entry, `.cache.clear()` drops all of them, and `.cache.cache` is a read-only view of the entries.
91
+
92
+ Set `ONECACHE_NO_EXTENSIONS` to any non-empty value to force the pure Python implementation. If the extension failed to build when installing from source (e.g. no compiler), pip may keep reusing that pure Python build from its cache; reinstall with `pip install --no-cache-dir --force-reinstall onecache` after installing a compiler.
93
+
94
+
95
+ # Development
96
+
97
+ Install dependencies with Poetry (includes dev + test groups):
98
+ ```bash
99
+ poetry install --with test,dev
100
+ ```
101
+
102
+ Build the Cython extension in place (needed after any change to `onecache/_ccore.pyx`):
103
+ ```bash
104
+ make build_ext
105
+ ```
106
+
107
+ Run the test suite against the compiled and the pure Python implementations:
108
+ ```bash
109
+ poetry run pytest
110
+ ONECACHE_NO_EXTENSIONS=1 poetry run pytest
111
+ ```
112
+
113
+ `onecache/_ccore.pyx` and `onecache/_core.py` implement the same API and must be kept in sync.
114
+
115
+ # Releasing
116
+
117
+ Pull requests only build cp310 and cp314 wheels, so first run the `Wheels` workflow by hand on `master` (Actions → Wheels → Run workflow) to build and test every Python version. Then bump `version` in `pyproject.toml` and push a tag with the same value (e.g. `0.9.0`). The `Wheels` workflow builds the wheels with cibuildwheel, the sdist and a pure Python fallback wheel, and publishes them to PyPI through trusted publishing. That needs a `pypi` environment in the GitHub repository settings and a trusted publisher on PyPI for this repository, workflow `wheels.yml` and environment `pypi`.
118
+
119
+ Lint and format checks:
120
+ ```bash
121
+ poetry run flake8
122
+ poetry run autopep8 --in-place --recursive onecache tests
123
+ ```
124
+
125
+ # Contribute
126
+
127
+ 1. Fork
128
+ 2. create a branch `feature/your_feature`
129
+ 3. commit - push - pull request
130
+
131
+ Thanks :)
@@ -0,0 +1,9 @@
1
+ onecache/__init__.py,sha256=2U27qdtNKwZ0rXbjV2Lfzpx5LA4kKH0_GoUSPZQAaR4,4044
2
+ onecache/_ccore.cp38-win_amd64.pyd,sha256=GrxdqauFTYyxeBy628gMtF66oDj03r7gRmQIYB6NK-k,110080
3
+ onecache/_core.py,sha256=X_dMV_BlijwL7Nuvk9gtTpYRfSUSSW-ao_13HInK8PI,10451
4
+ onecache/cache_value.py,sha256=9cLTkNHjQxlaVydUbS1UlDfuoiU2y_FRtMQOdsVrnvU,61
5
+ onecache/utils.py,sha256=KyWmvw_lDVCvcFmD4qGzHOFa1yv7I02fem37jpsVzTo,279
6
+ onecache-0.9.0.dist-info/METADATA,sha256=gkFVa2fMBntm3ODqpZYoBSqajHsWkHn0yOTe6tYS5qI,5725
7
+ onecache-0.9.0.dist-info/WHEEL,sha256=TFndZn0SXD1XqMUEIKDojL47weLF1ldHBOPnF9B_sLo,99
8
+ onecache-0.9.0.dist-info/top_level.txt,sha256=e1pCmlJnfcMBz3KIqLPb37lC1a2bYAZgXhfCfvFHriw,9
9
+ onecache-0.9.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.3.4)
3
+ Root-Is-Purelib: false
4
+ Tag: cp38-cp38-win_amd64
5
+
@@ -0,0 +1 @@
1
+ onecache