cachu 0.1.3__py3-none-any.whl → 0.2.1__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,240 @@
1
+ """SQLite-based cache backend.
2
+ """
3
+ import fnmatch
4
+ import pickle
5
+ import sqlite3
6
+ import threading
7
+ import time
8
+ from collections.abc import Iterator
9
+ from typing import Any
10
+
11
+ from . import NO_VALUE, Backend
12
+
13
+
14
+ class SqliteBackend(Backend):
15
+ """SQLite file-based cache backend.
16
+ """
17
+
18
+ def __init__(self, filepath: str) -> None:
19
+ self._filepath = filepath
20
+ self._lock = threading.RLock()
21
+ self._init_db()
22
+
23
+ def _init_db(self) -> None:
24
+ """Initialize database schema.
25
+ """
26
+ with self._lock:
27
+ conn = sqlite3.connect(self._filepath)
28
+ try:
29
+ conn.execute('''
30
+ CREATE TABLE IF NOT EXISTS cache (
31
+ key TEXT PRIMARY KEY,
32
+ value BLOB NOT NULL,
33
+ created_at REAL NOT NULL,
34
+ expires_at REAL NOT NULL
35
+ )
36
+ ''')
37
+ conn.execute('''
38
+ CREATE INDEX IF NOT EXISTS idx_cache_expires
39
+ ON cache(expires_at)
40
+ ''')
41
+ conn.commit()
42
+ finally:
43
+ conn.close()
44
+
45
+ def _get_connection(self) -> sqlite3.Connection:
46
+ """Get a database connection.
47
+ """
48
+ return sqlite3.connect(self._filepath)
49
+
50
+ def get(self, key: str) -> Any:
51
+ """Get value by key. Returns NO_VALUE if not found or expired.
52
+ """
53
+ with self._lock:
54
+ conn = self._get_connection()
55
+ try:
56
+ cursor = conn.execute(
57
+ 'SELECT value, expires_at FROM cache WHERE key = ?',
58
+ (key,),
59
+ )
60
+ row = cursor.fetchone()
61
+
62
+ if row is None:
63
+ return NO_VALUE
64
+
65
+ value_blob, expires_at = row
66
+ if time.time() > expires_at:
67
+ conn.execute('DELETE FROM cache WHERE key = ?', (key,))
68
+ conn.commit()
69
+ return NO_VALUE
70
+
71
+ return pickle.loads(value_blob)
72
+ except Exception:
73
+ return NO_VALUE
74
+ finally:
75
+ conn.close()
76
+
77
+ def get_with_metadata(self, key: str) -> tuple[Any, float | None]:
78
+ """Get value and creation timestamp. Returns (NO_VALUE, None) if not found.
79
+ """
80
+ with self._lock:
81
+ conn = self._get_connection()
82
+ try:
83
+ cursor = conn.execute(
84
+ 'SELECT value, created_at, expires_at FROM cache WHERE key = ?',
85
+ (key,),
86
+ )
87
+ row = cursor.fetchone()
88
+
89
+ if row is None:
90
+ return NO_VALUE, None
91
+
92
+ value_blob, created_at, expires_at = row
93
+ if time.time() > expires_at:
94
+ conn.execute('DELETE FROM cache WHERE key = ?', (key,))
95
+ conn.commit()
96
+ return NO_VALUE, None
97
+
98
+ return pickle.loads(value_blob), created_at
99
+ except Exception:
100
+ return NO_VALUE, None
101
+ finally:
102
+ conn.close()
103
+
104
+ def set(self, key: str, value: Any, ttl: int) -> None:
105
+ """Set value with TTL in seconds.
106
+ """
107
+ now = time.time()
108
+ value_blob = pickle.dumps(value)
109
+
110
+ with self._lock:
111
+ conn = self._get_connection()
112
+ try:
113
+ conn.execute(
114
+ '''INSERT OR REPLACE INTO cache (key, value, created_at, expires_at)
115
+ VALUES (?, ?, ?, ?)''',
116
+ (key, value_blob, now, now + ttl),
117
+ )
118
+ conn.commit()
119
+ finally:
120
+ conn.close()
121
+
122
+ def delete(self, key: str) -> None:
123
+ """Delete value by key.
124
+ """
125
+ with self._lock:
126
+ conn = self._get_connection()
127
+ try:
128
+ conn.execute('DELETE FROM cache WHERE key = ?', (key,))
129
+ conn.commit()
130
+ except Exception:
131
+ pass
132
+ finally:
133
+ conn.close()
134
+
135
+ def clear(self, pattern: str | None = None) -> int:
136
+ """Clear entries matching pattern. Returns count of cleared entries.
137
+ """
138
+ with self._lock:
139
+ conn = self._get_connection()
140
+ try:
141
+ if pattern is None:
142
+ cursor = conn.execute('SELECT COUNT(*) FROM cache')
143
+ count = cursor.fetchone()[0]
144
+ conn.execute('DELETE FROM cache')
145
+ conn.commit()
146
+ return count
147
+
148
+ glob_pattern = self._fnmatch_to_glob(pattern)
149
+ cursor = conn.execute(
150
+ 'SELECT COUNT(*) FROM cache WHERE key GLOB ?',
151
+ (glob_pattern,),
152
+ )
153
+ count = cursor.fetchone()[0]
154
+ conn.execute('DELETE FROM cache WHERE key GLOB ?', (glob_pattern,))
155
+ conn.commit()
156
+ return count
157
+ except Exception:
158
+ return 0
159
+ finally:
160
+ conn.close()
161
+
162
+ def keys(self, pattern: str | None = None) -> Iterator[str]:
163
+ """Iterate over keys matching pattern.
164
+ """
165
+ now = time.time()
166
+
167
+ with self._lock:
168
+ conn = self._get_connection()
169
+ try:
170
+ if pattern is None:
171
+ cursor = conn.execute(
172
+ 'SELECT key FROM cache WHERE expires_at > ?',
173
+ (now,),
174
+ )
175
+ else:
176
+ glob_pattern = self._fnmatch_to_glob(pattern)
177
+ cursor = conn.execute(
178
+ 'SELECT key FROM cache WHERE key GLOB ? AND expires_at > ?',
179
+ (glob_pattern, now),
180
+ )
181
+
182
+ all_keys = [row[0] for row in cursor.fetchall()]
183
+ finally:
184
+ conn.close()
185
+
186
+ for key in all_keys:
187
+ yield key
188
+
189
+ def count(self, pattern: str | None = None) -> int:
190
+ """Count keys matching pattern.
191
+ """
192
+ now = time.time()
193
+
194
+ with self._lock:
195
+ conn = self._get_connection()
196
+ try:
197
+ if pattern is None:
198
+ cursor = conn.execute(
199
+ 'SELECT COUNT(*) FROM cache WHERE expires_at > ?',
200
+ (now,),
201
+ )
202
+ else:
203
+ glob_pattern = self._fnmatch_to_glob(pattern)
204
+ cursor = conn.execute(
205
+ 'SELECT COUNT(*) FROM cache WHERE key GLOB ? AND expires_at > ?',
206
+ (glob_pattern, now),
207
+ )
208
+
209
+ return cursor.fetchone()[0]
210
+ except Exception:
211
+ return 0
212
+ finally:
213
+ conn.close()
214
+
215
+ def _fnmatch_to_glob(self, pattern: str) -> str:
216
+ """Convert fnmatch pattern to SQLite GLOB pattern.
217
+
218
+ fnmatch uses * and ? which are the same as SQLite GLOB.
219
+ The main difference is character classes [...] which we don't use.
220
+ """
221
+ return pattern
222
+
223
+ def cleanup_expired(self) -> int:
224
+ """Remove expired entries. Returns count of removed entries.
225
+ """
226
+ now = time.time()
227
+
228
+ with self._lock:
229
+ conn = self._get_connection()
230
+ try:
231
+ cursor = conn.execute(
232
+ 'SELECT COUNT(*) FROM cache WHERE expires_at <= ?',
233
+ (now,),
234
+ )
235
+ count = cursor.fetchone()[0]
236
+ conn.execute('DELETE FROM cache WHERE expires_at <= ?', (now,))
237
+ conn.commit()
238
+ return count
239
+ finally:
240
+ conn.close()
cachu/decorator.py CHANGED
@@ -4,9 +4,9 @@ import logging
4
4
  import os
