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 ADDED
@@ -0,0 +1,39 @@
1
+ """appkit — the shared Django + DRF foundation every app package and host in this ecosystem
2
+ depends on.
3
+
4
+ Not an installable feature; it is what ``backend/tools/`` (cache, mixins, crypto) and
5
+ ``config/logging.py``'s request-ID plumbing move into once this package exists
6
+ (``BASE-DESIGN.md`` §3). Every other app package declares appkit as a dependency and imports
7
+ its helpers instead of reimplementing them.
8
+
9
+ This module intentionally re-exports nothing. Each submodule below is its own public surface —
10
+ import from ``appkit.<module>`` directly (e.g. ``from appkit.cache import cached_call``), never
11
+ from ``appkit`` itself. ``appkit.conf`` is explicitly *not* re-exported here even internally
12
+ (docs/CONTRACT.md §2.16: "not re-exported from a top-level ``appkit`` namespace").
13
+
14
+ Public modules (docs/CONTRACT.md §2):
15
+ ``appkit.cache`` — cache namespace versioning, key building, endpoint caching
16
+ ``appkit.mixins`` — ``CachedListMixin``, a DRF list-view response caching mixin
17
+ ``appkit.exceptions`` — the standard DRF exception handler and the ten error codes
18
+ ``appkit.request_id`` — the request-ID ContextVar, ASGI middleware, and logging filter
19
+ ``appkit.crypto`` — Fernet encryption taking its key at call time (``crypto`` extra)
20
+ ``appkit.permissions`` — shared DRF permission classes
21
+ ``appkit.pagination`` — the shared default pagination class
22
+ ``appkit.validation`` — query-param validation, HTML sanitisation, an ORM lookup allowlist
23
+ ``appkit.files`` — upload/image validation via magic-byte sniffing (``images`` extra)
24
+ ``appkit.net`` — trust-boundary real client IP extraction
25
+ ``appkit.media`` — media URL absolutisation (never ``appkit.urls`` — see below)
26
+ ``appkit.text`` — truncation and digit normalisation shared with the frontend half
27
+ ``appkit.dates`` — Gregorian <-> Jalali conversion using stdlib types only
28
+ ``appkit.money`` — integer money parsing/formatting shared with the frontend half
29
+ ``appkit.throttling`` — DRF throttle-scope string construction
30
+ ``appkit.testing`` — the opt-in pytest plugin (``-p appkit.testing``)
31
+
32
+ Internal-but-stable (docs/CONTRACT.md §2.16):
33
+ ``appkit.conf`` — the ``APPKIT`` settings-dict accessor and its ``DEFAULTS``
34
+
35
+ appkit ships no ``urlpatterns`` and is never ``include()``d anywhere, by any host — there is no
36
+ ``appkit.urls``, deliberately (docs/CONTRACT.md §10). appkit ships no models, no migrations, no
37
+ admin, no ``services.py``, no ``signals.py``, and no Celery/``django.tasks`` integration
38
+ (docs/CONTRACT.md §0, §10).
39
+ """
appkit/apps.py ADDED
@@ -0,0 +1,33 @@
1
+ """appkit's ``AppConfig``.
2
+
3
+ ``INSTALLED_APPS`` membership is confirmed, not merely left standing (docs/CONTRACT.md §5), for
4
+ two reasons: translations become real only when appkit is a genuine ``INSTALLED_APPS`` member
5
+ (``standard_exception_handler``'s user-facing strings are wrapped in ``gettext_lazy`` and
6
+ discovered via a shipped ``locale/`` directory), and the system checks in :mod:`appkit.checks`
7
+ must be registered from ``ready()`` to run at all.
8
+
9
+ appkit defines no models, so there is no ``default_auto_field`` to set.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from django.apps import AppConfig
15
+ from django.utils.translation import gettext_lazy as _
16
+
17
+
18
+ class AppKitConfig(AppConfig):
19
+ name = "appkit"
20
+ verbose_name = _("App Kit")
21
+
22
+ def ready(self) -> None:
23
+ from django.core.checks import register
24
+
25
+ from appkit import checks
26
+
27
+ register(checks.check_request_id_middleware)
28
+ register(checks.check_exception_handler)
29
+ register(checks.check_middleware_order)
30
+ register(checks.check_unknown_settings_keys)
31
+ register(checks.check_throttle_scopes)
32
+ register(checks.check_logging_filter)
33
+ register(checks.check_num_proxies_throttle_agreement)
appkit/cache.py ADDED
@@ -0,0 +1,222 @@
1
+ """Cache namespace versioning, key building, get-or-set, and endpoint-level response caching.
2
+
3
+ Public surface (docs/CONTRACT.md §2.1), implemented in a later phase:
4
+
5
+ namespace_version(namespace: str) -> int
6
+ Opaque version number for a cache namespace. Seeds from ``int(time.time())``, not the
7
+ literal ``1`` — the return value must be treated as opaque, never assumed to start at 1.
8
+
9
+ invalidate_namespace(namespace: str) -> int
10
+ Bumps a namespace's version, effectively invalidating every key built against it.
11
+
12
+ build_cache_key(namespace: str, *parts: object) -> str
13
+ Builds a cache key incorporating the namespace's current version.
14
+
15
+ cached_call(key: str, timeout: int | None, producer: Callable[[], T]) -> T
16
+ Get-or-set around an arbitrary producer callable. ``timeout`` accepts appkit.conf.UNSET
17
+ to mean "use APPKIT['CACHE_TIMEOUT']" — resolved to avoid the ambiguity of a bare
18
+ ``None`` meaning either "no timeout" or "use the default".
19
+
20
+ cache_endpoint(*, namespace: str, timeout: int | None = UNSET, per_user: bool = True,
21
+ vary_headers: Sequence[str] = (), cache_statuses: Container[int] = (200,))
22
+ Decorator for endpoint-level response caching. Raises ImproperlyConfigured at
23
+ decoration time (import time) if ``namespace`` is empty. ``per_user`` exists
24
+ specifically to prevent cross-user cache leakage.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import hashlib
30
+ import time
31
+ from collections.abc import Callable, Container, Sequence
32
+ from functools import wraps
33
+ from typing import Any, cast
34
+
35
+ from django.core.cache import cache
36
+ from django.core.exceptions import ImproperlyConfigured
37
+ from rest_framework.request import Request
38
+ from rest_framework.response import Response
39
+
40
+ from appkit.conf import UNSET, _Unset, get_setting
41
+
42
+ __all__ = [
43
+ "build_cache_key",
44
+ "cache_endpoint",
45
+ "cached_call",
46
+ "invalidate_namespace",
47
+ "namespace_version",
48
+ ]
49
+
50
+ # Keeps generated keys short and free of characters the cache backend (or a log line) might
51
+ # treat specially, once a part gets long or contains something other than
52
+ # alphanumerics/dashes/underscores/periods.
53
+ #
54
+ # **Deviation from the scaffold's `_SAFE_PART`:** the scaffold's version includes `:` as a
55
+ # "safe" character for an individual *part*, which would defeat the segment-smuggling
56
+ # protection below — a part containing `:` (the join delimiter used one line down) would be
57
+ # embedded raw instead of hashed, letting it forge extra `namespace:version:...` segments.
58
+ # Excluding `:` here is what actually satisfies "a delimiter must never smuggle a second
59
+ # segment into the key". docs/CONTRACT.md §2.1's own notation (`[A-Za-z0-9\-_.]`) already
60
+ # excludes it too and states this exclusion explicitly — the two agree.
61
+ _MAX_RAW_PART_LEN = 40
62
+ _SAFE_PART = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.")
63
+
64
+
65
+ def _version_key(namespace: str) -> str:
66
+ return f"{namespace}:version"
67
+
68
+
69
+ def namespace_version(namespace: str) -> int:
70
+ """Returns the current version for `namespace`, seeding it on first use.
71
+
72
+ **Changed from the scaffold:** seeds from `int(time.time())`, not the literal `1`
73
+ (docs/CONTRACT.md §2.1). The scaffold's get-then-increment isn't atomic against Django's
74
+ cache API; if the version key is evicted under memory pressure and reseeds at `1`, every key
75
+ built against a *higher* version before the eviction becomes reachable again — silently
76
+ resurrecting data an earlier `invalidate_namespace` call explicitly invalidated. Seeding from
77
+ a wall-clock second makes any reseed monotonically ahead of every version that could
78
+ plausibly have been issued before it. The return value is therefore **opaque** — never
79
+ assume it starts at `1`. Never raises.
80
+ """
81
+ version = cache.get(_version_key(namespace))
82
+ if version is None:
83
+ # `add`, not `set`: two processes racing to seed the same namespace must not let the
84
+ # second clobber the first's (very slightly later, but already-issued) timestamp.
85
+ cache.add(_version_key(namespace), int(time.time()), timeout=None)
86
+ version = cache.get(_version_key(namespace))
87
+ return int(version)
88
+
89
+
90
+ def invalidate_namespace(namespace: str) -> int:
91
+ """Bumps `namespace`'s version, invalidating every key previously built against it.
92
+
93
+ Returns the new version — guaranteed strictly greater than what came before it in this
94
+ process's view. Never raises (calls `namespace_version` first to guarantee the key exists
95
+ before `cache.incr`).
96
+ """
97
+ namespace_version(namespace) # ensure it exists before incrementing
98
+ return cache.incr(_version_key(namespace))
99
+
100
+
101
+ def _normalize_part(part: object) -> str:
102
+ raw = str(part)
103
+ if len(raw) > _MAX_RAW_PART_LEN or not set(raw) <= _SAFE_PART:
104
+ return hashlib.sha256(raw.encode()).hexdigest()[:16]
105
+ return raw
106
+
107
+
108
+ def build_cache_key(namespace: str, *parts: object) -> str:
109
+ """Builds a stable, namespace-versioned cache key.
110
+
111
+ `namespace:version:part1:part2:...` — long or unsafe parts are hashed rather than embedded
112
+ raw, so an arbitrary string (a user-supplied search query, say) can't blow up key length or
113
+ smuggle a delimiter into the key. Never raises.
114
+ """
115
+ version = namespace_version(namespace)
116
+ segments = [namespace, str(version), *(_normalize_part(p) for p in parts)]
117
+ return ":".join(segments)
118
+
119
+
120
+ def cached_call[T](
121
+ key: str,
122
+ timeout: int | _Unset | None,
123
+ producer: Callable[[], T],
124
+ ) -> T:
125
+ """Get-or-set: returns the cached value at `key`, computing and storing it via
126
+ `producer()` on a miss. `producer` is called at most once per miss.
127
+
128
+ `timeout=None` means "cache forever" (Django's own cache semantics) and therefore cannot
129
+ double as "use the configured default" — pass `appkit.conf.UNSET` for that instead,
130
+ resolved here to `APPKIT["CACHE_TIMEOUT"]` (docs/CONTRACT.md §2.1). `UNSET` is not part of
131
+ this function's *documented* public type (`int | None`) — it renders in docs as "omit the
132
+ argument" — but is accepted and typed here so `appkit.mixins`/`cache_endpoint` passing it
133
+ through still type-checks.
134
+
135
+ A `producer` that returns `None` is never actually cached — Django's cache API can't
136
+ distinguish "miss" from "cached `None`" through `.get()`'s default. Fine for the typical use
137
+ (caching a queryset result, a serialized dict), but don't reach for this to cache a value
138
+ that's legitimately `None`. Never raises on its own; propagates whatever `producer` raises.
139
+ """
140
+ resolved_timeout = get_setting("CACHE_TIMEOUT") if timeout is UNSET else timeout
141
+ value = cache.get(key)
142
+ if value is None:
143
+ value = producer()
144
+ cache.set(key, value, timeout=resolved_timeout)
145
+ return cast("T", value)
146
+
147
+
148
+ def _user_cache_token(request: Request, *, per_user: bool) -> str:
149
+ """`per_user=False` shares one cache entry across every caller — valid only where the
150
+ response is byte-identical for everyone, including anonymous. `per_user=True` isolates by
151
+ the user's `pk`, falling back to a fixed `"anon"` bucket rather than folding an anonymous
152
+ caller into whatever the *first* unauthenticated request happened to produce a falsy-looking
153
+ identity for — an explicit `is None` check, not `pk or "anon"`, so a real user whose `pk`
154
+ happens to be `0` is never treated as anonymous.
155
+ """
156
+ if not per_user:
157
+ return "shared"
158
+ pk = getattr(request.user, "pk", None)
159
+ return str(pk) if pk is not None else "anon"
160
+
161
+
162
+ def cache_endpoint[F: Callable[..., Response]](
163
+ *,
164
+ namespace: str,
165
+ timeout: int | _Unset | None = UNSET,
166
+ per_user: bool = True,
167
+ vary_headers: Sequence[str] = (),
168
+ cache_statuses: Container[int] = (200,),
169
+ ) -> Callable[[F], F]:
170
+ """Decorator wrapping a DRF view method (`list`/`retrieve`/...) with response caching, the
171
+ way `appkit.mixins.CachedListMixin` wraps `ListAPIView.list` — for views that aren't plain
172
+ list views.
173
+
174
+ `namespace` is **required, no default** — an unprefixed key is exactly the
175
+ two-apps-collide scenario `APP-DESIGN.md` §1.3 exists to prevent, so there is no safe
176
+ default to fall back to. Raises `ImproperlyConfigured` at decoration time (import time) if
177
+ `namespace` is empty.
178
+
179
+ `per_user=True` is the load-bearing default. **Non-obvious failure path:** with
180
+ `per_user=False` on a permission-gated view, user A's response is served verbatim to user
181
+ B — an authorization bypass via the cache layer, not a cache bug. `per_user=False` is valid
182
+ *only* where the response is byte-identical for every caller including anonymous users.
183
+
184
+ `vary_headers` folds additional request headers into the cache key (e.g. `Accept-Language`
185
+ for a bilingual endpoint) beyond user + full path. `cache_statuses` restricts caching to
186
+ responses whose status is in this set — a 403/404 is never cached by default, since caching
187
+ an authorization failure can make it outlive the state that caused it. Caches
188
+ `{"data": ..., "status": ...}`, never the `Response` object itself.
189
+ """
190
+ if not namespace:
191
+ raise ImproperlyConfigured(
192
+ "cache_endpoint() requires a non-empty `namespace` — an unprefixed cache key is "
193
+ "exactly the two-apps-collide scenario APP-DESIGN.md §1.3 exists to prevent."
194
+ )
195
+
196
+ def decorator(view_method: F) -> F:
197
+ @wraps(view_method)
198
+ def wrapper(self: Any, request: Request, *args: Any, **kwargs: Any) -> Response:
199
+ key_parts: list[object] = [
200
+ _user_cache_token(request, per_user=per_user),
201
+ request.get_full_path(),
202
+ *(request.headers.get(header, "") for header in vary_headers),
203
+ ]
204
+ key = build_cache_key(namespace, *key_parts)
205
+
206
+ cached = cache.get(key)
207
+ if cached is not None:
208
+ return Response(cached["data"], status=cached["status"])
209
+
210
+ response = view_method(self, request, *args, **kwargs)
211
+ if response.status_code in cache_statuses:
212
+ resolved_timeout = get_setting("CACHE_TIMEOUT") if timeout is UNSET else timeout
213
+ cache.set(
214
+ key,
215
+ {"data": response.data, "status": response.status_code},
216
+ timeout=resolved_timeout,
217
+ )
218
+ return response
219
+
220
+ return cast("F", wrapper)
221
+
222
+ return decorator