psycache 26.1.0__tar.gz → 26.2.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.
- psycache-26.2.0/.git_archival.txt +3 -0
- psycache-26.2.0/.gitattributes +1 -0
- {psycache-26.1.0 → psycache-26.2.0}/.github/workflows/ci.yml +22 -0
- {psycache-26.1.0 → psycache-26.2.0}/.gitignore +1 -0
- psycache-26.2.0/.readthedocs.yaml +26 -0
- psycache-26.2.0/CHANGELOG.md +35 -0
- psycache-26.2.0/PKG-INFO +109 -0
- psycache-26.2.0/README.md +72 -0
- {psycache-26.1.0 → psycache-26.2.0}/conftest.py +9 -7
- psycache-26.2.0/docs/api-types.md +9 -0
- psycache-26.2.0/docs/api.md +58 -0
- psycache-26.2.0/docs/assets/javascript/readthedocs.js +8 -0
- psycache-26.2.0/docs/async.md +64 -0
- psycache-26.2.0/docs/cleanup.md +69 -0
- psycache-26.2.0/docs/getting-started.md +86 -0
- psycache-26.2.0/docs/index.md +39 -0
- psycache-26.2.0/docs/instrumentation/custom.md +40 -0
- psycache-26.2.0/docs/instrumentation/index.md +32 -0
- psycache-26.2.0/docs/instrumentation/prometheus.md +28 -0
- psycache-26.2.0/docs/instrumentation/sentry.md +12 -0
- psycache-26.2.0/docs/pool-adapters.md +68 -0
- psycache-26.2.0/docs/quality-of-life.md +73 -0
- psycache-26.2.0/docs/raw-queries.md +81 -0
- {psycache-26.1.0 → psycache-26.2.0}/pyproject.toml +3 -2
- {psycache-26.1.0 → psycache-26.2.0}/src/psycache/_async.py +53 -27
- {psycache-26.1.0 → psycache-26.2.0}/src/psycache/_sync.py +70 -20
- {psycache-26.1.0 → psycache-26.2.0}/src/psycache/_tables.py +6 -0
- {psycache-26.1.0 → psycache-26.2.0}/src/psycache/instrumentation/sentry.py +25 -11
- {psycache-26.1.0 → psycache-26.2.0}/src/psycache/psycopg_pool.py +13 -6
- {psycache-26.1.0 → psycache-26.2.0}/src/psycache/sqlalchemy.py +17 -7
- {psycache-26.1.0 → psycache-26.2.0}/src/psycache/typing.py +4 -4
- {psycache-26.1.0 → psycache-26.2.0}/tests/test_maintenance.py +1 -1
- {psycache-26.1.0 → psycache-26.2.0}/tests/test_raw.py +1 -1
- {psycache-26.1.0 → psycache-26.2.0}/tests/test_raw_async.py +2 -2
- {psycache-26.1.0 → psycache-26.2.0}/tests/typing/core.py +3 -2
- {psycache-26.1.0 → psycache-26.2.0}/tox.ini +17 -2
- psycache-26.2.0/zensical.toml +156 -0
- psycache-26.1.0/CHANGELOG.md +0 -16
- psycache-26.1.0/PKG-INFO +0 -381
- psycache-26.1.0/README.md +0 -343
- {psycache-26.1.0 → psycache-26.2.0}/.github/AI_POLICY.md +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/.github/CODE_OF_CONDUCT.md +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/.github/CONTRIBUTING.md +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/.github/FUNDING.yml +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/.github/dependabot.yml +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/.github/workflows/codeql-analysis.yml +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/.github/workflows/pypi-package.yml +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/.github/workflows/zizmor.yml +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/.pre-commit-config.yaml +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/.python-version +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/LICENSE +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/src/psycache/__init__.py +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/src/psycache/__main__.py +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/src/psycache/_durations.py +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/src/psycache/_sql.py +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/src/psycache/instrumentation/__init__.py +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/src/psycache/instrumentation/_spans.py +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/src/psycache/instrumentation/prometheus.py +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/src/psycache/py.typed +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/tests/__init__.py +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/tests/test_cli.py +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/tests/test_psycopg_pool.py +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/tests/test_sqlalchemy.py +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/tests/typing/README.md +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/tests/typing/psycopg_pool.py +0 -0
- {psycache-26.1.0 → psycache-26.2.0}/tests/typing/sqlalchemy.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.git_archival.txt export-subst
|
|
@@ -189,6 +189,27 @@ jobs:
|
|
|
189
189
|
uvx --with tox-uv
|
|
190
190
|
tox run -e docs-doctests
|
|
191
191
|
|
|
192
|
+
docs-build:
|
|
193
|
+
name: Build the documentation site
|
|
194
|
+
needs: build-package
|
|
195
|
+
runs-on: ubuntu-latest
|
|
196
|
+
steps:
|
|
197
|
+
- name: Download pre-built packages
|
|
198
|
+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
199
|
+
with:
|
|
200
|
+
name: Packages
|
|
201
|
+
path: dist
|
|
202
|
+
- run: tar xf dist/*.tar.gz --strip-components=1
|
|
203
|
+
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
|
204
|
+
with:
|
|
205
|
+
# Keep in sync with tox.ini's base_python_file.
|
|
206
|
+
python-version-file: .python-version
|
|
207
|
+
- uses: hynek/setup-cached-uv@4300ec2180bc77d705e626a34e381b81a4772c51 # v2.5.0
|
|
208
|
+
|
|
209
|
+
- run: >
|
|
210
|
+
uvx --with tox-uv
|
|
211
|
+
tox run -e docs-build -- --strict
|
|
212
|
+
|
|
192
213
|
install-dev:
|
|
193
214
|
name: Verify dev env
|
|
194
215
|
runs-on: ubuntu-latest
|
|
@@ -216,6 +237,7 @@ jobs:
|
|
|
216
237
|
- install-dev
|
|
217
238
|
- typing
|
|
218
239
|
- docs
|
|
240
|
+
- docs-build
|
|
219
241
|
|
|
220
242
|
runs-on: ubuntu-latest
|
|
221
243
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
version: 2
|
|
3
|
+
|
|
4
|
+
build:
|
|
5
|
+
os: ubuntu-lts-latest
|
|
6
|
+
tools:
|
|
7
|
+
# Keep in sync with .python-version and tox.ini's docs base_python.
|
|
8
|
+
python: "3.14"
|
|
9
|
+
jobs:
|
|
10
|
+
create_environment:
|
|
11
|
+
# Need the tags to calculate the version.
|
|
12
|
+
- git fetch --tags
|
|
13
|
+
|
|
14
|
+
- asdf plugin add uv
|
|
15
|
+
- asdf install uv latest
|
|
16
|
+
- asdf global uv latest
|
|
17
|
+
|
|
18
|
+
build:
|
|
19
|
+
html:
|
|
20
|
+
# Set canonical URL
|
|
21
|
+
- sed -i "s|https://psycache.hynek.me/|$READTHEDOCS_CANONICAL_URL|g" zensical.toml
|
|
22
|
+
- uvx --with tox-uv tox run -e docs-build
|
|
23
|
+
# Zensical builds into site/; hand it to Read the Docs.
|
|
24
|
+
# https://docs.readthedocs.com/platform/latest/intro/zensical.html
|
|
25
|
+
- mkdir -p $READTHEDOCS_OUTPUT/html/
|
|
26
|
+
- cp --recursive site/* $READTHEDOCS_OUTPUT/html/
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/) and this project adheres to [Calendar Versioning](https://calver.org/).
|
|
6
|
+
|
|
7
|
+
The **first number** of the version is the year.
|
|
8
|
+
The **second number** is incremented with each release, starting at 1 for each year.
|
|
9
|
+
The **third number** is for emergencies when we need to start branches for older releases.
|
|
10
|
+
|
|
11
|
+
> [!IMPORTANT]
|
|
12
|
+
> This package is currently in beta and looks forward to your feedback.
|
|
13
|
+
> The code is battle-tested, but APIs may change.
|
|
14
|
+
|
|
15
|
+
<!-- changelog follows -->
|
|
16
|
+
|
|
17
|
+
## [26.2.0](https://github.com/hynek/psycache/compare/26.1.0...26.2.0) - 2026-06-25
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- Proper docs at <https://psycache.hynek.me/>.
|
|
22
|
+
[#2](https://github.com/hynek/psycache/pull/2)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
## Changed
|
|
26
|
+
|
|
27
|
+
- Replaced *attrs* by hand-written classes.
|
|
28
|
+
Sadly, the documentation ecosystem is not ready and *dataclasses* are not fit for public APIs.
|
|
29
|
+
This means *psycopg* is the **only** dependency.
|
|
30
|
+
[#3](https://github.com/hynek/psycache/pull/3)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
## [26.1.0](https://github.com/hynek/psycache/tree/26.1.0) - 2026-06-24
|
|
34
|
+
|
|
35
|
+
Initial release.
|
psycache-26.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: psycache
|
|
3
|
+
Version: 26.2.0
|
|
4
|
+
Summary: A psycopg-Backed PostgreSQL Cache
|
|
5
|
+
Project-URL: Documentation, https://psycache.hynek.me/
|
|
6
|
+
Project-URL: Changelog, https://github.com/hynek/psycache/blob/main/CHANGELOG.md
|
|
7
|
+
Project-URL: GitHub, https://github.com/hynek/psycache
|
|
8
|
+
Project-URL: Funding, https://github.com/sponsors/hynek
|
|
9
|
+
Project-URL: Tidelift, https://tidelift.com?utm_source=lifter&utm_medium=referral&utm_campaign=hynek
|
|
10
|
+
Project-URL: Mastodon, https://mastodon.social/@hynek
|
|
11
|
+
Project-URL: Bluesky, https://bsky.app/profile/hynek.me
|
|
12
|
+
Project-URL: Twitter, https://twitter.com/hynek
|
|
13
|
+
Author-email: Hynek Schlawack <hs@ox.cx>
|
|
14
|
+
License-Expression: MIT
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Keywords: cache,postgres,postgresql,psycopg
|
|
17
|
+
Classifier: Development Status :: 4 - Beta
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Requires-Dist: psycopg
|
|
26
|
+
Provides-Extra: pool
|
|
27
|
+
Requires-Dist: psycopg-pool; extra == 'pool'
|
|
28
|
+
Provides-Extra: prometheus
|
|
29
|
+
Requires-Dist: prometheus-client; extra == 'prometheus'
|
|
30
|
+
Provides-Extra: sentry
|
|
31
|
+
Requires-Dist: sentry-sdk; extra == 'sentry'
|
|
32
|
+
Provides-Extra: sqlalchemy
|
|
33
|
+
Requires-Dist: sqlalchemy; extra == 'sqlalchemy'
|
|
34
|
+
Provides-Extra: sqlalchemy-asyncio
|
|
35
|
+
Requires-Dist: sqlalchemy[asyncio]; extra == 'sqlalchemy-asyncio'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# *psycache*: *psycopg*-Backed PostgreSQL Cache
|
|
39
|
+
|
|
40
|
+
[](https://github.com/hynek/psycache/blob/main/LICENSE)
|
|
41
|
+
[](https://psycache.hynek.me/)
|
|
42
|
+
[](https://pypi.org/project/psycache/)
|
|
43
|
+
[](https://github.com/hynek/psycache/blob/main/.github/AI_POLICY.md)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
A simple key-value cache that stores JSON in PostgreSQL through [*psycopg*](https://www.psycopg.org/) 3, with TTL-based expiration and pluggable instrumentation.
|
|
47
|
+
|
|
48
|
+
- Sync and async ✔︎
|
|
49
|
+
- Type-safe ✔︎
|
|
50
|
+
- Adapters for [SQLAlchemy](https://www.sqlalchemy.org) and [*psycopg-pool*](https://www.psycopg.org/psycopg3/docs/api/pool.html) ✔︎
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
*psycache* uses an [unlogged table](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-UNLOGGED) for performance and stores values as [JSONB](https://www.postgresql.org/docs/current/datatype-json.html) for versatility.
|
|
55
|
+
|
|
56
|
+
It's a great fit when you already have PostgreSQL and need a fast cache without introducing another piece of infrastructure like Redis.
|
|
57
|
+
For example, you can safely share a SQLAlchemy [`Engine`](https://docs.sqlalchemy.org/en/20/core/connections.html#sqlalchemy.engine.Engine) (or [`AsyncEngine`](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.AsyncEngine)) with *psycache*.
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
## Quick Start
|
|
61
|
+
|
|
62
|
+
Let's hitch-hike on a SQLAlchemy engine as a quick example!
|
|
63
|
+
|
|
64
|
+
First, install *psycache* from PyPI with the `sqlalchemy` extra:
|
|
65
|
+
|
|
66
|
+
```console
|
|
67
|
+
$ uv pip install "psycache[sqlalchemy]"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Initialize the cache table once (`python -Im psycache init-db <dsn>` does the same from the shell), then store and retrieve JSON with a TTL:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import psycopg
|
|
74
|
+
|
|
75
|
+
from sqlalchemy import create_engine
|
|
76
|
+
|
|
77
|
+
import psycache
|
|
78
|
+
|
|
79
|
+
from psycache import PostgresCache
|
|
80
|
+
from psycache.sqlalchemy import SQLAlchemyCachePool
|
|
81
|
+
|
|
82
|
+
with psycopg.connect(
|
|
83
|
+
"postgresql://psycache@127.0.0.1/psycache", autocommit=True
|
|
84
|
+
) as conn:
|
|
85
|
+
psycache.init_db(conn)
|
|
86
|
+
|
|
87
|
+
engine = create_engine("postgresql+psycopg://psycache@127.0.0.1/psycache")
|
|
88
|
+
cache = PostgresCache(SQLAlchemyCachePool(engine))
|
|
89
|
+
|
|
90
|
+
cache.put_raw("user:alice", {"score": 42}, ttl=300)
|
|
91
|
+
value = cache.get_raw("user:alice")
|
|
92
|
+
# {"score": 42}
|
|
93
|
+
|
|
94
|
+
engine.dispose()
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
## Documentation
|
|
99
|
+
|
|
100
|
+
Full documentation lives at **<https://psycache.hynek.me/>**.
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
<!-- --8<-- [start:credits] -->
|
|
104
|
+
## Credits
|
|
105
|
+
|
|
106
|
+
*psycache* is written by [Hynek Schlawack](https://hynek.me/) and distributed under the terms of the [MIT license](https://choosealicense.com/licenses/mit/).
|
|
107
|
+
|
|
108
|
+
The development is kindly supported by my employer [Variomedia AG](https://www.variomedia.de/) and all my fabulous [GitHub Sponsors](https://github.com/sponsors/hynek).
|
|
109
|
+
<!-- --8<-- [end:credits] -->
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# *psycache*: *psycopg*-Backed PostgreSQL Cache
|
|
2
|
+
|
|
3
|
+
[](https://github.com/hynek/psycache/blob/main/LICENSE)
|
|
4
|
+
[](https://psycache.hynek.me/)
|
|
5
|
+
[](https://pypi.org/project/psycache/)
|
|
6
|
+
[](https://github.com/hynek/psycache/blob/main/.github/AI_POLICY.md)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
A simple key-value cache that stores JSON in PostgreSQL through [*psycopg*](https://www.psycopg.org/) 3, with TTL-based expiration and pluggable instrumentation.
|
|
10
|
+
|
|
11
|
+
- Sync and async ✔︎
|
|
12
|
+
- Type-safe ✔︎
|
|
13
|
+
- Adapters for [SQLAlchemy](https://www.sqlalchemy.org) and [*psycopg-pool*](https://www.psycopg.org/psycopg3/docs/api/pool.html) ✔︎
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
*psycache* uses an [unlogged table](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-UNLOGGED) for performance and stores values as [JSONB](https://www.postgresql.org/docs/current/datatype-json.html) for versatility.
|
|
18
|
+
|
|
19
|
+
It's a great fit when you already have PostgreSQL and need a fast cache without introducing another piece of infrastructure like Redis.
|
|
20
|
+
For example, you can safely share a SQLAlchemy [`Engine`](https://docs.sqlalchemy.org/en/20/core/connections.html#sqlalchemy.engine.Engine) (or [`AsyncEngine`](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.AsyncEngine)) with *psycache*.
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
## Quick Start
|
|
24
|
+
|
|
25
|
+
Let's hitch-hike on a SQLAlchemy engine as a quick example!
|
|
26
|
+
|
|
27
|
+
First, install *psycache* from PyPI with the `sqlalchemy` extra:
|
|
28
|
+
|
|
29
|
+
```console
|
|
30
|
+
$ uv pip install "psycache[sqlalchemy]"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Initialize the cache table once (`python -Im psycache init-db <dsn>` does the same from the shell), then store and retrieve JSON with a TTL:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import psycopg
|
|
37
|
+
|
|
38
|
+
from sqlalchemy import create_engine
|
|
39
|
+
|
|
40
|
+
import psycache
|
|
41
|
+
|
|
42
|
+
from psycache import PostgresCache
|
|
43
|
+
from psycache.sqlalchemy import SQLAlchemyCachePool
|
|
44
|
+
|
|
45
|
+
with psycopg.connect(
|
|
46
|
+
"postgresql://psycache@127.0.0.1/psycache", autocommit=True
|
|
47
|
+
) as conn:
|
|
48
|
+
psycache.init_db(conn)
|
|
49
|
+
|
|
50
|
+
engine = create_engine("postgresql+psycopg://psycache@127.0.0.1/psycache")
|
|
51
|
+
cache = PostgresCache(SQLAlchemyCachePool(engine))
|
|
52
|
+
|
|
53
|
+
cache.put_raw("user:alice", {"score": 42}, ttl=300)
|
|
54
|
+
value = cache.get_raw("user:alice")
|
|
55
|
+
# {"score": 42}
|
|
56
|
+
|
|
57
|
+
engine.dispose()
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
## Documentation
|
|
62
|
+
|
|
63
|
+
Full documentation lives at **<https://psycache.hynek.me/>**.
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
<!-- --8<-- [start:credits] -->
|
|
67
|
+
## Credits
|
|
68
|
+
|
|
69
|
+
*psycache* is written by [Hynek Schlawack](https://hynek.me/) and distributed under the terms of the [MIT license](https://choosealicense.com/licenses/mit/).
|
|
70
|
+
|
|
71
|
+
The development is kindly supported by my employer [Variomedia AG](https://www.variomedia.de/) and all my fabulous [GitHub Sponsors](https://github.com/sponsors/hynek).
|
|
72
|
+
<!-- --8<-- [end:credits] -->
|
|
@@ -6,14 +6,14 @@ import importlib.util
|
|
|
6
6
|
|
|
7
7
|
from collections.abc import AsyncIterator, Iterator
|
|
8
8
|
from contextlib import asynccontextmanager, contextmanager
|
|
9
|
+
from dataclasses import dataclass
|
|
9
10
|
from doctest import ELLIPSIS
|
|
10
11
|
|
|
11
|
-
import attrs
|
|
12
12
|
import psycopg
|
|
13
13
|
import pytest
|
|
14
14
|
|
|
15
15
|
from sybil import Sybil
|
|
16
|
-
from sybil.parsers import
|
|
16
|
+
from sybil.parsers import markdown
|
|
17
17
|
|
|
18
18
|
from psycache import AsyncPostgresCache, PostgresCache, init_db
|
|
19
19
|
from psycache.instrumentation.prometheus import PrometheusInstrumentation
|
|
@@ -26,11 +26,13 @@ collect_ignore = [
|
|
|
26
26
|
if importlib.util.find_spec(name) is None
|
|
27
27
|
]
|
|
28
28
|
|
|
29
|
+
# The docs are CommonMark/Material (Zensical), so use the Markdown parsers.
|
|
30
|
+
# Executable examples use plain ```python fences; illustrative snippets are
|
|
31
|
+
# preceded by an HTML comment <!-- skip: next -->.
|
|
29
32
|
pytest_collect_file = Sybil(
|
|
30
33
|
parsers=[
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
myst.SkipParser(),
|
|
34
|
+
markdown.PythonCodeBlockParser(doctest_optionflags=ELLIPSIS),
|
|
35
|
+
markdown.SkipParser(),
|
|
34
36
|
],
|
|
35
37
|
patterns=["*.md"],
|
|
36
38
|
fixtures=["psycache_database"],
|
|
@@ -77,7 +79,7 @@ _INSTRUMENTATIONS = [
|
|
|
77
79
|
]
|
|
78
80
|
|
|
79
81
|
|
|
80
|
-
@
|
|
82
|
+
@dataclass
|
|
81
83
|
class _RawCachePool:
|
|
82
84
|
"""
|
|
83
85
|
A minimal CachePool that opens a psycopg connection per checkout.
|
|
@@ -106,7 +108,7 @@ def _cache(request: pytest.FixtureRequest, db_dsn: str):
|
|
|
106
108
|
return PostgresCache(_RawCachePool(db_dsn), instrumentations=request.param)
|
|
107
109
|
|
|
108
110
|
|
|
109
|
-
@
|
|
111
|
+
@dataclass
|
|
110
112
|
class _RawAsyncCachePool:
|
|
111
113
|
"""
|
|
112
114
|
A minimal AsyncCachePool that opens a psycopg connection per checkout.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
---
|
|
2
|
+
icon: material/api
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# API Reference
|
|
6
|
+
|
|
7
|
+
## Core
|
|
8
|
+
|
|
9
|
+
::: psycache
|
|
10
|
+
options:
|
|
11
|
+
members:
|
|
12
|
+
- init_db
|
|
13
|
+
- PostgresCache
|
|
14
|
+
- AsyncPostgresCache
|
|
15
|
+
- CleanupService
|
|
16
|
+
- AsyncCleanupService
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
## Pool adapters
|
|
20
|
+
|
|
21
|
+
### *psycopg_pool*
|
|
22
|
+
|
|
23
|
+
::: psycache.psycopg_pool.PsycopgCachePool
|
|
24
|
+
options:
|
|
25
|
+
show_root_heading: true
|
|
26
|
+
members: false
|
|
27
|
+
heading_level: 4
|
|
28
|
+
|
|
29
|
+
::: psycache.psycopg_pool.AsyncPsycopgCachePool
|
|
30
|
+
options:
|
|
31
|
+
show_root_heading: true
|
|
32
|
+
members: false
|
|
33
|
+
heading_level: 4
|
|
34
|
+
|
|
35
|
+
### SQLAlchemy
|
|
36
|
+
|
|
37
|
+
::: psycache.sqlalchemy.SQLAlchemyCachePool
|
|
38
|
+
options:
|
|
39
|
+
show_root_heading: true
|
|
40
|
+
members: false
|
|
41
|
+
|
|
42
|
+
::: psycache.sqlalchemy.AsyncSQLAlchemyCachePool
|
|
43
|
+
options:
|
|
44
|
+
show_root_heading: true
|
|
45
|
+
members: false
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
## Instrumentation
|
|
49
|
+
|
|
50
|
+
::: psycache.instrumentation.prometheus.PrometheusInstrumentation
|
|
51
|
+
options:
|
|
52
|
+
show_root_heading: true
|
|
53
|
+
members: false
|
|
54
|
+
|
|
55
|
+
::: psycache.instrumentation.sentry.SentryInstrumentation
|
|
56
|
+
options:
|
|
57
|
+
show_root_heading: true
|
|
58
|
+
members: false
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
document.addEventListener("DOMContentLoaded", function(event) {
|
|
2
|
+
// Trigger Read the Docs' search addon instead of Zensical default
|
|
3
|
+
document.querySelector(".md-search").addEventListener("click", (e) => {
|
|
4
|
+
e.preventDefault();
|
|
5
|
+
const event = new CustomEvent("readthedocs-search-show");
|
|
6
|
+
document.dispatchEvent(event);
|
|
7
|
+
});
|
|
8
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Async
|
|
2
|
+
|
|
3
|
+
*psycache* ships asyncio-native API that mirror the synchronous ones.
|
|
4
|
+
|
|
5
|
+
So, [`AsyncPostgresCache`][psycache.AsyncPostgresCache] exposes `get_raw`, `put_raw`, `remove`, `cleanup_expired`, and `flush` – all async methods with the same signatures as their synchronous counterparts.
|
|
6
|
+
It needs an [`AsyncCachePool`][psycache.typing.AsyncCachePool]: anything with an async `connect()` that yields a [`psycopg.AsyncConnection`][].
|
|
7
|
+
|
|
8
|
+
Two adapters are included, again, mirroring their synchronous counterparts.
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
## SQLAlchemy
|
|
12
|
+
|
|
13
|
+
[`AsyncSQLAlchemyCachePool`][psycache.sqlalchemy.AsyncSQLAlchemyCachePool] wraps a SQLAlchemy [`AsyncEngine`][sqlalchemy.ext.asyncio.AsyncEngine] (requires `psycache[sqlalchemy-asyncio]`):
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import asyncio
|
|
17
|
+
|
|
18
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
19
|
+
|
|
20
|
+
from psycache import AsyncPostgresCache
|
|
21
|
+
from psycache.sqlalchemy import AsyncSQLAlchemyCachePool
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def main() -> None:
|
|
25
|
+
engine = create_async_engine(
|
|
26
|
+
"postgresql+psycopg://psycache@127.0.0.1/psycache"
|
|
27
|
+
)
|
|
28
|
+
cache = AsyncPostgresCache(AsyncSQLAlchemyCachePool(engine))
|
|
29
|
+
|
|
30
|
+
await cache.put_raw("my-key", {"user": "alice"}, ttl=300)
|
|
31
|
+
value = await cache.get_raw("my-key")
|
|
32
|
+
|
|
33
|
+
await engine.dispose()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
asyncio.run(main())
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
## psycopg-pool
|
|
41
|
+
|
|
42
|
+
[`AsyncPsycopgCachePool`][psycache.psycopg_pool.AsyncPsycopgCachePool] wraps a [`psycopg_pool.AsyncConnectionPool`][psycopg_pool.AsyncConnectionPool] (requires `psycache[pool]`):
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import asyncio
|
|
46
|
+
|
|
47
|
+
from psycopg_pool import AsyncConnectionPool
|
|
48
|
+
|
|
49
|
+
from psycache import AsyncPostgresCache
|
|
50
|
+
from psycache.psycopg_pool import AsyncPsycopgCachePool
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def main() -> None:
|
|
54
|
+
async with AsyncConnectionPool(
|
|
55
|
+
"postgresql://psycache@127.0.0.1/psycache", open=False
|
|
56
|
+
) as pool:
|
|
57
|
+
cache = AsyncPostgresCache(AsyncPsycopgCachePool(pool))
|
|
58
|
+
|
|
59
|
+
await cache.put_raw("my-key", {"user": "alice"}, ttl=300)
|
|
60
|
+
value = await cache.get_raw("my-key")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
asyncio.run(main())
|
|
64
|
+
```
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Background Cleanup
|
|
2
|
+
|
|
3
|
+
*psycache* ignores expired keys when reading, but their rows stick around until something deletes them.
|
|
4
|
+
You can call [`PostgresCache.cleanup_expired()`][psycache.PostgresCache.cleanup_expired] yourself, or let *psycache* do it for you in the background.
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
## A cleanup thread
|
|
8
|
+
|
|
9
|
+
For synchronous pools, [`PostgresCache.start_cleanup_thread()`][psycache.PostgresCache.start_cleanup_thread] starts a daemon thread that periodically deletes expired entries.
|
|
10
|
+
Use it as a context manager to stop the thread automatically:
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from sqlalchemy import create_engine
|
|
14
|
+
|
|
15
|
+
from psycache import PostgresCache
|
|
16
|
+
from psycache.sqlalchemy import SQLAlchemyCachePool
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
engine = create_engine("postgresql+psycopg://psycache@127.0.0.1/psycache")
|
|
20
|
+
cache = PostgresCache(SQLAlchemyCachePool(engine))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
with cache.start_cleanup_thread(interval=60):
|
|
24
|
+
... # your application runs here
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or manage its lifecycle manually through the returned [`CleanupService`][psycache.CleanupService]:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
svc = cache.start_cleanup_thread(interval=60)
|
|
31
|
+
try:
|
|
32
|
+
... # your application runs here
|
|
33
|
+
finally:
|
|
34
|
+
svc.stop()
|
|
35
|
+
|
|
36
|
+
engine.dispose()
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## ... or a cleanup task
|
|
40
|
+
|
|
41
|
+
For async pools, use [`AsyncPostgresCache.start_cleanup_task()`][psycache.AsyncPostgresCache.start_cleanup_task] inside a running event loop.
|
|
42
|
+
It starts an [`asyncio.Task`][] that periodically deletes expired entries and can be used as an async context manager.
|
|
43
|
+
|
|
44
|
+
Otherwise, it mirrors the behavior of [`PostgresCache.start_cleanup_thread()`][psycache.PostgresCache.start_cleanup_thread]:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import asyncio
|
|
48
|
+
|
|
49
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
50
|
+
|
|
51
|
+
from psycache import AsyncPostgresCache
|
|
52
|
+
from psycache.sqlalchemy import AsyncSQLAlchemyCachePool
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
aengine = create_async_engine("postgresql+psycopg://psycache@127.0.0.1/psycache")
|
|
56
|
+
acache = AsyncPostgresCache(AsyncSQLAlchemyCachePool(aengine))
|
|
57
|
+
|
|
58
|
+
async def main():
|
|
59
|
+
async with acache.start_cleanup_task(interval=60):
|
|
60
|
+
... # your application runs here
|
|
61
|
+
|
|
62
|
+
svc = acache.start_cleanup_task(interval=60)
|
|
63
|
+
try:
|
|
64
|
+
... # your application runs here
|
|
65
|
+
finally:
|
|
66
|
+
await svc.stop()
|
|
67
|
+
|
|
68
|
+
asyncio.run(main())
|
|
69
|
+
```
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
icon: material/rocket-launch
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Getting Started
|
|
6
|
+
|
|
7
|
+
!!! note
|
|
8
|
+
|
|
9
|
+
This documentation uses [*uv*](https://docs.astral.sh/uv/) for package management and so should you.
|
|
10
|
+
It's trivial, though, to translate the commands to your package manager of choice.
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
*psycache* is published on [PyPI](https://pypi.org/project/psycache/), so install it with your favorite package manager:
|
|
16
|
+
|
|
17
|
+
```console
|
|
18
|
+
$ uv pip install psycache
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The core package depends only on [*psycopg*](https://www.psycopg.org/).
|
|
22
|
+
The pool adapters are optional and each lives behind an extra.
|
|
23
|
+
The examples here use SQLAlchemy, so install the `sqlalchemy` extra too:
|
|
24
|
+
|
|
25
|
+
```console
|
|
26
|
+
$ uv pip install "psycache[sqlalchemy]"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
!!! tip
|
|
30
|
+
|
|
31
|
+
See [Connection Pool Adapters][] for the full list of extras and how to bring your own pool.
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
## Initialize the database
|
|
35
|
+
|
|
36
|
+
*psycache* keeps everything in a single [unlogged table](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-UNLOGGED).
|
|
37
|
+
Create it once – either from Python using [`init_db()`][psycache.init_db]:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import psycopg
|
|
41
|
+
|
|
42
|
+
import psycache
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
with psycopg.connect(
|
|
46
|
+
"postgresql://psycache@127.0.0.1/psycache", autocommit=True
|
|
47
|
+
) as conn:
|
|
48
|
+
psycache.init_db(conn)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
…or from the command line:
|
|
52
|
+
|
|
53
|
+
```console
|
|
54
|
+
$ python -Im psycache init-db postgresql://psycache@127.0.0.1/psycache
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
This creates the `psycache` unlogged table and an index on `expires_at`.
|
|
58
|
+
[`init_db()`][psycache.init_db] is idempotent, so running it again leaves existing data untouched.
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
## Store and retrieve
|
|
62
|
+
|
|
63
|
+
Assuming you already have a SQLAlchemy [`Engine`][sqlalchemy.engine.Engine] in your application, wrap it in a [`SQLAlchemyCachePool`][psycache.sqlalchemy.SQLAlchemyCachePool] and hand it to [`PostgresCache`][psycache.PostgresCache]:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from sqlalchemy import create_engine
|
|
67
|
+
|
|
68
|
+
from psycache import PostgresCache
|
|
69
|
+
from psycache.sqlalchemy import SQLAlchemyCachePool
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
engine = create_engine("postgresql+psycopg://psycache@127.0.0.1/psycache")
|
|
73
|
+
cache = PostgresCache(SQLAlchemyCachePool(engine))
|
|
74
|
+
|
|
75
|
+
# Store a value with a TTL of 300 seconds.
|
|
76
|
+
cache.put_raw("user:alice", {"score": 42}, ttl=300)
|
|
77
|
+
|
|
78
|
+
# Retrieve it (returns None if missing or expired).
|
|
79
|
+
value = cache.get_raw("user:alice")
|
|
80
|
+
# {"score": 42}
|
|
81
|
+
|
|
82
|
+
engine.dispose()
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
That's it!
|
|
86
|
+
You've got a working cache.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# psycache
|
|
2
|
+
|
|
3
|
+
*A psycopg-backed PostgreSQL cache.*
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
**psycache** is a simple key-value cache that stores JSON in PostgreSQL through [*psycopg*](https://www.psycopg.org/psycopg3/docs/) 3, with TTL-based expiration, and pluggable instrumentation.
|
|
8
|
+
|
|
9
|
+
- **Sync and async**: [`PostgresCache`][psycache.PostgresCache] and a fully async [`AsyncPostgresCache`][psycache.AsyncPostgresCache] that mirrors its API.
|
|
10
|
+
|
|
11
|
+
- **Type-safe**: fully typed, checked using Mypy, *ty*, Pyrefly, and Pyright.
|
|
12
|
+
|
|
13
|
+
- **Bring your own pool**: first-class adapters for [SQLAlchemy](https://www.sqlalchemy.org) and [*psycopg-pool*](https://www.psycopg.org/psycopg3/docs/api/pool.html), or implement a tiny protocol (*one* method!) yourself.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
## Why PostgreSQL?
|
|
17
|
+
|
|
18
|
+
Modern PostgreSQL on modern servers is fast enough for almost everyone's use cases.
|
|
19
|
+
|
|
20
|
+
*psycache* stores values as [JSONB](https://www.postgresql.org/docs/current/datatype-json.html) in an [unlogged table](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-UNLOGGED).
|
|
21
|
+
Unlogged tables skip the [write-ahead log](https://www.postgresql.org/docs/current/wal.html) for speed, while JSONB keeps values flexible and queryable.
|
|
22
|
+
|
|
23
|
+
It's a great fit when you **already run PostgreSQL** and want a fast cache without operating another piece of infrastructure like Redis.
|
|
24
|
+
You can even share an existing SQLAlchemy [`Engine`][sqlalchemy.engine.Engine] (or [`AsyncEngine`][sqlalchemy.ext.asyncio.AsyncEngine]) with *psycache*, so the cache rides on the connections you already have.
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
## Get started
|
|
28
|
+
|
|
29
|
+
<div class="grid cards" markdown>
|
|
30
|
+
|
|
31
|
+
- :material-rocket-launch: **[Getting Started](getting-started.md)**: install *psycache*, initialize the table, and store your first value.
|
|
32
|
+
- :material-book-open-variant: **[Raw Queries][]**: Our low-level API: `get_raw`, `put_raw`, TTLs, and instrumentation span names.
|
|
33
|
+
- :material-cog: **[Connection Pool Adapters][]**: wire up SQLAlchemy, *psycopg-pool*, or your own pool.
|
|
34
|
+
- :material-beach: **[Quality of Life][]**: embrace the simple life.
|
|
35
|
+
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
--8<-- "README.md:credits"
|