cachetout 0.1.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.
- cachetout-0.1.0/PKG-INFO +122 -0
- cachetout-0.1.0/README.md +111 -0
- cachetout-0.1.0/pyproject.toml +25 -0
- cachetout-0.1.0/src/cachetout/__init__.py +7 -0
- cachetout-0.1.0/src/cachetout/backends/__init__.py +0 -0
- cachetout-0.1.0/src/cachetout/backends/abc.py +15 -0
- cachetout-0.1.0/src/cachetout/backends/memory.py +54 -0
- cachetout-0.1.0/src/cachetout/backends/sqlite.py +61 -0
- cachetout-0.1.0/src/cachetout/cache.py +47 -0
- cachetout-0.1.0/src/cachetout/functools.py +52 -0
- cachetout-0.1.0/src/cachetout/py.typed +0 -0
cachetout-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: cachetout
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A persistent, type-safe caching library for Python
|
|
5
|
+
Author: Thomas Leese
|
|
6
|
+
Author-email: Thomas Leese <thomas@leese.io>
|
|
7
|
+
Requires-Dist: msgspec>=0.21.1
|
|
8
|
+
Requires-Dist: platformdirs>=4.11.0
|
|
9
|
+
Requires-Python: >=3.14
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# Cachetout
|
|
13
|
+
|
|
14
|
+
A persistent, type-safe caching library for Python.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```shell
|
|
19
|
+
pip install cachetout
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
### `Cache` class
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from cachetout import Cache
|
|
28
|
+
|
|
29
|
+
# Create a cache (uses SQLite backend by default)
|
|
30
|
+
cache = Cache("my_app_cache")
|
|
31
|
+
|
|
32
|
+
# Store a value
|
|
33
|
+
cache.set("user:123", {"name": "Alice", "age": 30})
|
|
34
|
+
|
|
35
|
+
# Retrieve a value
|
|
36
|
+
user = cache.get("user:123", type=dict)
|
|
37
|
+
print(user) # {'name': 'Alice', 'age': 30}
|
|
38
|
+
|
|
39
|
+
# Delete a value
|
|
40
|
+
cache.delete("user:123")
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### `cache` decorator
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from datetime import timedelta
|
|
47
|
+
from cachetout import cache
|
|
48
|
+
|
|
49
|
+
@cache(expires_in=timedelta(hours=1))
|
|
50
|
+
def fetch_user_data(user_id: int) -> dict:
|
|
51
|
+
# This function will only be called once per user_id per hour
|
|
52
|
+
print("Fetching data from API...")
|
|
53
|
+
return {"id": user_id, "data": "..."}
|
|
54
|
+
|
|
55
|
+
# First call: fetches from source
|
|
56
|
+
result1 = fetch_user_data(123)
|
|
57
|
+
|
|
58
|
+
# Second call with same args: returns cached result
|
|
59
|
+
result2 = fetch_user_data(123)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Serialisation
|
|
63
|
+
|
|
64
|
+
The library uses [msgspec] for serialisation, which respects the Python type hints:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from dataclasses import dataclass
|
|
68
|
+
from cachetout import Cache
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class User:
|
|
72
|
+
id: int
|
|
73
|
+
name: str
|
|
74
|
+
email: str
|
|
75
|
+
|
|
76
|
+
cache = Cache("user_cache")
|
|
77
|
+
|
|
78
|
+
# Store a dataclass instance
|
|
79
|
+
user = User(id=1, name="Alice", email="alice@example.com")
|
|
80
|
+
cache.set("user_1", user)
|
|
81
|
+
|
|
82
|
+
# Retrieve with type hint
|
|
83
|
+
retrieved: User = cache.get("user_1", type=User)
|
|
84
|
+
print(retrieved.name) # "Alice"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
[msgspec]: https://msgspec.dev/
|
|
88
|
+
|
|
89
|
+
### Expiration
|
|
90
|
+
|
|
91
|
+
Set expiration when storing values:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from datetime import datetime, timedelta, UTC
|
|
95
|
+
from cachetout import Cache
|
|
96
|
+
|
|
97
|
+
cache = Cache("temp_cache")
|
|
98
|
+
|
|
99
|
+
# Expire in 1 hour
|
|
100
|
+
cache.set("temp_data", "value", expires_at=datetime.now(tz=UTC) + timedelta(hours=1))
|
|
101
|
+
|
|
102
|
+
# Or use timedelta with decorator
|
|
103
|
+
@cache(expires_in=timedelta(minutes=30))
|
|
104
|
+
def get_stock_price(symbol: str) -> float:
|
|
105
|
+
return fetch_from_api(symbol)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Development
|
|
109
|
+
|
|
110
|
+
### Tests
|
|
111
|
+
|
|
112
|
+
```shell
|
|
113
|
+
$ uv run pytest
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Linting
|
|
117
|
+
|
|
118
|
+
```shell
|
|
119
|
+
$ uv run ruff format
|
|
120
|
+
$ uv run ruff check
|
|
121
|
+
$ uv run ty check
|
|
122
|
+
```
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Cachetout
|
|
2
|
+
|
|
3
|
+
A persistent, type-safe caching library for Python.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```shell
|
|
8
|
+
pip install cachetout
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### `Cache` class
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from cachetout import Cache
|
|
17
|
+
|
|
18
|
+
# Create a cache (uses SQLite backend by default)
|
|
19
|
+
cache = Cache("my_app_cache")
|
|
20
|
+
|
|
21
|
+
# Store a value
|
|
22
|
+
cache.set("user:123", {"name": "Alice", "age": 30})
|
|
23
|
+
|
|
24
|
+
# Retrieve a value
|
|
25
|
+
user = cache.get("user:123", type=dict)
|
|
26
|
+
print(user) # {'name': 'Alice', 'age': 30}
|
|
27
|
+
|
|
28
|
+
# Delete a value
|
|
29
|
+
cache.delete("user:123")
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### `cache` decorator
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from datetime import timedelta
|
|
36
|
+
from cachetout import cache
|
|
37
|
+
|
|
38
|
+
@cache(expires_in=timedelta(hours=1))
|
|
39
|
+
def fetch_user_data(user_id: int) -> dict:
|
|
40
|
+
# This function will only be called once per user_id per hour
|
|
41
|
+
print("Fetching data from API...")
|
|
42
|
+
return {"id": user_id, "data": "..."}
|
|
43
|
+
|
|
44
|
+
# First call: fetches from source
|
|
45
|
+
result1 = fetch_user_data(123)
|
|
46
|
+
|
|
47
|
+
# Second call with same args: returns cached result
|
|
48
|
+
result2 = fetch_user_data(123)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Serialisation
|
|
52
|
+
|
|
53
|
+
The library uses [msgspec] for serialisation, which respects the Python type hints:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from dataclasses import dataclass
|
|
57
|
+
from cachetout import Cache
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class User:
|
|
61
|
+
id: int
|
|
62
|
+
name: str
|
|
63
|
+
email: str
|
|
64
|
+
|
|
65
|
+
cache = Cache("user_cache")
|
|
66
|
+
|
|
67
|
+
# Store a dataclass instance
|
|
68
|
+
user = User(id=1, name="Alice", email="alice@example.com")
|
|
69
|
+
cache.set("user_1", user)
|
|
70
|
+
|
|
71
|
+
# Retrieve with type hint
|
|
72
|
+
retrieved: User = cache.get("user_1", type=User)
|
|
73
|
+
print(retrieved.name) # "Alice"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
[msgspec]: https://msgspec.dev/
|
|
77
|
+
|
|
78
|
+
### Expiration
|
|
79
|
+
|
|
80
|
+
Set expiration when storing values:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from datetime import datetime, timedelta, UTC
|
|
84
|
+
from cachetout import Cache
|
|
85
|
+
|
|
86
|
+
cache = Cache("temp_cache")
|
|
87
|
+
|
|
88
|
+
# Expire in 1 hour
|
|
89
|
+
cache.set("temp_data", "value", expires_at=datetime.now(tz=UTC) + timedelta(hours=1))
|
|
90
|
+
|
|
91
|
+
# Or use timedelta with decorator
|
|
92
|
+
@cache(expires_in=timedelta(minutes=30))
|
|
93
|
+
def get_stock_price(symbol: str) -> float:
|
|
94
|
+
return fetch_from_api(symbol)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Development
|
|
98
|
+
|
|
99
|
+
### Tests
|
|
100
|
+
|
|
101
|
+
```shell
|
|
102
|
+
$ uv run pytest
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Linting
|
|
106
|
+
|
|
107
|
+
```shell
|
|
108
|
+
$ uv run ruff format
|
|
109
|
+
$ uv run ruff check
|
|
110
|
+
$ uv run ty check
|
|
111
|
+
```
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "cachetout"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A persistent, type-safe caching library for Python"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Thomas Leese", email = "thomas@leese.io" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.14"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"msgspec>=0.21.1",
|
|
12
|
+
"platformdirs>=4.11.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["uv_build>=0.11.26,<0.12.0"]
|
|
17
|
+
build-backend = "uv_build"
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = [
|
|
21
|
+
"freezegun>=1.5.5",
|
|
22
|
+
"pytest>=9.1.1",
|
|
23
|
+
"ruff>=0.16.0",
|
|
24
|
+
"ty>=0.0.63",
|
|
25
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Backend(ABC):
|
|
6
|
+
@abstractmethod
|
|
7
|
+
def get(self, key: bytes, *, default: bytes | None = None) -> bytes | None: ...
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def set(
|
|
11
|
+
self, key: bytes, value: bytes, *, expires_at: datetime | None = None
|
|
12
|
+
) -> None: ...
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def delete(self, key: bytes) -> bool: ...
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from datetime import UTC, datetime
|
|
2
|
+
from threading import Lock
|
|
3
|
+
|
|
4
|
+
from .abc import Backend
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MemoryBackend(Backend):
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self._lock = Lock()
|
|
10
|
+
self._data: dict[bytes, bytes] = {}
|
|
11
|
+
self._expirations: dict[bytes, datetime] = {}
|
|
12
|
+
|
|
13
|
+
def get(self, key: bytes, *, default: bytes | None = None) -> bytes | None:
|
|
14
|
+
with self._lock:
|
|
15
|
+
value = self._data.get(key, default)
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
expires_at = self._expirations[key]
|
|
19
|
+
if expires_at < datetime.now(tz=UTC):
|
|
20
|
+
self._delete(key)
|
|
21
|
+
value = default
|
|
22
|
+
except KeyError:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
def set(
|
|
28
|
+
self, key: bytes, value: bytes, *, expires_at: datetime | None = None
|
|
29
|
+
) -> None:
|
|
30
|
+
with self._lock:
|
|
31
|
+
self._data[key] = value
|
|
32
|
+
|
|
33
|
+
if expires_at is not None:
|
|
34
|
+
self._expirations[key] = expires_at
|
|
35
|
+
else:
|
|
36
|
+
self._delete_expiration(key)
|
|
37
|
+
|
|
38
|
+
def delete(self, key: bytes) -> bool:
|
|
39
|
+
with self._lock:
|
|
40
|
+
return self._delete(key)
|
|
41
|
+
|
|
42
|
+
def _delete(self, key: bytes) -> bool:
|
|
43
|
+
try:
|
|
44
|
+
del self._data[key]
|
|
45
|
+
self._delete_expiration(key)
|
|
46
|
+
return True
|
|
47
|
+
except KeyError:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
def _delete_expiration(self, key: bytes):
|
|
51
|
+
try:
|
|
52
|
+
del self._expirations[key]
|
|
53
|
+
except KeyError:
|
|
54
|
+
pass
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from datetime import UTC, datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .abc import Backend
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SQLiteBackend(Backend):
|
|
9
|
+
create_table_sql = """
|
|
10
|
+
CREATE TABLE IF NOT EXISTS cache(
|
|
11
|
+
key BLOB PRIMARY KEY NOT NULL,
|
|
12
|
+
value BLOB NOT NULL,
|
|
13
|
+
expires_at TIMESTAMP
|
|
14
|
+
)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, *, path: Path):
|
|
18
|
+
self.connection = sqlite3.connect(path)
|
|
19
|
+
self.cursor = self.connection.cursor()
|
|
20
|
+
|
|
21
|
+
self.cursor.execute(self.create_table_sql)
|
|
22
|
+
self.connection.commit()
|
|
23
|
+
|
|
24
|
+
def get(self, key: bytes, *, default: bytes | None = None) -> bytes | None:
|
|
25
|
+
self.cursor.execute("SELECT value, expires_at FROM cache WHERE key = ?", (key,))
|
|
26
|
+
row = self.cursor.fetchone()
|
|
27
|
+
|
|
28
|
+
if row is None:
|
|
29
|
+
return default
|
|
30
|
+
|
|
31
|
+
value, expires_at_isoformat = row
|
|
32
|
+
|
|
33
|
+
if expires_at_isoformat is not None and datetime.fromisoformat(
|
|
34
|
+
expires_at_isoformat
|
|
35
|
+
) < datetime.now(tz=UTC):
|
|
36
|
+
self.delete(key)
|
|
37
|
+
return default
|
|
38
|
+
|
|
39
|
+
return value
|
|
40
|
+
|
|
41
|
+
def set(
|
|
42
|
+
self, key: bytes, value: bytes, *, expires_at: datetime | None = None
|
|
43
|
+
) -> None:
|
|
44
|
+
expires_at_isoformat = (
|
|
45
|
+
expires_at.isoformat() if expires_at is not None else None
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
self.cursor.execute(
|
|
49
|
+
"""
|
|
50
|
+
INSERT INTO cache (key, value, expires_at)
|
|
51
|
+
VALUES (?, ?, ?)
|
|
52
|
+
ON CONFLICT(key) DO UPDATE SET value = ?, expires_at = ?
|
|
53
|
+
""",
|
|
54
|
+
(key, value, expires_at_isoformat, value, expires_at_isoformat),
|
|
55
|
+
)
|
|
56
|
+
self.connection.commit()
|
|
57
|
+
|
|
58
|
+
def delete(self, key: bytes) -> bool:
|
|
59
|
+
self.cursor.execute("DELETE FROM cache WHERE key = ?", (key,))
|
|
60
|
+
self.connection.commit()
|
|
61
|
+
return self.cursor.rowcount > 0
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import TypeVar
|
|
3
|
+
|
|
4
|
+
import msgspec.msgpack
|
|
5
|
+
import platformdirs
|
|
6
|
+
|
|
7
|
+
from .backends.abc import Backend
|
|
8
|
+
from .backends.sqlite import SQLiteBackend
|
|
9
|
+
|
|
10
|
+
K = TypeVar("K")
|
|
11
|
+
V = TypeVar("V")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Cache:
|
|
15
|
+
def __init__(
|
|
16
|
+
self, name: str, app_name: str | None = None, backend: Backend | None = None
|
|
17
|
+
):
|
|
18
|
+
self.name = name
|
|
19
|
+
|
|
20
|
+
if backend is not None:
|
|
21
|
+
self.backend = backend
|
|
22
|
+
else:
|
|
23
|
+
base_path = platformdirs.user_cache_path(app_name or name)
|
|
24
|
+
path = base_path / f"{name}.db"
|
|
25
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
self.backend = SQLiteBackend(path=path)
|
|
27
|
+
|
|
28
|
+
self.encoder = msgspec.msgpack.Encoder()
|
|
29
|
+
|
|
30
|
+
def get(self, key: K, *, type: type[V], default: V | None = None) -> V | None:
|
|
31
|
+
encoded_key = self.encoder.encode(key)
|
|
32
|
+
|
|
33
|
+
value = self.backend.get(encoded_key)
|
|
34
|
+
if value is None:
|
|
35
|
+
return default
|
|
36
|
+
|
|
37
|
+
return msgspec.msgpack.decode(value, type=type)
|
|
38
|
+
|
|
39
|
+
def set(self, key: K, value: V, *, expires_at: datetime | None = None) -> None:
|
|
40
|
+
encoded_key = self.encoder.encode(key)
|
|
41
|
+
encoded_value = self.encoder.encode(value)
|
|
42
|
+
|
|
43
|
+
self.backend.set(encoded_key, encoded_value, expires_at=expires_at)
|
|
44
|
+
|
|
45
|
+
def delete(self, key: K) -> bool:
|
|
46
|
+
encoded_key = self.encoder.encode(key)
|
|
47
|
+
return self.backend.delete(encoded_key)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import UTC, datetime, timedelta
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from inspect import signature
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from cachetout.backends.abc import Backend
|
|
8
|
+
|
|
9
|
+
from .cache import Cache
|
|
10
|
+
|
|
11
|
+
DEFAULT = object()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Key:
|
|
16
|
+
args: tuple[Any]
|
|
17
|
+
kwargs: dict[str, Any]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def cache(*args, **kwargs):
|
|
21
|
+
name: str | None = kwargs.get("name")
|
|
22
|
+
app_name: str | None = kwargs.get("app_name")
|
|
23
|
+
backend: Backend | None = kwargs.get("backend")
|
|
24
|
+
expires_in: timedelta | None = kwargs.get("expires_in")
|
|
25
|
+
|
|
26
|
+
def decorator(f):
|
|
27
|
+
cache = Cache(name or f.__name__, app_name=app_name, backend=backend)
|
|
28
|
+
sig = signature(f)
|
|
29
|
+
|
|
30
|
+
@wraps(f)
|
|
31
|
+
def wrapper(*args, **kwargs):
|
|
32
|
+
key = Key(args, kwargs)
|
|
33
|
+
|
|
34
|
+
value = cache.get(key, default=DEFAULT, type=sig.return_annotation)
|
|
35
|
+
if value is DEFAULT:
|
|
36
|
+
value = f(*args, **kwargs)
|
|
37
|
+
|
|
38
|
+
if expires_in is not None:
|
|
39
|
+
expires_at = datetime.now(tz=UTC) + expires_in
|
|
40
|
+
else:
|
|
41
|
+
expires_at = None
|
|
42
|
+
|
|
43
|
+
cache.set(key, value, expires_at=expires_at)
|
|
44
|
+
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
return wrapper
|
|
48
|
+
|
|
49
|
+
if len(args) == 1 and callable(args[0]):
|
|
50
|
+
return decorator(args[0])
|
|
51
|
+
else:
|
|
52
|
+
return decorator
|
|
File without changes
|