btrcache 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.
- btrcache/__init__.py +9 -0
- btrcache/__version__.py +24 -0
- btrcache/asyncio/__init__.py +12 -0
- btrcache/asyncio/cache.py +18 -0
- btrcache/asyncio/decorators.py +395 -0
- btrcache/cache.py +21 -0
- btrcache/keys.py +213 -0
- btrcache-0.1.0.dist-info/METADATA +96 -0
- btrcache-0.1.0.dist-info/RECORD +11 -0
- btrcache-0.1.0.dist-info/WHEEL +4 -0
- btrcache-0.1.0.dist-info/licenses/LICENSE +21 -0
btrcache/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Copyright (C) 2026 Lucas Dias
|
|
2
|
+
|
|
3
|
+
"""Caching tools, collections and decorators.
|
|
4
|
+
|
|
5
|
+
This package provides in-memory cache implementations and memoizing
|
|
6
|
+
decorators for synchronous and asynchronous callables, cache collections
|
|
7
|
+
implementing multiple algorithms and other utilities for working with
|
|
8
|
+
caches.
|
|
9
|
+
"""
|
btrcache/__version__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Copyright (C) 2026 Lucas Dias
|
|
2
|
+
|
|
3
|
+
"""Asyncio-based caching package.
|
|
4
|
+
|
|
5
|
+
Provides decorators for caching coroutines.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import typing
|
|
9
|
+
|
|
10
|
+
from src.btrcache.asyncio.decorators import async_cached, async_cachedmethod
|
|
11
|
+
|
|
12
|
+
__all__: typing.Final = ("async_cached", "async_cachedmethod")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Copyright (C) 2026 Lucas Dias
|
|
2
|
+
|
|
3
|
+
"""Type aliases for asynchronous cache implementations.
|
|
4
|
+
|
|
5
|
+
This module defines generic cache aliases used by the asynchronous
|
|
6
|
+
decorators.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import typing
|
|
11
|
+
|
|
12
|
+
from src.btrcache.cache import Cache
|
|
13
|
+
|
|
14
|
+
__all__: typing.Final = ("AsyncCache",)
|
|
15
|
+
|
|
16
|
+
# Each asynchronous cache entry stores the executing `Task`
|
|
17
|
+
# together with the shared `Future` returned
|
|
18
|
+
type AsyncCache[R] = Cache[tuple[asyncio.Task[R], asyncio.Future[R]]]
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
# Copyright (C) 2026 Lucas Dias
|
|
2
|
+
#
|
|
3
|
+
# Portions Copyright (C) 2026 James Ward
|
|
4
|
+
# Source: https://github.com/imnotjames/cachetools-async/blob/53d453441dac736b16ec549ee4dd50453c9669c9/src/cachetools_async/decorators.py
|
|
5
|
+
# License: MIT (See LICENSE file for details)
|
|
6
|
+
|
|
7
|
+
"""Asynchronous caching utilities for coroutines and methods.
|
|
8
|
+
|
|
9
|
+
This module provides async-compatible decorator patterns similar to
|
|
10
|
+
`cachetools`, designed to store and reuse `Future` instances to prevent
|
|
11
|
+
duplicate in-flight executions (cache stampede) and cache completed
|
|
12
|
+
results.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import contextlib
|
|
17
|
+
import functools
|
|
18
|
+
import inspect
|
|
19
|
+
import typing
|
|
20
|
+
|
|
21
|
+
from src.btrcache.keys import HashedKey, KeyHashStrategy, KeyHashStrategyProtocol
|
|
22
|
+
|
|
23
|
+
if typing.TYPE_CHECKING:
|
|
24
|
+
import collections.abc
|
|
25
|
+
|
|
26
|
+
from src.btrcache.asyncio.cache import AsyncCache
|
|
27
|
+
|
|
28
|
+
__all__: typing.Final = ("async_cached", "async_cachedmethod")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def __async_cache_get[R](
|
|
32
|
+
*,
|
|
33
|
+
cache: AsyncCache[R],
|
|
34
|
+
key: HashedKey,
|
|
35
|
+
call_fn: collections.abc.Callable[[], collections.abc.Coroutine[typing.Any, typing.Any, R]],
|
|
36
|
+
result_predicate: collections.abc.Callable[[typing.Any], bool] = lambda _: True,
|
|
37
|
+
exception_predicate: collections.abc.Callable[[BaseException], bool] = lambda _: True,
|
|
38
|
+
) -> tuple[asyncio.Task[R], asyncio.Future[R]]:
|
|
39
|
+
with contextlib.suppress(KeyError):
|
|
40
|
+
# If alrady exists a cached version, return it
|
|
41
|
+
return cache[key]
|
|
42
|
+
|
|
43
|
+
# Creates a new task for this call
|
|
44
|
+
loop = asyncio.get_event_loop()
|
|
45
|
+
task = loop.create_task(call_fn())
|
|
46
|
+
future: asyncio.Future[R] = loop.create_future()
|
|
47
|
+
|
|
48
|
+
# `done` callback for when future is concluded
|
|
49
|
+
def done(t: asyncio.Task[R]) -> None:
|
|
50
|
+
# Removes cancelled tasks from the cache
|
|
51
|
+
if t.cancelled():
|
|
52
|
+
cache.pop(key, None)
|
|
53
|
+
future.cancel()
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
# Treats thrown exceptions inside executed task
|
|
57
|
+
exception = t.exception()
|
|
58
|
+
if exception is not None:
|
|
59
|
+
# Validates if the thrown exception should invalidate caching
|
|
60
|
+
if not exception_predicate(exception):
|
|
61
|
+
cache.pop(key, None)
|
|
62
|
+
|
|
63
|
+
# Propagates the exception
|
|
64
|
+
future.set_exception(exception)
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
# Successful result
|
|
68
|
+
result = t.result()
|
|
69
|
+
|
|
70
|
+
# Validates if the result should invalidate caching
|
|
71
|
+
if not result_predicate(result):
|
|
72
|
+
cache.pop(key, None)
|
|
73
|
+
|
|
74
|
+
# Stores the successful result in the shared future
|
|
75
|
+
future.set_result(result)
|
|
76
|
+
|
|
77
|
+
# Adds `done` callback of future to task
|
|
78
|
+
task.add_done_callback(done)
|
|
79
|
+
|
|
80
|
+
# Returns the cached value
|
|
81
|
+
with contextlib.suppress(ValueError):
|
|
82
|
+
cache[key] = (task, future)
|
|
83
|
+
|
|
84
|
+
return task, future
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class __AsyncCachedFunctionWrapperProtocol[**P, R](typing.Protocol):
|
|
88
|
+
"""Protocol for cached asynchronous function wrappers."""
|
|
89
|
+
|
|
90
|
+
cache: AsyncCache[R] | None
|
|
91
|
+
cache_key: KeyHashStrategyProtocol
|
|
92
|
+
cache_lock: contextlib.AbstractContextManager[typing.Any] | None
|
|
93
|
+
cache_info: bool | None
|
|
94
|
+
cache_clear: collections.abc.Callable[[], None]
|
|
95
|
+
|
|
96
|
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> collections.abc.Awaitable[R]:
|
|
97
|
+
raise NotImplementedError
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class __AsyncCachedMethodWrapperProtocol[**P, R](typing.Protocol):
|
|
101
|
+
"""Protocol for cached asynchronous method wrappers."""
|
|
102
|
+
|
|
103
|
+
cache: collections.abc.Callable[[typing.Any], AsyncCache[R] | None]
|
|
104
|
+
cache_key: KeyHashStrategyProtocol
|
|
105
|
+
cache_lock: contextlib.AbstractContextManager[typing.Any] | None
|
|
106
|
+
cache_info: bool | None
|
|
107
|
+
cache_clear: collections.abc.Callable[[typing.Any], None]
|
|
108
|
+
|
|
109
|
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> collections.abc.Awaitable[R]:
|
|
110
|
+
raise NotImplementedError
|
|
111
|
+
|
|
112
|
+
def __get__(
|
|
113
|
+
self,
|
|
114
|
+
# IGNORE: Type `Any` was used as it must The descriptor
|
|
115
|
+
# is accessed from an instance of the class containing
|
|
116
|
+
# the decorated method which must be any arbitrary
|
|
117
|
+
# user-defined type.
|
|
118
|
+
instance: typing.Any, # noqa: ANN401
|
|
119
|
+
owner: type | None,
|
|
120
|
+
) -> __AsyncCachedMethodWrapperProtocol[P, R]:
|
|
121
|
+
if instance is None:
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
# Binds the method to the instance at runtime
|
|
125
|
+
bound = typing.cast(
|
|
126
|
+
"__AsyncCachedMethodWrapperProtocol[P, R]",
|
|
127
|
+
functools.partial(self.__call__, instance),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Mirrors the cache attributes to the bound wrapper
|
|
131
|
+
bound.cache = self.cache
|
|
132
|
+
bound.cache_key = self.cache_key
|
|
133
|
+
bound.cache_lock = self.cache_lock
|
|
134
|
+
bound.cache_info = self.cache_info
|
|
135
|
+
bound.cache_clear = self.cache_clear
|
|
136
|
+
|
|
137
|
+
return bound
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class __AsyncFunctionDecoratorProtocol(typing.Protocol):
|
|
141
|
+
"""Protocol representing a decorator that wraps an async function."""
|
|
142
|
+
|
|
143
|
+
def __call__[**P, R](
|
|
144
|
+
self,
|
|
145
|
+
fn: collections.abc.Callable[P, collections.abc.Awaitable[R]],
|
|
146
|
+
) -> __AsyncCachedFunctionWrapperProtocol[P, R]:
|
|
147
|
+
raise NotImplementedError
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class __AsyncMethodDecoratorProtocol(typing.Protocol):
|
|
151
|
+
"""Protocol representing a decorator that wraps an async instance method."""
|
|
152
|
+
|
|
153
|
+
def __call__[SelfT, **P, R](
|
|
154
|
+
self,
|
|
155
|
+
fn: collections.abc.Callable[typing.Concatenate[SelfT, P], collections.abc.Awaitable[R]],
|
|
156
|
+
) -> __AsyncCachedMethodWrapperProtocol[P, R]:
|
|
157
|
+
raise NotImplementedError
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# IGNORE: Function defines a public interface
|
|
161
|
+
# that necessitates many arguments
|
|
162
|
+
def async_cached( # noqa: PLR0913
|
|
163
|
+
cache: AsyncCache[typing.Any] | None,
|
|
164
|
+
*,
|
|
165
|
+
key_hash_strategy: KeyHashStrategyProtocol = KeyHashStrategy,
|
|
166
|
+
lock: contextlib.AbstractContextManager[typing.Any] | None = None,
|
|
167
|
+
info: bool = False,
|
|
168
|
+
result_predicate: collections.abc.Callable[[typing.Any], bool] = lambda _: True,
|
|
169
|
+
exception_predicate: collections.abc.Callable[[BaseException], bool] = lambda _: True,
|
|
170
|
+
) -> __AsyncFunctionDecoratorProtocol:
|
|
171
|
+
"""Decorate caching async function results using asyncio `Future`.
|
|
172
|
+
|
|
173
|
+
This decorator stores `Future` objects in a shared mapping keyed by
|
|
174
|
+
deterministic `HashedKey` objects generated from the supplied
|
|
175
|
+
`key_hash_strategy`. It ensures that concurrent callers awaiting the
|
|
176
|
+
same computation will reuse the same in-progress `Future`, preventing
|
|
177
|
+
duplicate execution (cache stampede protection).
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
cache (AsyncCache[Any] | None):
|
|
181
|
+
Cache used to store tasks and futures. If None, caching is disabled.
|
|
182
|
+
|
|
183
|
+
key_hash_strategy (KeyHashStrategyProtocol):
|
|
184
|
+
Strategy used to compute deterministic cache keys from the
|
|
185
|
+
positional and keyword arguments. Defaults to `KeyHashStrategy`.
|
|
186
|
+
|
|
187
|
+
lock (AbstractContextManager[Any] | None):
|
|
188
|
+
Optional lock context manager. Not supported in this implementation.
|
|
189
|
+
|
|
190
|
+
info (bool):
|
|
191
|
+
If True, raises NotImplementedError (not supported).
|
|
192
|
+
|
|
193
|
+
result_predicate (Callable[[Any], bool]):
|
|
194
|
+
A custom callable that receives the completed result of the
|
|
195
|
+
coroutine and returns True if it should remain cached. If it
|
|
196
|
+
returns False, the result is evicted from the cache immediately
|
|
197
|
+
upon completion. Defaults to a lambda that always returns True.
|
|
198
|
+
|
|
199
|
+
exception_predicate (Callable[[BaseException], bool]):
|
|
200
|
+
A custom callable that receives the raised exception if the
|
|
201
|
+
coroutine fails, returning True if the exception should be cached.
|
|
202
|
+
If it returns False, the failed future is evicted, allowing
|
|
203
|
+
subsequent calls to retry execution. Defaults to a lambda that
|
|
204
|
+
always returns True.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
__AsyncFunctionDecoratorProtocol:
|
|
208
|
+
A decorator that wraps an async function with caching behavior.
|
|
209
|
+
|
|
210
|
+
Raises:
|
|
211
|
+
NotImplementedError:
|
|
212
|
+
If `info` or `lock` are provided.
|
|
213
|
+
|
|
214
|
+
"""
|
|
215
|
+
if info:
|
|
216
|
+
msg = "`info` is not supported"
|
|
217
|
+
raise NotImplementedError(msg)
|
|
218
|
+
|
|
219
|
+
if lock is not None:
|
|
220
|
+
msg = "`lock` is not supported"
|
|
221
|
+
raise NotImplementedError(msg)
|
|
222
|
+
|
|
223
|
+
def decorator[**P, R](
|
|
224
|
+
fn: collections.abc.Callable[P, collections.abc.Awaitable[R]],
|
|
225
|
+
) -> __AsyncCachedFunctionWrapperProtocol[P, R]:
|
|
226
|
+
if not inspect.iscoroutinefunction(fn):
|
|
227
|
+
msg = f"Expected 'Coroutine' function, got {fn}"
|
|
228
|
+
raise TypeError(msg)
|
|
229
|
+
|
|
230
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
231
|
+
if cache is None:
|
|
232
|
+
return await fn(*args, **kwargs)
|
|
233
|
+
|
|
234
|
+
# Generates key
|
|
235
|
+
generated_key = HashedKey(args, kwargs, hash_strategy=key_hash_strategy)
|
|
236
|
+
|
|
237
|
+
task, future = __async_cache_get(
|
|
238
|
+
cache=cache,
|
|
239
|
+
key=generated_key,
|
|
240
|
+
call_fn=lambda: fn(*args, **kwargs),
|
|
241
|
+
result_predicate=result_predicate,
|
|
242
|
+
exception_predicate=exception_predicate,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
return await future
|
|
247
|
+
except asyncio.CancelledError:
|
|
248
|
+
# If an awaiting caller is cancelled, the corresponding cache entry is
|
|
249
|
+
# removed and the underlying task is cancelled when appropriate
|
|
250
|
+
|
|
251
|
+
# Evicts from cache instantly
|
|
252
|
+
cache.pop(generated_key, None)
|
|
253
|
+
|
|
254
|
+
# Directly terminates the underlying task loop execution
|
|
255
|
+
if not task.done():
|
|
256
|
+
task.cancel()
|
|
257
|
+
|
|
258
|
+
raise
|
|
259
|
+
|
|
260
|
+
wrapped = typing.cast(
|
|
261
|
+
"__AsyncCachedFunctionWrapperProtocol[P, R]",
|
|
262
|
+
functools.update_wrapper(wrapper, fn),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
wrapped.cache = cache
|
|
266
|
+
wrapped.cache_key = key_hash_strategy
|
|
267
|
+
wrapped.cache_lock = None
|
|
268
|
+
wrapped.cache_info = None
|
|
269
|
+
wrapped.cache_clear = lambda: cache.clear() if cache is not None else None
|
|
270
|
+
|
|
271
|
+
return wrapped
|
|
272
|
+
|
|
273
|
+
return decorator
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# IGNORE: Function defines a public interface
|
|
277
|
+
# that necessitates many arguments
|
|
278
|
+
def async_cachedmethod( # noqa: PLR0913
|
|
279
|
+
cache: collections.abc.Callable[[typing.Any], AsyncCache[typing.Any] | None],
|
|
280
|
+
*,
|
|
281
|
+
key_hash_strategy: KeyHashStrategyProtocol = KeyHashStrategy,
|
|
282
|
+
lock: collections.abc.Callable[[typing.Any], contextlib.AbstractContextManager[typing.Any]]
|
|
283
|
+
| None = None,
|
|
284
|
+
info: bool = False,
|
|
285
|
+
result_predicate: collections.abc.Callable[[typing.Any], bool] = lambda _: True,
|
|
286
|
+
exception_predicate: collections.abc.Callable[[BaseException], bool] = lambda _: True,
|
|
287
|
+
) -> __AsyncMethodDecoratorProtocol:
|
|
288
|
+
"""Decorate caching async instance/class methods.
|
|
289
|
+
|
|
290
|
+
This is similar to `cached`, but the cache is resolved dynamically
|
|
291
|
+
per instance (or class) via the provided cache function.
|
|
292
|
+
|
|
293
|
+
It is primarily used for memoizing async methods where cache storage
|
|
294
|
+
is attached to `self` or another runtime context.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
cache (Callable[[Any], AsyncCache[Any] | None]):
|
|
298
|
+
Callable that returns an `AsyncCache` for the given instance,
|
|
299
|
+
or None to disable caching for that instance.
|
|
300
|
+
|
|
301
|
+
key_hash_strategy (KeyHashStrategyProtocol):
|
|
302
|
+
Strategy used to compute deterministic cache keys from the
|
|
303
|
+
positional and keyword arguments. Defaults to `KeyHashStrategy`.
|
|
304
|
+
|
|
305
|
+
lock (Callable[[Any], AbstractContextManager[Any]] | None):
|
|
306
|
+
Optional lock context manager factory. Not supported.
|
|
307
|
+
|
|
308
|
+
info (bool):
|
|
309
|
+
If True, raises NotImplementedError (not supported).
|
|
310
|
+
|
|
311
|
+
result_predicate (Callable[[Any], bool]):
|
|
312
|
+
A custom callable that receives the completed result of the
|
|
313
|
+
coroutine and returns True if it should remain cached. If it
|
|
314
|
+
returns False, the result is evicted from the cache immediately
|
|
315
|
+
upon completion. Defaults to a lambda that always returns True.
|
|
316
|
+
|
|
317
|
+
exception_predicate (Callable[[BaseException], bool]):
|
|
318
|
+
A custom callable that receives the raised exception if the
|
|
319
|
+
coroutine fails, returning True if the exception should be cached.
|
|
320
|
+
If it returns False, the failed future is evicted, allowing
|
|
321
|
+
subsequent calls to retry execution. Defaults to a lambda that
|
|
322
|
+
always returns True.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
__AsyncMethodDecoratorProtocol:
|
|
326
|
+
A decorator that wraps an async method with caching behavior.
|
|
327
|
+
|
|
328
|
+
Raises:
|
|
329
|
+
NotImplementedError:
|
|
330
|
+
If `info` or `lock` are provided.
|
|
331
|
+
|
|
332
|
+
"""
|
|
333
|
+
if info:
|
|
334
|
+
msg = "`info` is not supported"
|
|
335
|
+
raise NotImplementedError(msg)
|
|
336
|
+
|
|
337
|
+
if lock is not None:
|
|
338
|
+
msg = "`lock` is not supported"
|
|
339
|
+
raise NotImplementedError(msg)
|
|
340
|
+
|
|
341
|
+
def decorator[SelfT, **P, R](
|
|
342
|
+
fn: collections.abc.Callable[typing.Concatenate[SelfT, P], collections.abc.Awaitable[R]],
|
|
343
|
+
) -> __AsyncCachedMethodWrapperProtocol[P, R]:
|
|
344
|
+
if not inspect.iscoroutinefunction(fn):
|
|
345
|
+
msg = f"Expected 'Coroutine' function, got {fn}"
|
|
346
|
+
raise TypeError(msg)
|
|
347
|
+
|
|
348
|
+
async def wrapper(self_obj: SelfT, *args: P.args, **kwargs: P.kwargs) -> R:
|
|
349
|
+
self_cache = cache(self_obj)
|
|
350
|
+
|
|
351
|
+
if self_cache is None:
|
|
352
|
+
return await fn(self_obj, *args, **kwargs)
|
|
353
|
+
|
|
354
|
+
# Generates key
|
|
355
|
+
generated_key = HashedKey(args, kwargs, hash_strategy=key_hash_strategy)
|
|
356
|
+
|
|
357
|
+
task, future = __async_cache_get(
|
|
358
|
+
cache=self_cache,
|
|
359
|
+
key=generated_key,
|
|
360
|
+
call_fn=lambda: fn(self_obj, *args, **kwargs),
|
|
361
|
+
result_predicate=result_predicate,
|
|
362
|
+
exception_predicate=exception_predicate,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
try:
|
|
366
|
+
return await future
|
|
367
|
+
except asyncio.CancelledError:
|
|
368
|
+
# If an awaiting caller is cancelled, the corresponding cache entry is
|
|
369
|
+
# removed and the underlying task is cancelled when appropriate
|
|
370
|
+
|
|
371
|
+
# Evicts from cache instantly
|
|
372
|
+
self_cache.pop(generated_key, None)
|
|
373
|
+
|
|
374
|
+
# Directly terminates the underlying task loop execution
|
|
375
|
+
if not task.done():
|
|
376
|
+
task.cancel()
|
|
377
|
+
|
|
378
|
+
raise
|
|
379
|
+
|
|
380
|
+
wrapped = typing.cast(
|
|
381
|
+
"__AsyncCachedMethodWrapperProtocol[P, R]",
|
|
382
|
+
functools.update_wrapper(wrapper, fn),
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
wrapped.cache = cache
|
|
386
|
+
wrapped.cache_key = key_hash_strategy
|
|
387
|
+
wrapped.cache_lock = None
|
|
388
|
+
wrapped.cache_info = None
|
|
389
|
+
wrapped.cache_clear = lambda self_obj: (
|
|
390
|
+
self_cache.clear() if (self_cache := cache(self_obj)) is not None else None
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
return wrapped
|
|
394
|
+
|
|
395
|
+
return decorator
|
btrcache/cache.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Copyright (C) 2026 Lucas Dias
|
|
2
|
+
|
|
3
|
+
"""Cache collections and related abstractions.
|
|
4
|
+
|
|
5
|
+
This module provides shared cache collection interfaces, generic type
|
|
6
|
+
aliases, and cache implementations.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import typing
|
|
10
|
+
|
|
11
|
+
from src.btrcache.keys import HashedKey
|
|
12
|
+
|
|
13
|
+
if typing.TYPE_CHECKING:
|
|
14
|
+
import collections.abc
|
|
15
|
+
|
|
16
|
+
__all__: typing.Final = ("Cache",)
|
|
17
|
+
|
|
18
|
+
type Cache[R] = collections.abc.MutableMapping[
|
|
19
|
+
HashedKey,
|
|
20
|
+
R,
|
|
21
|
+
]
|
btrcache/keys.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# Copyright (C) 2026 Lucas Dias
|
|
2
|
+
#
|
|
3
|
+
# Portions Copyright (C) 2026 Thomas Kemmer
|
|
4
|
+
# Source: https://github.com/tkem/cachetools/blob/48284d73d0a8834c9c50f8d41bb99e6f93b2dfed/src/cachetools/keys.py
|
|
5
|
+
# License: MIT (See LICENSE file for details)
|
|
6
|
+
|
|
7
|
+
"""Utilities for constructing deterministic, hashable cache keys.
|
|
8
|
+
|
|
9
|
+
This module provides strategies for converting positional and keyword
|
|
10
|
+
arguments into immutable tuple representations suitable for hashing and
|
|
11
|
+
cache lookups, along with a lightweight wrapper that lazily caches the
|
|
12
|
+
computed hash value.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import typing
|
|
16
|
+
|
|
17
|
+
__all__: typing.Final = (
|
|
18
|
+
"HashedKey",
|
|
19
|
+
"KeyHashStrategy",
|
|
20
|
+
"KeyHashStrategyProtocol",
|
|
21
|
+
"KeyHashTypedStrategy",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Immutable tuple representation used internally for cache keys.
|
|
25
|
+
type RawCacheKey = tuple[typing.Any, ...]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class KeyHashStrategyProtocol(typing.Protocol):
|
|
29
|
+
"""Protocol implemented by cache key generation strategies."""
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def hashkey(args: tuple[object, ...], kwargs: dict[str, object]) -> RawCacheKey:
|
|
33
|
+
"""Convert function arguments into a hashable cache key.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
args (tuple[object, ...]):
|
|
37
|
+
Positional arguments.
|
|
38
|
+
|
|
39
|
+
kwargs (dict[str, object]):
|
|
40
|
+
Keyword arguments.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
RawCacheKey:
|
|
44
|
+
Immutable tuple representing the cache key.
|
|
45
|
+
|
|
46
|
+
"""
|
|
47
|
+
raise NotImplementedError
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class KeyHashStrategy:
|
|
51
|
+
"""Generate cache keys using only argument values."""
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def hashkey(args: tuple[object, ...], kwargs: dict[str, object]) -> RawCacheKey:
|
|
55
|
+
"""Construct a deterministic cache key.
|
|
56
|
+
|
|
57
|
+
Positional arguments are stored first. Keyword arguments are
|
|
58
|
+
sorted by name and appended after a sentinel marker to ensure
|
|
59
|
+
deterministic ordering and avoid ambiguity with positional
|
|
60
|
+
arguments.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
args (tuple[object, ...]):
|
|
64
|
+
Positional arguments.
|
|
65
|
+
|
|
66
|
+
kwargs (dict[str, object]):
|
|
67
|
+
Keyword arguments.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
RawCacheKey:
|
|
71
|
+
Immutable tuple representing the cache key.
|
|
72
|
+
|
|
73
|
+
"""
|
|
74
|
+
# No keyword arguments are present
|
|
75
|
+
if not kwargs:
|
|
76
|
+
return tuple(args)
|
|
77
|
+
|
|
78
|
+
# Appends a marker followed by sorted keyword argument pairs to ensure
|
|
79
|
+
# deterministic ordering and distinguish them from positional arguments
|
|
80
|
+
return args + HashedKey.KWARGS_MARKER + tuple(sorted(kwargs.items()))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class KeyHashTypedStrategy:
|
|
84
|
+
"""Generate cache keys that also encode argument types."""
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def hashkey(args: tuple[object, ...], kwargs: dict[str, object]) -> RawCacheKey:
|
|
88
|
+
"""Construct a type-aware cache key.
|
|
89
|
+
|
|
90
|
+
In addition to argument values, the runtime type of each argument
|
|
91
|
+
is appended to distinguish values that compare equal but have
|
|
92
|
+
different types.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
args (tuple[object, ...]):
|
|
96
|
+
Positional arguments.
|
|
97
|
+
|
|
98
|
+
kwargs (dict[str, object]):
|
|
99
|
+
Keyword arguments.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
RawCacheKey:
|
|
103
|
+
Immutable tuple representing the cache key.
|
|
104
|
+
|
|
105
|
+
"""
|
|
106
|
+
# Starts with the positional argument values and appends
|
|
107
|
+
# every value type to distinguish between them in the final
|
|
108
|
+
# hash
|
|
109
|
+
key = args + tuple(type(v) for v in args)
|
|
110
|
+
|
|
111
|
+
if kwargs:
|
|
112
|
+
# Sorts keyword arguments to produce a deterministic key
|
|
113
|
+
sorted_kwargs = tuple(sorted(kwargs.items()))
|
|
114
|
+
|
|
115
|
+
# Appends the sentinel marker and the keyword argument values
|
|
116
|
+
key += HashedKey.KWARGS_MARKER + sorted_kwargs
|
|
117
|
+
|
|
118
|
+
# Append the type of each keyword argument value
|
|
119
|
+
key += tuple(type(v) for _, v in sorted_kwargs)
|
|
120
|
+
|
|
121
|
+
return key
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class HashedKey:
|
|
125
|
+
"""Immutable tuple-like object that caches its computed hash value.
|
|
126
|
+
|
|
127
|
+
The hash is computed lazily on first use and cached for subsequent
|
|
128
|
+
calls to avoid repeated tuple hashing overhead in cache key generation.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
# Sentinel marker used to separate positional arguments from keyword arguments
|
|
132
|
+
# when building cache keys.
|
|
133
|
+
#
|
|
134
|
+
# This prevents ambiguity between calls like:
|
|
135
|
+
# f(1, ("x", 2)) -> positional tuple argument
|
|
136
|
+
# f(1, x=2) -> keyword argument form
|
|
137
|
+
#
|
|
138
|
+
# Without a separator, both could normalize to the same tuple representation,
|
|
139
|
+
# causing cache key collisions.
|
|
140
|
+
#
|
|
141
|
+
# The value is a class object because it is guaranteed unique, immutable and hashable.
|
|
142
|
+
KWARGS_MARKER: typing.ClassVar[tuple[type]] = (tuple,)
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
args: tuple[object, ...],
|
|
147
|
+
kwargs: dict[str, object],
|
|
148
|
+
*,
|
|
149
|
+
hash_strategy: KeyHashStrategyProtocol = KeyHashStrategy,
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Initialize a hashable cache key from function arguments.
|
|
152
|
+
|
|
153
|
+
This converts positional and keyword arguments into a single immutable
|
|
154
|
+
tuple representation used for hashing and equality comparisons.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
args (tuple[object, ...]):
|
|
158
|
+
Positional arguments.
|
|
159
|
+
|
|
160
|
+
kwargs (dict[str, object]):
|
|
161
|
+
Keyword arguments.
|
|
162
|
+
|
|
163
|
+
hash_strategy (KeyHashStrategyProtocol):
|
|
164
|
+
Strategy used to normalize the arguments into an immutable cache
|
|
165
|
+
key representation.
|
|
166
|
+
|
|
167
|
+
"""
|
|
168
|
+
self.__hashvalue: int
|
|
169
|
+
self.__tuple: RawCacheKey = hash_strategy.hashkey(args, kwargs)
|
|
170
|
+
|
|
171
|
+
@typing.override
|
|
172
|
+
def __eq__(self, value: object, /) -> bool:
|
|
173
|
+
# Keys must share the same class
|
|
174
|
+
if not isinstance(value, HashedKey):
|
|
175
|
+
return NotImplemented
|
|
176
|
+
|
|
177
|
+
# If they share the same hash, must be the same key
|
|
178
|
+
return self.__hash__() == value.__hash__()
|
|
179
|
+
|
|
180
|
+
@typing.override
|
|
181
|
+
def __hash__(self) -> int:
|
|
182
|
+
# Try-except is used instead of `is None` for
|
|
183
|
+
# performance issues
|
|
184
|
+
try:
|
|
185
|
+
return self.__hashvalue
|
|
186
|
+
except AttributeError:
|
|
187
|
+
# It will raise error if tuple has unhashable element
|
|
188
|
+
self.__hashvalue = self.__tuple.__hash__()
|
|
189
|
+
return self.__hashvalue
|
|
190
|
+
|
|
191
|
+
@typing.override
|
|
192
|
+
def __getstate__(self) -> object:
|
|
193
|
+
# Only keeps the given arguments in pickle
|
|
194
|
+
# (keeping hash stored is not safe)
|
|
195
|
+
return self.__tuple
|
|
196
|
+
|
|
197
|
+
def __setstate__(self, state: RawCacheKey) -> None:
|
|
198
|
+
"""Restore the object state during unpickling.
|
|
199
|
+
|
|
200
|
+
This method reconstructs the internal immutable tuple representation
|
|
201
|
+
from the serialized state. Any cached hash value is intentionally
|
|
202
|
+
not restored, ensuring that the hash is recomputed lazily after
|
|
203
|
+
unpickling if needed.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
state (RawCacheKey):
|
|
207
|
+
The previously serialized tuple representing the cache key.
|
|
208
|
+
This contains only the semantic argument structure and does
|
|
209
|
+
not include any runtime-cached values such as the hash.
|
|
210
|
+
|
|
211
|
+
"""
|
|
212
|
+
# Restores internal tuple only
|
|
213
|
+
self.__tuple = state
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: btrcache
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: High-performance caching tools, memoization decorators, and cache collections for Python.
|
|
5
|
+
Project-URL: Homepage, https://github.com/lucas-azdias/btrcache?tab=readme-ov-file
|
|
6
|
+
Project-URL: Source, https://github.com/lucas-azdias/btrcache
|
|
7
|
+
Project-URL: Issues, https://github.com/lucas-azdias/btrcache/issues
|
|
8
|
+
Author-email: Lucas Dias <lucas@azdias.com.br>
|
|
9
|
+
Maintainer-email: Lucas Dias <lucas@azdias.com.br>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.14
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# `btrcache`
|
|
23
|
+
|
|
24
|
+
[](https://raw.github.com/lucas-azdias/btrcache/master/LICENSE)
|
|
25
|
+
|
|
26
|
+
[](https://codecov.io/gh/lucas-azdias/btrcache)
|
|
27
|
+
|
|
28
|
+
High-performance caching tools, memoization decorators, and cache collections for Python.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Overview
|
|
33
|
+
|
|
34
|
+
**`btrcache`** is a Python library that provides a flexible set of in-memory caching primitives, decorators, and cache collections designed for both synchronous and asynchronous workloads.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install btrcache
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
<!--
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
45
|
+
### Simple function memoization
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from btrcache import cached
|
|
49
|
+
|
|
50
|
+
@cached()
|
|
51
|
+
def fib(n: int) -> int:
|
|
52
|
+
if n < 2:
|
|
53
|
+
return n
|
|
54
|
+
return fib(n - 1) + fib(n - 2)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Custom cache size
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from btrcache import cached, LRUCache
|
|
61
|
+
|
|
62
|
+
@cached(cache_factory=lambda: LRUCache(maxsize=1024))
|
|
63
|
+
def compute(x):
|
|
64
|
+
return x * x
|
|
65
|
+
```
|
|
66
|
+
-->
|
|
67
|
+
|
|
68
|
+
## Roadmap
|
|
69
|
+
|
|
70
|
+
* [X] Key generators
|
|
71
|
+
* [X] Asynchronous decorators
|
|
72
|
+
* [ ] Create tests
|
|
73
|
+
* [ ] Release in PyPi
|
|
74
|
+
* [ ] Synchronous decorators
|
|
75
|
+
* [ ] Cache collections
|
|
76
|
+
* [ ] Optimizations
|
|
77
|
+
|
|
78
|
+
## Contributing
|
|
79
|
+
|
|
80
|
+
Contributions are welcome. Please ensure:
|
|
81
|
+
|
|
82
|
+
* Code is type-annotated;
|
|
83
|
+
* Tests cover edge cases.
|
|
84
|
+
|
|
85
|
+
## Acknowledgements
|
|
86
|
+
|
|
87
|
+
This project builds upon ideas and implementations from the following open-source projects:
|
|
88
|
+
|
|
89
|
+
- [`cachetools`](https://github.com/tkem/cachetools/) - An extensible set of memoizing collections and decorators for Python. Developed and maintained by [Thomas Kemmer](https://github.com/tkem/).
|
|
90
|
+
- [`cachetools-async`](https://github.com/imnotjames/cachetools-async/) - Python library that extends `cachetools` with async decorators. Created by [James Ward](https://github.com/imnotjames/).
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
Copyright (C) 2026 Lucas Dias.
|
|
95
|
+
|
|
96
|
+
Licensed under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
btrcache/__init__.py,sha256=xV1uBiQrCqH7JOahvX3eUUwzfwMGf4vCjiIIb4uVy50,303
|
|
2
|
+
btrcache/__version__.py,sha256=n_5vdJsPNu7wZ57LGuRL585uvll-hiuvZUBWzdG0RQU,520
|
|
3
|
+
btrcache/cache.py,sha256=-mTu0MFgI-SuYpNlKdKsk_B1sDj7WC7FxvlIPjGbmBY,409
|
|
4
|
+
btrcache/keys.py,sha256=T5jEYt9p7Dp8c10kmmQmOiifehg8k4mg33zsM7spMEU,7058
|
|
5
|
+
btrcache/asyncio/__init__.py,sha256=fErA-0szqAW0vAcEBuTbPyWSwTtMKXnVk3tSpKmT2xQ,273
|
|
6
|
+
btrcache/asyncio/cache.py,sha256=jzrsl5wWkB2FFxbBXlTYm3N5QNXpJK5qejBjxzCT6gQ,458
|
|
7
|
+
btrcache/asyncio/decorators.py,sha256=3T4zzQGq3SNxlXBeVwwuh-RW_IZRV5TvZZ45xrdOtII,14517
|
|
8
|
+
btrcache-0.1.0.dist-info/METADATA,sha256=d7HmKyT9uGd0qqExzoPKnIna_5IG6yXGu9IO16A3NEI,2811
|
|
9
|
+
btrcache-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
10
|
+
btrcache-0.1.0.dist-info/licenses/LICENSE,sha256=MdnrHR7Fd0qpOZbLajTEsB7ifgteTCTTiCcha0ASkWA,1067
|
|
11
|
+
btrcache-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lucas Dias
|
|
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.
|