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.
Files changed (32) hide show
  1. action0_django_acache-0.1.0/.github/workflows/ci.yml +137 -0
  2. action0_django_acache-0.1.0/.github/workflows/release.yml +62 -0
  3. action0_django_acache-0.1.0/.gitignore +20 -0
  4. action0_django_acache-0.1.0/.python-version +1 -0
  5. action0_django_acache-0.1.0/CLAUDE.md +76 -0
  6. action0_django_acache-0.1.0/LICENSE +21 -0
  7. action0_django_acache-0.1.0/PKG-INFO +164 -0
  8. action0_django_acache-0.1.0/README.md +127 -0
  9. action0_django_acache-0.1.0/docs/api.md +46 -0
  10. action0_django_acache-0.1.0/docs/conf.py +51 -0
  11. action0_django_acache-0.1.0/docs/index.md +59 -0
  12. action0_django_acache-0.1.0/docs/usage.md +193 -0
  13. action0_django_acache-0.1.0/pyproject.toml +160 -0
  14. action0_django_acache-0.1.0/src/action0/django_acache/__init__.py +19 -0
  15. action0_django_acache-0.1.0/src/action0/django_acache/backend.py +155 -0
  16. action0_django_acache-0.1.0/src/action0/django_acache/client.py +149 -0
  17. action0_django_acache-0.1.0/src/action0/django_acache/options.py +69 -0
  18. action0_django_acache-0.1.0/src/action0/django_acache/pools.py +212 -0
  19. action0_django_acache-0.1.0/src/action0/django_acache/py.typed +0 -0
  20. action0_django_acache-0.1.0/src/action0/django_acache/validation.py +86 -0
  21. action0_django_acache-0.1.0/tests/__init__.py +0 -0
  22. action0_django_acache-0.1.0/tests/action0/__init__.py +0 -0
  23. action0_django_acache-0.1.0/tests/action0/django_acache/__init__.py +0 -0
  24. action0_django_acache-0.1.0/tests/action0/django_acache/support.py +108 -0
  25. action0_django_acache-0.1.0/tests/action0/django_acache/test_backend.py +346 -0
  26. action0_django_acache-0.1.0/tests/action0/django_acache/test_client.py +114 -0
  27. action0_django_acache-0.1.0/tests/action0/django_acache/test_init.py +23 -0
  28. action0_django_acache-0.1.0/tests/action0/django_acache/test_options.py +88 -0
  29. action0_django_acache-0.1.0/tests/action0/django_acache/test_pools.py +233 -0
  30. action0_django_acache-0.1.0/tests/action0/django_acache/test_settings.py +62 -0
  31. action0_django_acache-0.1.0/tests/action0/django_acache/test_validation.py +96 -0
  32. 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
+ [![CI](https://github.com/LaughInJar/action0-django-acache/actions/workflows/ci.yml/badge.svg)](https://github.com/LaughInJar/action0-django-acache/actions/workflows/ci.yml)
41
+ [![PyPI](https://img.shields.io/pypi/v/action0-django-acache)](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
+ [![CI](https://github.com/LaughInJar/action0-django-acache/actions/workflows/ci.yml/badge.svg)](https://github.com/LaughInJar/action0-django-acache/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/action0-django-acache)](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
+ ```