hjtdev-appkit 2.0.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 (33) hide show
  1. hjtdev_appkit-2.0.0/LICENSE +21 -0
  2. hjtdev_appkit-2.0.0/MANIFEST.in +7 -0
  3. hjtdev_appkit-2.0.0/PKG-INFO +17 -0
  4. hjtdev_appkit-2.0.0/pyproject.toml +219 -0
  5. hjtdev_appkit-2.0.0/setup.cfg +4 -0
  6. hjtdev_appkit-2.0.0/src/appkit/__init__.py +39 -0
  7. hjtdev_appkit-2.0.0/src/appkit/apps.py +33 -0
  8. hjtdev_appkit-2.0.0/src/appkit/cache.py +222 -0
  9. hjtdev_appkit-2.0.0/src/appkit/checks.py +513 -0
  10. hjtdev_appkit-2.0.0/src/appkit/conf.py +71 -0
  11. hjtdev_appkit-2.0.0/src/appkit/crypto.py +102 -0
  12. hjtdev_appkit-2.0.0/src/appkit/dates.py +183 -0
  13. hjtdev_appkit-2.0.0/src/appkit/exceptions.py +158 -0
  14. hjtdev_appkit-2.0.0/src/appkit/files.py +302 -0
  15. hjtdev_appkit-2.0.0/src/appkit/locale/fa/LC_MESSAGES/django.mo +0 -0
  16. hjtdev_appkit-2.0.0/src/appkit/locale/fa/LC_MESSAGES/django.po +29 -0
  17. hjtdev_appkit-2.0.0/src/appkit/media.py +102 -0
  18. hjtdev_appkit-2.0.0/src/appkit/mixins.py +68 -0
  19. hjtdev_appkit-2.0.0/src/appkit/money.py +69 -0
  20. hjtdev_appkit-2.0.0/src/appkit/net.py +115 -0
  21. hjtdev_appkit-2.0.0/src/appkit/pagination.py +30 -0
  22. hjtdev_appkit-2.0.0/src/appkit/permissions.py +48 -0
  23. hjtdev_appkit-2.0.0/src/appkit/py.typed +0 -0
  24. hjtdev_appkit-2.0.0/src/appkit/request_id.py +96 -0
  25. hjtdev_appkit-2.0.0/src/appkit/testing.py +259 -0
  26. hjtdev_appkit-2.0.0/src/appkit/text.py +62 -0
  27. hjtdev_appkit-2.0.0/src/appkit/throttling.py +41 -0
  28. hjtdev_appkit-2.0.0/src/appkit/validation.py +195 -0
  29. hjtdev_appkit-2.0.0/src/hjtdev_appkit.egg-info/PKG-INFO +17 -0
  30. hjtdev_appkit-2.0.0/src/hjtdev_appkit.egg-info/SOURCES.txt +31 -0
  31. hjtdev_appkit-2.0.0/src/hjtdev_appkit.egg-info/dependency_links.txt +1 -0
  32. hjtdev_appkit-2.0.0/src/hjtdev_appkit.egg-info/requires.txt +11 -0
  33. hjtdev_appkit-2.0.0/src/hjtdev_appkit.egg-info/top_level.txt +1 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mohammad Hojjat Nikoobakht
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,7 @@
1
+ # Ships locale/ in the wheel/sdist — not .py files, so no build backend includes them by
2
+ # default (APP-DESIGN.md §2). Paired with include-package-data = true in pyproject.toml.
3
+ #
4
+ # No templates/, no static/: docs/CONTRACT.md never affirmatively commits appkit to shipping
5
+ # either (it commits only to locale/, §5) — appkit has no views, no rendering, and is
6
+ # explicitly "not a component library" (docs/CONTRACT.md §J).
7
+ recursive-include src/appkit/locale *.po *.mo
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: hjtdev-appkit
3
+ Version: 2.0.0
4
+ Summary: Shared Django + DRF foundation every app package and host in this ecosystem depends on — cache, mixins, error envelope, request-ID plumbing, and the HttpClient/provider contract. Not an installable feature; the thing every other app is built on.
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.13
7
+ License-File: LICENSE
8
+ Requires-Dist: django<7.0,>=5.2
9
+ Requires-Dist: djangorestframework<4.0,>=3.15
10
+ Requires-Dist: nh3<1.0,>=0.2
11
+ Requires-Dist: puremagic<3,>=2
12
+ Requires-Dist: jdatetime<7,>=5
13
+ Provides-Extra: crypto
14
+ Requires-Dist: cryptography<51,>=42; extra == "crypto"
15
+ Provides-Extra: images
16
+ Requires-Dist: pillow<13,>=11.3; extra == "images"
17
+ Dynamic: license-file
@@ -0,0 +1,219 @@
1
+ [build-system]
2
+ # setuptools-scm deliberately NOT included here (APP-DESIGN.md §3.1 declares it but never
3
+ # wires it up — no `dynamic = ["version"]`, no [tool.setuptools_scm] table — so it would be
4
+ # dead weight, and it would fight CLAUDE.md's "pyproject.toml/package.json/CHANGELOG.md agree"
5
+ # rule once all three files exist).
6
+ requires = ["setuptools>=77"]
7
+ build-backend = "setuptools.build_meta"
8
+
9
+ [project]
10
+ # "hjtdev-appkit", not the bare "appkit" — the unscoped name is an unrelated, already-registered
11
+ # PyPI package (AppKit, a Webkit desktop app framework), so a prefixed name is not a style choice
12
+ # but the only one available. Same situation, same fix, as frontend/package.json's
13
+ # "@hjtdev/appkit" (docs/CONTRACT.md §22). This is the DISTRIBUTION name only — the importable
14
+ # module stays `appkit` everywhere (`import appkit`, `INSTALLED_APPS`, `AppConfig.name`, the
15
+ # appkit.E001-W006 check IDs) exactly like `python-dateutil` ships `import dateutil`.
16
+ name = "hjtdev-appkit"
17
+ # 0.x = no stability guarantee while the public surface (docs/CONTRACT.md) is still being
18
+ # written into code. Bumped to 1.0.0 here, deliberately, at Phase 8 — not incrementally per
19
+ # phase, since nothing consumed the intermediate numbers. Bumped to 2.0.0 for the PyPI
20
+ # publication/rename above: unlike the frontend's 1.0.1 rename, the backend's prior install
21
+ # command (`uv add "git+...#subdirectory=backend"`) actually worked, so changing the
22
+ # requirement string is a real break for an existing consumer, not a fix to something broken.
23
+ # Must match frontend/package.json and CHANGELOG.md; CI's version-lockstep job enforces it.
24
+ version = "2.0.0"
25
+ description = "Shared Django + DRF foundation every app package and host in this ecosystem depends on — cache, mixins, error envelope, request-ID plumbing, and the HttpClient/provider contract. Not an installable feature; the thing every other app is built on."
26
+ requires-python = ">=3.13"
27
+ license = "MIT"
28
+ license-files = ["LICENSE"]
29
+
30
+ # appkit is the single most widely shared dependency in the entire ecosystem (docs/CONTRACT.md
31
+ # §9) — an exact pin here is the worst possible place for one. Every range below is verified
32
+ # against PyPI/OSV.dev in docs/CONTRACT.md §9, not guessed.
33
+ dependencies = [
34
+ "django>=5.2,<7.0",
35
+ "djangorestframework>=3.15,<4.0",
36
+ # nh3 is a HARD dependency, not an extra, on purpose: an optional HTML sanitiser is a
37
+ # sanitiser some app skips, and skipping it is a stored-XSS bug waiting to happen.
38
+ "nh3>=0.2,<1.0",
39
+ # puremagic over python-magic (needs system libmagic, absent from slim/Alpine images —
40
+ # unacceptable for a dependency installed into an arbitrary host) and over filetype
41
+ # (effectively unmaintained since 2022).
42
+ "puremagic>=2,<3",
43
+ # Pulls in jalali-core>=1.0 transitively (pure Python, ~10 KB, zero further deps) —
44
+ # named explicitly here per docs/CONTRACT.md §9 so `uv tree` doesn't surprise anyone.
45
+ "jdatetime>=5,<7",
46
+ ]
47
+
48
+ # Extras = features a CONSUMER opts into, installed with hjtdev-appkit[crypto] /
49
+ # hjtdev-appkit[images]. cryptography (42 lifetime OSV advisories) and pillow (153) are kept
50
+ # optional specifically because of that advisory count — every hard dependency above has zero. A
51
+ # missing extra must fail with an actionable "install with hjtdev-appkit[crypto]" message, never
52
+ # a bare ImportError (docs/CONTRACT.md §9) — enforced by importing them lazily inside
53
+ # appkit.crypto / appkit.files, never at module top level.
54
+ [project.optional-dependencies]
55
+ crypto = ["cryptography>=42,<51"]
56
+ # >=11.3, not just >=10: pillow only started shipping cp314 wheels at 11.3.0, and its sdist
57
+ # fails to build on 3.14 (setup.py's version detection raises KeyError against current
58
+ # setuptools) — found live by Phase 8's resolution-matrix CI job against Python 3.14, the
59
+ # version this package's own tooling standardizes on (.python-version, playground, CI default).
60
+ images = ["pillow>=11.3,<13"]
61
+
62
+ # PEP 735 groups = tooling for developing appkit ITSELF. Never installed by a consumer, never
63
+ # published, not visible to a host at all.
64
+ #
65
+ # `pytest` is deliberately absent from BOTH `dependencies` and any extra, even though
66
+ # appkit.testing (the pytest plugin, docs/CONTRACT.md §2.17) imports it at module scope: the
67
+ # plugin only ever loads via `-p appkit.testing` from inside an already-running pytest
68
+ # process, where pytest is present by definition. Making it a hard/extra dependency would put
69
+ # a test framework in every host's production image for a module no host imports directly.
70
+ # (Gap in docs/CONTRACT.md §9, which never places pytest — resolved here.)
71
+ #
72
+ # No factory-boy: appkit ships no models, so APP-DESIGN.md §7.3's factories.py surface does
73
+ # not apply (docs/CONTRACT.md §10).
74
+ [dependency-groups]
75
+ dev = [
76
+ "ruff>=0.12",
77
+ "mypy>=1.14",
78
+ "django-stubs[compatible-mypy]>=5.1",
79
+ "djangorestframework-stubs>=3.15",
80
+ "pre-commit>=4.0",
81
+ ]
82
+ test = [
83
+ "pytest>=8.3",
84
+ "pytest-django>=4.9",
85
+ "pytest-cov>=6.0",
86
+ "pytest-xdist>=3.6",
87
+ # >=3.2.10, not just >=3.2: psycopg-binary only started shipping cp314 wheels at 3.2.10 —
88
+ # found live by Phase 8's resolution-matrix CI job, which resolves this floor for real
89
+ # against Python 3.14 rather than assuming any 3.2.x satisfies it. tests run on Postgres,
90
+ # APP-DESIGN.md §7.5.
91
+ "psycopg[binary]>=3.2.10",
92
+ ]
93
+
94
+ [tool.uv]
95
+ default-groups = ["dev", "test"] # `uv sync` gives a contributor everything
96
+
97
+ [tool.setuptools]
98
+ # Package data (locale/) is not .py, so it silently doesn't ship without this — paired with
99
+ # MANIFEST.in. APP-DESIGN.md §2's "Package data must be declared" note.
100
+ include-package-data = true
101
+
102
+ [tool.setuptools.packages.find]
103
+ where = ["src"]
104
+
105
+ # Belt and braces alongside MANIFEST.in: package-data is what actually lands package data in the
106
+ # *wheel* (MANIFEST.in governs the sdist). py.typed was previously covered by neither — it
107
+ # appeared in the stale egg-info SOURCES.txt but nothing affirmatively included it in a built
108
+ # wheel, which would have silently stripped every type hint from every consumer. Verified by
109
+ # Phase 8's wheel-smoke-test CI job, not assumed.
110
+ [tool.setuptools.package-data]
111
+ appkit = ["py.typed", "locale/*/LC_MESSAGES/*.po", "locale/*/LC_MESSAGES/*.mo"]
112
+
113
+ # ---------------------------------------------------------------- ruff
114
+ [tool.ruff]
115
+ line-length = 100
116
+ target-version = "py313"
117
+ src = ["src", "../tests"]
118
+
119
+ [tool.ruff.lint]
120
+ select = [
121
+ "E", "W", # pycodestyle
122
+ "F", # pyflakes
123
+ "I", # isort
124
+ "UP", # pyupgrade
125
+ "B", # bugbear
126
+ "DJ", # flake8-django
127
+ "S", # bandit — security
128
+ "TID", # tidy-imports (used to enforce the host-boundary rule, see below)
129
+ "RUF",
130
+ ]
131
+ ignore = ["S101"] # assert is fine in tests
132
+
133
+ [tool.ruff.lint.per-file-ignores]
134
+ "../tests/**" = ["S", "TID251"]
135
+
136
+ [tool.ruff.lint.flake8-tidy-imports]
137
+ ban-relative-imports = "parents"
138
+
139
+ # --- Host modules. THE rule that actually bites for this package (CLAUDE.md rule 2). An app
140
+ # package cannot assume a host's internals exist at a stable import path (APP-DESIGN.md §4).
141
+ # Structural for appkit specifically: appkit IS what tools.* moves into (BASE-DESIGN.md §3),
142
+ # so importing back would also be circular.
143
+ [tool.ruff.lint.flake8-tidy-imports.banned-api]
144
+ "tools".msg = "appkit must never import host modules — APP-DESIGN.md §4. appkit IS what tools.* moves into."
145
+ "core".msg = "appkit must never import host modules — APP-DESIGN.md §4."
146
+ "config".msg = "appkit must never import host modules — APP-DESIGN.md §4."
147
+
148
+ # --- Sibling app packages: currently an EMPTY SET. appkit is app package #1, so there is no
149
+ # sibling to ban yet. Add one line per app as each is created:
150
+ # "<app>_app".msg = "App packages must never import each other — see APP-DESIGN.md §6."
151
+ #
152
+ # appkit itself is NEVER added to any app's banned-api list — it is a declared dependency
153
+ # every app is expected to import (APP-DESIGN.md §1.1's named exception), not a sibling the
154
+ # rule exists to keep out.
155
+ #
156
+ # No factories entry either: appkit ships no models, so no factories.py (docs/CONTRACT.md
157
+ # §10) — APP-DESIGN.md §7.3's third public test surface does not apply here.
158
+
159
+ # ---------------------------------------------------------------- mypy
160
+ [tool.mypy]
161
+ python_version = "3.13"
162
+ strict = true
163
+ plugins = ["mypy_django_plugin.main", "mypy_drf_plugin.main"]
164
+ warn_unreachable = true
165
+ # NOT in APP-DESIGN.md §3.1's example. Without it, [tool.django-stubs]'s
166
+ # django_settings_module = "tests.backend.settings" is unresolvable when mypy runs from
167
+ # backend/ (the settings module lives one level up, outside src/) and `uv run mypy src` fails.
168
+ mypy_path = "..:src"
169
+
170
+ # jdatetime ships no py.typed marker and no stub package exists on PyPI (verified) — appkit.dates
171
+ # is the only module that imports it, and does so as internal implementation detail only, never
172
+ # in a public signature (docs/CONTRACT.md §2.13), so a blanket ignore here can't leak an Any into
173
+ # any consumer-facing type.
174
+ [[tool.mypy.overrides]]
175
+ module = "jdatetime"
176
+ ignore_missing_imports = true
177
+
178
+ [tool.django-stubs]
179
+ django_settings_module = "tests.backend.settings"
180
+
181
+ # ---------------------------------------------------------------- pytest
182
+ [tool.pytest.ini_options]
183
+ DJANGO_SETTINGS_MODULE = "tests.backend.settings"
184
+ pythonpath = ["src", ".."]
185
+ testpaths = ["../tests/backend"]
186
+ python_files = ["test_*.py"]
187
+ # Coverage gate is 95%, not the usual 85% (CLAUDE.md) — appkit ships complete rather than
188
+ # growing under four installed apps, so its own suite stands in for usage feedback.
189
+ #
190
+ # DO NOT add `-p appkit.testing` to the addopts below. It looks like the obviously-correct
191
+ # dogfooding move — appkit.testing IS meant to be used this way — and that's exactly why this
192
+ # comment exists: someone will try it again. Verified directly, not assumed: a plugin loaded via
193
+ # `-p` in addopts is imported during pytest's early `consider_preparse` phase, BEFORE pytest-cov's
194
+ # own tracer attaches. coverage.py then permanently reports that module (and everything it
195
+ # imports at module scope) as "previously imported but not measured" — measured on this exact
196
+ # codebase: appkit.request_id's coverage dropped from 100% to 42%, and appkit.testing's own from
197
+ # ~95% to 63%, purely from this measurement artefact, with no change in what actually ran or
198
+ # passed. Re-adding `-p appkit.testing` here will silently reproduce both drops and can push the
199
+ # 95% gate to fail depending on what else changed.
200
+ #
201
+ # `tests/backend/test_testing_plugin.py` calls each fixture's own `_fixture_function` directly
202
+ # instead (normal import timing, fully measured) to exercise appkit.testing in-process. Its one
203
+ # subprocess test, `test_reflective_user_fixture_against_a_non_username_user_model`, is what
204
+ # actually proves the real `-p appkit.testing` opt-in path works end to end — its own docstring
205
+ # explains exactly what would fail (and how) if `-p` stopped working, so that proof doesn't
206
+ # depend on this module being in addopts at all.
207
+ addopts = """
208
+ -ra --strict-markers --strict-config
209
+ --cov=appkit --cov-report=term-missing --cov-fail-under=95
210
+ """
211
+ markers = [
212
+ "slow: excluded from the default run, opt in with -m slow",
213
+ "integration: crosses a real DB boundary rather than mocking it",
214
+ "requires_extra: needs the crypto and/or images extra installed — deselected on the bare-install leg (-m 'not requires_extra', docs/CONTRACT.md §9's two-leg strategy)",
215
+ ]
216
+ filterwarnings = ["error::DeprecationWarning"]
217
+
218
+ [tool.coverage.run]
219
+ omit = ["*/tests/*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,39 @@
1
+ """appkit — the shared Django + DRF foundation every app package and host in this ecosystem
2
+ depends on.
3
+
4
+ Not an installable feature; it is what ``backend/tools/`` (cache, mixins, crypto) and
5
+ ``config/logging.py``'s request-ID plumbing move into once this package exists
6
+ (``BASE-DESIGN.md`` §3). Every other app package declares appkit as a dependency and imports
7
+ its helpers instead of reimplementing them.
8
+
9
+ This module intentionally re-exports nothing. Each submodule below is its own public surface —
10
+ import from ``appkit.<module>`` directly (e.g. ``from appkit.cache import cached_call``), never
11
+ from ``appkit`` itself. ``appkit.conf`` is explicitly *not* re-exported here even internally
12
+ (docs/CONTRACT.md §2.16: "not re-exported from a top-level ``appkit`` namespace").
13
+
14
+ Public modules (docs/CONTRACT.md §2):
15
+ ``appkit.cache`` — cache namespace versioning, key building, endpoint caching
16
+ ``appkit.mixins`` — ``CachedListMixin``, a DRF list-view response caching mixin
17
+ ``appkit.exceptions`` — the standard DRF exception handler and the ten error codes
18
+ ``appkit.request_id`` — the request-ID ContextVar, ASGI middleware, and logging filter
19
+ ``appkit.crypto`` — Fernet encryption taking its key at call time (``crypto`` extra)
20
+ ``appkit.permissions`` — shared DRF permission classes
21
+ ``appkit.pagination`` — the shared default pagination class
22
+ ``appkit.validation`` — query-param validation, HTML sanitisation, an ORM lookup allowlist
23
+ ``appkit.files`` — upload/image validation via magic-byte sniffing (``images`` extra)
24
+ ``appkit.net`` — trust-boundary real client IP extraction
25
+ ``appkit.media`` — media URL absolutisation (never ``appkit.urls`` — see below)
26
+ ``appkit.text`` — truncation and digit normalisation shared with the frontend half
27
+ ``appkit.dates`` — Gregorian <-> Jalali conversion using stdlib types only
28
+ ``appkit.money`` — integer money parsing/formatting shared with the frontend half
29
+ ``appkit.throttling`` — DRF throttle-scope string construction
30
+ ``appkit.testing`` — the opt-in pytest plugin (``-p appkit.testing``)
31
+
32
+ Internal-but-stable (docs/CONTRACT.md §2.16):
33
+ ``appkit.conf`` — the ``APPKIT`` settings-dict accessor and its ``DEFAULTS``
34
+
35
+ appkit ships no ``urlpatterns`` and is never ``include()``d anywhere, by any host — there is no
36
+ ``appkit.urls``, deliberately (docs/CONTRACT.md §10). appkit ships no models, no migrations, no
37
+ admin, no ``services.py``, no ``signals.py``, and no Celery/``django.tasks`` integration
38
+ (docs/CONTRACT.md §0, §10).
39
+ """
@@ -0,0 +1,33 @@
1
+ """appkit's ``AppConfig``.
2
+
3
+ ``INSTALLED_APPS`` membership is confirmed, not merely left standing (docs/CONTRACT.md §5), for
4
+ two reasons: translations become real only when appkit is a genuine ``INSTALLED_APPS`` member
5
+ (``standard_exception_handler``'s user-facing strings are wrapped in ``gettext_lazy`` and
6
+ discovered via a shipped ``locale/`` directory), and the system checks in :mod:`appkit.checks`
7
+ must be registered from ``ready()`` to run at all.
8
+
9
+ appkit defines no models, so there is no ``default_auto_field`` to set.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from django.apps import AppConfig
15
+ from django.utils.translation import gettext_lazy as _
16
+
17
+
18
+ class AppKitConfig(AppConfig):
19
+ name = "appkit"
20
+ verbose_name = _("App Kit")
21
+
22
+ def ready(self) -> None:
23
+ from django.core.checks import register
24
+
25
+ from appkit import checks
26
+
27
+ register(checks.check_request_id_middleware)
28
+ register(checks.check_exception_handler)
29
+ register(checks.check_middleware_order)
30
+ register(checks.check_unknown_settings_keys)
31
+ register(checks.check_throttle_scopes)
32
+ register(checks.check_logging_filter)
33
+ register(checks.check_num_proxies_throttle_agreement)
@@ -0,0 +1,222 @@
1
+ """Cache namespace versioning, key building, get-or-set, and endpoint-level response caching.
2
+
3
+ Public surface (docs/CONTRACT.md §2.1), implemented in a later phase:
4
+
5
+ namespace_version(namespace: str) -> int
6
+ Opaque version number for a cache namespace. Seeds from ``int(time.time())``, not the
7
+ literal ``1`` — the return value must be treated as opaque, never assumed to start at 1.
8
+
9
+ invalidate_namespace(namespace: str) -> int
10
+ Bumps a namespace's version, effectively invalidating every key built against it.
11
+
12
+ build_cache_key(namespace: str, *parts: object) -> str
13
+ Builds a cache key incorporating the namespace's current version.
14
+
15
+ cached_call(key: str, timeout: int | None, producer: Callable[[], T]) -> T
16
+ Get-or-set around an arbitrary producer callable. ``timeout`` accepts appkit.conf.UNSET
17
+ to mean "use APPKIT['CACHE_TIMEOUT']" — resolved to avoid the ambiguity of a bare
18
+ ``None`` meaning either "no timeout" or "use the default".
19
+
20
+ cache_endpoint(*, namespace: str, timeout: int | None = UNSET, per_user: bool = True,
21
+ vary_headers: Sequence[str] = (), cache_statuses: Container[int] = (200,))
22
+ Decorator for endpoint-level response caching. Raises ImproperlyConfigured at
23
+ decoration time (import time) if ``namespace`` is empty. ``per_user`` exists
24
+ specifically to prevent cross-user cache leakage.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import hashlib
30
+ import time
31
+ from collections.abc import Callable, Container, Sequence
32
+ from functools import wraps
33
+ from typing import Any, cast
34
+
35
+ from django.core.cache import cache
36
+ from django.core.exceptions import ImproperlyConfigured
37
+ from rest_framework.request import Request
38
+ from rest_framework.response import Response
39
+
40
+ from appkit.conf import UNSET, _Unset, get_setting
41
+
42
+ __all__ = [
43
+ "build_cache_key",
44
+ "cache_endpoint",
45
+ "cached_call",
46
+ "invalidate_namespace",
47
+ "namespace_version",
48
+ ]
49
+
50
+ # Keeps generated keys short and free of characters the cache backend (or a log line) might
51
+ # treat specially, once a part gets long or contains something other than
52
+ # alphanumerics/dashes/underscores/periods.
53
+ #
54
+ # **Deviation from the scaffold's `_SAFE_PART`:** the scaffold's version includes `:` as a
55
+ # "safe" character for an individual *part*, which would defeat the segment-smuggling
56
+ # protection below — a part containing `:` (the join delimiter used one line down) would be
57
+ # embedded raw instead of hashed, letting it forge extra `namespace:version:...` segments.
58
+ # Excluding `:` here is what actually satisfies "a delimiter must never smuggle a second
59
+ # segment into the key". docs/CONTRACT.md §2.1's own notation (`[A-Za-z0-9\-_.]`) already
60
+ # excludes it too and states this exclusion explicitly — the two agree.
61
+ _MAX_RAW_PART_LEN = 40
62
+ _SAFE_PART = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.")
63
+
64
+
65
+ def _version_key(namespace: str) -> str:
66
+ return f"{namespace}:version"
67
+
68
+
69
+ def namespace_version(namespace: str) -> int:
70
+ """Returns the current version for `namespace`, seeding it on first use.
71
+
72
+ **Changed from the scaffold:** seeds from `int(time.time())`, not the literal `1`
73
+ (docs/CONTRACT.md §2.1). The scaffold's get-then-increment isn't atomic against Django's
74
+ cache API; if the version key is evicted under memory pressure and reseeds at `1`, every key
75
+ built against a *higher* version before the eviction becomes reachable again — silently
76
+ resurrecting data an earlier `invalidate_namespace` call explicitly invalidated. Seeding from
77
+ a wall-clock second makes any reseed monotonically ahead of every version that could
78
+ plausibly have been issued before it. The return value is therefore **opaque** — never
79
+ assume it starts at `1`. Never raises.
80
+ """
81
+ version = cache.get(_version_key(namespace))
82
+ if version is None:
83
+ # `add`, not `set`: two processes racing to seed the same namespace must not let the
84
+ # second clobber the first's (very slightly later, but already-issued) timestamp.
85
+ cache.add(_version_key(namespace), int(time.time()), timeout=None)
86
+ version = cache.get(_version_key(namespace))
87
+ return int(version)
88
+
89
+
90
+ def invalidate_namespace(namespace: str) -> int:
91
+ """Bumps `namespace`'s version, invalidating every key previously built against it.
92
+
93
+ Returns the new version — guaranteed strictly greater than what came before it in this
94
+ process's view. Never raises (calls `namespace_version` first to guarantee the key exists
95
+ before `cache.incr`).
96
+ """
97
+ namespace_version(namespace) # ensure it exists before incrementing
98
+ return cache.incr(_version_key(namespace))
99
+
100
+
101
+ def _normalize_part(part: object) -> str:
102
+ raw = str(part)
103
+ if len(raw) > _MAX_RAW_PART_LEN or not set(raw) <= _SAFE_PART:
104
+ return hashlib.sha256(raw.encode()).hexdigest()[:16]
105
+ return raw
106
+
107
+
108
+ def build_cache_key(namespace: str, *parts: object) -> str:
109
+ """Builds a stable, namespace-versioned cache key.
110
+
111
+ `namespace:version:part1:part2:...` — long or unsafe parts are hashed rather than embedded
112
+ raw, so an arbitrary string (a user-supplied search query, say) can't blow up key length or
113
+ smuggle a delimiter into the key. Never raises.
114
+ """
115
+ version = namespace_version(namespace)
116
+ segments = [namespace, str(version), *(_normalize_part(p) for p in parts)]
117
+ return ":".join(segments)
118
+
119
+
120
+ def cached_call[T](
121
+ key: str,
122
+ timeout: int | _Unset | None,
123
+ producer: Callable[[], T],
124
+ ) -> T:
125
+ """Get-or-set: returns the cached value at `key`, computing and storing it via
126
+ `producer()` on a miss. `producer` is called at most once per miss.
127
+
128
+ `timeout=None` means "cache forever" (Django's own cache semantics) and therefore cannot
129
+ double as "use the configured default" — pass `appkit.conf.UNSET` for that instead,
130
+ resolved here to `APPKIT["CACHE_TIMEOUT"]` (docs/CONTRACT.md §2.1). `UNSET` is not part of
131
+ this function's *documented* public type (`int | None`) — it renders in docs as "omit the
132
+ argument" — but is accepted and typed here so `appkit.mixins`/`cache_endpoint` passing it
133
+ through still type-checks.
134
+
135
+ A `producer` that returns `None` is never actually cached — Django's cache API can't
136
+ distinguish "miss" from "cached `None`" through `.get()`'s default. Fine for the typical use
137
+ (caching a queryset result, a serialized dict), but don't reach for this to cache a value
138
+ that's legitimately `None`. Never raises on its own; propagates whatever `producer` raises.
139
+ """
140
+ resolved_timeout = get_setting("CACHE_TIMEOUT") if timeout is UNSET else timeout
141
+ value = cache.get(key)
142
+ if value is None:
143
+ value = producer()
144
+ cache.set(key, value, timeout=resolved_timeout)
145
+ return cast("T", value)
146
+
147
+
148
+ def _user_cache_token(request: Request, *, per_user: bool) -> str:
149
+ """`per_user=False` shares one cache entry across every caller — valid only where the
150
+ response is byte-identical for everyone, including anonymous. `per_user=True` isolates by
151
+ the user's `pk`, falling back to a fixed `"anon"` bucket rather than folding an anonymous
152
+ caller into whatever the *first* unauthenticated request happened to produce a falsy-looking
153
+ identity for — an explicit `is None` check, not `pk or "anon"`, so a real user whose `pk`
154
+ happens to be `0` is never treated as anonymous.
155
+ """
156
+ if not per_user:
157
+ return "shared"
158
+ pk = getattr(request.user, "pk", None)
159
+ return str(pk) if pk is not None else "anon"
160
+
161
+
162
+ def cache_endpoint[F: Callable[..., Response]](
163
+ *,
164
+ namespace: str,
165
+ timeout: int | _Unset | None = UNSET,
166
+ per_user: bool = True,
167
+ vary_headers: Sequence[str] = (),
168
+ cache_statuses: Container[int] = (200,),
169
+ ) -> Callable[[F], F]:
170
+ """Decorator wrapping a DRF view method (`list`/`retrieve`/...) with response caching, the
171
+ way `appkit.mixins.CachedListMixin` wraps `ListAPIView.list` — for views that aren't plain
172
+ list views.
173
+
174
+ `namespace` is **required, no default** — an unprefixed key is exactly the
175
+ two-apps-collide scenario `APP-DESIGN.md` §1.3 exists to prevent, so there is no safe
176
+ default to fall back to. Raises `ImproperlyConfigured` at decoration time (import time) if
177
+ `namespace` is empty.
178
+
179
+ `per_user=True` is the load-bearing default. **Non-obvious failure path:** with
180
+ `per_user=False` on a permission-gated view, user A's response is served verbatim to user
181
+ B — an authorization bypass via the cache layer, not a cache bug. `per_user=False` is valid
182
+ *only* where the response is byte-identical for every caller including anonymous users.
183
+
184
+ `vary_headers` folds additional request headers into the cache key (e.g. `Accept-Language`
185
+ for a bilingual endpoint) beyond user + full path. `cache_statuses` restricts caching to
186
+ responses whose status is in this set — a 403/404 is never cached by default, since caching
187
+ an authorization failure can make it outlive the state that caused it. Caches
188
+ `{"data": ..., "status": ...}`, never the `Response` object itself.
189
+ """
190
+ if not namespace:
191
+ raise ImproperlyConfigured(
192
+ "cache_endpoint() requires a non-empty `namespace` — an unprefixed cache key is "
193
+ "exactly the two-apps-collide scenario APP-DESIGN.md §1.3 exists to prevent."
194
+ )
195
+
196
+ def decorator(view_method: F) -> F:
197
+ @wraps(view_method)
198
+ def wrapper(self: Any, request: Request, *args: Any, **kwargs: Any) -> Response:
199
+ key_parts: list[object] = [
200
+ _user_cache_token(request, per_user=per_user),
201
+ request.get_full_path(),
202
+ *(request.headers.get(header, "") for header in vary_headers),
203
+ ]
204
+ key = build_cache_key(namespace, *key_parts)
205
+
206
+ cached = cache.get(key)
207
+ if cached is not None:
208
+ return Response(cached["data"], status=cached["status"])
209
+
210
+ response = view_method(self, request, *args, **kwargs)
211
+ if response.status_code in cache_statuses:
212
+ resolved_timeout = get_setting("CACHE_TIMEOUT") if timeout is UNSET else timeout
213
+ cache.set(
214
+ key,
215
+ {"data": response.data, "status": response.status_code},
216
+ timeout=resolved_timeout,
217
+ )
218
+ return response
219
+
220
+ return cast("F", wrapper)
221
+
222
+ return decorator