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.
- hjtdev_appkit-2.0.0/LICENSE +21 -0
- hjtdev_appkit-2.0.0/MANIFEST.in +7 -0
- hjtdev_appkit-2.0.0/PKG-INFO +17 -0
- hjtdev_appkit-2.0.0/pyproject.toml +219 -0
- hjtdev_appkit-2.0.0/setup.cfg +4 -0
- hjtdev_appkit-2.0.0/src/appkit/__init__.py +39 -0
- hjtdev_appkit-2.0.0/src/appkit/apps.py +33 -0
- hjtdev_appkit-2.0.0/src/appkit/cache.py +222 -0
- hjtdev_appkit-2.0.0/src/appkit/checks.py +513 -0
- hjtdev_appkit-2.0.0/src/appkit/conf.py +71 -0
- hjtdev_appkit-2.0.0/src/appkit/crypto.py +102 -0
- hjtdev_appkit-2.0.0/src/appkit/dates.py +183 -0
- hjtdev_appkit-2.0.0/src/appkit/exceptions.py +158 -0
- hjtdev_appkit-2.0.0/src/appkit/files.py +302 -0
- hjtdev_appkit-2.0.0/src/appkit/locale/fa/LC_MESSAGES/django.mo +0 -0
- hjtdev_appkit-2.0.0/src/appkit/locale/fa/LC_MESSAGES/django.po +29 -0
- hjtdev_appkit-2.0.0/src/appkit/media.py +102 -0
- hjtdev_appkit-2.0.0/src/appkit/mixins.py +68 -0
- hjtdev_appkit-2.0.0/src/appkit/money.py +69 -0
- hjtdev_appkit-2.0.0/src/appkit/net.py +115 -0
- hjtdev_appkit-2.0.0/src/appkit/pagination.py +30 -0
- hjtdev_appkit-2.0.0/src/appkit/permissions.py +48 -0
- hjtdev_appkit-2.0.0/src/appkit/py.typed +0 -0
- hjtdev_appkit-2.0.0/src/appkit/request_id.py +96 -0
- hjtdev_appkit-2.0.0/src/appkit/testing.py +259 -0
- hjtdev_appkit-2.0.0/src/appkit/text.py +62 -0
- hjtdev_appkit-2.0.0/src/appkit/throttling.py +41 -0
- hjtdev_appkit-2.0.0/src/appkit/validation.py +195 -0
- hjtdev_appkit-2.0.0/src/hjtdev_appkit.egg-info/PKG-INFO +17 -0
- hjtdev_appkit-2.0.0/src/hjtdev_appkit.egg-info/SOURCES.txt +31 -0
- hjtdev_appkit-2.0.0/src/hjtdev_appkit.egg-info/dependency_links.txt +1 -0
- hjtdev_appkit-2.0.0/src/hjtdev_appkit.egg-info/requires.txt +11 -0
- 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,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
|