coredis-utils 0.1.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.
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
import inspect
|
|
5
|
+
import pickle
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import timedelta
|
|
10
|
+
from functools import wraps
|
|
11
|
+
from hashlib import sha256
|
|
12
|
+
from typing import Any, AnyStr, Generic, ParamSpec, TypeVar, overload
|
|
13
|
+
|
|
14
|
+
from anyio import sleep
|
|
15
|
+
from coredis import PureToken, Redis, RedisCluster
|
|
16
|
+
from coredis.commands import CommandRequest
|
|
17
|
+
from coredis.typing import KeyT
|
|
18
|
+
|
|
19
|
+
P = ParamSpec("P")
|
|
20
|
+
R = TypeVar("R")
|
|
21
|
+
|
|
22
|
+
VERSION = "0.1.0"
|
|
23
|
+
LIMITER_SCRIPT = """
|
|
24
|
+
local val = redis.call('incr', KEYS[1])
|
|
25
|
+
if val == 1 then
|
|
26
|
+
redis.call('expire', KEYS[1], ARGV[1])
|
|
27
|
+
end
|
|
28
|
+
return val
|
|
29
|
+
"""
|
|
30
|
+
__version__ = VERSION
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _limit(key: KeyT, period: int) -> CommandRequest[int]: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _make_cache_key(
|
|
37
|
+
prefix: str,
|
|
38
|
+
fn: Callable[..., Any],
|
|
39
|
+
signature: inspect.Signature,
|
|
40
|
+
args: tuple[Any, ...],
|
|
41
|
+
kwargs: dict[str, Any],
|
|
42
|
+
) -> str:
|
|
43
|
+
bound = signature.bind(*args, **kwargs)
|
|
44
|
+
bound.apply_defaults()
|
|
45
|
+
canonical = pickle.dumps(tuple(bound.arguments.items()))
|
|
46
|
+
digest = sha256(canonical).hexdigest()[:16]
|
|
47
|
+
return f"{prefix}cache:{fn.__module__}.{fn.__qualname__}:{digest}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ttl_seconds(ttl: timedelta | int) -> int:
|
|
51
|
+
if isinstance(ttl, timedelta):
|
|
52
|
+
return round(ttl.total_seconds())
|
|
53
|
+
return ttl
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# cache envelopes to distinguish a stored value from a stored error
|
|
57
|
+
@dataclass(slots=True, frozen=True)
|
|
58
|
+
class _Ok:
|
|
59
|
+
value: Any
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(slots=True, frozen=True)
|
|
63
|
+
class _Err:
|
|
64
|
+
exc: BaseException
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CoredisUtils(Generic[AnyStr]):
|
|
68
|
+
"""
|
|
69
|
+
Wraps a coredis client to provide additional functionality.
|
|
70
|
+
|
|
71
|
+
:param client: basic or cluster client to use
|
|
72
|
+
:param prefix: prefix for all keys used in Redis
|
|
73
|
+
:param ttl: default TTL for cached results/idempotency keys, defaults to 5 minutes
|
|
74
|
+
:param signing_secret:
|
|
75
|
+
if provided, used to sign results cached in Redis, which improves security since
|
|
76
|
+
serialization uses pickle. To generate a key, try: `secrets.token_urlsafe(32)`
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
__slots__ = ("_client", "_limit", "prefix", "signing_secret", "ttl")
|
|
80
|
+
|
|
81
|
+
@overload
|
|
82
|
+
def __init__(
|
|
83
|
+
self: CoredisUtils[bytes],
|
|
84
|
+
client: Redis[bytes] | RedisCluster[bytes],
|
|
85
|
+
*,
|
|
86
|
+
prefix: str | None = ...,
|
|
87
|
+
ttl: timedelta | int | None = ...,
|
|
88
|
+
signing_secret: str | None = ...,
|
|
89
|
+
) -> None: ...
|
|
90
|
+
|
|
91
|
+
@overload
|
|
92
|
+
def __init__(
|
|
93
|
+
self: CoredisUtils[str],
|
|
94
|
+
client: Redis[str] | RedisCluster[str],
|
|
95
|
+
*,
|
|
96
|
+
prefix: str | None = ...,
|
|
97
|
+
ttl: timedelta | int | None = ...,
|
|
98
|
+
signing_secret: str | None = ...,
|
|
99
|
+
) -> None: ...
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self: CoredisUtils[Any],
|
|
103
|
+
client: Redis[Any] | RedisCluster[Any],
|
|
104
|
+
*,
|
|
105
|
+
prefix: str | None = "coredis-utils",
|
|
106
|
+
ttl: timedelta | int | None = 300,
|
|
107
|
+
signing_secret: str | None = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
# Redis connection
|
|
110
|
+
self._client = client
|
|
111
|
+
self.prefix = prefix + ":" if prefix else ""
|
|
112
|
+
self.ttl = ttl
|
|
113
|
+
self.signing_secret = signing_secret.encode() if signing_secret else None
|
|
114
|
+
# coredis FFI stubs for Lua script
|
|
115
|
+
self._limit = client.register_script(LIMITER_SCRIPT).wraps()(_limit)
|
|
116
|
+
|
|
117
|
+
async def idempotent(self, key: str, ttl: timedelta | int | None = 60) -> bool:
|
|
118
|
+
"""
|
|
119
|
+
Shields code from being run multiple times.
|
|
120
|
+
|
|
121
|
+
:param key: idempotency key to use
|
|
122
|
+
:param ttl: how long to prevent duplicate runs, defaults to 1 minute
|
|
123
|
+
"""
|
|
124
|
+
ttl = ttl or self.ttl
|
|
125
|
+
return await self._client.set(
|
|
126
|
+
f"{self.prefix}idempotent:{key}", 1, condition=PureToken.NX, ex=ttl
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def limit(self, key: str, limit: int, period: timedelta | int) -> bool:
|
|
130
|
+
"""
|
|
131
|
+
Limits the number of successful calls per period to the given number using a
|
|
132
|
+
fixed-window rate limiting algorithm.
|
|
133
|
+
|
|
134
|
+
:param key: unique identifier, usually some combination of user/IP/route
|
|
135
|
+
:param limit: maximum number of calls that can succeed per period
|
|
136
|
+
:param period: duration of window before more calls can succeed
|
|
137
|
+
"""
|
|
138
|
+
count = await self._limit(f"{self.prefix}limit:{key}", _ttl_seconds(period))
|
|
139
|
+
return count <= limit
|
|
140
|
+
|
|
141
|
+
def _serialize(self, data: Any) -> str | bytes:
|
|
142
|
+
try:
|
|
143
|
+
serialized = pickle.dumps(data)
|
|
144
|
+
except Exception as e:
|
|
145
|
+
raise RuntimeError(f"Failed to serialize data: {data}") from e
|
|
146
|
+
if self.signing_secret:
|
|
147
|
+
serialized += hmac.digest(self.signing_secret, serialized, "sha256")
|
|
148
|
+
return serialized
|
|
149
|
+
|
|
150
|
+
def _deserialize(self, data: Any) -> Any:
|
|
151
|
+
if self.signing_secret:
|
|
152
|
+
data_bytes, signature = data[:-32], data[-32:]
|
|
153
|
+
verify = hmac.digest(self.signing_secret, data_bytes, "sha256")
|
|
154
|
+
if not hmac.compare_digest(signature, verify):
|
|
155
|
+
raise RuntimeError("Invalid signature for task data!")
|
|
156
|
+
data = data_bytes
|
|
157
|
+
try:
|
|
158
|
+
return pickle.loads(data)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
raise RuntimeError(f"Failed to deserialize data: {data}") from e
|
|
161
|
+
|
|
162
|
+
async def _try_read(self, key: str) -> _Ok | None:
|
|
163
|
+
raw = await self._client.get(key)
|
|
164
|
+
if raw is not None:
|
|
165
|
+
val = self._deserialize(raw)
|
|
166
|
+
if isinstance(val, _Err):
|
|
167
|
+
raise val.exc
|
|
168
|
+
if isinstance(val, _Ok):
|
|
169
|
+
return val
|
|
170
|
+
|
|
171
|
+
@overload
|
|
172
|
+
def cached(self, fn: Callable[P, Awaitable[R]], /) -> Callable[P, Awaitable[R]]: ...
|
|
173
|
+
|
|
174
|
+
@overload
|
|
175
|
+
def cached(
|
|
176
|
+
self,
|
|
177
|
+
*,
|
|
178
|
+
ttl: timedelta | int | None = ...,
|
|
179
|
+
error_ttl: timedelta | int | None = ...,
|
|
180
|
+
lock_timeout: timedelta | int = ...,
|
|
181
|
+
) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]: ...
|
|
182
|
+
|
|
183
|
+
def cached(
|
|
184
|
+
self,
|
|
185
|
+
fn: Callable[P, Awaitable[R]] | None = None,
|
|
186
|
+
*,
|
|
187
|
+
ttl: timedelta | int | None = None,
|
|
188
|
+
error_ttl: timedelta | int | None = None,
|
|
189
|
+
lock_timeout: timedelta | int = 60,
|
|
190
|
+
) -> Any:
|
|
191
|
+
"""
|
|
192
|
+
Cache the function's results in Redis. Uses a lock to implement "singleflight",
|
|
193
|
+
protecting against thundering herds.
|
|
194
|
+
|
|
195
|
+
:param ttl: duration to cache results, defaults to `cache_ttl`
|
|
196
|
+
:param error_ttl:
|
|
197
|
+
duration to cache errors, defaults to `cache_ttl`, 0 means disabled
|
|
198
|
+
:param lock_timeout: TTL of the stampede protection lock, defaults to 1 minute
|
|
199
|
+
"""
|
|
200
|
+
ttl = ttl or self.ttl
|
|
201
|
+
error_ttl = error_ttl if error_ttl is not None else (self.ttl or 0)
|
|
202
|
+
|
|
203
|
+
def decorator(_fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
|
|
204
|
+
sig = inspect.signature(_fn)
|
|
205
|
+
|
|
206
|
+
@wraps(_fn)
|
|
207
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
208
|
+
key = _make_cache_key(self.prefix, _fn, sig, args, kwargs)
|
|
209
|
+
# fast path: cache hit
|
|
210
|
+
if res := await self._try_read(key):
|
|
211
|
+
return res.value
|
|
212
|
+
# slow path: add lock for stampede protection
|
|
213
|
+
lock = self._client.lock(
|
|
214
|
+
f"{self.prefix}lock:{key}",
|
|
215
|
+
timeout=_ttl_seconds(lock_timeout),
|
|
216
|
+
blocking=False,
|
|
217
|
+
)
|
|
218
|
+
if await lock.acquire():
|
|
219
|
+
try:
|
|
220
|
+
if res := await self._try_read(key):
|
|
221
|
+
return res.value
|
|
222
|
+
value = await _fn(*args, **kwargs)
|
|
223
|
+
await self._client.set(key, self._serialize(_Ok(value)), ex=ttl)
|
|
224
|
+
return value
|
|
225
|
+
except Exception as e:
|
|
226
|
+
if error_ttl != 0:
|
|
227
|
+
await self._client.set(
|
|
228
|
+
key, self._serialize(_Err(exc=e)), ex=error_ttl
|
|
229
|
+
)
|
|
230
|
+
raise
|
|
231
|
+
finally:
|
|
232
|
+
await lock.release()
|
|
233
|
+
# wait for leader's result by polling the cache
|
|
234
|
+
deadline = time.monotonic() + _ttl_seconds(lock_timeout)
|
|
235
|
+
sleep_time = 0.005 # 5ms initial backoff
|
|
236
|
+
while time.monotonic() < deadline:
|
|
237
|
+
await sleep(sleep_time)
|
|
238
|
+
if res := await self._try_read(key):
|
|
239
|
+
return res.value
|
|
240
|
+
sleep_time = min(sleep_time * 2, 0.1) # capped exponential backoff
|
|
241
|
+
# leader didn't complete in time, just run ourselves
|
|
242
|
+
return await _fn(*args, **kwargs)
|
|
243
|
+
|
|
244
|
+
return wrapper
|
|
245
|
+
|
|
246
|
+
if fn is None:
|
|
247
|
+
return decorator
|
|
248
|
+
return decorator(fn)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
__all__ = ["CoredisUtils"]
|
coredis_utils/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: coredis-utils
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A collection of helpful utilities for coredis.
|
|
5
|
+
Project-URL: Homepage, https://github.com/tastyware/coredis-utils
|
|
6
|
+
Project-URL: Funding, https://github.com/sponsors/tastyware
|
|
7
|
+
Project-URL: Source, https://github.com/tastyware/coredis-utils
|
|
8
|
+
Project-URL: Changelog, https://github.com/tastyware/coredis-utils/releases
|
|
9
|
+
Author-email: Graeme Holliday <graeme@tastyware.dev>
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 tastyware
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
|
33
|
+
Classifier: Environment :: Console
|
|
34
|
+
Classifier: Framework :: AnyIO
|
|
35
|
+
Classifier: Framework :: AsyncIO
|
|
36
|
+
Classifier: Framework :: Django
|
|
37
|
+
Classifier: Framework :: FastAPI
|
|
38
|
+
Classifier: Framework :: Flask
|
|
39
|
+
Classifier: Framework :: Trio
|
|
40
|
+
Classifier: Intended Audience :: Developers
|
|
41
|
+
Classifier: Intended Audience :: Information Technology
|
|
42
|
+
Classifier: Intended Audience :: System Administrators
|
|
43
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
44
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
45
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
46
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
47
|
+
Classifier: Operating System :: Unix
|
|
48
|
+
Classifier: Programming Language :: Python
|
|
49
|
+
Classifier: Programming Language :: Python :: 3
|
|
50
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
51
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
52
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
53
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
54
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
55
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
56
|
+
Classifier: Topic :: System :: Clustering
|
|
57
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
58
|
+
Classifier: Topic :: System :: Monitoring
|
|
59
|
+
Classifier: Topic :: System :: Systems Administration
|
|
60
|
+
Classifier: Typing :: Typed
|
|
61
|
+
Requires-Python: >=3.11
|
|
62
|
+
Requires-Dist: coredis>=6.6.1
|
|
63
|
+
Description-Content-Type: text/markdown
|
|
64
|
+
|
|
65
|
+
[](https://pypi.org/project/coredis-utils)
|
|
66
|
+
[](https://pepy.tech/project/coredis-utils)
|
|
67
|
+
[](https://github.com/tastyware/coredis-utils/releases)
|
|
68
|
+
|
|
69
|
+
# coredis-utils
|
|
70
|
+
|
|
71
|
+
A collection of helpful utilities for [coredis](https://coredis.readthedocs.io/en/latest/).
|
|
72
|
+
|
|
73
|
+
## Features
|
|
74
|
+
|
|
75
|
+
- Caching decorator with thundering herd protection and error caching
|
|
76
|
+
- Idempotency keys
|
|
77
|
+
- Fixed-window rate limiting
|
|
78
|
+
|
|
79
|
+
## Installation
|
|
80
|
+
|
|
81
|
+
```console
|
|
82
|
+
$ pip install coredis-utils
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Getting started
|
|
86
|
+
|
|
87
|
+
First, create a `CoredisUtils` object wrapping a `coredis.Redis` instance:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from coredis import Redis
|
|
91
|
+
from coredis_utils import CoredisUtils
|
|
92
|
+
|
|
93
|
+
client = Redis(...)
|
|
94
|
+
utils = CoredisUtils(client)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Caching is implemented with a decorator:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
@utils.cached(ttl=60)
|
|
101
|
+
async def my_task() -> int: ...
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Idempotency uses a simple check:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
if await utils.idempotent("my-key", ttl=60):
|
|
108
|
+
... # code in this block can only run once
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Rate limiting is similar:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
for _ in range(15):
|
|
115
|
+
print(await utils.limit("my-ip-addr", 10, 1)) # limit to 10/second
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
True
|
|
120
|
+
True
|
|
121
|
+
True
|
|
122
|
+
True
|
|
123
|
+
True
|
|
124
|
+
True
|
|
125
|
+
True
|
|
126
|
+
True
|
|
127
|
+
True
|
|
128
|
+
True
|
|
129
|
+
False
|
|
130
|
+
False
|
|
131
|
+
False
|
|
132
|
+
False
|
|
133
|
+
False
|
|
134
|
+
```
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
coredis_utils/__init__.py,sha256=XKZZNPbxilmRwUcu35nfWS1BzY_1l3yMK79Q-7PY5KU,8727
|
|
2
|
+
coredis_utils/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
coredis_utils-0.1.0.dist-info/METADATA,sha256=rfwroTv_i8OiF-a7yiYEJnyZLkaI6YiRCA_oTpteKHc,4448
|
|
4
|
+
coredis_utils-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
5
|
+
coredis_utils-0.1.0.dist-info/licenses/LICENSE,sha256=f-Rk7kLfjs6x8rrDYsXn-joTBb-fo9hEUKRowhK4puY,1066
|
|
6
|
+
coredis_utils-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tastyware
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|