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.
@@ -0,0 +1,173 @@
1
+ # SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """
6
+ Generic span helpers.
7
+ """
8
+
9
+ from collections.abc import Iterator, Sequence
10
+ from contextlib import ExitStack, contextmanager
11
+
12
+ from psycache.typing import (
13
+ CacheCleanupSpan,
14
+ CacheFlushSpan,
15
+ CacheGetSpan,
16
+ CacheInstrumentation,
17
+ CachePutSpan,
18
+ CacheRemoveSpan,
19
+ )
20
+
21
+
22
+ class _AggregatingGetSpan:
23
+ def __init__(self, spans: Sequence[CacheGetSpan]) -> None:
24
+ self._spans = spans
25
+
26
+ def record_cache_hit(self, item_size: int) -> None:
27
+ for span in self._spans:
28
+ span.record_cache_hit(item_size)
29
+
30
+ def record_cache_miss(self) -> None:
31
+ for span in self._spans:
32
+ span.record_cache_miss()
33
+
34
+
35
+ class _AggregatingPutSpan:
36
+ def __init__(self, spans: Sequence[CachePutSpan]) -> None:
37
+ self._spans = spans
38
+
39
+ def record_put(self, item_size: int) -> None:
40
+ for span in self._spans:
41
+ span.record_put(item_size)
42
+
43
+
44
+ class _AggregatingRemoveSpan:
45
+ def __init__(self, spans: Sequence[CacheRemoveSpan]) -> None:
46
+ self._spans = spans
47
+
48
+ def record_removed(self) -> None:
49
+ for span in self._spans:
50
+ span.record_removed()
51
+
52
+
53
+ class _AggregatingCleanupSpan:
54
+ def __init__(self, spans: Sequence[CacheCleanupSpan]) -> None:
55
+ self._spans = spans
56
+
57
+ def record_cleanup(self, num_deleted: int) -> None:
58
+ for span in self._spans:
59
+ span.record_cleanup(num_deleted)
60
+
61
+
62
+ class _AggregatingFlushSpan:
63
+ def __init__(self, spans: Sequence[CacheFlushSpan]) -> None:
64
+ self._spans = spans
65
+
66
+ def record_flush(self, num_flushed: int) -> None:
67
+ for span in self._spans:
68
+ span.record_flush(num_flushed)
69
+
70
+
71
+ class NoopAnySpan:
72
+ def record_cache_hit(self, item_size: int) -> None:
73
+ pass
74
+
75
+ def record_cache_miss(self) -> None:
76
+ pass
77
+
78
+ def record_put(self, item_size: int) -> None:
79
+ pass
80
+
81
+ def record_removed(self) -> None:
82
+ pass
83
+
84
+ def record_cleanup(self, num_deleted: int) -> None:
85
+ pass
86
+
87
+ def record_flush(self, num_flushed: int) -> None:
88
+ pass
89
+
90
+
91
+ @contextmanager
92
+ def _lookup_span(
93
+ instrumentations: Sequence[CacheInstrumentation],
94
+ key: str,
95
+ name: str | None,
96
+ ) -> Iterator[CacheGetSpan]:
97
+ if not instrumentations:
98
+ yield NoopAnySpan()
99
+ return
100
+
101
+ with ExitStack() as stack:
102
+ spans = [
103
+ stack.enter_context(inst.start_cache_get_span(key, name))
104
+ for inst in instrumentations
105
+ ]
106
+ yield _AggregatingGetSpan(spans)
107
+
108
+
109
+ @contextmanager
110
+ def _put_span(
111
+ instrumentations: Sequence[CacheInstrumentation],
112
+ key: str,
113
+ name: str | None,
114
+ ) -> Iterator[CachePutSpan]:
115
+ if not instrumentations:
116
+ yield NoopAnySpan()
117
+ return
118
+
119
+ with ExitStack() as stack:
120
+ spans = [
121
+ stack.enter_context(inst.start_cache_put_span(key, name))
122
+ for inst in instrumentations
123
+ ]
124
+ yield _AggregatingPutSpan(spans)
125
+
126
+
127
+ @contextmanager
128
+ def _remove_span(
129
+ instrumentations: Sequence[CacheInstrumentation],
130
+ key: str,
131
+ ) -> Iterator[CacheRemoveSpan]:
132
+ if not instrumentations:
133
+ yield NoopAnySpan()
134
+ return
135
+
136
+ with ExitStack() as stack:
137
+ spans = [
138
+ stack.enter_context(inst.start_cache_remove_span(key))
139
+ for inst in instrumentations
140
+ ]
141
+ yield _AggregatingRemoveSpan(spans)
142
+
143
+
144
+ @contextmanager
145
+ def _cleanup_span(
146
+ instrumentations: Sequence[CacheInstrumentation],
147
+ ) -> Iterator[CacheCleanupSpan]:
148
+ if not instrumentations:
149
+ yield NoopAnySpan()
150
+ return
151
+
152
+ with ExitStack() as stack:
153
+ spans = [
154
+ stack.enter_context(inst.start_cache_cleanup_span())
155
+ for inst in instrumentations
156
+ ]
157
+ yield _AggregatingCleanupSpan(spans)
158
+
159
+
160
+ @contextmanager
161
+ def _flush_span(
162
+ instrumentations: Sequence[CacheInstrumentation],
163
+ ) -> Iterator[CacheFlushSpan]:
164
+ if not instrumentations:
165
+ yield NoopAnySpan()
166
+ return
167
+
168
+ with ExitStack() as stack:
169
+ spans = [
170
+ stack.enter_context(inst.start_cache_flush_span())
171
+ for inst in instrumentations
172
+ ]
173
+ yield _AggregatingFlushSpan(spans)
@@ -0,0 +1,156 @@
1
+ # SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """
6
+ Prometheus-backed cache instrumentation.
7
+ """
8
+
9
+ from collections.abc import Iterator
10
+ from contextlib import contextmanager
11
+
12
+ from prometheus_client import Counter, Gauge, Histogram
13
+
14
+ from psycache.typing import (
15
+ CacheCleanupSpan,
16
+ CacheFlushSpan,
17
+ CacheGetSpan,
18
+ CachePutSpan,
19
+ CacheRemoveSpan,
20
+ )
21
+
22
+
23
+ _LABEL_NAMES = ["span_name"]
24
+
25
+ CACHE_HITS = Counter(
26
+ "psycache_hits_total",
27
+ "Number of cache hits",
28
+ labelnames=_LABEL_NAMES,
29
+ )
30
+
31
+ CACHE_MISSES = Counter(
32
+ "psycache_misses_total",
33
+ "Number of cache misses",
34
+ labelnames=_LABEL_NAMES,
35
+ )
36
+
37
+ CACHE_GET_DURATION = Histogram(
38
+ "psycache_get_duration_seconds",
39
+ "Time taken for cache get operations",
40
+ labelnames=_LABEL_NAMES,
41
+ )
42
+
43
+ CACHE_PUT_DURATION = Histogram(
44
+ "psycache_put_duration_seconds",
45
+ "Time taken for cache put operations",
46
+ labelnames=_LABEL_NAMES,
47
+ )
48
+
49
+ CACHE_REMOVE_DURATION = Histogram(
50
+ "psycache_remove_duration_seconds",
51
+ "Time taken for cache remove operations",
52
+ )
53
+
54
+ CACHE_FLUSH_DURATION = Histogram(
55
+ "psycache_flush_duration_seconds",
56
+ "Time taken for cache flush operations",
57
+ )
58
+
59
+ CACHE_ITEM_SIZE = Histogram(
60
+ "psycache_item_size_bytes",
61
+ "Size of cache items in bytes",
62
+ labelnames=_LABEL_NAMES,
63
+ buckets=[64, 256, 1024, 4096, 16384, 65536, 262144, 1048576],
64
+ )
65
+
66
+ CACHE_FLUSHED_ENTRIES = Histogram(
67
+ "psycache_flushed_entries",
68
+ "Number of entries flushed per flush operation",
69
+ buckets=[0, 1, 5, 10, 25, 50, 100, 250, 500, 1000],
70
+ )
71
+
72
+ CACHE_CLEANUP_LAST_RUN = Gauge(
73
+ "psycache_cleanup_last_run_timestamp_seconds",
74
+ "Timestamp of the last expired-entry cleanup run",
75
+ )
76
+
77
+ CACHE_CLEANUP_DELETED = Gauge(
78
+ "psycache_cleanup_deleted_entries",
79
+ "Number of expired entries deleted in the last cleanup run",
80
+ )
81
+
82
+
83
+ class _PrometheusCacheGetSpan:
84
+ def __init__(self, span_name: str) -> None:
85
+ self._span_name = span_name
86
+
87
+ def record_cache_hit(self, item_size: int) -> None:
88
+ CACHE_HITS.labels(span_name=self._span_name).inc()
89
+ CACHE_ITEM_SIZE.labels(span_name=self._span_name).observe(item_size)
90
+
91
+ def record_cache_miss(self) -> None:
92
+ CACHE_MISSES.labels(span_name=self._span_name).inc()
93
+
94
+
95
+ class _PrometheusCachePutSpan:
96
+ def __init__(self, span_name: str) -> None:
97
+ self._span_name = span_name
98
+
99
+ def record_put(self, item_size: int) -> None:
100
+ CACHE_ITEM_SIZE.labels(span_name=self._span_name).observe(item_size)
101
+
102
+
103
+ class _PrometheusCacheRemoveSpan:
104
+ def record_removed(self) -> None:
105
+ pass
106
+
107
+
108
+ class _PrometheusCacheCleanupSpan:
109
+ def record_cleanup(self, num_deleted: int) -> None:
110
+ CACHE_CLEANUP_LAST_RUN.set_to_current_time()
111
+ CACHE_CLEANUP_DELETED.set(num_deleted)
112
+
113
+
114
+ class _PrometheusCacheFlushSpan:
115
+ def record_flush(self, num_flushed: int) -> None:
116
+ CACHE_FLUSHED_ENTRIES.observe(num_flushed)
117
+
118
+
119
+ class PrometheusInstrumentation:
120
+ """
121
+ Prometheus-backed instrumentation for cache operations.
122
+ """
123
+
124
+ @contextmanager
125
+ def start_cache_get_span(
126
+ self, key: str, name: str | None
127
+ ) -> Iterator[CacheGetSpan]:
128
+ span_name = name or ""
129
+ with CACHE_GET_DURATION.labels(span_name=span_name).time():
130
+ yield _PrometheusCacheGetSpan(span_name)
131
+
132
+ @contextmanager
133
+ def start_cache_put_span(
134
+ self, key: str, name: str | None
135
+ ) -> Iterator[CachePutSpan]:
136
+ span_name = name or ""
137
+ with CACHE_PUT_DURATION.labels(span_name=span_name).time():
138
+ yield _PrometheusCachePutSpan(span_name)
139
+
140
+ @contextmanager
141
+ def start_cache_remove_span(self, key: str) -> Iterator[CacheRemoveSpan]:
142
+ with CACHE_REMOVE_DURATION.time():
143
+ yield _PrometheusCacheRemoveSpan()
144
+
145
+ @contextmanager
146
+ def start_cache_cleanup_span(
147
+ self,
148
+ ) -> Iterator[CacheCleanupSpan]:
149
+ yield _PrometheusCacheCleanupSpan()
150
+
151
+ @contextmanager
152
+ def start_cache_flush_span(
153
+ self,
154
+ ) -> Iterator[CacheFlushSpan]:
155
+ with CACHE_FLUSH_DURATION.time():
156
+ yield _PrometheusCacheFlushSpan()
@@ -0,0 +1,142 @@
1
+ # SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """
6
+ Sentry-backed cache instrumentation.
7
+
8
+ See also:
9
+
10
+ - <https://docs.sentry.io/platforms/python/tracing/instrumentation/custom-instrumentation/caches-module/>
11
+ - <https://develop.sentry.dev/sdk/telemetry/traces/modules/caches/>
12
+
13
+ Span instances are not frozen because they're instantiated a lot so we go for
14
+ performance.
15
+ """
16
+
17
+ from collections.abc import Iterator
18
+ from contextlib import contextmanager
19
+
20
+ import attrs
21
+ import sentry_sdk
22
+
23
+ from sentry_sdk.tracing import Span
24
+
25
+ from psycache.instrumentation import NoopAnySpan
26
+ from psycache.typing import (
27
+ CacheCleanupSpan,
28
+ CacheFlushSpan,
29
+ CacheGetSpan,
30
+ CachePutSpan,
31
+ CacheRemoveSpan,
32
+ )
33
+
34
+
35
+ @attrs.define
36
+ class _SentryCacheGetSpan:
37
+ """
38
+ Sentry-backed span for cache get operations.
39
+ """
40
+
41
+ _key: str
42
+ _span: Span
43
+
44
+ def record_cache_hit(self, item_size: int) -> None:
45
+ self._span.set_data("cache.hit", True)
46
+ self._span.set_data("cache.item_size", item_size)
47
+ self._span.set_data("cache.success", True)
48
+
49
+ def record_cache_miss(self) -> None:
50
+ self._span.set_data("cache.hit", False)
51
+ self._span.set_data("cache.success", True)
52
+
53
+
54
+ @attrs.define
55
+ class _SentryCachePutSpan:
56
+ """
57
+ Sentry-backed span for cache put operations.
58
+ """
59
+
60
+ _key: str
61
+ _span: Span
62
+
63
+ def record_put(self, item_size: int) -> None:
64
+ self._span.set_data("cache.item_size", item_size)
65
+ self._span.set_data("cache.success", True)
66
+
67
+
68
+ @attrs.define
69
+ class _SentryCacheRemoveSpan:
70
+ """
71
+ Sentry-backed span for cache removal operations.
72
+ """
73
+
74
+ _key: str
75
+ _span: Span
76
+
77
+ def record_removed(self) -> None:
78
+ self._span.set_data("cache.success", True)
79
+
80
+
81
+ @attrs.define
82
+ class _SentryCacheFlushSpan:
83
+ """
84
+ Sentry-backed span for cache flush operations.
85
+ """
86
+
87
+ _span: Span
88
+
89
+ def record_flush(self, num_flushed: int) -> None:
90
+ self._span.set_data("cache.num_flushed", num_flushed)
91
+
92
+
93
+ class SentryInstrumentation:
94
+ """
95
+ Sentry-backed instrumentation for cache operations.
96
+ """
97
+
98
+ @contextmanager
99
+ def start_cache_get_span(
100
+ self, key: str, name: str | None
101
+ ) -> Iterator[CacheGetSpan]:
102
+ with sentry_sdk.start_span(
103
+ op="cache.get", name=name or "psycache get"
104
+ ) as span:
105
+ span.set_data("cache.key", [key])
106
+
107
+ yield _SentryCacheGetSpan(key, span)
108
+
109
+ @contextmanager
110
+ def start_cache_put_span(
111
+ self, key: str, name: str | None
112
+ ) -> Iterator[CachePutSpan]:
113
+ with sentry_sdk.start_span(
114
+ op="cache.put", name=name or "psycache put"
115
+ ) as span:
116
+ span.set_data("cache.key", [key])
117
+
118
+ yield _SentryCachePutSpan(key, span)
119
+
120
+ @contextmanager
121
+ def start_cache_remove_span(self, key: str) -> Iterator[CacheRemoveSpan]:
122
+ with sentry_sdk.start_span(
123
+ op="cache.remove", name="psycache remove"
124
+ ) as span:
125
+ span.set_data("cache.key", [key])
126
+
127
+ yield _SentryCacheRemoveSpan(key, span)
128
+
129
+ @contextmanager
130
+ def start_cache_cleanup_span(
131
+ self,
132
+ ) -> Iterator[CacheCleanupSpan]:
133
+ yield NoopAnySpan()
134
+
135
+ @contextmanager
136
+ def start_cache_flush_span(
137
+ self,
138
+ ) -> Iterator[CacheFlushSpan]:
139
+ with sentry_sdk.start_span(
140
+ op="cache.flush", name="psycache flush"
141
+ ) as span:
142
+ yield _SentryCacheFlushSpan(span)
@@ -0,0 +1,56 @@
1
+ # SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """
6
+ Integration with `psycopg_pool`: <https://www.psycopg.org/psycopg3/docs/api/pool.html>
7
+ """
8
+
9
+ from collections.abc import AsyncIterator, Iterator
10
+ from contextlib import asynccontextmanager, contextmanager
11
+ from typing import Any
12
+
13
+ import attrs
14
+ import psycopg
15
+
16
+ from psycopg_pool import AsyncConnectionPool, ConnectionPool
17
+
18
+
19
+ @attrs.frozen
20
+ class PsycopgCachePool:
21
+ """
22
+ A cache pool based on `psycopg_pool.ConnectionPool`.
23
+ """
24
+
25
+ _pool: ConnectionPool[Any] = attrs.field(alias="pool")
26
+
27
+ @contextmanager
28
+ def connect(self) -> Iterator[psycopg.Connection]:
29
+ with self._pool.connection() as conn:
30
+ autocommit = conn.autocommit
31
+ conn.autocommit = True
32
+
33
+ try:
34
+ yield conn
35
+ finally:
36
+ conn.autocommit = autocommit
37
+
38
+
39
+ @attrs.frozen
40
+ class AsyncPsycopgCachePool:
41
+ """
42
+ A cache pool based on `psycopg_pool.AsyncConnectionPool`.
43
+ """
44
+
45
+ _pool: AsyncConnectionPool[Any] = attrs.field(alias="pool")
46
+
47
+ @asynccontextmanager
48
+ async def connect(self) -> AsyncIterator[psycopg.AsyncConnection]:
49
+ async with self._pool.connection() as conn:
50
+ autocommit = conn.autocommit
51
+ await conn.set_autocommit(True)
52
+
53
+ try:
54
+ yield conn
55
+ finally:
56
+ await conn.set_autocommit(autocommit)
psycache/py.typed ADDED
File without changes
psycache/sqlalchemy.py ADDED
@@ -0,0 +1,51 @@
1
+ # SPDX-FileCopyrightText: 2026 Hynek Schlawack <hs@ox.cx>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """
6
+ Integration with SQLAlchemy.
7
+ """
8
+
9
+ from collections.abc import AsyncIterator, Iterator
10
+ from contextlib import asynccontextmanager, contextmanager
11
+
12
+ import attrs
13
+ import psycopg
14
+
15
+ from sqlalchemy.engine import Engine
16
+ from sqlalchemy.ext.asyncio import AsyncEngine
17
+
18
+
19
+ @attrs.frozen
20
+ class SQLAlchemyCachePool:
21
+ """
22
+ A cache pool based on a SQLAlchemy engine.
23
+ """
24
+
25
+ engine: Engine
26
+
27
+ @contextmanager
28
+ def connect(self) -> Iterator[psycopg.Connection]:
29
+ with self.engine.connect().execution_options(
30
+ isolation_level="AUTOCOMMIT"
31
+ ) as conn:
32
+ yield conn.connection # type: ignore[misc] # ty: ignore[invalid-yield]
33
+
34
+
35
+ @attrs.frozen
36
+ class AsyncSQLAlchemyCachePool:
37
+ """
38
+ A cache pool based on a SQLAlchemy async engine.
39
+ """
40
+
41
+ engine: AsyncEngine
42
+
43
+ @asynccontextmanager
44
+ async def connect(self) -> AsyncIterator[psycopg.AsyncConnection]:
45
+ async with self.engine.connect() as sa_conn:
46
+ conn = await sa_conn.execution_options(
47
+ isolation_level="AUTOCOMMIT"
48
+ )
49
+ raw = await conn.get_raw_connection()
50
+
51
+ yield raw.driver_connection # type: ignore[misc] # ty: ignore[invalid-yield]