cachu 0.1.3__tar.gz → 0.2.1__tar.gz
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.
- {cachu-0.1.3/src/cachu.egg-info → cachu-0.2.1}/PKG-INFO +71 -20
- cachu-0.1.3/PKG-INFO → cachu-0.2.1/README.md +63 -35
- {cachu-0.1.3 → cachu-0.2.1}/pyproject.toml +8 -4
- {cachu-0.1.3 → cachu-0.2.1}/setup.cfg +1 -1
- {cachu-0.1.3 → cachu-0.2.1}/src/cachu/__init__.py +18 -1
- cachu-0.2.1/src/cachu/async_decorator.py +262 -0
- cachu-0.2.1/src/cachu/async_operations.py +178 -0
- {cachu-0.1.3 → cachu-0.2.1}/src/cachu/backends/__init__.py +6 -1
- cachu-0.2.1/src/cachu/backends/async_base.py +50 -0
- cachu-0.2.1/src/cachu/backends/async_memory.py +111 -0
- cachu-0.2.1/src/cachu/backends/async_redis.py +141 -0
- cachu-0.2.1/src/cachu/backends/async_sqlite.py +244 -0
- cachu-0.2.1/src/cachu/backends/file.py +10 -0
- cachu-0.2.1/src/cachu/backends/sqlite.py +240 -0
- {cachu-0.1.3 → cachu-0.2.1}/src/cachu/decorator.py +5 -5
- cachu-0.1.3/README.md → cachu-0.2.1/src/cachu.egg-info/PKG-INFO +86 -16
- {cachu-0.1.3 → cachu-0.2.1}/src/cachu.egg-info/SOURCES.txt +13 -1
- {cachu-0.1.3 → cachu-0.2.1}/src/cachu.egg-info/requires.txt +7 -2
- cachu-0.2.1/tests/test_async_memory.py +157 -0
- cachu-0.2.1/tests/test_async_redis.py +146 -0
- cachu-0.2.1/tests/test_async_sqlite.py +164 -0
- cachu-0.2.1/tests/test_sqlite_backend.py +163 -0
- cachu-0.2.1/tests/test_ttl_isolation.py +246 -0
- cachu-0.1.3/src/cachu/backends/file.py +0 -158
- {cachu-0.1.3 → cachu-0.2.1}/src/cachu/backends/memory.py +0 -0
- {cachu-0.1.3 → cachu-0.2.1}/src/cachu/backends/redis.py +0 -0
- {cachu-0.1.3 → cachu-0.2.1}/src/cachu/config.py +0 -0
- {cachu-0.1.3 → cachu-0.2.1}/src/cachu/keys.py +0 -0
- {cachu-0.1.3 → cachu-0.2.1}/src/cachu/operations.py +0 -0
- {cachu-0.1.3 → cachu-0.2.1}/src/cachu/types.py +0 -0
- {cachu-0.1.3 → cachu-0.2.1}/src/cachu.egg-info/dependency_links.txt +0 -0
- {cachu-0.1.3 → cachu-0.2.1}/src/cachu.egg-info/top_level.txt +0 -0
- {cachu-0.1.3 → cachu-0.2.1}/tests/test_clearing.py +0 -0
- {cachu-0.1.3 → cachu-0.2.1}/tests/test_config.py +0 -0
- {cachu-0.1.3 → cachu-0.2.1}/tests/test_defaultcache.py +0 -0
- {cachu-0.1.3 → cachu-0.2.1}/tests/test_delete_keys.py +0 -0
- {cachu-0.1.3 → cachu-0.2.1}/tests/test_disable.py +0 -0
- {cachu-0.1.3 → cachu-0.2.1}/tests/test_exclude_params.py +0 -0
- {cachu-0.1.3 → cachu-0.2.1}/tests/test_file_cache.py +0 -0
- {cachu-0.1.3 → cachu-0.2.1}/tests/test_integration.py +0 -0
- {cachu-0.1.3 → cachu-0.2.1}/tests/test_memory_cache.py +0 -0
- {cachu-0.1.3 → cachu-0.2.1}/tests/test_namespace.py +0 -0
- {cachu-0.1.3 → cachu-0.2.1}/tests/test_namespace_isolation.py +0 -0
- {cachu-0.1.3 → cachu-0.2.1}/tests/test_redis_cache.py +0 -0
- {cachu-0.1.3 → cachu-0.2.1}/tests/test_set_keys.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cachu
|
|
3
|
-
Version: 0.1
|
|
4
|
-
Summary: Flexible caching library
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Flexible caching library with sync and async support for memory, file (SQLite), and Redis backends
|
|
5
5
|
Author: bissli
|
|
6
6
|
License-Expression: 0BSD
|
|
7
7
|
Project-URL: Repository, https://github.com/bissli/cachu.git
|
|
@@ -9,13 +9,17 @@ Requires-Python: >=3.10
|
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
Requires-Dist: dogpile.cache
|
|
11
11
|
Requires-Dist: func-timeout
|
|
12
|
+
Provides-Extra: async
|
|
13
|
+
Requires-Dist: aiosqlite; extra == "async"
|
|
12
14
|
Provides-Extra: redis
|
|
13
|
-
Requires-Dist: redis; extra == "redis"
|
|
15
|
+
Requires-Dist: redis>=4.2.0; extra == "redis"
|
|
14
16
|
Provides-Extra: test
|
|
15
17
|
Requires-Dist: pytest; extra == "test"
|
|
18
|
+
Requires-Dist: pytest-asyncio; extra == "test"
|
|
16
19
|
Requires-Dist: pytest-mock; extra == "test"
|
|
17
|
-
Requires-Dist: redis; extra == "test"
|
|
20
|
+
Requires-Dist: redis>=4.2.0; extra == "test"
|
|
18
21
|
Requires-Dist: testcontainers[redis]; extra == "test"
|
|
22
|
+
Requires-Dist: aiosqlite; extra == "test"
|
|
19
23
|
|
|
20
24
|
# cachu
|
|
21
25
|
|
|
@@ -71,13 +75,13 @@ cachu.configure(
|
|
|
71
75
|
|
|
72
76
|
### Configuration Options
|
|
73
77
|
|
|
74
|
-
| Option
|
|
75
|
-
|
|
76
|
-
| `backend`
|
|
77
|
-
| `key_prefix`
|
|
78
|
-
| `file_dir`
|
|
79
|
-
| `redis_url`
|
|
80
|
-
| `redis_distributed` | `False`
|
|
78
|
+
| Option | Default | Description |
|
|
79
|
+
| ------------------- | ---------------------------- | ------------------------------------------------- |
|
|
80
|
+
| `backend` | `'memory'` | Default backend type |
|
|
81
|
+
| `key_prefix` | `''` | Prefix for all cache keys (useful for versioning) |
|
|
82
|
+
| `file_dir` | `'/tmp'` | Directory for file-based caches |
|
|
83
|
+
| `redis_url` | `'redis://localhost:6379/0'` | Redis connection URL |
|
|
84
|
+
| `redis_distributed` | `False` | Enable distributed locks for Redis |
|
|
81
85
|
|
|
82
86
|
### Package Isolation
|
|
83
87
|
|
|
@@ -300,12 +304,12 @@ cache_clear()
|
|
|
300
304
|
|
|
301
305
|
**Clearing behavior:**
|
|
302
306
|
|
|
303
|
-
| `ttl`
|
|
304
|
-
|
|
305
|
-
| `300`
|
|
306
|
-
| `300`
|
|
307
|
-
| `None` | `None`
|
|
308
|
-
| `None` | `'users'` | `None`
|
|
307
|
+
| `ttl` | `tag` | `backend` | Behavior |
|
|
308
|
+
| ------ | --------- | ---------- | -------------------------------------- |
|
|
309
|
+
| `300` | `None` | `'memory'` | All keys in 300s memory region |
|
|
310
|
+
| `300` | `'users'` | `'memory'` | Only "users" tag in 300s memory region |
|
|
311
|
+
| `None` | `None` | `'memory'` | All memory regions |
|
|
312
|
+
| `None` | `'users'` | `None` | "users" tag across all backends |
|
|
309
313
|
|
|
310
314
|
### Cross-Module Clearing
|
|
311
315
|
|
|
@@ -363,6 +367,38 @@ if cachu.is_disabled():
|
|
|
363
367
|
print("Caching is disabled")
|
|
364
368
|
```
|
|
365
369
|
|
|
370
|
+
## Async Support
|
|
371
|
+
|
|
372
|
+
The library provides full async/await support with matching APIs:
|
|
373
|
+
|
|
374
|
+
```python
|
|
375
|
+
from cachu import async_cache, async_cache_get, async_cache_set, async_cache_delete
|
|
376
|
+
from cachu import async_cache_clear, async_cache_info
|
|
377
|
+
|
|
378
|
+
@async_cache(ttl=300, backend='memory')
|
|
379
|
+
async def get_user(user_id: int) -> dict:
|
|
380
|
+
return await fetch_from_database(user_id)
|
|
381
|
+
|
|
382
|
+
# Usage
|
|
383
|
+
user = await get_user(123) # Cache miss
|
|
384
|
+
user = await get_user(123) # Cache hit
|
|
385
|
+
|
|
386
|
+
# Per-call control works the same way
|
|
387
|
+
user = await get_user(123, _skip_cache=True)
|
|
388
|
+
user = await get_user(123, _overwrite_cache=True)
|
|
389
|
+
|
|
390
|
+
# CRUD operations
|
|
391
|
+
cached = await async_cache_get(get_user, user_id=123)
|
|
392
|
+
await async_cache_set(get_user, {'id': 123, 'name': 'Test'}, user_id=123)
|
|
393
|
+
await async_cache_delete(get_user, user_id=123)
|
|
394
|
+
await async_cache_clear(backend='memory', ttl=300)
|
|
395
|
+
|
|
396
|
+
# Statistics
|
|
397
|
+
info = await async_cache_info(get_user)
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
All decorator options (`ttl`, `backend`, `tag`, `exclude`, `cache_if`, `validate`, `package`) work identically to the sync version.
|
|
401
|
+
|
|
366
402
|
## Advanced
|
|
367
403
|
|
|
368
404
|
### Direct Backend Access
|
|
@@ -397,25 +433,40 @@ from cachu import (
|
|
|
397
433
|
enable,
|
|
398
434
|
is_disabled,
|
|
399
435
|
|
|
400
|
-
# Decorator
|
|
436
|
+
# Sync Decorator
|
|
401
437
|
cache,
|
|
402
438
|
|
|
403
|
-
# CRUD Operations
|
|
439
|
+
# Sync CRUD Operations
|
|
404
440
|
cache_get,
|
|
405
441
|
cache_set,
|
|
406
442
|
cache_delete,
|
|
407
443
|
cache_clear,
|
|
408
444
|
cache_info,
|
|
409
445
|
|
|
446
|
+
# Async Decorator
|
|
447
|
+
async_cache,
|
|
448
|
+
|
|
449
|
+
# Async CRUD Operations
|
|
450
|
+
async_cache_get,
|
|
451
|
+
async_cache_set,
|
|
452
|
+
async_cache_delete,
|
|
453
|
+
async_cache_clear,
|
|
454
|
+
async_cache_info,
|
|
455
|
+
|
|
410
456
|
# Advanced
|
|
411
457
|
get_backend,
|
|
458
|
+
get_async_backend,
|
|
412
459
|
get_redis_client,
|
|
460
|
+
Backend,
|
|
461
|
+
AsyncBackend,
|
|
462
|
+
clear_async_backends,
|
|
413
463
|
)
|
|
414
464
|
```
|
|
415
465
|
|
|
416
466
|
## Features
|
|
417
467
|
|
|
418
|
-
- **Multiple backends**: Memory, file (
|
|
468
|
+
- **Multiple backends**: Memory, file (SQLite), and Redis
|
|
469
|
+
- **Async support**: Full async/await API with `@async_cache` decorator
|
|
419
470
|
- **Flexible TTL**: Configure different TTLs for different use cases
|
|
420
471
|
- **Tags**: Organize and selectively clear cache entries
|
|
421
472
|
- **Package isolation**: Each package gets isolated configuration
|
|
@@ -1,22 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: cachu
|
|
3
|
-
Version: 0.1.3
|
|
4
|
-
Summary: Flexible caching library built on dogpile.cache
|
|
5
|
-
Author: bissli
|
|
6
|
-
License-Expression: 0BSD
|
|
7
|
-
Project-URL: Repository, https://github.com/bissli/cachu.git
|
|
8
|
-
Requires-Python: >=3.10
|
|
9
|
-
Description-Content-Type: text/markdown
|
|
10
|
-
Requires-Dist: dogpile.cache
|
|
11
|
-
Requires-Dist: func-timeout
|
|
12
|
-
Provides-Extra: redis
|
|
13
|
-
Requires-Dist: redis; extra == "redis"
|
|
14
|
-
Provides-Extra: test
|
|
15
|
-
Requires-Dist: pytest; extra == "test"
|
|
16
|
-
Requires-Dist: pytest-mock; extra == "test"
|
|
17
|
-
Requires-Dist: redis; extra == "test"
|
|
18
|
-
Requires-Dist: testcontainers[redis]; extra == "test"
|
|
19
|
-
|
|
20
1
|
# cachu
|
|
21
2
|
|
|
22
3
|
Flexible caching library with support for memory, file, and Redis backends.
|
|
@@ -71,13 +52,13 @@ cachu.configure(
|
|
|
71
52
|
|
|
72
53
|
### Configuration Options
|
|
73
54
|
|
|
74
|
-
| Option
|
|
75
|
-
|
|
76
|
-
| `backend`
|
|
77
|
-
| `key_prefix`
|
|
78
|
-
| `file_dir`
|
|
79
|
-
| `redis_url`
|
|
80
|
-
| `redis_distributed` | `False`
|
|
55
|
+
| Option | Default | Description |
|
|
56
|
+
| ------------------- | ---------------------------- | ------------------------------------------------- |
|
|
57
|
+
| `backend` | `'memory'` | Default backend type |
|
|
58
|
+
| `key_prefix` | `''` | Prefix for all cache keys (useful for versioning) |
|
|
59
|
+
| `file_dir` | `'/tmp'` | Directory for file-based caches |
|
|
60
|
+
| `redis_url` | `'redis://localhost:6379/0'` | Redis connection URL |
|
|
61
|
+
| `redis_distributed` | `False` | Enable distributed locks for Redis |
|
|
81
62
|
|
|
82
63
|
### Package Isolation
|
|
83
64
|
|
|
@@ -300,12 +281,12 @@ cache_clear()
|
|
|
300
281
|
|
|
301
282
|
**Clearing behavior:**
|
|
302
283
|
|
|
303
|
-
| `ttl`
|
|
304
|
-
|
|
305
|
-
| `300`
|
|
306
|
-
| `300`
|
|
307
|
-
| `None` | `None`
|
|
308
|
-
| `None` | `'users'` | `None`
|
|
284
|
+
| `ttl` | `tag` | `backend` | Behavior |
|
|
285
|
+
| ------ | --------- | ---------- | -------------------------------------- |
|
|
286
|
+
| `300` | `None` | `'memory'` | All keys in 300s memory region |
|
|
287
|
+
| `300` | `'users'` | `'memory'` | Only "users" tag in 300s memory region |
|
|
288
|
+
| `None` | `None` | `'memory'` | All memory regions |
|
|
289
|
+
| `None` | `'users'` | `None` | "users" tag across all backends |
|
|
309
290
|
|
|
310
291
|
### Cross-Module Clearing
|
|
311
292
|
|
|
@@ -363,6 +344,38 @@ if cachu.is_disabled():
|
|
|
363
344
|
print("Caching is disabled")
|
|
364
345
|
```
|
|
365
346
|
|
|
347
|
+
## Async Support
|
|
348
|
+
|
|
349
|
+
The library provides full async/await support with matching APIs:
|
|
350
|
+
|
|
351
|
+
```python
|
|
352
|
+
from cachu import async_cache, async_cache_get, async_cache_set, async_cache_delete
|
|
353
|
+
from cachu import async_cache_clear, async_cache_info
|
|
354
|
+
|
|
355
|
+
@async_cache(ttl=300, backend='memory')
|
|
356
|
+
async def get_user(user_id: int) -> dict:
|
|
357
|
+
return await fetch_from_database(user_id)
|
|
358
|
+
|
|
359
|
+
# Usage
|
|
360
|
+
user = await get_user(123) # Cache miss
|
|
361
|
+
user = await get_user(123) # Cache hit
|
|
362
|
+
|
|
363
|
+
# Per-call control works the same way
|
|
364
|
+
user = await get_user(123, _skip_cache=True)
|
|
365
|
+
user = await get_user(123, _overwrite_cache=True)
|
|
366
|
+
|
|
367
|
+
# CRUD operations
|
|
368
|
+
cached = await async_cache_get(get_user, user_id=123)
|
|
369
|
+
await async_cache_set(get_user, {'id': 123, 'name': 'Test'}, user_id=123)
|
|
370
|
+
await async_cache_delete(get_user, user_id=123)
|
|
371
|
+
await async_cache_clear(backend='memory', ttl=300)
|
|
372
|
+
|
|
373
|
+
# Statistics
|
|
374
|
+
info = await async_cache_info(get_user)
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
All decorator options (`ttl`, `backend`, `tag`, `exclude`, `cache_if`, `validate`, `package`) work identically to the sync version.
|
|
378
|
+
|
|
366
379
|
## Advanced
|
|
367
380
|
|
|
368
381
|
### Direct Backend Access
|
|
@@ -397,25 +410,40 @@ from cachu import (
|
|
|
397
410
|
enable,
|
|
398
411
|
is_disabled,
|
|
399
412
|
|
|
400
|
-
# Decorator
|
|
413
|
+
# Sync Decorator
|
|
401
414
|
cache,
|
|
402
415
|
|
|
403
|
-
# CRUD Operations
|
|
416
|
+
# Sync CRUD Operations
|
|
404
417
|
cache_get,
|
|
405
418
|
cache_set,
|
|
406
419
|
cache_delete,
|
|
407
420
|
cache_clear,
|
|
408
421
|
cache_info,
|
|
409
422
|
|
|
423
|
+
# Async Decorator
|
|
424
|
+
async_cache,
|
|
425
|
+
|
|
426
|
+
# Async CRUD Operations
|
|
427
|
+
async_cache_get,
|
|
428
|
+
async_cache_set,
|
|
429
|
+
async_cache_delete,
|
|
430
|
+
async_cache_clear,
|
|
431
|
+
async_cache_info,
|
|
432
|
+
|
|
410
433
|
# Advanced
|
|
411
434
|
get_backend,
|
|
435
|
+
get_async_backend,
|
|
412
436
|
get_redis_client,
|
|
437
|
+
Backend,
|
|
438
|
+
AsyncBackend,
|
|
439
|
+
clear_async_backends,
|
|
413
440
|
)
|
|
414
441
|
```
|
|
415
442
|
|
|
416
443
|
## Features
|
|
417
444
|
|
|
418
|
-
- **Multiple backends**: Memory, file (
|
|
445
|
+
- **Multiple backends**: Memory, file (SQLite), and Redis
|
|
446
|
+
- **Async support**: Full async/await API with `@async_cache` decorator
|
|
419
447
|
- **Flexible TTL**: Configure different TTLs for different use cases
|
|
420
448
|
- **Tags**: Organize and selectively clear cache entries
|
|
421
449
|
- **Package isolation**: Each package gets isolated configuration
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "cachu"
|
|
3
|
-
version = "0.1
|
|
4
|
-
description = "Flexible caching library
|
|
3
|
+
version = "0.2.1"
|
|
4
|
+
description = "Flexible caching library with sync and async support for memory, file (SQLite), and Redis backends"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "0BSD"
|
|
7
7
|
authors = [{ name = "bissli" }]
|
|
@@ -12,12 +12,15 @@ dependencies = [
|
|
|
12
12
|
]
|
|
13
13
|
|
|
14
14
|
[project.optional-dependencies]
|
|
15
|
-
|
|
15
|
+
async = ["aiosqlite"]
|
|
16
|
+
redis = ["redis>=4.2.0"]
|
|
16
17
|
test = [
|
|
17
18
|
"pytest",
|
|
19
|
+
"pytest-asyncio",
|
|
18
20
|
"pytest-mock",
|
|
19
|
-
"redis",
|
|
21
|
+
"redis>=4.2.0",
|
|
20
22
|
"testcontainers[redis]",
|
|
23
|
+
"aiosqlite",
|
|
21
24
|
]
|
|
22
25
|
|
|
23
26
|
[project.urls]
|
|
@@ -31,6 +34,7 @@ testpaths = ["tests"]
|
|
|
31
34
|
python_files = ["test_*.py"]
|
|
32
35
|
python_classes = ["Test*"]
|
|
33
36
|
python_functions = ["test_*"]
|
|
37
|
+
asyncio_mode = "auto"
|
|
34
38
|
markers = [
|
|
35
39
|
"redis: marks tests requiring Redis (deselect with '-m \"not redis\"')",
|
|
36
40
|
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
"""Flexible caching library with support for memory, file, and Redis backends.
|
|
2
2
|
"""
|
|
3
|
-
__version__ = '0.1
|
|
3
|
+
__version__ = '0.2.1'
|
|
4
4
|
|
|
5
|
+
from .async_decorator import async_cache, clear_async_backends
|
|
6
|
+
from .async_decorator import get_async_backend, get_async_cache_info
|
|
7
|
+
from .async_operations import async_cache_clear, async_cache_delete
|
|
8
|
+
from .async_operations import async_cache_get, async_cache_info
|
|
9
|
+
from .async_operations import async_cache_set
|
|
10
|
+
from .backends import AsyncBackend, Backend
|
|
5
11
|
from .backends.redis import get_redis_client
|
|
6
12
|
from .config import configure, disable, enable, get_all_configs, get_config
|
|
7
13
|
from .config import is_disabled
|
|
@@ -24,4 +30,15 @@ __all__ = [
|
|
|
24
30
|
'cache_info',
|
|
25
31
|
'get_backend',
|
|
26
32
|
'get_redis_client',
|
|
33
|
+
'Backend',
|
|
34
|
+
'AsyncBackend',
|
|
35
|
+
'async_cache',
|
|
36
|
+
'async_cache_get',
|
|
37
|
+
'async_cache_set',
|
|
38
|
+
'async_cache_delete',
|
|
39
|
+
'async_cache_clear',
|
|
40
|
+
'async_cache_info',
|
|
41
|
+
'get_async_backend',
|
|
42
|
+
'get_async_cache_info',
|
|
43
|
+
'clear_async_backends',
|
|
27
44
|
]
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Async cache decorator implementation.
|
|
2
|
+
"""
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from functools import wraps
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .backends import NO_VALUE
|
|
12
|
+
from .backends.async_base import AsyncBackend
|
|
13
|
+
from .backends.async_memory import AsyncMemoryBackend
|
|
14
|
+
from .config import _get_caller_package, get_config, is_disabled
|
|
15
|
+
from .keys import make_key_generator, mangle_key
|
|
16
|
+
from .types import CacheEntry, CacheInfo, CacheMeta
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_async_backends: dict[tuple[str | None, str, int], AsyncBackend] = {}
|
|
21
|
+
_async_backends_lock = asyncio.Lock()
|
|
22
|
+
|
|
23
|
+
_async_stats: dict[int, tuple[int, int]] = {}
|
|
24
|
+
_async_stats_lock = asyncio.Lock()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def _get_async_backend(package: str | None, backend_type: str, ttl: int) -> AsyncBackend:
|
|
28
|
+
"""Get or create an async backend instance.
|
|
29
|
+
"""
|
|
30
|
+
key = (package, backend_type, ttl)
|
|
31
|
+
|
|
32
|
+
async with _async_backends_lock:
|
|
33
|
+
if key in _async_backends:
|
|
34
|
+
return _async_backends[key]
|
|
35
|
+
|
|
36
|
+
cfg = get_config(package)
|
|
37
|
+
|
|
38
|
+
if backend_type == 'memory':
|
|
39
|
+
backend: AsyncBackend = AsyncMemoryBackend()
|
|
40
|
+
elif backend_type == 'file':
|
|
41
|
+
from .backends.async_sqlite import AsyncSqliteBackend
|
|
42
|
+
|
|
43
|
+
if ttl < 60:
|
|
44
|
+
filename = f'cache{ttl}sec.db'
|
|
45
|
+
elif ttl < 3600:
|
|
46
|
+
filename = f'cache{ttl // 60}min.db'
|
|
47
|
+
else:
|
|
48
|
+
filename = f'cache{ttl // 3600}hour.db'
|
|
49
|
+
|
|
50
|
+
if package:
|
|
51
|
+
filename = f'{package}_{filename}'
|
|
52
|
+
|
|
53
|
+
filepath = os.path.join(cfg.file_dir, filename)
|
|
54
|
+
backend = AsyncSqliteBackend(filepath)
|
|
55
|
+
elif backend_type == 'redis':
|
|
56
|
+
from .backends.async_redis import AsyncRedisBackend
|
|
57
|
+
backend = AsyncRedisBackend(cfg.redis_url, cfg.redis_distributed)
|
|
58
|
+
else:
|
|
59
|
+
raise ValueError(f'Unknown backend type: {backend_type}')
|
|
60
|
+
|
|
61
|
+
_async_backends[key] = backend
|
|
62
|
+
logger.debug(f"Created async {backend_type} backend for package '{package}', {ttl}s TTL")
|
|
63
|
+
return backend
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def get_async_backend(
|
|
67
|
+
backend_type: str | None = None,
|
|
68
|
+
package: str | None = None,
|
|
69
|
+
*,
|
|
70
|
+
ttl: int,
|
|
71
|
+
) -> AsyncBackend:
|
|
72
|
+
"""Get an async backend instance.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
backend_type: 'memory', 'file', or 'redis'. Uses config default if None.
|
|
76
|
+
package: Package name. Auto-detected if None.
|
|
77
|
+
ttl: TTL in seconds (used for backend separation).
|
|
78
|
+
"""
|
|
79
|
+
if package is None:
|
|
80
|
+
package = _get_caller_package()
|
|
81
|
+
|
|
82
|
+
if backend_type is None:
|
|
83
|
+
cfg = get_config(package)
|
|
84
|
+
backend_type = cfg.backend
|
|
85
|
+
|
|
86
|
+
return await _get_async_backend(package, backend_type, ttl)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def async_cache(
|
|
90
|
+
ttl: int = 300,
|
|
91
|
+
backend: str | None = None,
|
|
92
|
+
tag: str = '',
|
|
93
|
+
exclude: set[str] | None = None,
|
|
94
|
+
cache_if: Callable[[Any], bool] | None = None,
|
|
95
|
+
validate: Callable[[CacheEntry], bool] | None = None,
|
|
96
|
+
package: str | None = None,
|
|
97
|
+
) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]:
|
|
98
|
+
"""Async cache decorator with configurable backend and behavior.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
ttl: Time-to-live in seconds (default: 300)
|
|
102
|
+
backend: Backend type ('memory', 'file', 'redis'). Uses config default if None.
|
|
103
|
+
tag: Tag for grouping related cache entries
|
|
104
|
+
exclude: Parameter names to exclude from cache key
|
|
105
|
+
cache_if: Function to determine if result should be cached.
|
|
106
|
+
Called with result value, caches if returns True.
|
|
107
|
+
validate: Function to validate cached entries before returning.
|
|
108
|
+
Called with CacheEntry, returns False to recompute.
|
|
109
|
+
package: Package name for config isolation. Auto-detected if None.
|
|
110
|
+
|
|
111
|
+
Per-call control via reserved kwargs (not passed to function):
|
|
112
|
+
_skip_cache: If True, bypass cache completely for this call
|
|
113
|
+
_overwrite_cache: If True, execute function and overwrite cached value
|
|
114
|
+
|
|
115
|
+
Example:
|
|
116
|
+
@async_cache(ttl=300, tag='users')
|
|
117
|
+
async def get_user(user_id: int) -> dict:
|
|
118
|
+
return await fetch_user(user_id)
|
|
119
|
+
|
|
120
|
+
# Normal call
|
|
121
|
+
user = await get_user(123)
|
|
122
|
+
|
|
123
|
+
# Skip cache
|
|
124
|
+
user = await get_user(123, _skip_cache=True)
|
|
125
|
+
|
|
126
|
+
# Force refresh
|
|
127
|
+
user = await get_user(123, _overwrite_cache=True)
|
|
128
|
+
"""
|
|
129
|
+
resolved_package = package if package is not None else _get_caller_package()
|
|
130
|
+
|
|
131
|
+
if backend is None:
|
|
132
|
+
cfg = get_config(resolved_package)
|
|
133
|
+
resolved_backend = cfg.backend
|
|
134
|
+
else:
|
|
135
|
+
resolved_backend = backend
|
|
136
|
+
|
|
137
|
+
def decorator(fn: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
|
|
138
|
+
key_generator = make_key_generator(fn, tag, exclude)
|
|
139
|
+
|
|
140
|
+
meta = CacheMeta(
|
|
141
|
+
ttl=ttl,
|
|
142
|
+
backend=resolved_backend,
|
|
143
|
+
tag=tag,
|
|
144
|
+
exclude=exclude or set(),
|
|
145
|
+
cache_if=cache_if,
|
|
146
|
+
validate=validate,
|
|
147
|
+
package=resolved_package,
|
|
148
|
+
key_generator=key_generator,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
@wraps(fn)
|
|
152
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
153
|
+
skip_cache = kwargs.pop('_skip_cache', False)
|
|
154
|
+
overwrite_cache = kwargs.pop('_overwrite_cache', False)
|
|
155
|
+
|
|
156
|
+
if is_disabled() or skip_cache:
|
|
157
|
+
return await fn(*args, **kwargs)
|
|
158
|
+
|
|
159
|
+
backend_instance = await _get_async_backend(resolved_package, resolved_backend, ttl)
|
|
160
|
+
cfg = get_config(resolved_package)
|
|
161
|
+
|
|
162
|
+
base_key = key_generator(*args, **kwargs)
|
|
163
|
+
cache_key = mangle_key(base_key, cfg.key_prefix, ttl)
|
|
164
|
+
|
|
165
|
+
if not overwrite_cache:
|
|
166
|
+
value, created_at = await backend_instance.get_with_metadata(cache_key)
|
|
167
|
+
|
|
168
|
+
if value is not NO_VALUE:
|
|
169
|
+
if validate is not None and created_at is not None:
|
|
170
|
+
entry = CacheEntry(
|
|
171
|
+
value=value,
|
|
172
|
+
created_at=created_at,
|
|
173
|
+
age=time.time() - created_at,
|
|
174
|
+
)
|
|
175
|
+
if not validate(entry):
|
|
176
|
+
logger.debug(f'Cache validation failed for {fn.__name__}')
|
|
177
|
+
else:
|
|
178
|
+
await _record_async_hit(wrapper)
|
|
179
|
+
return value
|
|
180
|
+
else:
|
|
181
|
+
await _record_async_hit(wrapper)
|
|
182
|
+
return value
|
|
183
|
+
|
|
184
|
+
await _record_async_miss(wrapper)
|
|
185
|
+
result = await fn(*args, **kwargs)
|
|
186
|
+
|
|
187
|
+
should_cache = cache_if is None or cache_if(result)
|
|
188
|
+
|
|
189
|
+
if should_cache:
|
|
190
|
+
await backend_instance.set(cache_key, result, ttl)
|
|
191
|
+
logger.debug(f'Cached {fn.__name__} with key {cache_key}')
|
|
192
|
+
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
wrapper._cache_meta = meta # type: ignore
|
|
196
|
+
wrapper._cache_key_generator = key_generator # type: ignore
|
|
197
|
+
|
|
198
|
+
return wrapper
|
|
199
|
+
|
|
200
|
+
return decorator
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def _record_async_hit(fn: Callable[..., Any]) -> None:
|
|
204
|
+
"""Record a cache hit for the async function.
|
|
205
|
+
"""
|
|
206
|
+
fn_id = id(fn)
|
|
207
|
+
async with _async_stats_lock:
|
|
208
|
+
hits, misses = _async_stats.get(fn_id, (0, 0))
|
|
209
|
+
_async_stats[fn_id] = (hits + 1, misses)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def _record_async_miss(fn: Callable[..., Any]) -> None:
|
|
213
|
+
"""Record a cache miss for the async function.
|
|
214
|
+
"""
|
|
215
|
+
fn_id = id(fn)
|
|
216
|
+
async with _async_stats_lock:
|
|
217
|
+
hits, misses = _async_stats.get(fn_id, (0, 0))
|
|
218
|
+
_async_stats[fn_id] = (hits, misses + 1)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def get_async_cache_info(fn: Callable[..., Any]) -> CacheInfo:
|
|
222
|
+
"""Get cache statistics for an async decorated function.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
fn: A function decorated with @async_cache
|
|
226
|
+
|
|
227
|
+
Returns
|
|
228
|
+
CacheInfo with hits, misses, and currsize
|
|
229
|
+
"""
|
|
230
|
+
fn_id = id(fn)
|
|
231
|
+
|
|
232
|
+
async with _async_stats_lock:
|
|
233
|
+
hits, misses = _async_stats.get(fn_id, (0, 0))
|
|
234
|
+
|
|
235
|
+
meta = getattr(fn, '_cache_meta', None)
|
|
236
|
+
if meta is None:
|
|
237
|
+
return CacheInfo(hits=hits, misses=misses, currsize=0)
|
|
238
|
+
|
|
239
|
+
backend_instance = await _get_async_backend(meta.package, meta.backend, meta.ttl)
|
|
240
|
+
cfg = get_config(meta.package)
|
|
241
|
+
|
|
242
|
+
fn_name = getattr(fn, '__wrapped__', fn).__name__
|
|
243
|
+
pattern = f'*:{cfg.key_prefix}{fn_name}|*'
|
|
244
|
+
|
|
245
|
+
currsize = await backend_instance.count(pattern)
|
|
246
|
+
|
|
247
|
+
return CacheInfo(hits=hits, misses=misses, currsize=currsize)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
async def clear_async_backends(package: str | None = None) -> None:
|
|
251
|
+
"""Clear all async backend instances for a package. Primarily for testing.
|
|
252
|
+
"""
|
|
253
|
+
async with _async_backends_lock:
|
|
254
|
+
if package is None:
|
|
255
|
+
for backend in _async_backends.values():
|
|
256
|
+
await backend.close()
|
|
257
|
+
_async_backends.clear()
|
|
258
|
+
else:
|
|
259
|
+
keys_to_delete = [k for k in _async_backends if k[0] == package]
|
|
260
|
+
for key in keys_to_delete:
|
|
261
|
+
await _async_backends[key].close()
|
|
262
|
+
del _async_backends[key]
|