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/__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
|