cachu 0.1.2__tar.gz → 0.2.0__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.2/src/cachu.egg-info → cachu-0.2.0}/PKG-INFO +84 -22
- cachu-0.1.2/PKG-INFO → cachu-0.2.0/README.md +76 -37
- {cachu-0.1.2 → cachu-0.2.0}/pyproject.toml +8 -4
- {cachu-0.1.2 → cachu-0.2.0}/setup.cfg +1 -1
- {cachu-0.1.2 → cachu-0.2.0}/src/cachu/__init__.py +18 -1
- cachu-0.2.0/src/cachu/async_decorator.py +261 -0
- cachu-0.2.0/src/cachu/async_operations.py +178 -0
- {cachu-0.1.2 → cachu-0.2.0}/src/cachu/backends/__init__.py +6 -1
- cachu-0.2.0/src/cachu/backends/async_base.py +50 -0
- cachu-0.2.0/src/cachu/backends/async_memory.py +111 -0
- cachu-0.2.0/src/cachu/backends/async_redis.py +141 -0
- cachu-0.2.0/src/cachu/backends/async_sqlite.py +244 -0
- cachu-0.2.0/src/cachu/backends/file.py +10 -0
- cachu-0.2.0/src/cachu/backends/sqlite.py +240 -0
- {cachu-0.1.2 → cachu-0.2.0}/src/cachu/decorator.py +4 -9
- {cachu-0.1.2 → cachu-0.2.0}/src/cachu/operations.py +24 -16
- cachu-0.1.2/README.md → cachu-0.2.0/src/cachu.egg-info/PKG-INFO +99 -18
- {cachu-0.1.2 → cachu-0.2.0}/src/cachu.egg-info/SOURCES.txt +12 -1
- {cachu-0.1.2 → cachu-0.2.0}/src/cachu.egg-info/requires.txt +7 -2
- cachu-0.2.0/tests/test_async_memory.py +157 -0
- cachu-0.2.0/tests/test_async_redis.py +146 -0
- cachu-0.2.0/tests/test_async_sqlite.py +164 -0
- {cachu-0.1.2 → cachu-0.2.0}/tests/test_clearing.py +61 -0
- cachu-0.2.0/tests/test_sqlite_backend.py +163 -0
- cachu-0.1.2/src/cachu/backends/file.py +0 -158
- {cachu-0.1.2 → cachu-0.2.0}/src/cachu/backends/memory.py +0 -0
- {cachu-0.1.2 → cachu-0.2.0}/src/cachu/backends/redis.py +0 -0
- {cachu-0.1.2 → cachu-0.2.0}/src/cachu/config.py +0 -0
- {cachu-0.1.2 → cachu-0.2.0}/src/cachu/keys.py +0 -0
- {cachu-0.1.2 → cachu-0.2.0}/src/cachu/types.py +0 -0
- {cachu-0.1.2 → cachu-0.2.0}/src/cachu.egg-info/dependency_links.txt +0 -0
- {cachu-0.1.2 → cachu-0.2.0}/src/cachu.egg-info/top_level.txt +0 -0
- {cachu-0.1.2 → cachu-0.2.0}/tests/test_config.py +0 -0
- {cachu-0.1.2 → cachu-0.2.0}/tests/test_defaultcache.py +0 -0
- {cachu-0.1.2 → cachu-0.2.0}/tests/test_delete_keys.py +0 -0
- {cachu-0.1.2 → cachu-0.2.0}/tests/test_disable.py +0 -0
- {cachu-0.1.2 → cachu-0.2.0}/tests/test_exclude_params.py +0 -0
- {cachu-0.1.2 → cachu-0.2.0}/tests/test_file_cache.py +0 -0
- {cachu-0.1.2 → cachu-0.2.0}/tests/test_integration.py +0 -0
- {cachu-0.1.2 → cachu-0.2.0}/tests/test_memory_cache.py +0 -0
- {cachu-0.1.2 → cachu-0.2.0}/tests/test_namespace.py +0 -0
- {cachu-0.1.2 → cachu-0.2.0}/tests/test_namespace_isolation.py +0 -0
- {cachu-0.1.2 → cachu-0.2.0}/tests/test_redis_cache.py +0 -0
- {cachu-0.1.2 → cachu-0.2.0}/tests/test_set_keys.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cachu
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Flexible caching library
|
|
3
|
+
Version: 0.2.0
|
|
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,17 +75,17 @@ 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
|
|
|
84
|
-
Each package automatically gets isolated configuration
|
|
88
|
+
Each package automatically gets isolated configuration, preventing conflicts when multiple libraries use cachu:
|
|
85
89
|
|
|
86
90
|
```python
|
|
87
91
|
# In library_a/config.py
|
|
@@ -92,7 +96,18 @@ cachu.configure(key_prefix='lib_a:', redis_url='redis://redis-a:6379/0')
|
|
|
92
96
|
import cachu
|
|
93
97
|
cachu.configure(key_prefix='lib_b:', redis_url='redis://redis-b:6379/0')
|
|
94
98
|
|
|
95
|
-
# Each library
|
|
99
|
+
# Each library's @cache calls use its own configuration automatically
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
To override the automatic detection, specify the `package` parameter:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from cachu import cache
|
|
106
|
+
|
|
107
|
+
# This function will use library_a's configuration
|
|
108
|
+
@cache(ttl=300, package='library_a')
|
|
109
|
+
def get_shared_data(id: int) -> dict:
|
|
110
|
+
return fetch(id)
|
|
96
111
|
```
|
|
97
112
|
|
|
98
113
|
Retrieve configuration:
|
|
@@ -289,12 +304,12 @@ cache_clear()
|
|
|
289
304
|
|
|
290
305
|
**Clearing behavior:**
|
|
291
306
|
|
|
292
|
-
| `ttl`
|
|
293
|
-
|
|
294
|
-
| `300`
|
|
295
|
-
| `300`
|
|
296
|
-
| `None` | `None`
|
|
297
|
-
| `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 |
|
|
298
313
|
|
|
299
314
|
### Cross-Module Clearing
|
|
300
315
|
|
|
@@ -352,6 +367,38 @@ if cachu.is_disabled():
|
|
|
352
367
|
print("Caching is disabled")
|
|
353
368
|
```
|
|
354
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
|
+
|
|
355
402
|
## Advanced
|
|
356
403
|
|
|
357
404
|
### Direct Backend Access
|
|
@@ -386,25 +433,40 @@ from cachu import (
|
|
|
386
433
|
enable,
|
|
387
434
|
is_disabled,
|
|
388
435
|
|
|
389
|
-
# Decorator
|
|
436
|
+
# Sync Decorator
|
|
390
437
|
cache,
|
|
391
438
|
|
|
392
|
-
# CRUD Operations
|
|
439
|
+
# Sync CRUD Operations
|
|
393
440
|
cache_get,
|
|
394
441
|
cache_set,
|
|
395
442
|
cache_delete,
|
|
396
443
|
cache_clear,
|
|
397
444
|
cache_info,
|
|
398
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
|
+
|
|
399
456
|
# Advanced
|
|
400
457
|
get_backend,
|
|
458
|
+
get_async_backend,
|
|
401
459
|
get_redis_client,
|
|
460
|
+
Backend,
|
|
461
|
+
AsyncBackend,
|
|
462
|
+
clear_async_backends,
|
|
402
463
|
)
|
|
403
464
|
```
|
|
404
465
|
|
|
405
466
|
## Features
|
|
406
467
|
|
|
407
|
-
- **Multiple backends**: Memory, file (
|
|
468
|
+
- **Multiple backends**: Memory, file (SQLite), and Redis
|
|
469
|
+
- **Async support**: Full async/await API with `@async_cache` decorator
|
|
408
470
|
- **Flexible TTL**: Configure different TTLs for different use cases
|
|
409
471
|
- **Tags**: Organize and selectively clear cache entries
|
|
410
472
|
- **Package isolation**: Each package gets isolated configuration
|
|
@@ -1,22 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: cachu
|
|
3
|
-
Version: 0.1.2
|
|
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,17 +52,17 @@ 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
|
|
|
84
|
-
Each package automatically gets isolated configuration
|
|
65
|
+
Each package automatically gets isolated configuration, preventing conflicts when multiple libraries use cachu:
|
|
85
66
|
|
|
86
67
|
```python
|
|
87
68
|
# In library_a/config.py
|
|
@@ -92,7 +73,18 @@ cachu.configure(key_prefix='lib_a:', redis_url='redis://redis-a:6379/0')
|
|
|
92
73
|
import cachu
|
|
93
74
|
cachu.configure(key_prefix='lib_b:', redis_url='redis://redis-b:6379/0')
|
|
94
75
|
|
|
95
|
-
# Each library
|
|
76
|
+
# Each library's @cache calls use its own configuration automatically
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
To override the automatic detection, specify the `package` parameter:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from cachu import cache
|
|
83
|
+
|
|
84
|
+
# This function will use library_a's configuration
|
|
85
|
+
@cache(ttl=300, package='library_a')
|
|
86
|
+
def get_shared_data(id: int) -> dict:
|
|
87
|
+
return fetch(id)
|
|
96
88
|
```
|
|
97
89
|
|
|
98
90
|
Retrieve configuration:
|
|
@@ -289,12 +281,12 @@ cache_clear()
|
|
|
289
281
|
|
|
290
282
|
**Clearing behavior:**
|
|
291
283
|
|
|
292
|
-
| `ttl`
|
|
293
|
-
|
|
294
|
-
| `300`
|
|
295
|
-
| `300`
|
|
296
|
-
| `None` | `None`
|
|
297
|
-
| `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 |
|
|
298
290
|
|
|
299
291
|
### Cross-Module Clearing
|
|
300
292
|
|
|
@@ -352,6 +344,38 @@ if cachu.is_disabled():
|
|
|
352
344
|
print("Caching is disabled")
|
|
353
345
|
```
|
|
354
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
|
+
|
|
355
379
|
## Advanced
|
|
356
380
|
|
|
357
381
|
### Direct Backend Access
|
|
@@ -386,25 +410,40 @@ from cachu import (
|
|
|
386
410
|
enable,
|
|
387
411
|
is_disabled,
|
|
388
412
|
|
|
389
|
-
# Decorator
|
|
413
|
+
# Sync Decorator
|
|
390
414
|
cache,
|
|
391
415
|
|
|
392
|
-
# CRUD Operations
|
|
416
|
+
# Sync CRUD Operations
|
|
393
417
|
cache_get,
|
|
394
418
|
cache_set,
|
|
395
419
|
cache_delete,
|
|
396
420
|
cache_clear,
|
|
397
421
|
cache_info,
|
|
398
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
|
+
|
|
399
433
|
# Advanced
|
|
400
434
|
get_backend,
|
|
435
|
+
get_async_backend,
|
|
401
436
|
get_redis_client,
|
|
437
|
+
Backend,
|
|
438
|
+
AsyncBackend,
|
|
439
|
+
clear_async_backends,
|
|
402
440
|
)
|
|
403
441
|
```
|
|
404
442
|
|
|
405
443
|
## Features
|
|
406
444
|
|
|
407
|
-
- **Multiple backends**: Memory, file (
|
|
445
|
+
- **Multiple backends**: Memory, file (SQLite), and Redis
|
|
446
|
+
- **Async support**: Full async/await API with `@async_cache` decorator
|
|
408
447
|
- **Flexible TTL**: Configure different TTLs for different use cases
|
|
409
448
|
- **Tags**: Organize and selectively clear cache entries
|
|
410
449
|
- **Package isolation**: Each package gets isolated configuration
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "cachu"
|
|
3
|
-
version = "0.
|
|
4
|
-
description = "Flexible caching library
|
|
3
|
+
version = "0.2.0"
|
|
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.
|
|
3
|
+
__version__ = '0.2.0'
|
|
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,261 @@
|
|
|
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
|
+
ttl: int = 300,
|
|
70
|
+
) -> AsyncBackend:
|
|
71
|
+
"""Get an async backend instance.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
backend_type: 'memory', 'file', or 'redis'. Uses config default if None.
|
|
75
|
+
package: Package name. Auto-detected if None.
|
|
76
|
+
ttl: TTL in seconds (used for backend separation).
|
|
77
|
+
"""
|
|
78
|
+
if package is None:
|
|
79
|
+
package = _get_caller_package()
|
|
80
|
+
|
|
81
|
+
if backend_type is None:
|
|
82
|
+
cfg = get_config(package)
|
|
83
|
+
backend_type = cfg.backend
|
|
84
|
+
|
|
85
|
+
return await _get_async_backend(package, backend_type, ttl)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def async_cache(
|
|
89
|
+
ttl: int = 300,
|
|
90
|
+
backend: str | None = None,
|
|
91
|
+
tag: str = '',
|
|
92
|
+
exclude: set[str] | None = None,
|
|
93
|
+
cache_if: Callable[[Any], bool] | None = None,
|
|
94
|
+
validate: Callable[[CacheEntry], bool] | None = None,
|
|
95
|
+
package: str | None = None,
|
|
96
|
+
) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]:
|
|
97
|
+
"""Async cache decorator with configurable backend and behavior.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
ttl: Time-to-live in seconds (default: 300)
|
|
101
|
+
backend: Backend type ('memory', 'file', 'redis'). Uses config default if None.
|
|
102
|
+
tag: Tag for grouping related cache entries
|
|
103
|
+
exclude: Parameter names to exclude from cache key
|
|
104
|
+
cache_if: Function to determine if result should be cached.
|
|
105
|
+
Called with result value, caches if returns True.
|
|
106
|
+
validate: Function to validate cached entries before returning.
|
|
107
|
+
Called with CacheEntry, returns False to recompute.
|
|
108
|
+
package: Package name for config isolation. Auto-detected if None.
|
|
109
|
+
|
|
110
|
+
Per-call control via reserved kwargs (not passed to function):
|
|
111
|
+
_skip_cache: If True, bypass cache completely for this call
|
|
112
|
+
_overwrite_cache: If True, execute function and overwrite cached value
|
|
113
|
+
|
|
114
|
+
Example:
|
|
115
|
+
@async_cache(ttl=300, tag='users')
|
|
116
|
+
async def get_user(user_id: int) -> dict:
|
|
117
|
+
return await fetch_user(user_id)
|
|
118
|
+
|
|
119
|
+
# Normal call
|
|
120
|
+
user = await get_user(123)
|
|
121
|
+
|
|
122
|
+
# Skip cache
|
|
123
|
+
user = await get_user(123, _skip_cache=True)
|
|
124
|
+
|
|
125
|
+
# Force refresh
|
|
126
|
+
user = await get_user(123, _overwrite_cache=True)
|
|
127
|
+
"""
|
|
128
|
+
resolved_package = package if package is not None else _get_caller_package()
|
|
129
|
+
|
|
130
|
+
if backend is None:
|
|
131
|
+
cfg = get_config(resolved_package)
|
|
132
|
+
resolved_backend = cfg.backend
|
|
133
|
+
else:
|
|
134
|
+
resolved_backend = backend
|
|
135
|
+
|
|
136
|
+
def decorator(fn: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
|
|
137
|
+
key_generator = make_key_generator(fn, tag, exclude)
|
|
138
|
+
|
|
139
|
+
meta = CacheMeta(
|
|
140
|
+
ttl=ttl,
|
|
141
|
+
backend=resolved_backend,
|
|
142
|
+
tag=tag,
|
|
143
|
+
exclude=exclude or set(),
|
|
144
|
+
cache_if=cache_if,
|
|
145
|
+
validate=validate,
|
|
146
|
+
package=resolved_package,
|
|
147
|
+
key_generator=key_generator,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
@wraps(fn)
|
|
151
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
152
|
+
skip_cache = kwargs.pop('_skip_cache', False)
|
|
153
|
+
overwrite_cache = kwargs.pop('_overwrite_cache', False)
|
|
154
|
+
|
|
155
|
+
if is_disabled() or skip_cache:
|
|
156
|
+
return await fn(*args, **kwargs)
|
|
157
|
+
|
|
158
|
+
backend_instance = await _get_async_backend(resolved_package, resolved_backend, ttl)
|
|
159
|
+
cfg = get_config(resolved_package)
|
|
160
|
+
|
|
161
|
+
base_key = key_generator(*args, **kwargs)
|
|
162
|
+
cache_key = mangle_key(base_key, cfg.key_prefix, ttl)
|
|
163
|
+
|
|
164
|
+
if not overwrite_cache:
|
|
165
|
+
value, created_at = await backend_instance.get_with_metadata(cache_key)
|
|
166
|
+
|
|
167
|
+
if value is not NO_VALUE:
|
|
168
|
+
if validate is not None and created_at is not None:
|
|
169
|
+
entry = CacheEntry(
|
|
170
|
+
value=value,
|
|
171
|
+
created_at=created_at,
|
|
172
|
+
age=time.time() - created_at,
|
|
173
|
+
)
|
|
174
|
+
if not validate(entry):
|
|
175
|
+
logger.debug(f'Cache validation failed for {fn.__name__}')
|
|
176
|
+
else:
|
|
177
|
+
await _record_async_hit(wrapper)
|
|
178
|
+
return value
|
|
179
|
+
else:
|
|
180
|
+
await _record_async_hit(wrapper)
|
|
181
|
+
return value
|
|
182
|
+
|
|
183
|
+
await _record_async_miss(wrapper)
|
|
184
|
+
result = await fn(*args, **kwargs)
|
|
185
|
+
|
|
186
|
+
should_cache = cache_if is None or cache_if(result)
|
|
187
|
+
|
|
188
|
+
if should_cache:
|
|
189
|
+
await backend_instance.set(cache_key, result, ttl)
|
|
190
|
+
logger.debug(f'Cached {fn.__name__} with key {cache_key}')
|
|
191
|
+
|
|
192
|
+
return result
|
|
193
|
+
|
|
194
|
+
wrapper._cache_meta = meta # type: ignore
|
|
195
|
+
wrapper._cache_key_generator = key_generator # type: ignore
|
|
196
|
+
|
|
197
|
+
return wrapper
|
|
198
|
+
|
|
199
|
+
return decorator
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def _record_async_hit(fn: Callable[..., Any]) -> None:
|
|
203
|
+
"""Record a cache hit for the async function.
|
|
204
|
+
"""
|
|
205
|
+
fn_id = id(fn)
|
|
206
|
+
async with _async_stats_lock:
|
|
207
|
+
hits, misses = _async_stats.get(fn_id, (0, 0))
|
|
208
|
+
_async_stats[fn_id] = (hits + 1, misses)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
async def _record_async_miss(fn: Callable[..., Any]) -> None:
|
|
212
|
+
"""Record a cache miss for the async function.
|
|
213
|
+
"""
|
|
214
|
+
fn_id = id(fn)
|
|
215
|
+
async with _async_stats_lock:
|
|
216
|
+
hits, misses = _async_stats.get(fn_id, (0, 0))
|
|
217
|
+
_async_stats[fn_id] = (hits, misses + 1)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def get_async_cache_info(fn: Callable[..., Any]) -> CacheInfo:
|
|
221
|
+
"""Get cache statistics for an async decorated function.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
fn: A function decorated with @async_cache
|
|
225
|
+
|
|
226
|
+
Returns
|
|
227
|
+
CacheInfo with hits, misses, and currsize
|
|
228
|
+
"""
|
|
229
|
+
fn_id = id(fn)
|
|
230
|
+
|
|
231
|
+
async with _async_stats_lock:
|
|
232
|
+
hits, misses = _async_stats.get(fn_id, (0, 0))
|
|
233
|
+
|
|
234
|
+
meta = getattr(fn, '_cache_meta', None)
|
|
235
|
+
if meta is None:
|
|
236
|
+
return CacheInfo(hits=hits, misses=misses, currsize=0)
|
|
237
|
+
|
|
238
|
+
backend_instance = await _get_async_backend(meta.package, meta.backend, meta.ttl)
|
|
239
|
+
cfg = get_config(meta.package)
|
|
240
|
+
|
|
241
|
+
fn_name = getattr(fn, '__wrapped__', fn).__name__
|
|
242
|
+
pattern = f'*:{cfg.key_prefix}{fn_name}|*'
|
|
243
|
+
|
|
244
|
+
currsize = await backend_instance.count(pattern)
|
|
245
|
+
|
|
246
|
+
return CacheInfo(hits=hits, misses=misses, currsize=currsize)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
async def clear_async_backends(package: str | None = None) -> None:
|
|
250
|
+
"""Clear all async backend instances for a package. Primarily for testing.
|
|
251
|
+
"""
|
|
252
|
+
async with _async_backends_lock:
|
|
253
|
+
if package is None:
|
|
254
|
+
for backend in _async_backends.values():
|
|
255
|
+
await backend.close()
|
|
256
|
+
_async_backends.clear()
|
|
257
|
+
else:
|
|
258
|
+
keys_to_delete = [k for k in _async_backends if k[0] == package]
|
|
259
|
+
for key in keys_to_delete:
|
|
260
|
+
await _async_backends[key].close()
|
|
261
|
+
del _async_backends[key]
|