hjtdev-appkit 2.0.0__py3-none-any.whl
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.
- appkit/__init__.py +39 -0
- appkit/apps.py +33 -0
- appkit/cache.py +222 -0
- appkit/checks.py +513 -0
- appkit/conf.py +71 -0
- appkit/crypto.py +102 -0
- appkit/dates.py +183 -0
- appkit/exceptions.py +158 -0
- appkit/files.py +302 -0
- appkit/locale/fa/LC_MESSAGES/django.mo +0 -0
- appkit/locale/fa/LC_MESSAGES/django.po +29 -0
- appkit/media.py +102 -0
- appkit/mixins.py +68 -0
- appkit/money.py +69 -0
- appkit/net.py +115 -0
- appkit/pagination.py +30 -0
- appkit/permissions.py +48 -0
- appkit/py.typed +0 -0
- appkit/request_id.py +96 -0
- appkit/testing.py +259 -0
- appkit/text.py +62 -0
- appkit/throttling.py +41 -0
- appkit/validation.py +195 -0
- hjtdev_appkit-2.0.0.dist-info/METADATA +17 -0
- hjtdev_appkit-2.0.0.dist-info/RECORD +28 -0
- hjtdev_appkit-2.0.0.dist-info/WHEEL +5 -0
- hjtdev_appkit-2.0.0.dist-info/licenses/LICENSE +21 -0
- hjtdev_appkit-2.0.0.dist-info/top_level.txt +1 -0
appkit/checks.py
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
"""Django system checks registered by :class:`appkit.apps.AppKitConfig.ready`.
|
|
2
|
+
|
|
3
|
+
Present and safe: registered explicitly from ``ready()``, never picked up implicitly by Django's
|
|
4
|
+
own auto-discovery (docs/CONTRACT.md §10's collision-audit table).
|
|
5
|
+
|
|
6
|
+
Seven functions, eight check IDs (docs/CONTRACT.md §6):
|
|
7
|
+
|
|
8
|
+
appkit.E001 (Error) — RequestIDMiddleware absent from MIDDLEWARE
|
|
9
|
+
-> check_request_id_middleware
|
|
10
|
+
appkit.E002 (Error) — EXCEPTION_HANDLER unset or still DRF's own default
|
|
11
|
+
-> check_exception_handler
|
|
12
|
+
appkit.W001 (Warning) — EXCEPTION_HANDLER set to neither DRF's default nor
|
|
13
|
+
appkit.exceptions.standard_exception_handler
|
|
14
|
+
-> check_exception_handler
|
|
15
|
+
appkit.W002 (Warning) — RequestIDMiddleware present but ordered before
|
|
16
|
+
SecurityMiddleware
|
|
17
|
+
-> check_middleware_order
|
|
18
|
+
appkit.W003 (Warning) — APPKIT dict has a key not present in
|
|
19
|
+
appkit.conf.DEFAULTS
|
|
20
|
+
-> check_unknown_settings_keys
|
|
21
|
+
appkit.W004 (Warning) — a view reachable via ROOT_URLCONF declares a
|
|
22
|
+
throttle_scope with no matching
|
|
23
|
+
DEFAULT_THROTTLE_RATES entry
|
|
24
|
+
-> check_throttle_scopes
|
|
25
|
+
appkit.W005 (Warning) — LOGGING is configured but no handler references a
|
|
26
|
+
filter resolving to appkit.request_id.RequestIDFilter
|
|
27
|
+
-> check_logging_filter
|
|
28
|
+
appkit.W006 (Warning) — REST_FRAMEWORK["NUM_PROXIES"] disagrees with
|
|
29
|
+
APPKIT["TRUSTED_PROXY_COUNT"], or is unset while a
|
|
30
|
+
SimpleRateThrottle subclass is configured
|
|
31
|
+
-> check_num_proxies_throttle_agreement
|
|
32
|
+
|
|
33
|
+
Every function below is defensive by construction: a system check that raises breaks
|
|
34
|
+
``manage.py`` entirely, including the commands someone would use to fix the thing it's
|
|
35
|
+
complaining about. Each walks host-provided structures (``MIDDLEWARE``, ``REST_FRAMEWORK``,
|
|
36
|
+
``ROOT_URLCONF``, ``LOGGING``) that may be malformed, partially configured, or reference
|
|
37
|
+
something unimportable, and every one of those is treated as "nothing to report", never a crash.
|
|
38
|
+
|
|
39
|
+
Known limit, stated in docs/CONTRACT.md §5: every check below only runs if the host got
|
|
40
|
+
``INSTALLED_APPS`` right in the first place — Django never invokes ``ready()`` on an app that
|
|
41
|
+
isn't listed, and nothing inside appkit can self-detect that.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import logging
|
|
47
|
+
from typing import Any
|
|
48
|
+
|
|
49
|
+
from django.conf import settings
|
|
50
|
+
from django.core.checks import CheckMessage, Error, Warning
|
|
51
|
+
from django.utils.module_loading import import_string
|
|
52
|
+
|
|
53
|
+
from appkit import conf
|
|
54
|
+
from appkit.request_id import RequestIDFilter
|
|
55
|
+
|
|
56
|
+
logger = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
_REQUEST_ID_MIDDLEWARE = "appkit.request_id.RequestIDMiddleware"
|
|
59
|
+
_SECURITY_MIDDLEWARE = "django.middleware.security.SecurityMiddleware"
|
|
60
|
+
_DRF_DEFAULT_EXCEPTION_HANDLER = "rest_framework.views.exception_handler"
|
|
61
|
+
_APPKIT_EXCEPTION_HANDLER = "appkit.exceptions.standard_exception_handler"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def check_request_id_middleware(app_configs: Any, **kwargs: Any) -> list[CheckMessage]:
|
|
65
|
+
"""appkit.E001 — Error if ``appkit.request_id.RequestIDMiddleware`` is absent from
|
|
66
|
+
``MIDDLEWARE``.
|
|
67
|
+
|
|
68
|
+
Every error envelope's ``request_id`` field would otherwise silently read ``"-"``, with no
|
|
69
|
+
log line correlating to any other and no exception pointing at the cause.
|
|
70
|
+
"""
|
|
71
|
+
middleware = getattr(settings, "MIDDLEWARE", None) or []
|
|
72
|
+
if _REQUEST_ID_MIDDLEWARE in middleware:
|
|
73
|
+
return []
|
|
74
|
+
return [
|
|
75
|
+
Error(
|
|
76
|
+
"appkit.request_id.RequestIDMiddleware is not in MIDDLEWARE.",
|
|
77
|
+
hint=(
|
|
78
|
+
'Add "appkit.request_id.RequestIDMiddleware" to MIDDLEWARE, '
|
|
79
|
+
"right after SecurityMiddleware — docs/CONTRACT.md §8."
|
|
80
|
+
),
|
|
81
|
+
id="appkit.E001",
|
|
82
|
+
)
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def check_exception_handler(app_configs: Any, **kwargs: Any) -> list[CheckMessage]:
|
|
87
|
+
"""appkit.E002 / appkit.W001 — inspects ``REST_FRAMEWORK["EXCEPTION_HANDLER"]``.
|
|
88
|
+
|
|
89
|
+
Error (E002) if the key is unset or still DRF's own default
|
|
90
|
+
(``rest_framework.views.exception_handler``) — every app's client expects the
|
|
91
|
+
docs/CONTRACT.md §1 envelope; without this wired, DRF's raw ``{"detail": "..."}`` shape
|
|
92
|
+
ships instead.
|
|
93
|
+
|
|
94
|
+
Warning (W001, silenceable via ``SILENCED_SYSTEM_CHECKS``) if the handler is set to
|
|
95
|
+
something that is neither DRF's default nor
|
|
96
|
+
``appkit.exceptions.standard_exception_handler`` — a host wrapping appkit's handler in its
|
|
97
|
+
own is legitimate, so this is a nudge to confirm it's deliberate, not an error.
|
|
98
|
+
|
|
99
|
+
Reads the raw ``REST_FRAMEWORK`` dict rather than DRF's resolved ``api_settings``, and
|
|
100
|
+
compares dotted strings without importing ``appkit.exceptions`` — so "unset" and "set to
|
|
101
|
+
DRF's default" stay distinguishable, and this module never depends on a sibling appkit
|
|
102
|
+
module that isn't itself required for the check to run.
|
|
103
|
+
"""
|
|
104
|
+
drf_settings = getattr(settings, "REST_FRAMEWORK", None) or {}
|
|
105
|
+
handler = drf_settings.get("EXCEPTION_HANDLER")
|
|
106
|
+
|
|
107
|
+
if handler is None or handler == _DRF_DEFAULT_EXCEPTION_HANDLER:
|
|
108
|
+
return [
|
|
109
|
+
Error(
|
|
110
|
+
"REST_FRAMEWORK['EXCEPTION_HANDLER'] is not set to appkit's handler.",
|
|
111
|
+
hint=(
|
|
112
|
+
"Set REST_FRAMEWORK['EXCEPTION_HANDLER'] = "
|
|
113
|
+
f'"{_APPKIT_EXCEPTION_HANDLER}" — docs/CONTRACT.md §8.'
|
|
114
|
+
),
|
|
115
|
+
id="appkit.E002",
|
|
116
|
+
)
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
if handler != _APPKIT_EXCEPTION_HANDLER:
|
|
120
|
+
return [
|
|
121
|
+
Warning(
|
|
122
|
+
"REST_FRAMEWORK['EXCEPTION_HANDLER'] is set to neither DRF's default nor "
|
|
123
|
+
"appkit.exceptions.standard_exception_handler.",
|
|
124
|
+
hint=(
|
|
125
|
+
"If this wraps appkit's handler deliberately (e.g. to add a field to the "
|
|
126
|
+
"envelope), this warning can be silenced via SILENCED_SYSTEM_CHECKS. "
|
|
127
|
+
f'Otherwise set it to "{_APPKIT_EXCEPTION_HANDLER}" — docs/CONTRACT.md §8.'
|
|
128
|
+
),
|
|
129
|
+
id="appkit.W001",
|
|
130
|
+
)
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def check_middleware_order(app_configs: Any, **kwargs: Any) -> list[CheckMessage]:
|
|
137
|
+
"""appkit.W002 — Warning if ``RequestIDMiddleware`` is present but ordered before
|
|
138
|
+
``SecurityMiddleware`` in ``MIDDLEWARE`` (only evaluated when ``SecurityMiddleware`` is
|
|
139
|
+
present at all).
|
|
140
|
+
|
|
141
|
+
A swap doesn't crash anything; it just means the request ID is assigned before security
|
|
142
|
+
headers are considered — order-of-operations debt worth flagging, not blocking.
|
|
143
|
+
"""
|
|
144
|
+
middleware = getattr(settings, "MIDDLEWARE", None) or []
|
|
145
|
+
if _REQUEST_ID_MIDDLEWARE not in middleware or _SECURITY_MIDDLEWARE not in middleware:
|
|
146
|
+
return []
|
|
147
|
+
|
|
148
|
+
if middleware.index(_REQUEST_ID_MIDDLEWARE) < middleware.index(_SECURITY_MIDDLEWARE):
|
|
149
|
+
return [
|
|
150
|
+
Warning(
|
|
151
|
+
"appkit.request_id.RequestIDMiddleware is ordered before SecurityMiddleware.",
|
|
152
|
+
hint=(
|
|
153
|
+
"Move it to right after django.middleware.security.SecurityMiddleware in "
|
|
154
|
+
"MIDDLEWARE — docs/CONTRACT.md §8."
|
|
155
|
+
),
|
|
156
|
+
id="appkit.W002",
|
|
157
|
+
)
|
|
158
|
+
]
|
|
159
|
+
return []
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def check_unknown_settings_keys(app_configs: Any, **kwargs: Any) -> list[CheckMessage]:
|
|
163
|
+
"""appkit.W003 — Warning if the host's ``APPKIT`` dict contains a key not present in
|
|
164
|
+
``appkit.conf.DEFAULTS``.
|
|
165
|
+
|
|
166
|
+
A typo (``APPKIT = {"CACHE_TIMOUT": 30}``) would otherwise silently use the *default*
|
|
167
|
+
``CACHE_TIMEOUT`` forever, with the typo'd key simply ignored.
|
|
168
|
+
"""
|
|
169
|
+
configured = getattr(settings, "APPKIT", None) or {}
|
|
170
|
+
unknown = sorted(set(configured) - set(conf.DEFAULTS))
|
|
171
|
+
if not unknown:
|
|
172
|
+
return []
|
|
173
|
+
return [
|
|
174
|
+
Warning(
|
|
175
|
+
f"APPKIT contains unrecognised key(s): {', '.join(unknown)}.",
|
|
176
|
+
hint=(
|
|
177
|
+
f"Known APPKIT keys: {', '.join(sorted(conf.DEFAULTS))} "
|
|
178
|
+
"(docs/CONTRACT.md §7). A typo'd key is silently ignored — its value is never "
|
|
179
|
+
"read."
|
|
180
|
+
),
|
|
181
|
+
id="appkit.W003",
|
|
182
|
+
)
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def check_throttle_scopes(app_configs: Any, **kwargs: Any) -> list[CheckMessage]:
|
|
187
|
+
"""appkit.W004 — Warning if a view reachable by walking ``ROOT_URLCONF`` declares a
|
|
188
|
+
``throttle_scope`` string with no matching entry in
|
|
189
|
+
``REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]``.
|
|
190
|
+
|
|
191
|
+
DRF only raises ``AssertionError`` for a missing rate at request time, per request, so a
|
|
192
|
+
typo'd ``throttle_scope`` can ship to production and pass every test that doesn't happen to
|
|
193
|
+
exercise that exact view under throttling.
|
|
194
|
+
|
|
195
|
+
Detection scope (docs/CONTRACT.md §6): reliably finds a ``throttle_scope`` class attribute
|
|
196
|
+
on a class-based view reached via ``callback.view_class``/``.cls`` (which covers
|
|
197
|
+
``@api_view``-decorated function views too, since DRF wraps those in a real class). Does
|
|
198
|
+
**not** detect a scope assigned at runtime inside ``initial()``/``get_throttles()``, a
|
|
199
|
+
viewset choosing a scope per-action, or a scope on a plain function view with no DRF
|
|
200
|
+
wrapper — a clean run is not proof those don't exist somewhere.
|
|
201
|
+
|
|
202
|
+
The whole walk is defensive: resolving ``ROOT_URLCONF`` or recursing through
|
|
203
|
+
``include()``-nested patterns can raise for reasons entirely outside this check's control
|
|
204
|
+
(an unrelated import error in a host's ``urls.py``), and a system check raising breaks
|
|
205
|
+
``manage.py`` outright — so any failure here is treated as "nothing to report" rather than
|
|
206
|
+
propagated.
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
scopes, _throttle_classes = _collect_throttle_info()
|
|
210
|
+
except Exception:
|
|
211
|
+
# Never let this check crash manage.py — see docstring. Logged, not silent: an
|
|
212
|
+
# unwalkable URLconf is itself worth knowing about, just not at Error severity here.
|
|
213
|
+
logger.debug(
|
|
214
|
+
"appkit.checks.check_throttle_scopes: failed to walk ROOT_URLCONF", exc_info=True
|
|
215
|
+
)
|
|
216
|
+
return []
|
|
217
|
+
|
|
218
|
+
if not scopes:
|
|
219
|
+
return []
|
|
220
|
+
|
|
221
|
+
drf_settings = getattr(settings, "REST_FRAMEWORK", None) or {}
|
|
222
|
+
known_rates = set((drf_settings.get("DEFAULT_THROTTLE_RATES") or {}).keys())
|
|
223
|
+
missing = sorted(scopes - known_rates)
|
|
224
|
+
if not missing:
|
|
225
|
+
return []
|
|
226
|
+
|
|
227
|
+
return [
|
|
228
|
+
Warning(
|
|
229
|
+
f"throttle_scope {scope!r} has no matching "
|
|
230
|
+
"REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'] entry.",
|
|
231
|
+
hint=(
|
|
232
|
+
f"Add {scope!r} to REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'], or fix the typo "
|
|
233
|
+
"on the view that declares it. DRF only raises at request time for a missing "
|
|
234
|
+
"rate, per request — this can otherwise ship silently."
|
|
235
|
+
),
|
|
236
|
+
id="appkit.W004",
|
|
237
|
+
)
|
|
238
|
+
for scope in missing
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _collect_throttle_info() -> tuple[set[str], list[type]]:
|
|
243
|
+
"""Walk ``ROOT_URLCONF`` once and return every ``throttle_scope`` string AND every
|
|
244
|
+
``throttle_classes`` entry found on a reachable view.
|
|
245
|
+
|
|
246
|
+
Shared by ``appkit.W004`` (``check_throttle_scopes``) and ``appkit.W006``
|
|
247
|
+
(``check_num_proxies_throttle_agreement``) — one traversal of the URLconf serving both,
|
|
248
|
+
rather than each check walking it separately. A view's ``throttle_classes`` is read as a
|
|
249
|
+
resolved class list (DRF's ``APIView.throttle_classes`` is a class attribute, already
|
|
250
|
+
Python objects by the time a view module is imported — never dotted strings needing
|
|
251
|
+
``import_string`` the way ``REST_FRAMEWORK["DEFAULT_THROTTLE_CLASSES"]`` does), which is
|
|
252
|
+
what lets this catch a view that sets ``throttle_classes`` itself with no global default
|
|
253
|
+
configured at all — ``appkit.W006``'s per-view coverage gap the class-attribute default
|
|
254
|
+
alone wouldn't close.
|
|
255
|
+
|
|
256
|
+
Returns ``(set(), [])`` — never raises — if ``ROOT_URLCONF`` is unset, unimportable, or the
|
|
257
|
+
walk otherwise fails; callers treat that identically to "nothing found".
|
|
258
|
+
"""
|
|
259
|
+
from django.urls import URLResolver, get_resolver
|
|
260
|
+
|
|
261
|
+
root_urlconf = getattr(settings, "ROOT_URLCONF", None)
|
|
262
|
+
if not root_urlconf:
|
|
263
|
+
return set(), []
|
|
264
|
+
|
|
265
|
+
resolver = get_resolver(root_urlconf)
|
|
266
|
+
scopes: set[str] = set()
|
|
267
|
+
throttle_classes: list[type] = []
|
|
268
|
+
|
|
269
|
+
def _walk(patterns: Any) -> None:
|
|
270
|
+
for pattern in patterns:
|
|
271
|
+
if isinstance(pattern, URLResolver):
|
|
272
|
+
_walk(pattern.url_patterns)
|
|
273
|
+
continue
|
|
274
|
+
callback = getattr(pattern, "callback", None)
|
|
275
|
+
if callback is None:
|
|
276
|
+
continue
|
|
277
|
+
target = getattr(callback, "view_class", None) or getattr(callback, "cls", None)
|
|
278
|
+
if target is None:
|
|
279
|
+
continue
|
|
280
|
+
scope = getattr(target, "throttle_scope", None)
|
|
281
|
+
if isinstance(scope, str) and scope:
|
|
282
|
+
scopes.add(scope)
|
|
283
|
+
classes = getattr(target, "throttle_classes", None)
|
|
284
|
+
if classes:
|
|
285
|
+
throttle_classes.extend(cls for cls in classes if isinstance(cls, type))
|
|
286
|
+
|
|
287
|
+
_walk(resolver.url_patterns)
|
|
288
|
+
return scopes, throttle_classes
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def check_num_proxies_throttle_agreement(app_configs: Any, **kwargs: Any) -> list[CheckMessage]:
|
|
292
|
+
"""appkit.W006 — two independent conditions about ``REST_FRAMEWORK["NUM_PROXIES"]``, both
|
|
293
|
+
warned about because DRF's ``SimpleRateThrottle.get_ident()`` does its own
|
|
294
|
+
``X-Forwarded-For`` parsing that appkit has no way to inject
|
|
295
|
+
:func:`appkit.net.client_ip`'s trusted-hop logic into.
|
|
296
|
+
|
|
297
|
+
With ``NUM_PROXIES`` unset (DRF's own default, ``None``), ``get_ident()`` joins the
|
|
298
|
+
**entire** ``X-Forwarded-For`` header into one string and uses that as the throttle bucket
|
|
299
|
+
key — not the untrusted leftmost entry, not the trusted rightmost one, the whole chain. A
|
|
300
|
+
client prepending fake hops gets a fresh bucket key on every request, making the throttle a
|
|
301
|
+
no-op for exactly the client it exists to slow down.
|
|
302
|
+
|
|
303
|
+
Two conditions, both reported at this ID, with distinct messages because the fixes differ:
|
|
304
|
+
|
|
305
|
+
- **Unset** — ``NUM_PROXIES`` is ``None`` (whether omitted entirely or set to ``None``
|
|
306
|
+
explicitly — both behave identically in DRF) while any ``SimpleRateThrottle`` subclass is
|
|
307
|
+
configured, either globally via ``REST_FRAMEWORK["DEFAULT_THROTTLE_CLASSES"]`` or on any
|
|
308
|
+
view reachable via ``ROOT_URLCONF`` through its own ``throttle_classes``. Fix: set
|
|
309
|
+
``NUM_PROXIES`` to the same value as ``APPKIT["TRUSTED_PROXY_COUNT"]``.
|
|
310
|
+
- **Disagreement** — ``NUM_PROXIES`` is set to a value that differs from
|
|
311
|
+
``APPKIT["TRUSTED_PROXY_COUNT"]``. Fires regardless of which throttle classes are
|
|
312
|
+
configured: :func:`appkit.net.client_ip` and ``get_ident()`` would trust a different
|
|
313
|
+
number of proxy hops and disagree about who the client is, even though both are
|
|
314
|
+
individually "configured" rather than one being unset.
|
|
315
|
+
|
|
316
|
+
Detection is defensive throughout, matching every check in this module:
|
|
317
|
+
|
|
318
|
+
- A throttle class only counts if ``get_ident`` is the one it inherited from
|
|
319
|
+
``BaseThrottle``/``SimpleRateThrottle`` — a subclass overriding ``get_ident()`` does its
|
|
320
|
+
own parsing, and warning about it would be a false positive this check cannot resolve
|
|
321
|
+
without re-implementing that subclass's own logic.
|
|
322
|
+
- Throttle classes are gathered two ways: the raw
|
|
323
|
+
``REST_FRAMEWORK["DEFAULT_THROTTLE_CLASSES"]`` setting (dotted strings, resolved via
|
|
324
|
+
``import_string``), and each view's own ``throttle_classes`` attribute, walked via
|
|
325
|
+
``ROOT_URLCONF`` (:func:`_collect_throttle_info`, shared with ``appkit.W004``). A throttle
|
|
326
|
+
wired up some other way — ``get_throttles()`` overridden at runtime, a permission class
|
|
327
|
+
doing its own rate limiting — is invisible to this check; a clean run is not proof one
|
|
328
|
+
doesn't exist somewhere, the same limit ``appkit.W004`` already documents.
|
|
329
|
+
- ``rest_framework.throttling`` is imported **inside** this function, not at module scope:
|
|
330
|
+
``SimpleRateThrottle`` reads ``api_settings.DEFAULT_THROTTLE_RATES`` at class-definition
|
|
331
|
+
time, which would raise if this module were ever imported before Django settings are
|
|
332
|
+
configured.
|
|
333
|
+
- Any unimportable class path, or a failed ``ROOT_URLCONF`` walk, contributes nothing from
|
|
334
|
+
that source rather than raising — never lets this check crash ``manage.py``, see the
|
|
335
|
+
module docstring.
|
|
336
|
+
"""
|
|
337
|
+
drf_settings = getattr(settings, "REST_FRAMEWORK", None) or {}
|
|
338
|
+
num_proxies = drf_settings.get("NUM_PROXIES")
|
|
339
|
+
trusted_proxy_count = conf.get_setting("TRUSTED_PROXY_COUNT")
|
|
340
|
+
|
|
341
|
+
if num_proxies is not None and num_proxies != trusted_proxy_count:
|
|
342
|
+
return [
|
|
343
|
+
Warning(
|
|
344
|
+
f"REST_FRAMEWORK['NUM_PROXIES'] ({num_proxies!r}) disagrees with "
|
|
345
|
+
f"APPKIT['TRUSTED_PROXY_COUNT'] ({trusted_proxy_count!r}).",
|
|
346
|
+
hint=(
|
|
347
|
+
"appkit.net.client_ip() and DRF's SimpleRateThrottle.get_ident() will "
|
|
348
|
+
"trust a different number of X-Forwarded-For hops and disagree about who "
|
|
349
|
+
"the client is. Set REST_FRAMEWORK['NUM_PROXIES'] to the same value as "
|
|
350
|
+
"APPKIT['TRUSTED_PROXY_COUNT'] — docs/CONTRACT.md §6."
|
|
351
|
+
),
|
|
352
|
+
id="appkit.W006",
|
|
353
|
+
)
|
|
354
|
+
]
|
|
355
|
+
|
|
356
|
+
if num_proxies is None and _has_unguarded_simple_rate_throttle():
|
|
357
|
+
return [
|
|
358
|
+
Warning(
|
|
359
|
+
"REST_FRAMEWORK['NUM_PROXIES'] is unset while a rate-limiting throttle class "
|
|
360
|
+
"is configured.",
|
|
361
|
+
hint=(
|
|
362
|
+
"With NUM_PROXIES unset, DRF's SimpleRateThrottle.get_ident() joins the "
|
|
363
|
+
"entire X-Forwarded-For header into the throttle bucket key instead of "
|
|
364
|
+
"just the trusted rightmost hop — a client prepending fake hops gets a "
|
|
365
|
+
"fresh bucket on every request. Set REST_FRAMEWORK['NUM_PROXIES'] = "
|
|
366
|
+
"APPKIT['TRUSTED_PROXY_COUNT'] — docs/CONTRACT.md §6."
|
|
367
|
+
),
|
|
368
|
+
id="appkit.W006",
|
|
369
|
+
)
|
|
370
|
+
]
|
|
371
|
+
|
|
372
|
+
return []
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _has_unguarded_simple_rate_throttle() -> bool:
|
|
376
|
+
"""True if any throttle class gathered by :func:`_configured_throttle_classes` is a
|
|
377
|
+
``SimpleRateThrottle`` subclass that has **not** overridden ``get_ident`` — i.e. one that
|
|
378
|
+
would actually hit the ``NUM_PROXIES``-unset hazard ``appkit.W006`` warns about. Never
|
|
379
|
+
raises; see :func:`check_num_proxies_throttle_agreement`.
|
|
380
|
+
"""
|
|
381
|
+
try:
|
|
382
|
+
from rest_framework.throttling import BaseThrottle, SimpleRateThrottle
|
|
383
|
+
except Exception:
|
|
384
|
+
# DRF itself unimportable is a much bigger problem than this check can meaningfully
|
|
385
|
+
# report — treat it as "nothing to warn about" rather than crash manage.py.
|
|
386
|
+
logger.debug(
|
|
387
|
+
"appkit.checks._has_unguarded_simple_rate_throttle: could not import DRF throttling",
|
|
388
|
+
exc_info=True,
|
|
389
|
+
)
|
|
390
|
+
return False
|
|
391
|
+
|
|
392
|
+
for cls in _configured_throttle_classes():
|
|
393
|
+
if not issubclass(cls, SimpleRateThrottle):
|
|
394
|
+
continue
|
|
395
|
+
if cls.get_ident is not BaseThrottle.get_ident:
|
|
396
|
+
# Overrides get_ident() itself — DRF's whole-header-join behaviour isn't in play.
|
|
397
|
+
continue
|
|
398
|
+
return True
|
|
399
|
+
return False
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _configured_throttle_classes() -> set[type]:
|
|
403
|
+
"""Every throttle class reachable two ways: ``REST_FRAMEWORK["DEFAULT_THROTTLE_CLASSES"]``
|
|
404
|
+
(dotted strings, resolved via ``import_string``) and each view's own ``throttle_classes``
|
|
405
|
+
attribute, walked via ``ROOT_URLCONF`` (:func:`_collect_throttle_info`, shared with
|
|
406
|
+
``appkit.W004``). Never raises: an unimportable class path or a failed URLconf walk
|
|
407
|
+
contributes nothing from that source rather than propagating.
|
|
408
|
+
"""
|
|
409
|
+
classes: set[type] = set()
|
|
410
|
+
|
|
411
|
+
drf_settings = getattr(settings, "REST_FRAMEWORK", None) or {}
|
|
412
|
+
for path in drf_settings.get("DEFAULT_THROTTLE_CLASSES") or []:
|
|
413
|
+
try:
|
|
414
|
+
resolved = import_string(path) if isinstance(path, str) else path
|
|
415
|
+
except Exception:
|
|
416
|
+
logger.debug(
|
|
417
|
+
"appkit.checks._configured_throttle_classes: could not import %r",
|
|
418
|
+
path,
|
|
419
|
+
exc_info=True,
|
|
420
|
+
)
|
|
421
|
+
continue
|
|
422
|
+
if isinstance(resolved, type):
|
|
423
|
+
classes.add(resolved)
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
_scopes, view_throttle_classes = _collect_throttle_info()
|
|
427
|
+
except Exception:
|
|
428
|
+
# Never let this check crash manage.py — see module docstring.
|
|
429
|
+
logger.debug(
|
|
430
|
+
"appkit.checks._configured_throttle_classes: failed to walk ROOT_URLCONF",
|
|
431
|
+
exc_info=True,
|
|
432
|
+
)
|
|
433
|
+
view_throttle_classes = []
|
|
434
|
+
classes.update(view_throttle_classes)
|
|
435
|
+
|
|
436
|
+
return classes
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def check_logging_filter(app_configs: Any, **kwargs: Any) -> list[CheckMessage]:
|
|
440
|
+
"""appkit.W005 — Warning if ``settings.LOGGING`` is configured but no handler in it
|
|
441
|
+
references a filter resolving to ``appkit.request_id.RequestIDFilter`` (or a subclass).
|
|
442
|
+
|
|
443
|
+
Skipped entirely when ``LOGGING`` is unset/empty, or when ``LOGGING_CONFIG is None`` (a
|
|
444
|
+
host managing logging entirely outside Django's ``dictConfig`` integration). Middleware
|
|
445
|
+
running and the contextvar being set doesn't help if nothing ever reads ``record.request_id``
|
|
446
|
+
— any handler stamping the raw ``LogRecord`` (a plain ``%``-style file handler, a
|
|
447
|
+
mail-admins handler) would otherwise log ``request_id="-"`` forever, discovered only while
|
|
448
|
+
correlating a real incident.
|
|
449
|
+
|
|
450
|
+
Resolves each filter by what it actually points at, not by name: a host may register
|
|
451
|
+
``RequestIDFilter`` (or a subclass of it, e.g. to add a field) under any key it likes, so a
|
|
452
|
+
string match on a literal name would both miss legitimate configurations and be trivially
|
|
453
|
+
defeated by a rename. Only warns when **no** handler references a matching filter — a host
|
|
454
|
+
deliberately omitting it from one handler (e.g. mail-admins) is not itself a problem.
|
|
455
|
+
"""
|
|
456
|
+
if getattr(settings, "LOGGING_CONFIG", "logging.config.dictConfig") is None:
|
|
457
|
+
return []
|
|
458
|
+
|
|
459
|
+
logging_config = getattr(settings, "LOGGING", None) or {}
|
|
460
|
+
if not logging_config:
|
|
461
|
+
return []
|
|
462
|
+
|
|
463
|
+
filters = logging_config.get("filters") or {}
|
|
464
|
+
request_id_filter_keys: set[str] = set()
|
|
465
|
+
for name, filter_def in filters.items():
|
|
466
|
+
target = filter_def.get("()") if isinstance(filter_def, dict) else None
|
|
467
|
+
if target is None:
|
|
468
|
+
continue
|
|
469
|
+
try:
|
|
470
|
+
resolved = import_string(target) if isinstance(target, str) else target
|
|
471
|
+
except Exception:
|
|
472
|
+
# An unimportable path is the host's own misconfiguration to discover elsewhere —
|
|
473
|
+
# not a reason for this check to crash. Logged so it isn't silent either.
|
|
474
|
+
logger.debug(
|
|
475
|
+
"appkit.checks.check_logging_filter: could not import filter %r",
|
|
476
|
+
target,
|
|
477
|
+
exc_info=True,
|
|
478
|
+
)
|
|
479
|
+
continue
|
|
480
|
+
if _resolves_to_request_id_filter(resolved):
|
|
481
|
+
request_id_filter_keys.add(name)
|
|
482
|
+
|
|
483
|
+
if not request_id_filter_keys:
|
|
484
|
+
return [_missing_filter_warning()]
|
|
485
|
+
|
|
486
|
+
handlers = logging_config.get("handlers") or {}
|
|
487
|
+
for handler_def in handlers.values():
|
|
488
|
+
handler_filters = set(handler_def.get("filters") or [])
|
|
489
|
+
if handler_filters & request_id_filter_keys:
|
|
490
|
+
return []
|
|
491
|
+
|
|
492
|
+
return [_missing_filter_warning()]
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _resolves_to_request_id_filter(resolved: Any) -> bool:
|
|
496
|
+
if resolved is RequestIDFilter:
|
|
497
|
+
return True
|
|
498
|
+
if isinstance(resolved, type):
|
|
499
|
+
return issubclass(resolved, RequestIDFilter)
|
|
500
|
+
return isinstance(resolved, RequestIDFilter)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _missing_filter_warning() -> Warning:
|
|
504
|
+
return Warning(
|
|
505
|
+
"LOGGING is configured, but no handler references a filter resolving to "
|
|
506
|
+
"appkit.request_id.RequestIDFilter.",
|
|
507
|
+
hint=(
|
|
508
|
+
'Add \'"request_id": {"()": "appkit.request_id.RequestIDFilter"}\' under '
|
|
509
|
+
"LOGGING['filters'], and \"request_id\" to the relevant handler's "
|
|
510
|
+
'"filters" list — docs/CONTRACT.md §8.'
|
|
511
|
+
),
|
|
512
|
+
id="appkit.W005",
|
|
513
|
+
)
|
appkit/conf.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Settings access layer for appkit's ``APPKIT`` settings dict.
|
|
2
|
+
|
|
3
|
+
Internal-but-stable (docs/CONTRACT.md §2.16): not re-exported from a top-level ``appkit``
|
|
4
|
+
namespace, but every module below reads its configuration through :func:`get_setting`, so its
|
|
5
|
+
shape is held to the same "don't break it silently" standard as a public module even though it
|
|
6
|
+
sits one layer down. Follows ``APP-DESIGN.md`` §3.5's ``conf.py`` pattern exactly.
|
|
7
|
+
|
|
8
|
+
Four settings keys, all optional at the Python level (docs/CONTRACT.md §7):
|
|
9
|
+
|
|
10
|
+
APPKIT = {
|
|
11
|
+
"CACHE_TIMEOUT": 60, # appkit.cache / appkit.mixins default
|
|
12
|
+
"TRUSTED_PROXY_COUNT": 1, # appkit.net's trusted X-Forwarded-For hops
|
|
13
|
+
"MAX_UPLOAD_BYTES": 10 * 1024 * 1024, # appkit.files' semantic size cap
|
|
14
|
+
"SITE_URL": "", # optional-but-conditionally-required —
|
|
15
|
+
# appkit.media raises ImproperlyConfigured
|
|
16
|
+
# naming this setting the first time
|
|
17
|
+
# file_url/absolute_url is called with
|
|
18
|
+
# request=None and this is still unset.
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
Zero ``.env`` keys, required or optional, under any installed extra (docs/CONTRACT.md §7) —
|
|
22
|
+
every credential/secret appkit's surface ever touches is an app's own documented ``.env`` key,
|
|
23
|
+
never appkit's.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import enum
|
|
29
|
+
from typing import Any, Final
|
|
30
|
+
|
|
31
|
+
from django.conf import settings
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _Unset(enum.Enum):
|
|
35
|
+
"""Single-member enum backing :data:`UNSET`. Naming this class directly — as
|
|
36
|
+
`timeout: int | None | _Unset = UNSET` — gives a timeout parameter a real type under
|
|
37
|
+
``mypy --strict``, unlike a bare ``object()`` sentinel typed ``Any``. (``Literal[UNSET]``
|
|
38
|
+
does *not* work here: mypy's ``Literal`` requires the dotted enum-member expression itself,
|
|
39
|
+
not a variable that happens to hold it.)
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
UNSET = enum.auto()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
#: Sentinel distinguishing "no explicit value passed" from "pass None explicitly", used by
|
|
46
|
+
#: ``appkit.cache``, ``appkit.mixins``, and ``appkit.files`` to mean "fall back to the
|
|
47
|
+
#: documented ``APPKIT`` default" (docs/CONTRACT.md §2.1, §2.2, §2.9). Defined here because
|
|
48
|
+
#: falling back to a settings default is exactly conf.py's job, and defining it in any of the
|
|
49
|
+
#: three consuming modules would create an import cycle between them.
|
|
50
|
+
UNSET: Final = _Unset.UNSET
|
|
51
|
+
|
|
52
|
+
DEFAULTS: Final[dict[str, Any]] = {
|
|
53
|
+
"CACHE_TIMEOUT": 60,
|
|
54
|
+
"TRUSTED_PROXY_COUNT": 1,
|
|
55
|
+
"MAX_UPLOAD_BYTES": 10 * 1024 * 1024,
|
|
56
|
+
"SITE_URL": "",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_setting(key: str) -> Any:
|
|
61
|
+
"""Read an ``APPKIT`` setting, falling back to appkit's documented default.
|
|
62
|
+
|
|
63
|
+
Reads ``settings.APPKIT.get(key, DEFAULTS[key])`` — a host omitting a key gets the
|
|
64
|
+
documented default rather than a ``KeyError``/``AttributeError`` deep inside a view.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
KeyError: only for a ``key`` that isn't in :data:`DEFAULTS` at all — a programming
|
|
68
|
+
error inside appkit itself, never a host-facing failure mode.
|
|
69
|
+
"""
|
|
70
|
+
configured: dict[str, Any] = getattr(settings, "APPKIT", {})
|
|
71
|
+
return configured.get(key, DEFAULTS[key])
|