hjtdev-appkit 2.0.0__tar.gz → 2.0.2__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 (36) hide show
  1. hjtdev_appkit-2.0.2/PKG-INFO +698 -0
  2. hjtdev_appkit-2.0.2/README.md +675 -0
  3. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/pyproject.toml +31 -1
  4. hjtdev_appkit-2.0.2/src/hjtdev_appkit.egg-info/PKG-INFO +698 -0
  5. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/hjtdev_appkit.egg-info/SOURCES.txt +1 -0
  6. hjtdev_appkit-2.0.0/PKG-INFO +0 -17
  7. hjtdev_appkit-2.0.0/src/hjtdev_appkit.egg-info/PKG-INFO +0 -17
  8. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/LICENSE +0 -0
  9. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/MANIFEST.in +0 -0
  10. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/setup.cfg +0 -0
  11. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/appkit/__init__.py +0 -0
  12. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/appkit/apps.py +0 -0
  13. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/appkit/cache.py +0 -0
  14. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/appkit/checks.py +0 -0
  15. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/appkit/conf.py +0 -0
  16. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/appkit/crypto.py +0 -0
  17. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/appkit/dates.py +0 -0
  18. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/appkit/exceptions.py +0 -0
  19. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/appkit/files.py +0 -0
  20. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/appkit/locale/fa/LC_MESSAGES/django.mo +0 -0
  21. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/appkit/locale/fa/LC_MESSAGES/django.po +0 -0
  22. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/appkit/media.py +0 -0
  23. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/appkit/mixins.py +0 -0
  24. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/appkit/money.py +0 -0
  25. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/appkit/net.py +0 -0
  26. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/appkit/pagination.py +0 -0
  27. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/appkit/permissions.py +0 -0
  28. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/appkit/py.typed +0 -0
  29. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/appkit/request_id.py +0 -0
  30. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/appkit/testing.py +0 -0
  31. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/appkit/text.py +0 -0
  32. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/appkit/throttling.py +0 -0
  33. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/appkit/validation.py +0 -0
  34. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/hjtdev_appkit.egg-info/dependency_links.txt +0 -0
  35. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/hjtdev_appkit.egg-info/requires.txt +0 -0
  36. {hjtdev_appkit-2.0.0 → hjtdev_appkit-2.0.2}/src/hjtdev_appkit.egg-info/top_level.txt +0 -0
