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/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])