5
5
  import threading
6
6
  import time
7
+ from collections.abc import Callable
7
8
  from functools import wraps
8
9
  from typing import Any
9
- from collections.abc import Callable
10
10
 
11
11
  from .backends import NO_VALUE, Backend
12
12
  from .backends.file import FileBackend
@@ -39,11 +39,11 @@ def _get_backend(package: str | None, backend_type: str, ttl: int) -> Backend:
39
39
  backend = MemoryBackend()
40
40
  elif backend_type == 'file':
41
41
  if ttl < 60:
42
- filename = f'cache{ttl}sec'
42
+ filename = f'cache{ttl}sec.db'
43
43
  elif ttl < 3600:
44
- filename = f'cache{ttl // 60}min'
44
+ filename = f'cache{ttl // 60}min.db'
45
45
  else:
46
- filename = f'cache{ttl // 3600}hour'
46
+ filename = f'cache{ttl // 3600}hour.db'
47
47
 
48
48
  if package:
49
49
  filename = f'{package}_{filename}'
@@ -61,7 +61,7 @@ def _get_backend(package: str | None, backend_type: str, ttl: int) -> Backend:
61
61
  return backend
62
62
 
63
63
 
64
- def get_backend(backend_type: str | None = None, package: str | None = None, ttl: int = 300) -> Backend:
64
+ def get_backend(backend_type: str | None = None, package: str | None = None, *, ttl: int) -> Backend:
65
65
  """Get a backend instance.
66
66
 
67
67
  Args:
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cachu
3
- Version: 0.1.3
4
- Summary: Flexible caching library built on dogpile.cache
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 | Default | Description |
75
- |--------|---------|-------------|
76
- | `backend` | `'memory'` | Default backend type |
77
- | `key_prefix` | `''` | Prefix for all cache keys (useful for versioning) |
78
- | `file_dir` | `'/tmp'` | Directory for file-based caches |
79
- | `redis_url` | `'redis://localhost:6379/0'` | Redis connection URL |
80
- | `redis_distributed` | `False` | Enable distributed locks for Redis |
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` | `tag` | `backend` | Behavior |
304
- |-------|-------|-----------|----------|
305
- | `300` | `None` | `'memory'` | All keys in 300s memory region |
306
- | `300` | `'users'` | `'memory'` | Only "users" tag in 300s memory region |
307
- | `None` | `None` | `'memory'` | All memory regions |
308
- | `None` | `'users'` | `None` | "users" tag across all backends |
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 (DBM), and Redis
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
@@ -0,0 +1,21 @@
1
+ cachu/__init__.py,sha256=-eqMY3cCuepixdZ-FefQsRXPufVSgrEMlYkYwylSlTM,1286
2
+ cachu/async_decorator.py,sha256=Jx2fHESLlld7NZiD2-6kcozukJtp5efnt4cMhntDDRA,8939
3
+ cachu/async_operations.py,sha256=eVqhZk3FVLNip_abjnCzG8AajzvJTtXbpL--dpMXBlc,5597
4
+ cachu/config.py,sha256=KtcDGpSTJmjRrcNLz9_Om3O814oJJ3p8gntB84Pd6Dk,5922
5
+ cachu/decorator.py,sha256=RHwDRZxZfOkBgEK1XgRyis22bxQ0ba0X4NtHBd9FTb4,8161
6
+ cachu/keys.py,sha256=fwwNOpnDJFCIWZoQ5UGJWhJa6xu36hsBsURI-n2NJKU,3557
7
+ cachu/operations.py,sha256=t42_Er-O59vrwFa5jdf4yq3Jr4li2l7php4yMVJnxPs,5588
8
+ cachu/types.py,sha256=FghBN5GhxnrpuT4WUL9iNnAfdoH__cw9_Ag4kHbIXq4,723
9
+ cachu/backends/__init__.py,sha256=Jn2yBAMmJ8d0J_NyjOtxRt7UTyMLf1rlY8QJ049hXE8,1318
10
+ cachu/backends/async_base.py,sha256=oZ3K3PhsYkbgZxFLFk3_NbxBxtNopqS90HZBizwg_q8,1394
11
+ cachu/backends/async_memory.py,sha256=SQvSHeWbySa52BnQLF75nhVXgsydubNu84a8hvSzQSc,3457
12
+ cachu/backends/async_redis.py,sha256=8kefPIoIJDAZ6C6HJCvHqKFMDS10sJYh8YcJMpXpQm8,4455
13
+ cachu/backends/async_sqlite.py,sha256=r-c1cNVl6JEApMGhw8Qw7843Vuj_LVRAM-MGgoIjah0,8423
14
+ cachu/backends/file.py,sha256=Pu01VtgHDgK6ev5hqyZXuJRCSB2VbNKHQ4w4nNKNyeI,298
15
+ cachu/backends/memory.py,sha256=kIgrVU8k_3Aquyj2PDf8IPbTjCITM_0V5GU47m3fJmo,3138
16
+ cachu/backends/redis.py,sha256=yE5rEBgOij9QOeC1VhWdIbGCgi442q-aWfmbbG4aNSE,3858
17
+ cachu/backends/sqlite.py,sha256=whduN5G_bN6ZJNuCBwbraDcadv_sg0j-OEiFnP8EEsk,7803
18
+ cachu-0.2.1.dist-info/METADATA,sha256=XZxtIkb4Mqd3Mbbw0DAlyfW5N1NJeUKiEPP6ybzIS8Q,11992
19
+ cachu-0.2.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
20
+ cachu-0.2.1.dist-info/top_level.txt,sha256=g80nNoMvLMzhSwQWV-JotCBqtsLAHeFMBo_g8hCK8hQ,6
21
+ cachu-0.2.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5