@@ -0,0 +1,698 @@
1
+ Metadata-Version: 2.4
2
+ Name: hjtdev-appkit
3
+ Version: 2.0.2
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
+ Project-URL: Homepage, https://github.com/HjtDev/appkit
7
+ Project-URL: Repository, https://github.com/HjtDev/appkit
8
+ Project-URL: Changelog, https://github.com/HjtDev/appkit/blob/main/CHANGELOG.md
9
+ Project-URL: Documentation, https://github.com/HjtDev/ecosystem-docs/blob/main/APP-DESIGN.md
10
+ Requires-Python: >=3.13
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: django<7.0,>=5.2
14
+ Requires-Dist: djangorestframework<4.0,>=3.15
15
+ Requires-Dist: nh3<1.0,>=0.2
16
+ Requires-Dist: puremagic<3,>=2
17
+ Requires-Dist: jdatetime<7,>=5
18
+ Provides-Extra: crypto
19
+ Requires-Dist: cryptography<51,>=42; extra == "crypto"
20
+ Provides-Extra: images
21
+ Requires-Dist: pillow<13,>=11.3; extra == "images"
22
+ Dynamic: license-file
23
+
24
+ # appkit
25
+
26
+ The shared, versioned, dual-package dependency every app package in this ecosystem declares.
27
+ It replaces the base-scaffold's per-project `backend/tools/` helpers (cache, mixins,
28
+ error-envelope handling, request-ID plumbing) and `frontend/lib/`'s `HttpClient` contract with
29
+ one thing every app imports instead of reimplementing (`APP-DESIGN.md` §1.1, `BASE-DESIGN.md`
30
+ §3). It is app package #1 — not itself an installable feature, and not something a project adds
31
+ to solve a problem on its own. Every other app in the ecosystem depends on it; a host installs
32
+ it transitively the first time it installs any app.
33
+
34
+ Full package contract: `docs/APP-DESIGN.md`. This README follows its §8 structure.
35
+
36
+ ## Installation — backend
37
+
38
+ Published to the **public PyPI registry** as **`hjtdev-appkit`** (not the bare `appkit` name —
39
+ already taken by an unrelated package, same situation as `@hjtdev/appkit` on npm below). The
40
+ *import* name is unaffected — `import appkit` — only the installable/requirement name is
41
+ prefixed, exactly like `python-dateutil` ships `import dateutil`.
42
+
43
+ A host normally never runs this directly — every app package declares
44
+ `"hjtdev-appkit>=2.0,<3.0"` in `[project.dependencies]`, and `uv`/`pip` resolve it **transitively**
45
+ the first time any app is installed (`INTEGRATION-GUIDE.md` §2 step 2). To install directly (e.g.
46
+ this repo's own `playground/`, or a host without any apps yet):
47
+
48
+ ```bash
49
+ uv add "hjtdev-appkit>=2.0,<3.0"
50
+ # or: pip install "hjtdev-appkit>=2.0,<3.0"
51
+ ```
52
+
53
+ **Pinning an unreleased commit instead of a tagged version** (rare — normal installs use the line
54
+ above) still works via the git+subdirectory form, since `uv`/`pip` correctly implement Git's
55
+ `#subdirectory=` fragment:
56
+
57
+ ```toml
58
+ # host backend/pyproject.toml
59
+ [tool.uv.sources]
60
+ hjtdev-appkit = { git = "https://github.com/HjtDev/appkit.git", tag = "v2.0.0", subdirectory = "backend" }
61
+ ```
62
+
63
+ `HjtDev/appkit` is a **public** repo — no authentication needed for either half.
64
+
65
+ **Host action if upgrading from `<2.0`:** the requirement name changed from `appkit` to
66
+ `hjtdev-appkit` (see `CHANGELOG.md`'s `[2.0.0]` entry) — `uv remove appkit && uv add
67
+ "hjtdev-appkit>=2.0,<3.0"`, and drop any `[tool.uv.sources]` entry for the old git install if one
68
+ was added. No import changes anywhere.
69
+
70
+ ### Extras
71
+
72
+ Two optional dependency groups, installed with `hjtdev-appkit[extra]`. Omitted entirely, appkit's
73
+ own hard dependencies (`django`, `djangorestframework`, `nh3`, `puremagic`, `jdatetime` — see
74
+ "Compatibility" below) already cover the whole non-extra surface:
75
+
76
+ - **`crypto`** — pulls in `cryptography`, enabling `appkit.crypto.Cipher`/`generate_key`. Needed
77
+ only by an app encrypting a field with its own key (never appkit's own — `docs/CONTRACT.md`
78
+ §3, and "Required `.env` keys" below).
79
+ - **`images`** — pulls in `Pillow`, enabling `appkit.files.validate_image`'s raster-format
80
+ dimension checks. Needed only by an app that accepts image uploads.
81
+ - Compose both as `hjtdev-appkit[crypto,images]` when an app needs both.
82
+
83
+ Calling the corresponding function with the extra not installed raises `ImportError` with an
84
+ actionable message (`Install with: uv add "hjtdev-appkit[crypto]"`) rather than a bare traceback
85
+ three frames deep — confirmed live against a real bare-install container
86
+ (`playground/FINDINGS.md` §10.1).
87
+
88
+ ## Installation — frontend
89
+
90
+ Unlike the backend half, the frontend half is published to the **public npm registry** as
91
+ `@hjtdev/appkit` (not the bare `appkit` name — already taken by an unrelated package), and is
92
+ installed **explicitly and once per host**, even though every SDK declares it as a
93
+ `peerDependency` (`INTEGRATION-GUIDE.md` §2 step 3):
94
+
95
+ ```bash
96
+ npm install @hjtdev/appkit
97
+ ```
98
+
99
+ Already installed at a version satisfying every app's peer range? Skip this step. After
100
+ installing every app, `npm ls @hjtdev/appkit` should show exactly **one** resolved copy — two
101
+ copies means two separate React module instances, and `useApiClient` would silently return
102
+ `null` in half the tree.
103
+
104
+ **Why both halves ended up on a registry.** The backend half could, in principle, stay a plain
105
+ git dependency — `uv`/`pip` correctly implement Git's `#subdirectory=` fragment, unlike npm (see
106
+ below) — but that still left every host writing its own `[tool.uv.sources]` block by hand, since
107
+ `uv` never reads a transitive dependency's *own* sources table. Publishing to PyPI removes that
108
+ step entirely: a plain version range resolves like any other dependency. The frontend half has no
109
+ working git-install alternative at all: `github:HjtDev/appkit#vX:frontend` silently drops both the
110
+ tag and the subdirectory (npm parses `:frontend` as junk and falls back to the default branch),
111
+ and the documented-looking `::path:frontend` form only changes which `package.json` npm reads
112
+ metadata from — it still packs and installs the **entire repository root**, `backend/` and all,
113
+ so `import "..."` fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`. Neither is a bug in this repo's
114
+ layout; it's the ceiling of what npm's git installer supports. The registry install is the only
115
+ supported way to install the frontend half, and now the simplest way to install the backend half
116
+ too.
117
+
118
+ ## Compatibility
119
+
120
+ Verified directly against `backend/pyproject.toml` and `frontend/package.json`, not carried over
121
+ from CLAUDE.md's original targets unread:
122
+
123
+ - **Python** `>=3.13` · **Django** `>=5.2,<7.0` · **DRF** `>=3.15,<4.0` — wide ranges, never
124
+ exact pins (`CLAUDE.md`'s dependency-range rule). appkit is the most widely shared dependency
125
+ in the ecosystem, so an exact pin here would force every host and every other app package to
126
+ match it.
127
+ - **React** `>=18` · **`@tanstack/react-query`** `>=5` — peer dependencies only. The frontend
128
+ half has **zero runtime dependencies of its own**.
129
+ - appkit's own hard backend dependencies, inherited transitively by every host the first time
130
+ any app package is installed: `django>=5.2,<7.0`, `djangorestframework>=3.15,<4.0`,
131
+ `nh3>=0.2,<1.0` (HTML sanitisation — a hard dependency, not an extra, since a skippable
132
+ sanitiser is a stored-XSS bug waiting to happen), `puremagic>=2,<3` (magic-byte mimetype
133
+ sniffing), `jdatetime>=5,<7` (Gregorian↔Jalali conversion; pulls in `jalali-core` transitively).
134
+
135
+ ## Settings — add to `backend/config/settings.py`
136
+
137
+ The one-time wiring every fresh host needs the first time *any* app is installed
138
+ (`INTEGRATION-GUIDE.md` §2 step 5) — not repeated per app after that. Copy-pasteable as one
139
+ block, verbatim from `docs/CONTRACT.md` §8:
140
+
141
+ ```python
142
+ INSTALLED_APPS += ["appkit"]
143
+
144
+ MIDDLEWARE.insert(
145
+ MIDDLEWARE.index("django.middleware.security.SecurityMiddleware") + 1,
146
+ "appkit.request_id.RequestIDMiddleware",
147
+ ) # before anything that logs
148
+
149
+ REST_FRAMEWORK["EXCEPTION_HANDLER"] = "appkit.exceptions.standard_exception_handler"
150
+ REST_FRAMEWORK["DEFAULT_PAGINATION_CLASS"] = "appkit.pagination.DefaultPagination"
151
+ # No REST_FRAMEWORK["PAGE_SIZE"] needed — DefaultPagination carries its own page_size (25).
152
+
153
+ # Optional — every key below already defaults to the value shown if omitted entirely.
154
+ APPKIT = {
155
+ "CACHE_TIMEOUT": 60, # appkit.cache / appkit.mixins default, seconds
156
+ "TRUSTED_PROXY_COUNT": 1, # appkit.net's trusted X-Forwarded-For hops
157
+ "MAX_UPLOAD_BYTES": 10 * 1024 * 1024, # appkit.files' semantic size cap
158
+ "SITE_URL": "", # required only if appkit.media is ever called
159
+ # with no request in scope (a Celery task, a
160
+ # management command) — raises
161
+ # ImproperlyConfigured naming this key the first
162
+ # time that happens, rather than emitting a
163
+ # broken relative URL
164
+ }
165
+ ```
166
+
167
+ Settled: appkit **does** get an `INSTALLED_APPS` entry — `AppKitConfig.ready()` registers the
168
+ system checks named in `docs/CONTRACT.md` §6 (a host that wires the middleware/exception handler
169
+ wrong fails loudly at `manage.py check`, rather than silently losing request IDs or DRF's
170
+ default error shape).
171
+
172
+ `config/logging.py`'s `build_logging_config()` imports the request-ID filter from appkit
173
+ instead of defining it locally — the `LOGGING` dict's own `filters`/handler wiring is
174
+ unchanged, only where `RequestIDFilter` comes from:
175
+
176
+ ```python
177
+ # backend/config/logging.py
178
+ from appkit.request_id import RequestIDFilter, request_id_var # was defined locally
179
+ ```
180
+
181
+ If `config/logging.py` doesn't exist yet, the minimal shape that makes the import above
182
+ actually correlate anything — a handler has to list `"request_id"` in its own `filters`, not
183
+ just declare the filter (`docs/CONTRACT.md` §8; `appkit.checks.check_logging_filter`/
184
+ `appkit.W005` fires if no handler does):
185
+
186
+ ```python
187
+ LOGGING = {
188
+ "version": 1,
189
+ "disable_existing_loggers": False,
190
+ "filters": {"request_id": {"()": RequestIDFilter}},
191
+ "formatters": {
192
+ "with_request_id": {
193
+ "format": "%(asctime)s %(levelname)s [%(request_id)s] %(name)s: %(message)s",
194
+ },
195
+ },
196
+ "handlers": {
197
+ "console": {
198
+ "class": "logging.StreamHandler",
199
+ "filters": ["request_id"],
200
+ "formatter": "with_request_id",
201
+ },
202
+ },
203
+ "root": {"handlers": ["console"], "level": "INFO"},
204
+ }
205
+ ```
206
+
207
+ ### Four things the block above doesn't cover, but a real deployment needs
208
+
209
+ Found building this package's own `playground/` (`playground/FINDINGS.md`, Phase 6) — none of
210
+ these are `APPKIT` settings, so they don't belong in the dict above, but a host that skips them
211
+ still passes `manage.py check` cleanly and gets broken behavior anyway:
212
+
213
+ - **ASGI is required, not optional.** `appkit.request_id.RequestIDMiddleware` is async-only
214
+ (`sync_capable = False`) — a WSGI-only host (plain `runserver`, gunicorn's sync workers)
215
+ cannot run it at all. Serve via `uvicorn config.asgi:application` (or another ASGI server).
216
+ - **`SECURE_PROXY_SSL_HEADER` (or `BASE-DESIGN.md` §4.3's `TRUST_PROXY_SSL_HEADER`), if this
217
+ host sits behind a TLS-terminating proxy.** `appkit.media.absolute_url` delegates to Django's
218
+ own `request.build_absolute_uri()`, which only reports `https://` if this is set — appkit has
219
+ no setting of its own for it, but every media URL is `http://` (mixed content) behind TLS
220
+ without it.
221
+ - **A real cache backend.** `appkit.mixins.CachedListMixin` works against whatever
222
+ `CACHES["default"]` is configured to — Django's `LocMemCache` default means no caching, and no
223
+ cross-process invalidation, happens at all, silently.
224
+ - **`REST_FRAMEWORK["DEFAULT_THROTTLE_CLASSES"]` must include `ScopedRateThrottle`** for
225
+ `appkit.throttling.throttle_scope()` (and `appkit.checks`' `appkit.W004`) to have any runtime
226
+ effect — DRF only enforces a `throttle_scope` class attribute when a throttle class that reads
227
+ it is actually installed; a view can pass `W004` and still be completely unthrottled.
228
+
229
+ ## System checks
230
+
231
+ Registered from `AppKitConfig.ready()` — the reason appkit needs a real `INSTALLED_APPS` entry,
232
+ not just importability. Eight IDs, seven functions, all reachable via `manage.py check`. This is
233
+ appkit's substitute for the "signals emitted" section every other app package has: the contract
234
+ about what appkit will tell a host, and how to diagnose a misconfiguration without reading
235
+ appkit's source.
236
+
237
+ | ID | Severity | Catches | Fix |
238
+ |---|---|---|---|
239
+ | `appkit.E001` | Error | `RequestIDMiddleware` absent from `MIDDLEWARE` | Add it right after `SecurityMiddleware` — see "Settings" above |
240
+ | `appkit.E002` | Error | `EXCEPTION_HANDLER` unset, or still DRF's own default | Set `REST_FRAMEWORK["EXCEPTION_HANDLER"] = "appkit.exceptions.standard_exception_handler"` |
241
+ | `appkit.W001` | Warning | `EXCEPTION_HANDLER` set to neither DRF's default nor appkit's | Confirm it's deliberate (a handler wrapping appkit's own); silence via `SILENCED_SYSTEM_CHECKS` if so, otherwise fix it |
242
+ | `appkit.W002` | Warning | `RequestIDMiddleware` present but ordered before `SecurityMiddleware` | Move it to right after `SecurityMiddleware` in `MIDDLEWARE` |
243
+ | `appkit.W003` | Warning | `APPKIT` dict has a key not in `appkit.conf.DEFAULTS` | Fix the typo — an unrecognised key is silently ignored, its value never read |
244
+ | `appkit.W004` | Warning | A view reachable via `ROOT_URLCONF` declares a `throttle_scope` with no matching `DEFAULT_THROTTLE_RATES` entry | Add the rate, or fix the typo'd scope — DRF only raises for this at request time, per request, so it can otherwise ship silently |
245
+ | `appkit.W005` | Warning | `LOGGING` is configured but no handler references a filter resolving to `RequestIDFilter` | Add `"request_id": {"()": "appkit.request_id.RequestIDFilter"}` to `LOGGING["filters"]`, and `"request_id"` to the relevant handler's `filters` list |
246
+ | `appkit.W006` | Warning | `REST_FRAMEWORK["NUM_PROXIES"]` disagrees with `APPKIT["TRUSTED_PROXY_COUNT"]`, or is unset while a `SimpleRateThrottle` subclass (`ScopedRateThrottle`, `AnonRateThrottle`, `UserRateThrottle`, or a host's own) is configured, globally or on any view | Set `REST_FRAMEWORK["NUM_PROXIES"]` to the same value as `APPKIT["TRUSTED_PROXY_COUNT"]` — see "`client_ip()` — the trusted-hop algorithm" below; DRF's `get_ident()` does its own `X-Forwarded-For` parsing appkit cannot inject into, so the two settings drifting apart makes the throttle bucket spoofable even though `client_ip()` itself is correct |
247
+
248
+ Every check is defensive by construction — a system check that raises breaks `manage.py`
249
+ outright, including the very commands someone would use to fix what it's complaining about, so
250
+ each function treats a malformed or partially-configured host structure as "nothing to report,"
251
+ never a crash. One stated limit: every check above only runs if `INSTALLED_APPS` lists
252
+ `"appkit"` in the first place — Django never calls `ready()` on an app that isn't listed, and
253
+ nothing inside appkit can self-detect that omission.
254
+
255
+ ## The error envelope
256
+
257
+ Every error response `standard_exception_handler` produces has this shape, verbatim:
258
+
259
+ ```json
260
+ {
261
+ "error": {
262
+ "code": "validation_error",
263
+ "message": "...",
264
+ "details": {},
265
+ "request_id": "..."
266
+ }
267
+ }
268
+ ```
269
+
270
+ `details` is always present (`{}` when nothing is field-level). `request_id` is the same
271
+ correlation ID `appkit.request_id.request_id_var` carries. Headers DRF already sets
272
+ (`Retry-After` on `throttled`, `WWW-Authenticate` on `not_authenticated`/
273
+ `authentication_failed`) are untouched — the handler only ever rewrites `response.data`.
274
+
275
+ Ten `code` values, in this exact order (pinned against `tests/fixtures/error-codes.json`, which
276
+ both halves are independently verified against rather than against each other directly):
277
+
278
+ `validation_error`, `parse_error`, `not_authenticated`, `authentication_failed`,
279
+ `permission_denied`, `not_found`, `method_not_allowed`, `throttled`, `server_error`, **`error`**.
280
+
281
+ **`"error"` is the documented catch-all**, not an omission — it covers any `APIException` DRF
282
+ resolved to a response that isn't one of the other nine specific types. For `"error"`, the HTTP
283
+ status is authoritative, the code is not. New specific codes may be carved out of `"error"` in a
284
+ future **minor** version — the one place this ten-member set can grow without a major bump (see
285
+ "Semver triggers" in `CLAUDE.md`).
286
+
287
+ ## Testing — pytest fixtures (opt-in)
288
+
289
+ appkit ships a pytest plugin, `appkit.testing`, providing `appkit_api_client`, `appkit_user`,
290
+ `appkit_admin_user`, `appkit_auth_client`, `appkit_admin_client`, `appkit_frozen_request_id`,
291
+ `appkit_clear_cache`, and the `appkit_assert_error_envelope(response, *, code, status)` helper
292
+ (`docs/CONTRACT.md` §2.17).
293
+
294
+ **Every name carries an `appkit_` prefix** — `APP-DESIGN.md` §1.2's namespacing rule applied to
295
+ pytest's fixture registry, which is exactly the kind of shared, flat namespace that rule exists
296
+ for. This isn't theoretical: pytest-django ships its own built-in fixtures literally named
297
+ `admin_user` and `admin_client`, and (verified directly) pytest-django's versions win that name
298
+ collision **silently** wherever `db`/`django_db` is in play — a bare `admin_client` fixture
299
+ parameter returns pytest-django's plain Django `Client`, never appkit's DRF `APIClient`, with no
300
+ warning. Prefixing every name this plugin exposes — not just the two that happen to collide
301
+ today — is what keeps a future pytest-django release, another plugin, or a consuming app's own
302
+ `conftest.py` from silently shadowing the rest.
303
+
304
+ It is **opt-in, not automatic** — no `pytest11` entry point is registered, on purpose: appkit is
305
+ installed transitively by every host, so auto-loading would inject these fixtures into every
306
+ consuming app's test namespace whether or not it asked for them. Wire it up explicitly in the
307
+ consuming app's own `pyproject.toml`:
308
+
309
+ ```toml
310
+ [tool.pytest.ini_options]
311
+ addopts = "-p appkit.testing ..."
312
+ ```
313
+
314
+ An app package doing this gets `appkit_api_client`/`appkit_auth_client`/`appkit_user` for free
315
+ instead of hand-rolling a slightly different version of each in its own `conftest.py`.
316
+
317
+ **Why two of `appkit.testing`'s own imports are deferred into function bodies, not module
318
+ scope** (previously only in the source comments of `backend/src/appkit/testing.py`):
319
+
320
+ - `rest_framework.test.APIRequestFactory` reads DRF's `api_settings` at **class-definition**
321
+ time. A `-p` plugin named in `addopts` is imported during pytest's early
322
+ `consider_preparse` phase — ahead of pytest-django's own settings setup — so a module-scope
323
+ import here would raise `ImproperlyConfigured` the moment any consumer wires up
324
+ `-p appkit.testing`, before Django settings exist at all. Deferred into
325
+ `appkit_api_client()` instead.
326
+ - A module-scope `appkit.request_id` import here lands **before** pytest-cov's tracer attaches
327
+ (for the same early-loading reason), and coverage.py then permanently reports every line that
328
+ module's import executes as "previously imported but not measured" — verified directly against
329
+ this exact codebase: `request_id.py`'s own measured coverage drops from 100% to 42% the moment
330
+ this import moves to module scope, in appkit's own suite, with no change in what actually ran.
331
+ This is why appkit's own `pyproject.toml` deliberately does **not** dogfood
332
+ `-p appkit.testing` in its own `addopts` — doing so would reproduce both drops in appkit's own
333
+ coverage numbers, not just a downstream consumer's.
334
+
335
+ ### Two-leg strategy: gate run vs. bare-install check
336
+
337
+ Every test run happens twice — proving the core stands alone, and proving both extras work when
338
+ installed:
339
+
340
+ ```bash
341
+ make test # gate: authoritative, >=95% coverage, both extras installed, against Postgres
342
+ make test-bare # bare-install check: neither extra, -m "not requires_extra", no coverage gate
343
+ ```
344
+
345
+ Any test exercising `crypto`/`images`-gated behavior carries the `requires_extra` marker, so the
346
+ bare leg deselects it rather than failing on a missing optional dependency.
347
+
348
+ ### The golden-fixture rule
349
+
350
+ Any behaviour that must agree across the two halves — the ten error codes, Jalali round-trips,
351
+ money formatting, truncation — is verified by **one fixture file both suites load**
352
+ (`tests/fixtures/*.json`), never by two independently hand-written test files that happen to
353
+ agree today. `appkit.exceptions.ERROR_CODES` and the frontend's `ApiErrorCode` union are each
354
+ asserted against `tests/fixtures/error-codes.json` directly, not against each other — a
355
+ divergence between the halves is impossible to introduce by editing only one side's tests. See
356
+ `tests/fixtures/README.md` for the full fixture list.
357
+
358
+ ## Required `.env` keys
359
+
360
+ **None.** Settled: `appkit.crypto.Cipher` takes its key as a constructor argument, never from
361
+ Django settings or an environment variable — `docs/CONTRACT.md` §3. Field-level crypto wrapping
362
+ a host's own `FERNET_KEY` stays in the host's `tools/crypto.py` permanently; an app declaring
363
+ `hjtdev-appkit[crypto]` builds a `Cipher` from its own documented `.env` key instead. Every
364
+ configurable value the modules below read (`MAX_UPLOAD_BYTES`, `TRUSTED_PROXY_COUNT`,
365
+ `SITE_URL`, `CACHE_TIMEOUT`) is an optional `APPKIT` **settings** key, not an `.env` key — see
366
+ "Settings" above.
367
+
368
+ ## URL mounting
369
+
370
+ Not applicable — appkit ships no views, no `urls.py`. It's an importable library, not a
371
+ mounted app.
372
+
373
+ ## Migrations
374
+
375
+ Not applicable — appkit ships no models.
376
+
377
+ ## Services and signals
378
+
379
+ Not applicable, and deliberately so. Other app packages in this ecosystem follow a three-file
380
+ shape (`models.py`/`services.py`/`signals.py`); appkit's public surface is its importable
381
+ modules instead (`appkit.cache`, `appkit.mixins`, etc.) — there is no `services.py` to hold
382
+ business logic and no `signals.py` to emit, since appkit has no models to signal about.
383
+
384
+ ## Factories
385
+
386
+ Not applicable — appkit ships no models, so `APP-DESIGN.md`'s `factories.py` test-surface
387
+ convention (factory-boy) does not apply here.
388
+
389
+ ## Exports (backend)
390
+
391
+ Every module below is complete as of `docs/CONTRACT.md` §2 — exact signatures there, `README.md`
392
+ lists what each provides rather than repeating them verbatim.
393
+
394
+ | Module | Provides |
395
+ |---|---|
396
+ | `appkit.cache` | `build_cache_key(namespace, *parts) -> str`, `cached_call(key, timeout, producer) -> T`, `cache_endpoint(*, namespace, timeout=UNSET, per_user=True, vary_headers=(), cache_statuses=(200,))` — decorator wrapping a DRF view method (`list`/`retrieve`), `invalidate_namespace(namespace) -> int`, `namespace_version(namespace) -> int` |
397
+ | `appkit.mixins` | `CachedListMixin` — `cache_namespace: str` (required, no default — raises `ImproperlyConfigured` if left empty), `cache_timeout: int \| UNSET` |
398
+ | `appkit.exceptions` | `standard_exception_handler(exc, context) -> Response \| None`, `ERROR_CODES: tuple[str, ...]` — the ten `code` values, in order (see "The error envelope" above) |
399
+ | `appkit.request_id` | `request_id_var: ContextVar[str]`, `RequestIDMiddleware`, `RequestIDFilter` |
400
+ | `appkit.permissions` | `IsAppAdmin`, `IsObjectOwner` (`owner_field: str = "user"` — the IDOR-case permission) |
401
+ | `appkit.pagination` | `DefaultPagination` — `page_size=25`, `max_page_size=100` |
402
+ | `appkit.validation` | `validate_query_params(serializer_class, params) -> S`, `sanitize_html(value, *, allowed_tags=None) -> str`, `strip_html(value) -> str` (`nh3`-based), `ALLOWED_LOOKUPS: frozenset[str]`, `validate_lookup(lookup) -> bool`, `safe_filter_kwargs(params, allowed_fields, *, allow_relations=False) -> dict` (an ORM lookup-key allowlist — `regex`/`iregex` excluded, a ReDoS vector) |
403
+ | `appkit.files` | `ImageInfo` (`width`, `height`, `format`), `detect_mimetype(data: bytes) -> str`, `validate_upload(file, *, allowed_mimetypes, max_bytes=UNSET) -> None`, `validate_image(file, *, max_bytes=UNSET, max_dimensions=None, allow_svg=False) -> ImageInfo` — magic-byte mimetype validation, size limits, a hardcoded extension/mimetype agreement table, decompression-bomb-aware image dimension checks. `validate_image`'s raster-format path requires the `images` extra |
404
+ | `appkit.net` | `client_ip(request) -> str` — trusts only the proxy-appended `X-Forwarded-For` entry (`APPKIT["TRUSTED_PROXY_COUNT"]`-th from the right), never the client-controlled leftmost value |
405
+ | `appkit.media` | `file_url(value, *, request=None) -> str \| None`, `absolute_url(url, *, request=None) -> str \| None` — media URL absolutisation; this, not `appkit.urls`, is where a media-URL helper lives, since appkit exposes no `urlpatterns` at all |
406
+ | `appkit.text` | `truncate(value, length, *, suffix="…") -> str`, `to_english_digits(value) -> str`, `to_persian_digits(value) -> str` — codepoint-aware, matches the frontend half's `truncate` in *behaviour* exactly (the signature shape differs — TS has no keyword-only arguments; see "Frontend usage" below) |
407
+ | `appkit.dates` | `to_jalali(value: date \| datetime) -> tuple[int, int, int]`, `from_jalali(year, month, day) -> date`, `format_jalali(value, fmt="%Y/%m/%d") -> str`, `parse_jalali(value, fmt="%Y/%m/%d") -> date` — Gregorian↔Jalali conversion; no third-party type in any signature |
408
+ | `appkit.money` | `parse_amount(value: str \| int) -> int`, `format_amount(value: int, *, currency="") -> str` — fixed ASCII `,` grouping, never locale-dependent |
409
+ | `appkit.throttling` | `throttle_scope(app_namespace, action) -> str` — a §1.2 scope-prefix-naming helper |
410
+ | `appkit.conf` | `get_setting(key) -> Any`, `UNSET`, `DEFAULTS` (internal-but-stable, not re-exported from top-level `appkit`) |
411
+ | `appkit.crypto` | `Cipher(key: str \| bytes)` — `.encrypt(value) -> str`, `.decrypt(token) -> str`; `generate_key() -> str`. Fernet encryption taking its key at construction. Requires the `crypto` extra; resolved in `docs/CONTRACT.md` §3: appkit reads no `.env`/settings key of its own, ever |
412
+ | `appkit.testing` (pytest plugin, opt-in — see "Testing" above) | `appkit_api_client`, `appkit_user`, `appkit_admin_user`, `appkit_auth_client`, `appkit_admin_client`, `appkit_frozen_request_id`, `appkit_clear_cache`, `appkit_assert_error_envelope(response, *, code, status) -> None`. Every name is `appkit_`-prefixed on purpose — pytest-django ships its own built-in `admin_user`/`admin_client` fixtures, and (verified directly) pytest-django's win that exact name collision silently; the prefix is what keeps this plugin's fixtures from ever landing in that situation |
413
+
414
+ ### `client_ip()` — the trusted-hop algorithm
415
+
416
+ The table above gives the outcome ("trusts only the proxy-appended entry, `TRUSTED_PROXY_COUNT`-th
417
+ from the right"); this is the mechanism, since a host debugging a wrong client IP needs the
418
+ algorithm, not just the promise.
419
+
420
+ `X-Forwarded-For` is a comma-separated list a client can prepend arbitrary fake entries to —
421
+ every entry left of what your own infrastructure appended is attacker-controlled. `client_ip()`
422
+ never trusts the leftmost entry for exactly this reason. Instead:
423
+
424
+ 1. Split the header on `,`, trim whitespace from each part.
425
+ 2. Index **from the right**: `parts[-TRUSTED_PROXY_COUNT]` — the entry your own
426
+ `TRUSTED_PROXY_COUNT`-th proxy hop appended, never a client-suppliable position.
427
+ 3. Validate that entry as an IPv4/IPv6 address (stripping a `[bracket]:port` wrapper or a
428
+ trailing `:port` first) before returning it.
429
+
430
+ Every one of these four situations falls back to the connection's own `REMOTE_ADDR`, with a
431
+ logged warning, rather than raising or returning something wrong:
432
+
433
+ - `X-Forwarded-For` is absent or empty — no proxy in front of this request at all.
434
+ - The header has **fewer entries** than `TRUSTED_PROXY_COUNT` — a misconfigured proxy count, or a
435
+ request that skipped a hop somewhere.
436
+ - `TRUSTED_PROXY_COUNT` is **not positive** — `parts[-0]` is `parts[0]`, the spoofable leftmost
437
+ entry, so a zero or negative count is treated as "nothing configured" rather than silently
438
+ handing an attacker-controlled value back as the trusted client IP.
439
+ - The candidate at that position **isn't a valid IP address** once normalised — a malformed or
440
+ unexpected header shape.
441
+
442
+ `appkit.W006` (see "System checks" above) exists because DRF's own `SimpleRateThrottle.get_ident()`
443
+ does a **different**, simpler parse of the same header with no way for appkit to inject this
444
+ algorithm into it — the two silently disagreeing about who the client is is exactly the failure
445
+ that check catches.
446
+
447
+ ## Exports (frontend)
448
+
449
+ Full signatures, failure paths, and reasoning: `docs/CONTRACT.md` §14–§23 (frontend contract,
450
+ Session 2). Nothing beyond this table is exported from `src/index.ts`.
451
+
452
+ | Export | Provides |
453
+ |---|---|
454
+ | `HttpClient` | The five-method interface (`get`/`post`/`put`/`patch`/`delete`) a host's concrete client satisfies structurally — appkit never implements one |
455
+ | `ApiClientProvider` | The one shared provider a host mounts. Props: `client: HttpClient` (required), `basePaths?: Readonly<Record<string, string>>` (optional, defaults to `{}`), `headerSources?: ReadonlyArray<HeaderSource>` (optional, defaults to `[]`), `children` |
456
+ | `ApiClientProviderProps` | The type of the props object above |
457
+ | `useApiClient(key, defaultBasePath)` | Called from each installed app's own `api/config.ts`, never directly by a host. **Returns `{ client: HttpClient; basePath: string }`** — this shape is a semver-major-bump trigger (`CLAUDE.md`). Both arguments required — a missing `basePaths` entry falls back to the app's own default, never to `""`/`/`; throws if called outside a mounted `ApiClientProvider` |
458
+ | `HeaderSource` | `() => HeadersInit \| Promise<HeadersInit>` — see "Header injection" below |
459
+ | `ApiError` / `isApiError` | Matches the backend envelope exactly — one definition instead of one per app. `isApiError` is a brand check, not `instanceof`, so it survives a duplicate-copy install |
460
+ | `isApiErrorEnvelope` / `apiErrorFromEnvelope` | Pure envelope-parsing helpers — validate/construct from already-fetched data, never touch `fetch`/`Response` themselves. Forward-compatible on `code`: a code outside the ten-member union still validates as an envelope, and `apiErrorFromEnvelope` degrades it to `"error"` while preserving `message`/`details`/`request_id` and exposing the raw value on `ApiError.unrecognizedCode` (`null` for every known code) |
461
+ | `ApiErrorCode` / `ClientErrorCode` / `ApiErrorEnvelope` | Types — the ten backend codes plus this client's own `"unknown_error"`, kept as a separate type so the ten-member union stays a true mirror |
462
+ | `makeQueryClient()` | A factory (never a module-level singleton) — mirrors the scaffold's own `frontend/lib/query-client.ts` |
463
+ | `truncate(value, length, suffix?)` / `toEnglishDigits(value)` / `toPersianDigits(value)` | Mirror the backend's `appkit.text` in *behaviour* — codepoint-aware, not UTF-16-unit-aware. `suffix` is a plain optional positional parameter here (TS has no keyword-only arguments; the backend's equivalent is keyword-only) |
464
+ | `parseAmount(value)` / `formatAmount(value, currency?)` | Mirror `appkit.money` — fixed `,` separator, never locale-dependent |
465
+ | `toJalali` / `fromJalali` / `formatJalali` / `parseJalali` / `calendarDateIn` | Mirror `appkit.dates`. Date-only by default; `calendarDateIn(instant, timeZone)` is the explicit, no-default bridge from an instant to a calendar date |
466
+ | `JalaliDate` | `{ year: number; month: number; day: number }` — the type every function above operates on |
467
+ | `mediaUrl(value, baseUrl)` | Mirrors `appkit.media` — takes its base as an argument, never reads `NEXT_PUBLIC_API_URL` itself |
468
+
469
+ **Not exported, on purpose:** a concrete client/`apiClient` singleton, `getApiBaseUrl`, a
470
+ `QueryClient` singleton, the `ApiClientContext` object itself, any manager or config-hook shape,
471
+ any UI component, any storage helper. Reasoning for each: `docs/CONTRACT.md` §21.
472
+
473
+ ### `HttpClient`'s exact method signatures
474
+
475
+ The interface, verbatim (`docs/CONTRACT.md` §14) — a host's concrete client satisfies this
476
+ **structurally**, with no `implements` declaration and no import of this type required in the
477
+ host's own client module, since TypeScript is structurally typed:
478
+
479
+ ```ts
480
+ interface HttpClient {
481
+ get<T>(path: string, init?: RequestInit): Promise<T>;
482
+ post<T>(path: string, body?: unknown, init?: RequestInit): Promise<T>;
483
+ put<T>(path: string, body?: unknown, init?: RequestInit): Promise<T>;
484
+ patch<T>(path: string, body?: unknown, init?: RequestInit): Promise<T>;
485
+ delete<T>(path: string, init?: RequestInit): Promise<T>;
486
+ }
487
+
488
+ type HeaderSource = () => HeadersInit | Promise<HeadersInit>;
489
+ ```
490
+
491
+ `put` is included alongside the more obvious `get`/`post`/`patch`/`delete` because an SDK
492
+ wrapping a DRF `ViewSet`'s full-update action needs to express it — a deliberate deviation from
493
+ an earlier draft that listed only four methods. There is no sixth `request()` method: that would
494
+ leak a host implementation's own internal shape into this interface rather than describing
495
+ behaviour, and appkit never implements `HttpClient` itself, only injects it.
496
+
497
+ ## Header injection
498
+
499
+ Settled in Phase 0 (`docs/CONTRACT.md` §16): `ApiClientProvider` takes an optional
500
+ `headerSources?: ReadonlyArray<HeaderSource>` prop. Sources run left-to-right, then the call's
501
+ own `init.headers` last — later always wins, header names compared case-insensitively so
502
+ `authorization`/`Authorization` from two sources collapse into one. A source that throws or
503
+ rejects **fails the request**, naming which source failed, rather than silently shipping the
504
+ request without that header. This is how the future JWT app attaches `Authorization` without
505
+ appkit knowing anything about auth:
506
+
507
+ > appkit never reads, stores, refreshes, or inspects a token. It invokes opaque callbacks the
508
+ > host supplies and merges their output into request headers.
509
+
510
+ Token refresh / retry-on-401 is explicitly **not** appkit's job — see "What appkit deliberately
511
+ does not provide" below.
512
+
513
+ ## Usage — mounting the shared provider
514
+
515
+ Every host mounts `ApiClientProvider` **once**, nested under its existing
516
+ `QueryClientProvider`, regardless of how many apps are installed — installing a second app
517
+ adds a `basePaths` entry to this same provider, it never nests a second provider
518
+ (`INTEGRATION-GUIDE.md` §2 step 11):
519
+
520
+ ```tsx
521
+ // frontend/app/providers.tsx
522
+ "use client";
523
+
524
+ import { useState, useMemo } from "react";
525
+ import { QueryClientProvider } from "@tanstack/react-query";
526
+ import { ApiClientProvider, makeQueryClient } from "@hjtdev/appkit";
527
+ import { apiClient } from "@/lib/api-client";
528
+ import { getAuthHeaders } from "@/lib/auth"; // host's own — appkit knows nothing about it
529
+
530
+ export function Providers({ children }: { children: React.ReactNode }) {
531
+ const [queryClient] = useState(() => makeQueryClient());
532
+ const headerSources = useMemo(() => [getAuthHeaders], []); // stable reference — see below
533
+
534
+ return (
535
+ <QueryClientProvider client={queryClient}>
536
+ <ApiClientProvider
537
+ client={apiClient}
538
+ headerSources={headerSources}
539
+ basePaths={{
540
+ // ...entries for each installed app's own README-suggested prefix
541
+ }}
542
+ >
543
+ {children}
544
+ </ApiClientProvider>
545
+ </QueryClientProvider>
546
+ );
547
+ }
548
+ ```
549
+
550
+ `apiClient` is the host's own concrete client (`frontend/lib/api-client.ts`) — appkit owns the
551
+ `HttpClient` interface and this provider, never a client implementation. See `CLAUDE.md`'s
552
+ "The frontend boundary" for why that split is deliberate, not an oversight.
553
+
554
+ `headerSources` must be a **stable reference** (built with `useMemo`/module scope, never an
555
+ inline array literal) — the decorated client is memoised on it, and a new array identity every
556
+ render rebuilds every installed app's own manager on every render (`docs/CONTRACT.md` §15).
557
+
558
+ ## Frontend usage — the pattern every consuming SDK follows
559
+
560
+ This is the pattern every future app package's frontend takes over appkit, worked out and
561
+ verified with zero friction against `playground/demo-sdk` (`playground/FINDINGS.md` §14). Three
562
+ layers, in order:
563
+
564
+ 1. **`api/config.ts`** — a thin binding, never exported from the SDK's own `index.ts`:
565
+
566
+ ```ts
567
+ // api/config.ts — internal
568
+ "use client";
569
+ import { useApiClient } from "@hjtdev/appkit";
570
+
571
+ export const useDemoConfig = () => useApiClient("demo", "/api/v1/demo");
572
+ ```
573
+
574
+ 2. **An instance-based manager** — the *only* place a raw HTTP call happens, also never
575
+ exported:
576
+
577
+ ```ts
578
+ // api/manager.ts — internal
579
+ import type { HttpClient } from "@hjtdev/appkit";
580
+
581
+ export class DemoManager {
582
+ constructor(
583
+ private readonly client: HttpClient,
584
+ private readonly basePath: string,
585
+ ) {}
586
+
587
+ list(): Promise<DemoItemPage> {
588
+ return this.client.get<DemoItemPage>(`${this.basePath}/items/`);
589
+ }
590
+ }
591
+ ```
592
+
593
+ 3. **Hooks, reading from the binding** — the only layer the SDK's `index.ts` exports:
594
+
595
+ ```ts
596
+ // hooks/useDemoItems.ts
597
+ import { useQuery } from "@tanstack/react-query";
598
+ import { useDemoConfig } from "../api/config.js";
599
+ import { DemoManager } from "../api/manager.js";
600
+
601
+ export function useDemoItems() {
602
+ const { client, basePath } = useDemoConfig();
603
+ const manager = new DemoManager(client, basePath);
604
+ return useQuery({ queryKey: ["demo", "items"], queryFn: () => manager.list() });
605
+ }
606
+ ```
607
+
608
+ A host never imports `api/config.ts` or a manager directly — only the hooks (and any query-key
609
+ factory a host needs for its own invalidation) are public. This is what keeps `useApiClient`'s
610
+ two-required-arguments, throws-on-empty-default design (see "Exports (frontend)" above) doing its
611
+ job: every SDK built this way gets the host's injected client and per-app base path without ever
612
+ touching `fetch` itself.
613
+
614
+ **One risk worth naming for SDK authors:** never list `react`/`@tanstack/react-query` as both a
615
+ `peerDependency` *and* a real `devDependency` install target without an npm workspace guaranteeing
616
+ dedupe — without one, a second, physically separate copy resolves, with its own `React.Context`
617
+ object, and `useQuery`/`useApiClient` silently break as if no provider were mounted at all
618
+ (reproduced and root-caused in `playground/FINDINGS.md` §13; the exact "two copies" failure mode
619
+ this package's own duplicate-copy guard exists to catch on appkit's side, but cannot catch for a
620
+ peer dependency it doesn't own).
621
+
622
+ ## What appkit deliberately does not provide
623
+
624
+ - **No Celery / `django.tasks`.** A shared dependency that drags in a task runner forces every
625
+ consuming app *and* host to care about it. If a future shared helper genuinely needs async
626
+ work, that's a design discussion, not a default (`CLAUDE-CODE-GUIDE-APP.md` §1.3).
627
+ - **No models, no migrations, no admin.**
628
+ - **No UI components.** The frontend half is an SDK contract — hooks and a fetcher interface —
629
+ not a component library. A `share()` helper or anything else UI-shaped belongs in a separate
630
+ package if it's ever wanted.
631
+ - **No parallel validation framework.** DRF serializers do the work; appkit adds a thin helper
632
+ for validating `request.query_params` through a serializer, not a new declaration system.
633
+ - **No generic "XSS/SQL-injection checker."** The ORM already prevents SQL injection;
634
+ string-scanning for `<script>` is a blocklist that provides false confidence. The two real
635
+ pieces underneath are in scope instead: HTML sanitisation and an ORM lookup-key allowlist
636
+ (see Exports above).
637
+ - **No client implementation on the frontend half — interface and shared provider only.**
638
+ appkit owns `HttpClient` and `ApiClientProvider`/`useApiClient`; the host always constructs
639
+ and injects the real client (`NEXT_PUBLIC_API_URL`, CSRF, credentials mode — all host
640
+ configuration). See "The frontend boundary" in `CLAUDE.md`.
641
+ - **No retry-on-401 / token refresh**, anywhere in `headerSources` or the client appkit
642
+ decorates. A real refresh loop needs the refresh endpoint, infinite-loop protection, and
643
+ concurrent-refresh dedupe — all auth-specific knowledge appkit must never have. That's the
644
+ host's concrete client's job; the future JWT app's own README documents it there
645
+ (`docs/CONTRACT.md` §J).
646
+
647
+ ## Known caveats inherited from the base-scaffold helpers this replaces
648
+
649
+ Flagged here rather than silently fixed, since the base-scaffold's `backend/tools/` module is
650
+ the *source* appkit's own versions are built from, and this repo has no standing to edit it:
651
+
652
+ - `standard_exception_handler`'s fallback `code` (`"error"`, for an `APIException` outside the
653
+ eight specifically-mapped types) was undercounted here as outside "the nine documented
654
+ codes". **Resolved in `docs/CONTRACT.md` §1: the set is ten, not nine** — `"error"` is now a
655
+ documented member in its own right, with the HTTP status authoritative for it. See that
656
+ section for the full reasoning and the exhaustive `ApiErrorCode` this implies for the
657
+ frontend half.
658
+ - `invalidate_namespace` (get-then-increment) isn't atomic — a cache eviction between the two
659
+ calls raises. Low-probability, but appkit's blast radius means it now affects every app.
660
+ - `cached_call` can't distinguish "cache miss" from "legitimately cached `None`" — documented
661
+ behavior, not a bug, but worth restating here since this is now shared infrastructure.
662
+
663
+ ## Known caveat — Django's own request logging never carries the request ID
664
+
665
+ Found via `playground/` (`playground/FINDINGS.md`), root-caused against Django's own source
666
+ (`django/core/handlers/base.py`), not inherited from base-scaffold: Django's built-in
667
+ `django.request` logger — the one that auto-logs every 4xx/5xx response — will **never** carry
668
+ `request_id`, no matter how `MIDDLEWARE` is ordered. `BaseHandler.get_response_async` awaits the
669
+ entire middleware chain (including `RequestIDMiddleware`) to completion *before* it calls
670
+ `log_response()` for a 4xx/5xx response — by that point `RequestIDMiddleware`'s own
671
+ `finally: request_id_var.reset(token)` has already run. This isn't fixable inside appkit without
672
+ reintroducing the exact ID-bleed-under-concurrency bug that `finally: reset()` exists to prevent.
673
+
674
+ Correlation still works everywhere it matters: the response's own `X-Request-ID` header, any
675
+ logger your *own* view/handler code calls during request handling (e.g.
676
+ `standard_exception_handler`'s `logger.exception(...)`), and `appkit.testing`'s
677
+ `appkit_frozen_request_id` fixture all see the correct ID. Only Django's automatic, built-in
678
+ 4xx/5xx logging does not.
679
+
680
+ ## Test helpers
681
+
682
+ See "Testing — pytest fixtures (opt-in)" above for the full fixture/helper list and the
683
+ `appkit_`-prefix naming rationale. Two behaviors worth calling out beyond that list:
684
+
685
+ - `appkit_user`/`appkit_admin_user` build through `get_user_model().USERNAME_FIELD`
686
+ **reflectively** — they work against a custom, non-`username`-keyed user model without any
687
+ extra configuration.
688
+ - `appkit_clear_cache` is deliberately **not** `autouse` — under `pytest -n auto` against a
689
+ shared cache backend, an autouse cache-clear would clear another xdist worker's in-flight
690
+ data too.
691
+
692
+ ## Suggested Jazzmin icon
693
+
694
+ Not applicable — appkit registers no models.
695
+
696
+ ## Recommended periodic schedule
697
+
698
+ Not applicable — appkit ships no `django.tasks`/Celery tasks, deliberately (see above).