action0-django-acache 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.
- action0_django_acache-0.1.0/.github/workflows/ci.yml +137 -0
- action0_django_acache-0.1.0/.github/workflows/release.yml +62 -0
- action0_django_acache-0.1.0/.gitignore +20 -0
- action0_django_acache-0.1.0/.python-version +1 -0
- action0_django_acache-0.1.0/CLAUDE.md +76 -0
- action0_django_acache-0.1.0/LICENSE +21 -0
- action0_django_acache-0.1.0/PKG-INFO +164 -0
- action0_django_acache-0.1.0/README.md +127 -0
- action0_django_acache-0.1.0/docs/api.md +46 -0
- action0_django_acache-0.1.0/docs/conf.py +51 -0
- action0_django_acache-0.1.0/docs/index.md +59 -0
- action0_django_acache-0.1.0/docs/usage.md +193 -0
- action0_django_acache-0.1.0/pyproject.toml +160 -0
- action0_django_acache-0.1.0/src/action0/django_acache/__init__.py +19 -0
- action0_django_acache-0.1.0/src/action0/django_acache/backend.py +155 -0
- action0_django_acache-0.1.0/src/action0/django_acache/client.py +149 -0
- action0_django_acache-0.1.0/src/action0/django_acache/options.py +69 -0
- action0_django_acache-0.1.0/src/action0/django_acache/pools.py +212 -0
- action0_django_acache-0.1.0/src/action0/django_acache/py.typed +0 -0
- action0_django_acache-0.1.0/src/action0/django_acache/validation.py +86 -0
- action0_django_acache-0.1.0/tests/__init__.py +0 -0
- action0_django_acache-0.1.0/tests/action0/__init__.py +0 -0
- action0_django_acache-0.1.0/tests/action0/django_acache/__init__.py +0 -0
- action0_django_acache-0.1.0/tests/action0/django_acache/support.py +108 -0
- action0_django_acache-0.1.0/tests/action0/django_acache/test_backend.py +346 -0
- action0_django_acache-0.1.0/tests/action0/django_acache/test_client.py +114 -0
- action0_django_acache-0.1.0/tests/action0/django_acache/test_init.py +23 -0
- action0_django_acache-0.1.0/tests/action0/django_acache/test_options.py +88 -0
- action0_django_acache-0.1.0/tests/action0/django_acache/test_pools.py +233 -0
- action0_django_acache-0.1.0/tests/action0/django_acache/test_settings.py +62 -0
- action0_django_acache-0.1.0/tests/action0/django_acache/test_validation.py +96 -0
- action0_django_acache-0.1.0/uv.lock +1565 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
# cancel superseded runs of the same branch / PR
|
|
10
|
+
concurrency:
|
|
11
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
12
|
+
cancel-in-progress: true
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
checks:
|
|
16
|
+
name: checks (python ${{ matrix.python-version }})
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
continue-on-error: ${{ matrix.experimental }}
|
|
19
|
+
strategy:
|
|
20
|
+
fail-fast: false
|
|
21
|
+
matrix:
|
|
22
|
+
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
|
23
|
+
experimental: [false]
|
|
24
|
+
include:
|
|
25
|
+
# 3.15 is still a pre-release: run it, but don't fail the whole
|
|
26
|
+
# workflow while parts of the toolchain don't support it yet
|
|
27
|
+
- python-version: "3.15"
|
|
28
|
+
experimental: true
|
|
29
|
+
|
|
30
|
+
steps:
|
|
31
|
+
- uses: actions/checkout@v7
|
|
32
|
+
|
|
33
|
+
- name: Install uv
|
|
34
|
+
uses: astral-sh/setup-uv@v10.0.1
|
|
35
|
+
with:
|
|
36
|
+
python-version: ${{ matrix.python-version }}
|
|
37
|
+
|
|
38
|
+
- name: Install dependencies
|
|
39
|
+
run: uv sync --locked
|
|
40
|
+
|
|
41
|
+
- name: ruff format
|
|
42
|
+
run: uv run ruff format --check
|
|
43
|
+
|
|
44
|
+
- name: ruff check
|
|
45
|
+
run: uv run ruff check
|
|
46
|
+
|
|
47
|
+
- name: mypy
|
|
48
|
+
run: uv run mypy
|
|
49
|
+
|
|
50
|
+
- name: pyright
|
|
51
|
+
run: uv run pyright
|
|
52
|
+
|
|
53
|
+
- name: ty
|
|
54
|
+
run: uv run ty check
|
|
55
|
+
|
|
56
|
+
- name: pytest
|
|
57
|
+
run: uv run pytest
|
|
58
|
+
|
|
59
|
+
servers:
|
|
60
|
+
# the checks above run against fakeredis; this runs the same tests against real servers
|
|
61
|
+
name: tests against ${{ matrix.server.image }}
|
|
62
|
+
runs-on: ubuntu-latest
|
|
63
|
+
strategy:
|
|
64
|
+
fail-fast: false
|
|
65
|
+
matrix:
|
|
66
|
+
server:
|
|
67
|
+
- { image: "redis:8", cli: redis-cli }
|
|
68
|
+
- { image: "redis:7", cli: redis-cli }
|
|
69
|
+
- { image: "valkey/valkey:9", cli: valkey-cli }
|
|
70
|
+
- { image: "valkey/valkey:8", cli: valkey-cli }
|
|
71
|
+
services:
|
|
72
|
+
server:
|
|
73
|
+
image: ${{ matrix.server.image }}
|
|
74
|
+
ports:
|
|
75
|
+
- 6379:6379
|
|
76
|
+
options: >-
|
|
77
|
+
--health-cmd "${{ matrix.server.cli }} ping"
|
|
78
|
+
--health-interval 2s
|
|
79
|
+
--health-timeout 5s
|
|
80
|
+
--health-retries 15
|
|
81
|
+
env:
|
|
82
|
+
# the tests flush this database; the service container is thrown away afterwards
|
|
83
|
+
REDIS_URL: redis://localhost:6379/0
|
|
84
|
+
|
|
85
|
+
steps:
|
|
86
|
+
- uses: actions/checkout@v7
|
|
87
|
+
|
|
88
|
+
- name: Install uv
|
|
89
|
+
uses: astral-sh/setup-uv@v10.0.1
|
|
90
|
+
|
|
91
|
+
- name: Install dependencies
|
|
92
|
+
run: uv sync --locked
|
|
93
|
+
|
|
94
|
+
- name: pytest
|
|
95
|
+
run: uv run pytest
|
|
96
|
+
|
|
97
|
+
docs:
|
|
98
|
+
name: docs build
|
|
99
|
+
runs-on: ubuntu-latest
|
|
100
|
+
steps:
|
|
101
|
+
- uses: actions/checkout@v7
|
|
102
|
+
|
|
103
|
+
- name: Install uv
|
|
104
|
+
uses: astral-sh/setup-uv@v10.0.1
|
|
105
|
+
|
|
106
|
+
- name: Install dependencies
|
|
107
|
+
run: uv sync --locked --group docs
|
|
108
|
+
|
|
109
|
+
- name: Build documentation (warnings are errors)
|
|
110
|
+
run: uv run --group docs sphinx-build -W --keep-going -b html docs docs/_build/html
|
|
111
|
+
|
|
112
|
+
- name: Upload pages artifact
|
|
113
|
+
uses: actions/upload-pages-artifact@v5
|
|
114
|
+
with:
|
|
115
|
+
path: docs/_build/html
|
|
116
|
+
|
|
117
|
+
deploy-docs:
|
|
118
|
+
name: deploy docs
|
|
119
|
+
# publish only what landed on main
|
|
120
|
+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
121
|
+
needs: docs
|
|
122
|
+
runs-on: ubuntu-latest
|
|
123
|
+
permissions:
|
|
124
|
+
contents: read
|
|
125
|
+
pages: write
|
|
126
|
+
id-token: write
|
|
127
|
+
environment:
|
|
128
|
+
name: github-pages
|
|
129
|
+
url: ${{ steps.deployment.outputs.page_url }}
|
|
130
|
+
# the Pages site itself was enabled once via
|
|
131
|
+
# `gh api repos/<owner>/<repo>/pages -X POST -f build_type=workflow`;
|
|
132
|
+
# creating it from a workflow is impossible (GITHUB_TOKEN cannot get
|
|
133
|
+
# repo-administration rights)
|
|
134
|
+
steps:
|
|
135
|
+
- name: Deploy to GitHub Pages
|
|
136
|
+
id: deployment
|
|
137
|
+
uses: actions/deploy-pages@v5
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
name: check and build
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v7
|
|
13
|
+
|
|
14
|
+
- name: Install uv
|
|
15
|
+
uses: astral-sh/setup-uv@v10.0.1
|
|
16
|
+
|
|
17
|
+
- name: Install dependencies
|
|
18
|
+
run: uv sync --locked
|
|
19
|
+
|
|
20
|
+
- name: Run all checks
|
|
21
|
+
run: |
|
|
22
|
+
uv run ruff format --check
|
|
23
|
+
uv run ruff check
|
|
24
|
+
uv run mypy
|
|
25
|
+
uv run pyright
|
|
26
|
+
uv run ty check
|
|
27
|
+
uv run pytest
|
|
28
|
+
|
|
29
|
+
- name: Check that the tag matches __version__
|
|
30
|
+
run: |
|
|
31
|
+
version=$(uv run python -c "from action0.django_acache import __version__; print(__version__)")
|
|
32
|
+
if [ "v$version" != "$GITHUB_REF_NAME" ]; then
|
|
33
|
+
echo "tag $GITHUB_REF_NAME does not match __version__ $version" >&2
|
|
34
|
+
exit 1
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
- name: Build sdist and wheel
|
|
38
|
+
run: uv build
|
|
39
|
+
|
|
40
|
+
- uses: actions/upload-artifact@v7
|
|
41
|
+
with:
|
|
42
|
+
name: dist
|
|
43
|
+
path: dist/
|
|
44
|
+
|
|
45
|
+
publish:
|
|
46
|
+
name: publish to PyPI
|
|
47
|
+
needs: build
|
|
48
|
+
runs-on: ubuntu-latest
|
|
49
|
+
environment:
|
|
50
|
+
name: pypi
|
|
51
|
+
url: https://pypi.org/p/action0-django-acache
|
|
52
|
+
permissions:
|
|
53
|
+
# OIDC token for PyPI trusted publishing
|
|
54
|
+
id-token: write
|
|
55
|
+
steps:
|
|
56
|
+
- uses: actions/download-artifact@v8
|
|
57
|
+
with:
|
|
58
|
+
name: dist
|
|
59
|
+
path: dist/
|
|
60
|
+
|
|
61
|
+
- name: Publish to PyPI
|
|
62
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Python-generated files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[oc]
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
wheels/
|
|
7
|
+
*.egg-info
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
.venv
|
|
11
|
+
|
|
12
|
+
# Editor files
|
|
13
|
+
.idea
|
|
14
|
+
|
|
15
|
+
# Claude Code local state
|
|
16
|
+
.claude/settings.local.json
|
|
17
|
+
.claude/worktrees/
|
|
18
|
+
|
|
19
|
+
# Sphinx build output
|
|
20
|
+
docs/_build/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Project
|
|
6
|
+
|
|
7
|
+
`action0-django-acache` is a Django cache backend: Django's own Redis backend (`django.core.cache.backends.redis.RedisCache`) subclassed so that its async methods (`aget`, `aset`, ...) run natively on `redis.asyncio` instead of wrapping the sync methods in `sync_to_async`. It ships the `action0.django_acache` package (`action0` is a PEP 420 namespace package) from a `src/` layout, is built with hatchling, and uses `uv` for environment/dependency management. Runtime dependencies: `django>=5.2` and `redis>=5.0.1`; the `hiredis` extra passes through to `redis[hiredis]`. Settings use it as `"BACKEND": "action0.django_acache.RedisCache"`.
|
|
8
|
+
|
|
9
|
+
## Rules
|
|
10
|
+
|
|
11
|
+
- **Never commit without asking.** Also never push, tag, or publish on your own.
|
|
12
|
+
- **Branches + PRs.** All changes go through feature branches and GitHub pull requests that Simon reviews and merges — never commit to `main` directly. (Only the initial implementation is built directly on `main`; once that phase is over, this applies without exception.)
|
|
13
|
+
- **Discuss first.** Always present the plan and the intended edits and get agreement before changing files.
|
|
14
|
+
- Every code change comes with: tests, docstrings, inline comments where the code isn't self-explanatory, and updated usage examples in `README.md` and the Sphinx docs (`docs/usage.md`).
|
|
15
|
+
- Before considering work done, run ruff, mypy, pyright, ty and pytest (commands below) and fix what they report.
|
|
16
|
+
- Supported Python versions: 3.11 up to the latest release. Don't use syntax or stdlib features introduced after 3.11 (no PEP 695 `type` aliases or generics — use `TypeAlias`), and don't rely on behavior removed in newer versions.
|
|
17
|
+
- Prefer many small modules and short functions over large ones.
|
|
18
|
+
|
|
19
|
+
## Commands
|
|
20
|
+
|
|
21
|
+
`uv run` syncs the environment automatically (the dev dependency group is installed by default), so no separate install step is needed.
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
uv run pytest # all tests (fakeredis)
|
|
25
|
+
uv run pytest tests/action0/django_acache/test_pools.py # one file
|
|
26
|
+
uv run pytest tests/action0/django_acache/test_pools.py::LifecycleTestCase::test_threads # one test
|
|
27
|
+
REDIS_URL=redis://localhost:6379/15 uv run pytest # against a real server (flushes that db!)
|
|
28
|
+
|
|
29
|
+
uv run --with "django==5.2.*" pytest # the oldest supported Django (the lock has 6.x on 3.12+)
|
|
30
|
+
uv run --with "redis==5.0.1" pytest # the oldest supported redis-py
|
|
31
|
+
|
|
32
|
+
uv run ruff check # lint (add --fix to autofix)
|
|
33
|
+
uv run ruff format # format
|
|
34
|
+
uv run mypy # type-check (strict; files are configured in pyproject.toml)
|
|
35
|
+
uv run pyright # type-check
|
|
36
|
+
uv run ty check # type-check
|
|
37
|
+
|
|
38
|
+
uv run --group docs sphinx-build -W --keep-going -b html docs docs/_build/html # build docs
|
|
39
|
+
|
|
40
|
+
uv build # build sdist + wheel into dist/
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`pytest` also runs the `>>>` examples in the docstrings as doctests (`--doctest-modules` over `src/`), so docstring examples must produce their shown output exactly. pytest turns **every warning into an error** (`filterwarnings = ["error"]`) — deliberately, because the `ResourceWarning`s redis-py emits for unclosed connections are exactly what the pool lifecycle must prevent.
|
|
44
|
+
|
|
45
|
+
## Architecture
|
|
46
|
+
|
|
47
|
+
Modules under `src/action0/django_acache/`, from the leaves up (`options.py` uses `validation.py`):
|
|
48
|
+
|
|
49
|
+
- `options.py` — `async_pool_config(pool_options, overrides)`: derives the async pool class and pool kwargs from the kwargs Django built for its sync pools (`RedisCacheClient._pool_options`) plus the cache's `ASYNC_OPTIONS`. `pool_class`/`parser_class` default to the `redis.asyncio` variants (never the sync ones from `OPTIONS`) and accept dotted paths; `serializer` may not be overridden (both sides must read each other's values).
|
|
50
|
+
- `validation.py` — `check_async_options(options, overrides)`, called by `async_pool_config`: rejects sync redis-py values that would reach the async pools (`RULES`: `pool_class` must subclass `redis.asyncio.ConnectionPool`, `connection_class` `redis.asyncio.connection.AbstractConnection`, `retry` must be a `redis.asyncio.retry.Retry` or `None`) with an `ImproperlyConfigured` naming the key, whether it came from `OPTIONS` or `ASYNC_OPTIONS`, and the fix. Simon's decision; the motivating case: a sync `Retry` on an async connection is accepted silently and never retries (measured: 1 connect attempt instead of 4). Runs when the client is built, i.e. on the cache's first use — sync use included.
|
|
51
|
+
- `pools.py` — `PoolRegistry` and the module-level `registry`: one shared `redis.asyncio.Redis` client (and its pool) per running event loop and per configuration (pool class, URL, options). Lookup compares options by identity, then equality — not by hash, because Django passes an unhashable `redis.DriverInfo` dataclass. On a loop's first use the registry starts an async generator on it (`_close_at_shutdown`) whose `finally` closes that loop's pools; `loop.shutdown_asyncgens()` (called by `asyncio.run`, `asyncio.Runner`, uvicorn, asgiref's `async_to_sync`, `IsolatedAsyncioTestCase`) triggers it. `aclose()`/`aclose_pools()` close the running loop's pools early by closing that generator, i.e. through the same path. Loops closed without shutting down their async generators are purged when the next new loop registers.
|
|
52
|
+
- `client.py` — `RedisCacheClient(django RedisCacheClient)`: an async twin (`aadd`, `aget`, ...) of every sync method, line by line, plus `aget_client()` which asks the registry. Takes `async_options` in addition to Django's client arguments.
|
|
53
|
+
- `backend.py` — `RedisCache(django RedisCache)`: reads `ASYNC_OPTIONS` from the cache params, builds the client with it (`_cache`), and overrides every async method Django would otherwise hand to a thread: `aadd aget aset atouch adelete aget_many ahas_key aincr aset_many adelete_many aclear aclose`. The rest (`adecr`, `aget_or_set`, `aincr_version`, `adecr_version`) are Django's generic `BaseCache` versions, which build on these natively — `test_async_methods_never_use_the_sync_client` guards that. `get_backend_timeout` is overridden only to type the result as `int | None`.
|
|
54
|
+
|
|
55
|
+
Deliberate decisions worth keeping, all confirmed by Simon — don't reopen them without a new reason:
|
|
56
|
+
|
|
57
|
+
- **Pools shared per event loop, not per cache instance.** Under ASGI Django creates a cache instance per request (the cache handler stores instances in an asgiref context-local; a task only sees instances its parent context already created). Per-instance pools would open a connection per request and leak them. Rejected alternative: per-instance pools closed by an async `request_finished` receiver — a TCP connect per request, and code outside requests (management commands, `async_to_sync`, Celery) would still need the loop-shutdown hook.
|
|
58
|
+
- **One class for sync + async**, extending Django's backend; the sync side stays Django's untouched code.
|
|
59
|
+
- **`ASYNC_OPTIONS`** as a top-level cache setting next to `OPTIONS`, overriding keys for the async side. Rejected alternatives: nested `OPTIONS["async"]` and `async_*` prefixed keys — both would make the settings invalid for Django's stock backend, whereas a top-level key keeps them switchable.
|
|
60
|
+
- **One shared `redis.asyncio.Redis` per pool** instead of one per call like Django's sync side: constructing one costs ~47µs, about a local round trip.
|
|
61
|
+
- `aclose()` (and Django's per-request `close()`) do not close the shared pools.
|
|
62
|
+
- Tests: fakeredis locally (sync and async pools sharing one `FakeServer`), plus a CI job running the same suite against real Redis and Valkey containers via `REDIS_URL`.
|
|
63
|
+
|
|
64
|
+
Conventions:
|
|
65
|
+
|
|
66
|
+
- The version is single-sourced as `__version__` in `src/action0/django_acache/__init__.py`; hatch extracts it with the regex in `[tool.hatch.version]`. Bump it only there.
|
|
67
|
+
- Releases: pushing a `vX.Y.Z` tag triggers `.github/workflows/release.yml`, which re-runs all checks, verifies the tag matches `__version__`, builds, and publishes to PyPI via trusted publishing (environment `pypi`). Never bump the version, tag, or publish on your own — releasing is the user's call.
|
|
68
|
+
- CI (`ci.yml`): the checks matrix (Python 3.11–3.14, experimental 3.15) against fakeredis; the `servers` job runs pytest against `redis:8`, `redis:7`, `valkey/valkey:9`, `valkey/valkey:8` service containers; docs build + Pages deploy.
|
|
69
|
+
- The dev group pins `django>=6.0; python_version >= '3.12'` so the lock forks: Django 5.2 on 3.11, the latest Django on 3.12+. CI's 3.11 job is therefore the Django 5.2 test.
|
|
70
|
+
- Django and redis-py private attributes the code relies on (`_servers`, `_serializer`, `_pool_options`, `_pools`, `_get_connection_pool_index`, `_options`) are declared as annotations in the subclasses, because django-stubs doesn't list them. The async `DefaultParser` class depends on the redis-py version and on hiredis — never assert its concrete name.
|
|
71
|
+
- Tests mirror the `src/` layout under `tests/action0/django_acache/` (a regular package, for the relative imports of `support.py`) and are `unittest.TestCase`/`IsolatedAsyncioTestCase` classes, executed via pytest. `support.py` configures Django settings and provides `cache_settings()`, `make_cache()`, `async_server()` and `CacheTestCase`. Tests that use the sync side must close its pools (`close_sync_pools`), or a real server's sockets raise `ResourceWarning`. Cache keys in tests must be memcached-safe (no spaces), or Django's `CacheKeyWarning` fails them. Real-server tests: the Django cache handler shares instances with child tasks once the parent context touched them — simulate separate requests without touching `caches` in the parent.
|
|
72
|
+
- When an ignore is unavoidable, silence each checker with its own syntax (`# type: ignore[code] # ty: ignore[code]`). ty picks redis-py 8's sync overload for some async commands (e.g. `incr`), hence one `ty: ignore[invalid-await]`.
|
|
73
|
+
- Ruff enforces one import per line (isort `force-single-line`), line length 99, `action0` as first-party. Ruff only honours `.gitignore` inside a git repository.
|
|
74
|
+
- Docs live in `docs/` (Sphinx + Furo, MyST Markdown pages, autodoc for the API reference, intersphinx to Python, Django and redis-py). Docstrings are Sphinx-reST (`:param:`, `:py:func:` roles). CI builds them with `-W` on every run and deploys to GitHub Pages on pushes to `main`. Examples in `docs/usage.md` and `README.md` must stay truthful.
|
|
75
|
+
- The GitHub Pages site must be enabled once per repo before `deploy-docs` can run: `gh api repos/LaughInJar/action0-django-acache/pages -X POST -f build_type=workflow`.
|
|
76
|
+
- The README carries an AI-usage disclosure section — keep it accurate when the development workflow changes.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LaughInJar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: action0-django-acache
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Django's Redis cache backend with native async methods on redis.asyncio
|
|
5
|
+
Project-URL: Homepage, https://github.com/LaughInJar/action0-django-acache
|
|
6
|
+
Project-URL: Documentation, https://laughinjar.github.io/action0-django-acache/
|
|
7
|
+
Project-URL: Source, https://github.com/LaughInJar/action0-django-acache
|
|
8
|
+
Project-URL: Issues, https://github.com/LaughInJar/action0-django-acache/issues
|
|
9
|
+
Author: Simon Lachinger
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: asgi,async,asyncio,cache,django,redis,valkey
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Framework :: Django
|
|
16
|
+
Classifier: Framework :: Django :: 5.2
|
|
17
|
+
Classifier: Framework :: Django :: 6.0
|
|
18
|
+
Classifier: Framework :: Django :: 6.1
|
|
19
|
+
Classifier: Intended Audience :: Developers
|
|
20
|
+
Classifier: Operating System :: OS Independent
|
|
21
|
+
Classifier: Programming Language :: Python :: 3
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
27
|
+
Classifier: Topic :: Database
|
|
28
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
29
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
30
|
+
Classifier: Typing :: Typed
|
|
31
|
+
Requires-Python: >=3.11
|
|
32
|
+
Requires-Dist: django>=5.2
|
|
33
|
+
Requires-Dist: redis>=5.0.1
|
|
34
|
+
Provides-Extra: hiredis
|
|
35
|
+
Requires-Dist: redis[hiredis]>=5.0.1; extra == 'hiredis'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# Action0-Django-ACache
|
|
39
|
+
|
|
40
|
+
[](https://github.com/LaughInJar/action0-django-acache/actions/workflows/ci.yml)
|
|
41
|
+
[](https://pypi.org/project/action0-django-acache/)
|
|
42
|
+
|
|
43
|
+
Django's Redis cache backend with async methods that are actually async: they
|
|
44
|
+
talk to the server through `redis.asyncio` instead of running the sync methods
|
|
45
|
+
in a worker thread.
|
|
46
|
+
|
|
47
|
+
Requires Python 3.11 or newer, Django 5.2 or newer and redis-py 5.0.1 or newer.
|
|
48
|
+
Works with Redis and Valkey.
|
|
49
|
+
|
|
50
|
+
Full documentation including the API reference:
|
|
51
|
+
<https://laughinjar.github.io/action0-django-acache/>
|
|
52
|
+
|
|
53
|
+
**Status:** early. Every async cache method is native and covered by tests
|
|
54
|
+
(against fakeredis, and in CI against Redis 7/8 and Valkey 8/9); the API may
|
|
55
|
+
still move.
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```shell
|
|
60
|
+
pip install action0-django-acache # or: uv add action0-django-acache
|
|
61
|
+
pip install "action0-django-acache[hiredis]" # with the C parser
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
Swap the backend in your settings — everything else stays as with Django's
|
|
67
|
+
Redis backend:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
CACHES = {
|
|
71
|
+
"default": {
|
|
72
|
+
"BACKEND": "action0.django_acache.RedisCache",
|
|
73
|
+
"LOCATION": "redis://127.0.0.1:6379",
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
And await the cache in async code as usual:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from django.core.cache import cache
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def weather(request, city):
|
|
85
|
+
report = await cache.aget(f"weather:{city}")
|
|
86
|
+
if report is None:
|
|
87
|
+
report = await fetch_weather(city)
|
|
88
|
+
await cache.aset(f"weather:{city}", report, timeout=300)
|
|
89
|
+
...
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Django's own `RedisCache` runs every one of these calls in a thread via
|
|
93
|
+
`sync_to_async` (and on Django 5.2 its `aincr` is not even atomic: a get, then
|
|
94
|
+
a set). Here `aget`, `aset`, `aadd`, `atouch`, `adelete`, `ahas_key`,
|
|
95
|
+
`aget_many`, `aset_many`, `adelete_many`, `aincr` and `aclear` are coroutines
|
|
96
|
+
on `redis.asyncio`, and `aincr` is a single `INCRBY` on the server.
|
|
97
|
+
|
|
98
|
+
The sync methods are Django's, untouched, and both sides write the same keys
|
|
99
|
+
with the same serializer: sync and async code share one cache.
|
|
100
|
+
|
|
101
|
+
A few redis-py options come in a sync and an async variant. `ASYNC_OPTIONS`
|
|
102
|
+
overrides `OPTIONS` for the async side only — and a sync class or `Retry` that
|
|
103
|
+
would still reach the async side is an `ImproperlyConfigured` error naming the
|
|
104
|
+
fix:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
CACHES = {
|
|
108
|
+
"default": {
|
|
109
|
+
"BACKEND": "action0.django_acache.RedisCache",
|
|
110
|
+
"LOCATION": "redis://127.0.0.1:6379",
|
|
111
|
+
"OPTIONS": {
|
|
112
|
+
"pool_class": "redis.BlockingConnectionPool",
|
|
113
|
+
"max_connections": 50,
|
|
114
|
+
},
|
|
115
|
+
"ASYNC_OPTIONS": {
|
|
116
|
+
"pool_class": "redis.asyncio.BlockingConnectionPool",
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Under ASGI, Django creates a cache instance per request. The async connection
|
|
123
|
+
pools are therefore shared per event loop — all requests reuse the same
|
|
124
|
+
connections — and closed when the loop shuts down. `aclose_pools()` closes
|
|
125
|
+
them earlier:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from action0.django_acache import aclose_pools
|
|
129
|
+
|
|
130
|
+
await aclose_pools()
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
See the [usage guide](https://laughinjar.github.io/action0-django-acache/usage.html)
|
|
134
|
+
for the details: which method sends which commands, every `ASYNC_OPTIONS` key,
|
|
135
|
+
the pool lifecycle, and a fakeredis setup for your tests.
|
|
136
|
+
|
|
137
|
+
The `action0` namespace is simply the one the author likes to use for
|
|
138
|
+
personal projects.
|
|
139
|
+
|
|
140
|
+
## Development
|
|
141
|
+
|
|
142
|
+
```shell
|
|
143
|
+
uv run pytest # tests (incl. doctests in src/), against fakeredis
|
|
144
|
+
uv run ruff check # lint
|
|
145
|
+
uv run ruff format # format
|
|
146
|
+
uv run mypy # type-check (strict)
|
|
147
|
+
uv run pyright # type-check
|
|
148
|
+
uv run ty check # type-check
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`REDIS_URL=redis://localhost:6379/15 uv run pytest` runs the tests against a
|
|
152
|
+
real server instead — they flush that database.
|
|
153
|
+
|
|
154
|
+
## AI disclosure
|
|
155
|
+
|
|
156
|
+
This library is developed with heavy use of AI coding tools: the code,
|
|
157
|
+
tests, and documentation are largely written by
|
|
158
|
+
[Claude Code](https://claude.com/claude-code), working from the author's
|
|
159
|
+
design brief and reviewed by the author. If that changes how much you want
|
|
160
|
+
to rely on this package, that's a fair call — read the source, it's small.
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Action0-Django-ACache
|
|
2
|
+
|
|
3
|
+
[](https://github.com/LaughInJar/action0-django-acache/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/action0-django-acache/)
|
|
5
|
+
|
|
6
|
+
Django's Redis cache backend with async methods that are actually async: they
|
|
7
|
+
talk to the server through `redis.asyncio` instead of running the sync methods
|
|
8
|
+
in a worker thread.
|
|
9
|
+
|
|
10
|
+
Requires Python 3.11 or newer, Django 5.2 or newer and redis-py 5.0.1 or newer.
|
|
11
|
+
Works with Redis and Valkey.
|
|
12
|
+
|
|
13
|
+
Full documentation including the API reference:
|
|
14
|
+
<https://laughinjar.github.io/action0-django-acache/>
|
|
15
|
+
|
|
16
|
+
**Status:** early. Every async cache method is native and covered by tests
|
|
17
|
+
(against fakeredis, and in CI against Redis 7/8 and Valkey 8/9); the API may
|
|
18
|
+
still move.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```shell
|
|
23
|
+
pip install action0-django-acache # or: uv add action0-django-acache
|
|
24
|
+
pip install "action0-django-acache[hiredis]" # with the C parser
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
Swap the backend in your settings — everything else stays as with Django's
|
|
30
|
+
Redis backend:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
CACHES = {
|
|
34
|
+
"default": {
|
|
35
|
+
"BACKEND": "action0.django_acache.RedisCache",
|
|
36
|
+
"LOCATION": "redis://127.0.0.1:6379",
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
And await the cache in async code as usual:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from django.core.cache import cache
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def weather(request, city):
|
|
48
|
+
report = await cache.aget(f"weather:{city}")
|
|
49
|
+
if report is None:
|
|
50
|
+
report = await fetch_weather(city)
|
|
51
|
+
await cache.aset(f"weather:{city}", report, timeout=300)
|
|
52
|
+
...
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Django's own `RedisCache` runs every one of these calls in a thread via
|
|
56
|
+
`sync_to_async` (and on Django 5.2 its `aincr` is not even atomic: a get, then
|
|
57
|
+
a set). Here `aget`, `aset`, `aadd`, `atouch`, `adelete`, `ahas_key`,
|
|
58
|
+
`aget_many`, `aset_many`, `adelete_many`, `aincr` and `aclear` are coroutines
|
|
59
|
+
on `redis.asyncio`, and `aincr` is a single `INCRBY` on the server.
|
|
60
|
+
|
|
61
|
+
The sync methods are Django's, untouched, and both sides write the same keys
|
|
62
|
+
with the same serializer: sync and async code share one cache.
|
|
63
|
+
|
|
64
|
+
A few redis-py options come in a sync and an async variant. `ASYNC_OPTIONS`
|
|
65
|
+
overrides `OPTIONS` for the async side only — and a sync class or `Retry` that
|
|
66
|
+
would still reach the async side is an `ImproperlyConfigured` error naming the
|
|
67
|
+
fix:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
CACHES = {
|
|
71
|
+
"default": {
|
|
72
|
+
"BACKEND": "action0.django_acache.RedisCache",
|
|
73
|
+
"LOCATION": "redis://127.0.0.1:6379",
|
|
74
|
+
"OPTIONS": {
|
|
75
|
+
"pool_class": "redis.BlockingConnectionPool",
|
|
76
|
+
"max_connections": 50,
|
|
77
|
+
},
|
|
78
|
+
"ASYNC_OPTIONS": {
|
|
79
|
+
"pool_class": "redis.asyncio.BlockingConnectionPool",
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Under ASGI, Django creates a cache instance per request. The async connection
|
|
86
|
+
pools are therefore shared per event loop — all requests reuse the same
|
|
87
|
+
connections — and closed when the loop shuts down. `aclose_pools()` closes
|
|
88
|
+
them earlier:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from action0.django_acache import aclose_pools
|
|
92
|
+
|
|
93
|
+
await aclose_pools()
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
See the [usage guide](https://laughinjar.github.io/action0-django-acache/usage.html)
|
|
97
|
+
for the details: which method sends which commands, every `ASYNC_OPTIONS` key,
|
|
98
|
+
the pool lifecycle, and a fakeredis setup for your tests.
|
|
99
|
+
|
|
100
|
+
The `action0` namespace is simply the one the author likes to use for
|
|
101
|
+
personal projects.
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
```shell
|
|
106
|
+
uv run pytest # tests (incl. doctests in src/), against fakeredis
|
|
107
|
+
uv run ruff check # lint
|
|
108
|
+
uv run ruff format # format
|
|
109
|
+
uv run mypy # type-check (strict)
|
|
110
|
+
uv run pyright # type-check
|
|
111
|
+
uv run ty check # type-check
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`REDIS_URL=redis://localhost:6379/15 uv run pytest` runs the tests against a
|
|
115
|
+
real server instead — they flush that database.
|
|
116
|
+
|
|
117
|
+
## AI disclosure
|
|
118
|
+
|
|
119
|
+
This library is developed with heavy use of AI coding tools: the code,
|
|
120
|
+
tests, and documentation are largely written by
|
|
121
|
+
[Claude Code](https://claude.com/claude-code), working from the author's
|
|
122
|
+
design brief and reviewed by the author. If that changes how much you want
|
|
123
|
+
to rely on this package, that's a fair call — read the source, it's small.
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# API reference
|
|
2
|
+
|
|
3
|
+
Everything public is importable from the package root:
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from action0.django_acache import (
|
|
7
|
+
PoolRegistry,
|
|
8
|
+
RedisCache,
|
|
9
|
+
RedisCacheClient,
|
|
10
|
+
aclose_pools,
|
|
11
|
+
registry,
|
|
12
|
+
)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Backend
|
|
16
|
+
|
|
17
|
+
```{eval-rst}
|
|
18
|
+
.. automodule:: action0.django_acache.backend
|
|
19
|
+
:members:
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Client
|
|
23
|
+
|
|
24
|
+
```{eval-rst}
|
|
25
|
+
.. automodule:: action0.django_acache.client
|
|
26
|
+
:members: RedisCacheClient
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Pools
|
|
30
|
+
|
|
31
|
+
```{eval-rst}
|
|
32
|
+
.. automodule:: action0.django_acache.pools
|
|
33
|
+
:members: aclose_pools, registry, PoolRegistry
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Options
|
|
37
|
+
|
|
38
|
+
```{eval-rst}
|
|
39
|
+
.. automodule:: action0.django_acache.options
|
|
40
|
+
:members:
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```{eval-rst}
|
|
44
|
+
.. automodule:: action0.django_acache.validation
|
|
45
|
+
:members: check_async_options, RULES
|
|
46
|
+
```
|