psycache 26.1.0__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.
psycache/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ # SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ from ._async import AsyncCleanupService, AsyncPostgresCache
6
+ from ._sync import CleanupService, PostgresCache
7
+ from ._tables import init_db
8
+ from .typing import CacheInstrumentation
9
+
10
+
11
+ __all__ = [
12
+ "AsyncCleanupService",
13
+ "AsyncPostgresCache",
14
+ "CacheInstrumentation",
15
+ "CleanupService",
16
+ "PostgresCache",
17
+ "init_db",
18
+ ]
psycache/__main__.py ADDED
@@ -0,0 +1,57 @@
1
+ # SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from collections.abc import Sequence
9
+
10
+ import psycopg
11
+
12
+ from ._tables import init_db
13
+
14
+
15
+ def _do_init_db(dsn: str) -> int:
16
+ try:
17
+ with psycopg.connect(dsn, autocommit=True) as conn:
18
+ init_db(conn)
19
+ except psycopg.Error as e:
20
+ print(f"psycache: init-db failed: {e}", file=sys.stderr)
21
+ return 1
22
+
23
+ print("psycache: initialized the cache table.")
24
+ return 0
25
+
26
+
27
+ def _build_parser() -> argparse.ArgumentParser:
28
+ parser = argparse.ArgumentParser(
29
+ prog="python -m psycache",
30
+ description="Maintenance commands for psycache.",
31
+ )
32
+ subparsers = parser.add_subparsers(required=True)
33
+
34
+ init_db_parser = subparsers.add_parser(
35
+ "init-db",
36
+ help="Create the psycache table and index.",
37
+ description="Create the psycache table and index in the database "
38
+ "identified by DSN.",
39
+ )
40
+ init_db_parser.add_argument(
41
+ "dsn",
42
+ metavar="DSN",
43
+ help="A libpq connection string, e.g. postgresql://user@host/db.",
44
+ )
45
+
46
+ return parser
47
+
48
+
49
+ def main(argv: Sequence[str] | None = None) -> int:
50
+ parser = _build_parser()
51
+ args = parser.parse_args(argv)
52
+
53
+ return _do_init_db(args.dsn)
54
+
55
+
56
+ if __name__ == "__main__": # pragma: no cover
57
+ raise SystemExit(main())
psycache/_async.py ADDED
@@ -0,0 +1,203 @@
1
+ # SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ import asyncio
6
+ import datetime as dt
7
+ import logging
8
+
9
+ from collections.abc import Awaitable, Callable, Sequence
10
+ from contextlib import suppress
11
+ from typing import Any, Self
12
+
13
+ import attrs
14
+
15
+ from psycopg.types.json import Jsonb
16
+
17
+ from . import _sql
18
+ from ._durations import (
19
+ _coerce_cleanup_interval_seconds,
20
+ _coerce_stop_timeout_seconds,
21
+ )
22
+ from .instrumentation._spans import (
23
+ _cleanup_span,
24
+ _flush_span,
25
+ _lookup_span,
26
+ _put_span,
27
+ _remove_span,
28
+ )
29
+ from .typing import AsyncCachePool, CacheInstrumentation
30
+
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ async def _cleanup_loop(
36
+ cleanup: Callable[[], Awaitable[int]],
37
+ interval_seconds: float,
38
+ stop_event: asyncio.Event,
39
+ ) -> None:
40
+ while not stop_event.is_set():
41
+ try:
42
+ await cleanup()
43
+ except Exception:
44
+ logger.exception("Periodic cache cleanup failed")
45
+
46
+ with suppress(TimeoutError):
47
+ await asyncio.wait_for(stop_event.wait(), interval_seconds)
48
+
49
+
50
+ @attrs.frozen
51
+ class AsyncCleanupService:
52
+ """
53
+ Handle for a periodic cache cleanup task.
54
+
55
+ Can be used as an async context manager or stopped manually via `stop()`.
56
+ """
57
+
58
+ _task: asyncio.Task[None] = attrs.field(alias="task")
59
+ _stop_event: asyncio.Event = attrs.field(alias="stop_event")
60
+
61
+ async def stop(
62
+ self,
63
+ timeout: dt.timedelta | float | None = 5.0, # noqa: ASYNC109
64
+ ) -> bool:
65
+ """
66
+ Stop the cleanup task and wait for it to finish.
67
+
68
+ Return whether the task exited before the timeout elapsed.
69
+ """
70
+ self._stop_event.set()
71
+ try:
72
+ await asyncio.wait_for(
73
+ asyncio.shield(self._task),
74
+ _coerce_stop_timeout_seconds(timeout),
75
+ )
76
+ except TimeoutError:
77
+ logger.warning(
78
+ "Cleanup task did not stop within timeout=%s", timeout
79
+ )
80
+ return False
81
+
82
+ return True
83
+
84
+ async def __aenter__(self) -> Self:
85
+ return self
86
+
87
+ async def __aexit__(self, *args: object) -> None:
88
+ await self.stop()
89
+
90
+
91
+ @attrs.frozen
92
+ class AsyncPostgresCache:
93
+ """
94
+ An asyncio-based Postgres cache.
95
+ """
96
+
97
+ pool: AsyncCachePool
98
+ instrumentations: Sequence[CacheInstrumentation] = attrs.field(
99
+ default=(), kw_only=True
100
+ )
101
+
102
+ async def get_raw(
103
+ self, key: str, span_name: str | None = None
104
+ ) -> dict[str, Any] | None:
105
+ with _lookup_span(self.instrumentations, key, span_name) as span:
106
+ async with self.pool.connect() as conn:
107
+ cur = await conn.execute(_sql.GET, (key,))
108
+ row = await cur.fetchone()
109
+
110
+ if row is None:
111
+ span.record_cache_miss()
112
+ return None
113
+
114
+ span.record_cache_hit(row[1])
115
+
116
+ return row[0] # type: ignore[no-any-return]
117
+
118
+ async def put_raw(
119
+ self,
120
+ key: str,
121
+ value: dict[str, Any],
122
+ ttl: int | dt.timedelta,
123
+ span_name: str | None = None,
124
+ ) -> None:
125
+ with _put_span(self.instrumentations, key, span_name) as span:
126
+ if isinstance(ttl, int):
127
+ ttl = dt.timedelta(seconds=ttl)
128
+
129
+ expires_at = dt.datetime.now().astimezone() + ttl
130
+
131
+ async with self.pool.connect() as conn:
132
+ cur = await conn.execute(
133
+ _sql.PUT, (key, Jsonb(value), expires_at)
134
+ )
135
+ row = await cur.fetchone()
136
+
137
+ span.record_put(row[0]) # type: ignore[index] # ty: ignore[not-subscriptable]
138
+
139
+ async def remove(self, key: str) -> None:
140
+ with _remove_span(self.instrumentations, key) as span:
141
+ async with self.pool.connect() as conn:
142
+ await conn.execute(_sql.REMOVE, (key,))
143
+
144
+ span.record_removed()
145
+
146
+ async def cleanup_expired(self) -> int:
147
+ """
148
+ Delete all expired cache entries.
149
+
150
+ Return the number of deleted entries.
151
+ """
152
+ with _cleanup_span(self.instrumentations) as span:
153
+ async with self.pool.connect() as conn:
154
+ cur = await conn.execute(_sql.CLEANUP_EXPIRED)
155
+ num_deleted: int = cur.rowcount
156
+
157
+ span.record_cleanup(num_deleted)
158
+
159
+ return num_deleted
160
+
161
+ def start_cleanup_task(
162
+ self, interval: dt.timedelta | float
163
+ ) -> AsyncCleanupService:
164
+ """
165
+ Start a task that periodically deletes expired cache entries.
166
+
167
+ Must be called within a running asyncio event loop.
168
+
169
+ Args:
170
+ interval:
171
+ Time between cleanup runs. In seconds or as a timedelta.
172
+
173
+ Returns:
174
+ An `AsyncCleanupService` that can be used to stop the task.
175
+ """
176
+ interval_seconds = _coerce_cleanup_interval_seconds(interval)
177
+
178
+ stop_event = asyncio.Event()
179
+ task = asyncio.create_task(
180
+ _cleanup_loop(
181
+ self.cleanup_expired,
182
+ interval_seconds,
183
+ stop_event,
184
+ ),
185
+ name="psycache-cleanup",
186
+ )
187
+
188
+ return AsyncCleanupService(task=task, stop_event=stop_event)
189
+
190
+ async def flush(self) -> int:
191
+ """
192
+ Flush all cache entries.
193
+
194
+ Return the number of flushed entries.
195
+ """
196
+ with _flush_span(self.instrumentations) as span:
197
+ async with self.pool.connect() as conn:
198
+ cur = await conn.execute(_sql.FLUSH)
199
+ num_flushed: int = cur.rowcount
200
+
201
+ span.record_flush(num_flushed)
202
+
203
+ return num_flushed
psycache/_durations.py ADDED
@@ -0,0 +1,55 @@
1
+ # SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ import datetime as dt
6
+ import math
7
+
8
+
9
+ def _coerce_seconds(value: dt.timedelta | float, *, name: str) -> float:
10
+ seconds = (
11
+ value.total_seconds() if isinstance(value, dt.timedelta) else value
12
+ )
13
+
14
+ if not math.isfinite(seconds):
15
+ msg = f"{name} must be finite"
16
+ raise ValueError(msg)
17
+
18
+ return seconds
19
+
20
+
21
+ def _coerce_stop_timeout_seconds(
22
+ timeout: dt.timedelta | float | None,
23
+ *,
24
+ max_seconds: float | None = None,
25
+ ) -> float | None:
26
+ if timeout is None:
27
+ return None
28
+
29
+ timeout_seconds = _coerce_seconds(timeout, name="stop timeout")
30
+ if timeout_seconds < 0:
31
+ msg = "stop timeout must be non-negative"
32
+ raise ValueError(msg)
33
+
34
+ if max_seconds is not None and timeout_seconds > max_seconds:
35
+ msg = f"stop timeout must not exceed {max_seconds}"
36
+ raise ValueError(msg)
37
+
38
+ return timeout_seconds
39
+
40
+
41
+ def _coerce_cleanup_interval_seconds(
42
+ interval: dt.timedelta | float,
43
+ *,
44
+ max_seconds: float | None = None,
45
+ ) -> float:
46
+ interval_seconds = _coerce_seconds(interval, name="cleanup interval")
47
+ if interval_seconds <= 0:
48
+ msg = "cleanup interval must be positive"
49
+ raise ValueError(msg)
50
+
51
+ if max_seconds is not None and interval_seconds > max_seconds:
52
+ msg = f"cleanup interval must not exceed {max_seconds}"
53
+ raise ValueError(msg)
54
+
55
+ return interval_seconds
psycache/_sql.py ADDED
@@ -0,0 +1,33 @@
1
+ # SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ GET = """\
6
+ SELECT value, pg_column_size(value)
7
+ FROM psycache
8
+ WHERE key = %s
9
+ AND expires_at > statement_timestamp()
10
+ """
11
+
12
+ PUT = """\
13
+ INSERT INTO psycache (key, value, expires_at)
14
+ VALUES (%s, %s, %s)
15
+ ON CONFLICT (key) DO UPDATE SET
16
+ value = EXCLUDED.value,
17
+ expires_at = EXCLUDED.expires_at
18
+ RETURNING pg_column_size(value)
19
+ """
20
+
21
+ REMOVE = """\
22
+ DELETE FROM psycache
23
+ WHERE key = %s
24
+ """
25
+
26
+ CLEANUP_EXPIRED = """\
27
+ DELETE FROM psycache
28
+ WHERE expires_at < statement_timestamp()
29
+ """
30
+
31
+ FLUSH = """\
32
+ DELETE FROM psycache
33
+ """
psycache/_sync.py ADDED
@@ -0,0 +1,198 @@
1
+ # SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ import datetime as dt
6
+ import logging
7
+ import threading
8
+
9
+ from collections.abc import Callable, Sequence
10
+ from typing import Any, Self
11
+
12
+ import attrs
13
+
14
+ from psycopg.types.json import Jsonb
15
+
16
+ from . import _sql
17
+ from ._durations import (
18
+ _coerce_cleanup_interval_seconds,
19
+ _coerce_stop_timeout_seconds,
20
+ )
21
+ from .instrumentation._spans import (
22
+ _cleanup_span,
23
+ _flush_span,
24
+ _lookup_span,
25
+ _put_span,
26
+ _remove_span,
27
+ )
28
+ from .typing import CacheInstrumentation, CachePool
29
+
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ def _cleanup_loop(
35
+ cleanup: Callable[[], int],
36
+ interval_seconds: float,
37
+ stop_event: threading.Event,
38
+ ) -> None:
39
+ while not stop_event.is_set():
40
+ try:
41
+ cleanup()
42
+ except Exception:
43
+ logger.exception("Periodic cache cleanup failed")
44
+
45
+ stop_event.wait(interval_seconds)
46
+
47
+
48
+ @attrs.frozen
49
+ class CleanupService:
50
+ """
51
+ Handle for a periodic cache cleanup thread.
52
+
53
+ Can be used as a context manager or stopped manually via `stop()`.
54
+ """
55
+
56
+ _thread: threading.Thread = attrs.field(alias="thread")
57
+ _stop_event: threading.Event = attrs.field(alias="stop_event")
58
+
59
+ def stop(self, timeout: dt.timedelta | float | None = 5.0) -> bool:
60
+ """
61
+ Stop the cleanup thread and wait for it to finish.
62
+
63
+ Return whether the thread exited before the timeout elapsed.
64
+ """
65
+ self._stop_event.set()
66
+ self._thread.join(
67
+ _coerce_stop_timeout_seconds(
68
+ timeout, max_seconds=threading.TIMEOUT_MAX
69
+ )
70
+ )
71
+
72
+ stopped = not self._thread.is_alive()
73
+ if not stopped:
74
+ logger.warning(
75
+ "Cleanup thread did not stop within timeout=%s", timeout
76
+ )
77
+
78
+ return stopped
79
+
80
+ def __enter__(self) -> Self:
81
+ return self
82
+
83
+ def __exit__(self, *args: object) -> None:
84
+ self.stop()
85
+
86
+
87
+ @attrs.frozen
88
+ class PostgresCache:
89
+ """
90
+ A Postgres-based cache.
91
+ """
92
+
93
+ pool: CachePool
94
+ instrumentations: Sequence[CacheInstrumentation] = attrs.field(
95
+ default=(), kw_only=True
96
+ )
97
+
98
+ def get_raw(
99
+ self, key: str, span_name: str | None = None
100
+ ) -> dict[str, Any] | None:
101
+ with _lookup_span(self.instrumentations, key, span_name) as span:
102
+ with self.pool.connect() as conn:
103
+ row = conn.execute(_sql.GET, (key,)).fetchone()
104
+
105
+ if row is None:
106
+ span.record_cache_miss()
107
+ return None
108
+
109
+ span.record_cache_hit(row[1])
110
+
111
+ return row[0] # type: ignore[no-any-return]
112
+
113
+ def put_raw(
114
+ self,
115
+ key: str,
116
+ value: dict[str, Any],
117
+ ttl: int | dt.timedelta,
118
+ span_name: str | None = None,
119
+ ) -> None:
120
+ with _put_span(self.instrumentations, key, span_name) as span:
121
+ if isinstance(ttl, int):
122
+ ttl = dt.timedelta(seconds=ttl)
123
+
124
+ expires_at = dt.datetime.now().astimezone() + ttl
125
+
126
+ with self.pool.connect() as conn:
127
+ row = conn.execute(
128
+ _sql.PUT, (key, Jsonb(value), expires_at)
129
+ ).fetchone()
130
+
131
+ span.record_put(row[0]) # type: ignore[index] # ty: ignore[not-subscriptable]
132
+
133
+ def remove(self, key: str) -> None:
134
+ with _remove_span(self.instrumentations, key) as span:
135
+ with self.pool.connect() as conn:
136
+ conn.execute(_sql.REMOVE, (key,))
137
+
138
+ span.record_removed()
139
+
140
+ def cleanup_expired(self) -> int:
141
+ """
142
+ Delete all expired cache entries.
143
+
144
+ Return the number of deleted entries.
145
+ """
146
+ with _cleanup_span(self.instrumentations) as span:
147
+ with self.pool.connect() as conn:
148
+ num_deleted: int = conn.execute(_sql.CLEANUP_EXPIRED).rowcount
149
+
150
+ span.record_cleanup(num_deleted)
151
+
152
+ return num_deleted
153
+
154
+ def flush(self) -> int:
155
+ """
156
+ Flush all cache entries.
157
+
158
+ Return the number of flushed entries.
159
+ """
160
+ with _flush_span(self.instrumentations) as span:
161
+ with self.pool.connect() as conn:
162
+ num_flushed: int = conn.execute(_sql.FLUSH).rowcount
163
+
164
+ span.record_flush(num_flushed)
165
+
166
+ return num_flushed
167
+
168
+ def start_cleanup_thread(
169
+ self, interval: dt.timedelta | float
170
+ ) -> CleanupService:
171
+ """
172
+ Start a daemon thread that periodically deletes expired cache entries.
173
+
174
+ Args:
175
+ interval:
176
+ Time between cleanup runs. In seconds or as a timedelta.
177
+
178
+ Returns:
179
+ A `CleanupService` that can be used to stop the thread.
180
+ """
181
+ interval_seconds = _coerce_cleanup_interval_seconds(
182
+ interval, max_seconds=threading.TIMEOUT_MAX
183
+ )
184
+
185
+ stop_event = threading.Event()
186
+ thread = threading.Thread(
187
+ name="psycache-cleanup",
188
+ target=_cleanup_loop,
189
+ args=(
190
+ self.cleanup_expired,
191
+ interval_seconds,
192
+ stop_event,
193
+ ),
194
+ daemon=True,
195
+ )
196
+ thread.start()
197
+
198
+ return CleanupService(thread=thread, stop_event=stop_event)
psycache/_tables.py ADDED
@@ -0,0 +1,24 @@
1
+ # SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ import psycopg
6
+
7
+
8
+ _CREATE_TABLE = """\
9
+ CREATE UNLOGGED TABLE IF NOT EXISTS psycache (
10
+ key text PRIMARY KEY,
11
+ value jsonb NOT NULL,
12
+ expires_at timestamptz NOT NULL
13
+ )
14
+ """
15
+
16
+ _CREATE_INDEX = """\
17
+ CREATE INDEX IF NOT EXISTS ix_psycache_expires_at
18
+ ON psycache (expires_at)
19
+ """
20
+
21
+
22
+ def init_db(conn: psycopg.Connection) -> None:
23
+ conn.execute(_CREATE_TABLE)
24
+ conn.execute(_CREATE_INDEX)
@@ -0,0 +1,12 @@
1
+ # SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """
6
+ Instrumentation for cache operations.
7
+ """
8
+
9
+ from ._spans import NoopAnySpan
10
+
11
+
12
+ __all__ = ["NoopAnySpan"]