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.
@@ -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
+ ]
@@ -0,0 +1,7 @@
1
+ from .cache import Cache
2
+ from .functools import cache
3
+
4
+ __all__ = [
5
+ "Cache",
6
+ "cache",
7
+ ]
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