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/pagination.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""The shared default pagination class.
|
|
2
|
+
|
|
3
|
+
Public surface (docs/CONTRACT.md §2.7), implemented in a later phase:
|
|
4
|
+
|
|
5
|
+
class DefaultPagination(PageNumberPagination):
|
|
6
|
+
page_size = 25
|
|
7
|
+
page_size_query_param = "page_size"
|
|
8
|
+
max_page_size = 100
|
|
9
|
+
|
|
10
|
+
A host wiring appkit needs no REST_FRAMEWORK["PAGE_SIZE"] — DefaultPagination carries its own
|
|
11
|
+
page_size (docs/CONTRACT.md §8).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from rest_framework.pagination import PageNumberPagination
|
|
17
|
+
|
|
18
|
+
__all__ = ["DefaultPagination"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DefaultPagination(PageNumberPagination):
|
|
22
|
+
"""Shared default so every app avoids re-declaring the same three numbers. A view that can
|
|
23
|
+
return unbounded data sets this (or its own) `pagination_class` explicitly per
|
|
24
|
+
`APP-DESIGN.md` §4, rather than relying on a host's `DEFAULT_PAGINATION_CLASS`, which the
|
|
25
|
+
app can't know.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
page_size = 25
|
|
29
|
+
page_size_query_param = "page_size"
|
|
30
|
+
max_page_size = 100 # caps ?page_size= so a client can't defeat pagination entirely
|
appkit/permissions.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Shared DRF permission classes.
|
|
2
|
+
|
|
3
|
+
Public surface (docs/CONTRACT.md §2.6), implemented in a later phase:
|
|
4
|
+
|
|
5
|
+
class IsAppAdmin(BasePermission):
|
|
6
|
+
def has_permission(self, request: Request, view: APIView) -> bool: ...
|
|
7
|
+
|
|
8
|
+
class IsObjectOwner(BasePermission):
|
|
9
|
+
owner_field: str = "user"
|
|
10
|
+
|
|
11
|
+
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: ...
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from rest_framework.permissions import BasePermission
|
|
19
|
+
from rest_framework.request import Request
|
|
20
|
+
from rest_framework.views import APIView
|
|
21
|
+
|
|
22
|
+
__all__ = ["IsAppAdmin", "IsObjectOwner"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class IsAppAdmin(BasePermission):
|
|
26
|
+
"""Gates the custom admin-dashboard API surface (`APP-DESIGN.md` §5's second admin
|
|
27
|
+
surface). Relies only on what Django's user model already guarantees everywhere — never on
|
|
28
|
+
another app's model. Never raises.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def has_permission(self, request: Request, view: APIView) -> bool:
|
|
32
|
+
return bool(request.user and request.user.is_authenticated and request.user.is_staff)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class IsObjectOwner(BasePermission):
|
|
36
|
+
"""Denies access to another user's object — the IDOR case `APP-DESIGN.md` §7.4 and §9's
|
|
37
|
+
security checklist name explicitly.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
owner_field: str = "user"
|
|
41
|
+
|
|
42
|
+
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
|
|
43
|
+
if not (request.user and request.user.is_authenticated):
|
|
44
|
+
return False
|
|
45
|
+
# A misconfigured `owner_field` must deny access, never raise `AttributeError`
|
|
46
|
+
# mid-permission-check — failing closed with a wrong-looking answer is safer than
|
|
47
|
+
# failing open by accident.
|
|
48
|
+
return getattr(obj, self.owner_field, None) == request.user
|
appkit/py.typed
ADDED
|
File without changes
|
appkit/request_id.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Request-ID correlation — ContextVar, ASGI middleware, and logging filter, co-located.
|
|
2
|
+
|
|
3
|
+
There is deliberately no ``appkit.logging`` module (docs/CONTRACT.md §2 preamble, §4): the
|
|
4
|
+
``RequestIDFilter`` lives here, alongside the ``ContextVar`` and middleware it serves — exactly
|
|
5
|
+
how ``../base-scaffold/backend/config/logging.py`` co-locates all three in one file today. This
|
|
6
|
+
is a **port**, not a reimplementation: that file already worked out this module's sharp edges
|
|
7
|
+
over two phases, and docs/CONTRACT.md §2.4 freezes the behaviour below as contract.
|
|
8
|
+
|
|
9
|
+
No ``build_logging_config``, no ``structlog`` processor, no ``structlog`` dependency — log
|
|
10
|
+
rendering is host policy (docs/CONTRACT.md §4). Only the three names below are appkit's; a host
|
|
11
|
+
imports them into its own ``config/logging.py`` (docs/CONTRACT.md §8) and keeps everything else
|
|
12
|
+
about how logs are rendered exactly where it already is.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import re
|
|
19
|
+
import uuid
|
|
20
|
+
from collections.abc import Awaitable, Callable
|
|
21
|
+
from contextvars import ContextVar
|
|
22
|
+
|
|
23
|
+
from asgiref.sync import markcoroutinefunction
|
|
24
|
+
from django.http import HttpRequest, HttpResponse
|
|
25
|
+
|
|
26
|
+
request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
|
|
27
|
+
|
|
28
|
+
# Caps how much of an inbound X-Request-ID we trust, and restricts it to characters that
|
|
29
|
+
# can't inject newlines or control sequences into a log line.
|
|
30
|
+
_MAX_REQUEST_ID_LEN = 64
|
|
31
|
+
_VALID_REQUEST_ID = re.compile(r"^[A-Za-z0-9-]+$")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _clean_request_id(raw: str | None) -> str:
|
|
35
|
+
"""Accept an inbound X-Request-ID if it looks safe, else mint a new one."""
|
|
36
|
+
if raw and len(raw) <= _MAX_REQUEST_ID_LEN and _VALID_REQUEST_ID.match(raw):
|
|
37
|
+
return raw
|
|
38
|
+
return uuid.uuid4().hex
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RequestIDMiddleware:
|
|
42
|
+
"""Assigns/propagates a request ID for log correlation, and echoes it on the response.
|
|
43
|
+
|
|
44
|
+
Implemented as an async middleware (``async_capable = True``, ``sync_capable = False``):
|
|
45
|
+
a sync-only middleware would force Django to run the whole chain through a thread pool,
|
|
46
|
+
quietly undoing the reason a host is on ASGI in the first place. Belongs near the top of
|
|
47
|
+
``MIDDLEWARE`` — after ``SecurityMiddleware``, before anything that logs
|
|
48
|
+
(docs/CONTRACT.md §8, checked by :func:`appkit.checks.check_middleware_order`).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
sync_capable = False
|
|
52
|
+
async_capable = True
|
|
53
|
+
|
|
54
|
+
def __init__(self, get_response: Callable[[HttpRequest], Awaitable[HttpResponse]]) -> None:
|
|
55
|
+
self.get_response = get_response
|
|
56
|
+
# `sync_capable`/`async_capable` only tell Django's *own* `load_middleware` how to
|
|
57
|
+
# build this middleware's wrapper — they say nothing to a generic
|
|
58
|
+
# `inspect.iscoroutinefunction(instance)` check, which is what any middleware
|
|
59
|
+
# WRAPPING this one (e.g. SecurityMiddleware, via django.utils.deprecation's
|
|
60
|
+
# MiddlewareMixin) uses to decide whether to `await` it. Without this explicit
|
|
61
|
+
# mark, an instance's `async def __call__` is invisible to that check — Django's
|
|
62
|
+
# own `MiddlewareMixin` does this same marking internally; a raw, non-Mixin async
|
|
63
|
+
# middleware has to do it itself, or every outer sync-style middleware calls this
|
|
64
|
+
# one without awaiting it, crashing on the returned coroutine. Confirmed to break
|
|
65
|
+
# every real request (ASGI and WSGI both) without this line.
|
|
66
|
+
markcoroutinefunction(self)
|
|
67
|
+
|
|
68
|
+
async def __call__(self, request: HttpRequest) -> HttpResponse:
|
|
69
|
+
request_id = _clean_request_id(request.headers.get("X-Request-ID"))
|
|
70
|
+
# Always reset in `finally` — under ASGI concurrency, a set-without-reset leaks
|
|
71
|
+
# this request's ID into whatever runs next on the same task (most visibly, a
|
|
72
|
+
# Celery task enqueued mid-request, or an unrelated request if something goes wrong).
|
|
73
|
+
# This also covers the view raising: `reset` still runs before the exception
|
|
74
|
+
# propagates further up the middleware stack.
|
|
75
|
+
token = request_id_var.set(request_id)
|
|
76
|
+
try:
|
|
77
|
+
response = await self.get_response(request)
|
|
78
|
+
finally:
|
|
79
|
+
request_id_var.reset(token)
|
|
80
|
+
response["X-Request-ID"] = request_id
|
|
81
|
+
return response
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class RequestIDFilter(logging.Filter):
|
|
85
|
+
"""Stamps `record.request_id` from the contextvar, for any handler/formatter that reads
|
|
86
|
+
the raw `LogRecord` rather than a structlog event dict (e.g. a plain %-style file handler).
|
|
87
|
+
Never raises — logging outside a request cycle (management commands, Celery tasks, process
|
|
88
|
+
startup) must still work, defaulting to "-". This contract is what
|
|
89
|
+
:func:`appkit.checks.check_logging_filter` (``appkit.W005``) exists to catch a host
|
|
90
|
+
forgetting to wire up, and what ``appkit.testing``'s ``frozen_request_id`` fixture makes
|
|
91
|
+
directly assertable from a consuming app's own tests.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
95
|
+
record.request_id = request_id_var.get()
|
|
96
|
+
return True
|
appkit/testing.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""The opt-in pytest plugin — fixtures and an envelope assertion helper.
|
|
2
|
+
|
|
3
|
+
Opt-in is explicit: ``-p appkit.testing`` in the consumer's own ``addopts``
|
|
4
|
+
(``tool.pytest.ini_options``), never automatic. This module deliberately registers NO
|
|
5
|
+
``pytest11`` entry point in pyproject.toml — two alternatives were considered and both rejected
|
|
6
|
+
(docs/CONTRACT.md §2.17):
|
|
7
|
+
|
|
8
|
+
* A ``pytest11`` entry point would auto-load these fixtures into EVERY host's test suite the
|
|
9
|
+
moment appkit is merely installed (which is always, transitively) — invisible magic adding
|
|
10
|
+
fixtures nobody asked for into a namespace they didn't opt into.
|
|
11
|
+
* ``pytest_plugins = ["appkit.testing"]`` only works from the rootdir conftest in pytest 7+;
|
|
12
|
+
an app package's own ``testpaths = ["../tests/backend"]`` means the package's own conftest
|
|
13
|
+
isn't the rootdir conftest, so this wouldn't even work by default for the app packages that
|
|
14
|
+
need it most.
|
|
15
|
+
|
|
16
|
+
A consuming app wires this up itself, in its own ``pyproject.toml``::
|
|
17
|
+
|
|
18
|
+
[tool.pytest.ini_options]
|
|
19
|
+
addopts = "-p appkit.testing ..."
|
|
20
|
+
|
|
21
|
+
**Every name below carries an ``appkit_`` prefix — this is APP-DESIGN.md §1.3's namespacing
|
|
22
|
+
rule applied to pytest's fixture registry, not a stylistic choice.** pytest's fixture registry
|
|
23
|
+
is exactly the "shared, flat namespace" §1.3 is about: any name here can collide with a
|
|
24
|
+
consuming app's own conftest fixture, another plugin's fixture, or a future pytest-django
|
|
25
|
+
release. The concrete evidence this convention exists to prevent, found during this module's
|
|
26
|
+
own implementation and kept here as the rationale a future reader will otherwise wonder about:
|
|
27
|
+
pytest-django ships its OWN built-in fixtures literally named ``admin_user`` and
|
|
28
|
+
``admin_client``, and empirically (verified directly against the installed pytest-django, not
|
|
29
|
+
assumed) pytest-django's versions win that name collision **silently** — requesting
|
|
30
|
+
``admin_user``/``admin_client`` as an ordinary fixture parameter anywhere pytest-django is
|
|
31
|
+
active (which is everywhere ``db``/``django_db`` is used) returns pytest-django's plain
|
|
32
|
+
User/Client, never appkit's reflective ones, with no warning anywhere. ``user`` and
|
|
33
|
+
``api_client`` haven't collided with anything yet — but "hasn't collided yet" is precisely the
|
|
34
|
+
condition §1.3's prefix rule exists to guard against, not a reason two of eight names get a
|
|
35
|
+
prefix and six don't.
|
|
36
|
+
|
|
37
|
+
Public surface (docs/CONTRACT.md §2.17):
|
|
38
|
+
|
|
39
|
+
@pytest.fixture
|
|
40
|
+
def appkit_api_client() -> APIClient: ...
|
|
41
|
+
# An unauthenticated DRF APIClient.
|
|
42
|
+
|
|
43
|
+
@pytest.fixture
|
|
44
|
+
def appkit_user(db) -> AbstractBaseUser: ...
|
|
45
|
+
# Built through get_user_model().USERNAME_FIELD REFLECTIVELY, not a hardcoded
|
|
46
|
+
# create_user(username=...) call — must work against a host on an email-based custom
|
|
47
|
+
# user model.
|
|
48
|
+
|
|
49
|
+
@pytest.fixture
|
|
50
|
+
def appkit_admin_user(db) -> AbstractBaseUser: ...
|
|
51
|
+
# Same reflective construction, staff/admin.
|
|
52
|
+
|
|
53
|
+
@pytest.fixture
|
|
54
|
+
def appkit_auth_client(appkit_api_client, appkit_user) -> APIClient: ...
|
|
55
|
+
|
|
56
|
+
@pytest.fixture
|
|
57
|
+
def appkit_admin_client(appkit_api_client, appkit_admin_user) -> APIClient: ...
|
|
58
|
+
|
|
59
|
+
@pytest.fixture
|
|
60
|
+
def appkit_frozen_request_id() -> Iterator[str]: ...
|
|
61
|
+
# Yields a fixed request-ID string; asserts it's restored to "-" (or the prior value)
|
|
62
|
+
# on fixture teardown, making RequestIDMiddleware's reset-in-finally contract directly
|
|
63
|
+
# assertable from a consuming app's own tests.
|
|
64
|
+
|
|
65
|
+
@pytest.fixture
|
|
66
|
+
def appkit_clear_cache() -> None: ...
|
|
67
|
+
# Deliberately NOT autouse — under `pytest -n auto` (pytest-xdist) against a shared
|
|
68
|
+
# Redis instance, an autouse fixture clearing the cache between every test would clear
|
|
69
|
+
# another xdist worker's in-flight test data too.
|
|
70
|
+
|
|
71
|
+
def appkit_assert_error_envelope(response: Response, *, code: str, status: int) -> None: ...
|
|
72
|
+
# Plain function, not a fixture — prefixed anyway for consistency with the rest of this
|
|
73
|
+
# module's public surface. Shared assertion for the docs/CONTRACT.md §1 envelope so
|
|
74
|
+
# nine installed apps don't hand-roll nine slightly different assertions. Raises the
|
|
75
|
+
# test framework's own AssertionError with a diff-friendly message on mismatch.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
from __future__ import annotations
|
|
79
|
+
|
|
80
|
+
import uuid
|
|
81
|
+
from typing import TYPE_CHECKING, Any
|
|
82
|
+
|
|
83
|
+
import pytest
|
|
84
|
+
from django.contrib.auth import get_user_model
|
|
85
|
+
from django.core.cache import cache
|
|
86
|
+
|
|
87
|
+
if TYPE_CHECKING:
|
|
88
|
+
from collections.abc import Iterator
|
|
89
|
+
|
|
90
|
+
from django.contrib.auth.base_user import AbstractBaseUser
|
|
91
|
+
from rest_framework.response import Response
|
|
92
|
+
from rest_framework.test import APIClient
|
|
93
|
+
|
|
94
|
+
# Two distinct reasons the imports below are deferred into function bodies rather than living
|
|
95
|
+
# up here at module scope:
|
|
96
|
+
#
|
|
97
|
+
# `rest_framework.test.APIRequestFactory` reads DRF's `api_settings` at class-definition time
|
|
98
|
+
# (import time) — importing it at module scope here would break the moment ANY consumer loads
|
|
99
|
+
# `-p appkit.testing` before Django settings are configured, which is exactly how pytest loads
|
|
100
|
+
# `-p` plugins named in `addopts` (early, during `consider_preparse`, ahead of pytest-django's
|
|
101
|
+
# own settings setup). Imported lazily inside `appkit_api_client()`.
|
|
102
|
+
#
|
|
103
|
+
# `appkit.request_id` imports cleanly at that same early point, but a module-scope import here
|
|
104
|
+
# would make THIS module (also loaded that early, for the same `-p appkit.testing` reason)
|
|
105
|
+
# import `appkit.request_id` before pytest-cov's own tracer attaches — coverage.py can then
|
|
106
|
+
# never see any of that module's lines as executed, because the one-time module-body execution
|
|
107
|
+
# that defines them already happened untraced (verified directly: request_id.py's own coverage
|
|
108
|
+
# drops from 100% to 42% the moment this import moves to module scope, in appkit's own suite,
|
|
109
|
+
# if it also dogfoods `-p appkit.testing` via its own addopts — which is exactly why it
|
|
110
|
+
# deliberately doesn't; see backend/pyproject.toml's addopts comment). Imported lazily inside
|
|
111
|
+
# `appkit_frozen_request_id()` instead — a coverage-measurement concern, not a correctness one,
|
|
112
|
+
# but avoiding it costs nothing.
|
|
113
|
+
|
|
114
|
+
__all__ = [
|
|
115
|
+
"appkit_admin_client",
|
|
116
|
+
"appkit_admin_user",
|
|
117
|
+
"appkit_api_client",
|
|
118
|
+
"appkit_assert_error_envelope",
|
|
119
|
+
"appkit_auth_client",
|
|
120
|
+
"appkit_clear_cache",
|
|
121
|
+
"appkit_frozen_request_id",
|
|
122
|
+
"appkit_user",
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
_PLACEHOLDER_PASSWORD = "appkit-testing-placeholder" # noqa: S105
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _build_user(*, is_staff: bool = False, is_superuser: bool = False) -> AbstractBaseUser:
|
|
129
|
+
"""Builds a user through `get_user_model().USERNAME_FIELD` **reflectively** — never a
|
|
130
|
+
hardcoded `create_user(username=...)` call, so this works against a host on an
|
|
131
|
+
email-based (or any other) custom user model, not just Django's default `username`-keyed
|
|
132
|
+
one.
|
|
133
|
+
"""
|
|
134
|
+
user_model = get_user_model()
|
|
135
|
+
username_field = user_model.USERNAME_FIELD
|
|
136
|
+
unique = uuid.uuid4().hex[:12]
|
|
137
|
+
username_value = f"{unique}@example.com" if "email" in username_field else unique
|
|
138
|
+
|
|
139
|
+
field_values: dict[str, Any] = {username_field: username_value}
|
|
140
|
+
for required_field in user_model.REQUIRED_FIELDS:
|
|
141
|
+
if required_field == username_field:
|
|
142
|
+
continue
|
|
143
|
+
field_values[required_field] = (
|
|
144
|
+
f"{unique}@example.com" if required_field == "email" else unique
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
new_user = user_model._default_manager.create_user(
|
|
148
|
+
password=_PLACEHOLDER_PASSWORD, **field_values
|
|
149
|
+
)
|
|
150
|
+
if is_staff or is_superuser:
|
|
151
|
+
if is_staff:
|
|
152
|
+
new_user.is_staff = True
|
|
153
|
+
if is_superuser:
|
|
154
|
+
new_user.is_superuser = True
|
|
155
|
+
new_user.save()
|
|
156
|
+
return new_user
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@pytest.fixture
|
|
160
|
+
def appkit_api_client() -> APIClient:
|
|
161
|
+
"""An unauthenticated DRF `APIClient`."""
|
|
162
|
+
from rest_framework.test import APIClient
|
|
163
|
+
|
|
164
|
+
return APIClient()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@pytest.fixture
|
|
168
|
+
def appkit_user(db: None) -> AbstractBaseUser:
|
|
169
|
+
"""A plain (non-staff) user, built reflectively through `USERNAME_FIELD`."""
|
|
170
|
+
return _build_user()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@pytest.fixture
|
|
174
|
+
def appkit_admin_user(db: None) -> AbstractBaseUser:
|
|
175
|
+
"""A staff+superuser user, built reflectively through `USERNAME_FIELD`."""
|
|
176
|
+
return _build_user(is_staff=True, is_superuser=True)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@pytest.fixture
|
|
180
|
+
def appkit_auth_client(appkit_api_client: APIClient, appkit_user: AbstractBaseUser) -> APIClient:
|
|
181
|
+
"""`appkit_api_client`, force-authenticated as `appkit_user`."""
|
|
182
|
+
appkit_api_client.force_authenticate(user=appkit_user)
|
|
183
|
+
return appkit_api_client
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@pytest.fixture
|
|
187
|
+
def appkit_admin_client(
|
|
188
|
+
appkit_api_client: APIClient, appkit_admin_user: AbstractBaseUser
|
|
189
|
+
) -> APIClient:
|
|
190
|
+
"""`appkit_api_client`, force-authenticated as `appkit_admin_user`."""
|
|
191
|
+
appkit_api_client.force_authenticate(user=appkit_admin_user)
|
|
192
|
+
return appkit_api_client
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@pytest.fixture
|
|
196
|
+
def appkit_frozen_request_id() -> Iterator[str]:
|
|
197
|
+
"""Yields a fixed request-ID string, and asserts `request_id_var` is restored to its prior
|
|
198
|
+
value on teardown — making `RequestIDMiddleware`'s reset-in-`finally` contract directly
|
|
199
|
+
assertable from a consuming app's own tests, not just appkit's.
|
|
200
|
+
"""
|
|
201
|
+
from appkit.request_id import request_id_var
|
|
202
|
+
|
|
203
|
+
fixed_id = "frozen-test-request-id"
|
|
204
|
+
prior = request_id_var.get()
|
|
205
|
+
token = request_id_var.set(fixed_id)
|
|
206
|
+
try:
|
|
207
|
+
yield fixed_id
|
|
208
|
+
finally:
|
|
209
|
+
request_id_var.reset(token)
|
|
210
|
+
restored = request_id_var.get()
|
|
211
|
+
assert restored == prior, (
|
|
212
|
+
"appkit_frozen_request_id: request_id_var was not restored on teardown "
|
|
213
|
+
f"(expected {prior!r}, got {restored!r})"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@pytest.fixture
|
|
218
|
+
def appkit_clear_cache() -> None:
|
|
219
|
+
"""Clears Django's default cache. **Deliberately not `autouse`** — under
|
|
220
|
+
`pytest -n auto` (pytest-xdist) against a single shared Redis instance, an autouse fixture
|
|
221
|
+
clearing the cache between every test would clear another xdist worker's in-flight test
|
|
222
|
+
data too. Use `LocMemCache` for test settings (isolated per process) if that isolation is
|
|
223
|
+
wanted by default instead.
|
|
224
|
+
"""
|
|
225
|
+
cache.clear()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def appkit_assert_error_envelope(response: Response, *, code: str, status: int) -> None:
|
|
229
|
+
"""Asserts `response` carries the docs/CONTRACT.md §1 error envelope with the given `code`
|
|
230
|
+
and HTTP `status`. Raises a diff-friendly `AssertionError` on mismatch — the shared
|
|
231
|
+
assertion so N installed apps don't hand-roll N slightly different envelope checks.
|
|
232
|
+
"""
|
|
233
|
+
if response.status_code != status:
|
|
234
|
+
raise AssertionError(
|
|
235
|
+
f"appkit_assert_error_envelope: expected status {status}, got "
|
|
236
|
+
f"{response.status_code}. response.data={response.data!r}"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
data = response.data
|
|
240
|
+
if not isinstance(data, dict) or "error" not in data:
|
|
241
|
+
raise AssertionError(
|
|
242
|
+
"appkit_assert_error_envelope: response.data does not contain an 'error' "
|
|
243
|
+
f"envelope. response.data={data!r}"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
envelope = data["error"]
|
|
247
|
+
actual_code = envelope.get("code")
|
|
248
|
+
if actual_code != code:
|
|
249
|
+
raise AssertionError(
|
|
250
|
+
f"appkit_assert_error_envelope: expected error code {code!r}, got "
|
|
251
|
+
f"{actual_code!r}. envelope={envelope!r}"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
missing = [key for key in ("message", "details", "request_id") if key not in envelope]
|
|
255
|
+
if missing:
|
|
256
|
+
raise AssertionError(
|
|
257
|
+
f"appkit_assert_error_envelope: envelope is missing required key(s) {missing!r}. "
|
|
258
|
+
f"envelope={envelope!r}"
|
|
259
|
+
)
|
appkit/text.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Shared string helpers whose semantics must match the frontend half.
|
|
2
|
+
|
|
3
|
+
Flagged in docs/CONTRACT.md §11 as one of the contract's two weakest modules — it survives past
|
|
4
|
+
``truncate`` alone (which ``django.utils.text.Truncator`` already provides) only because the
|
|
5
|
+
frontend half ships a matching ``truncate``, and matching client/server truncation is worth one
|
|
6
|
+
small shared function.
|
|
7
|
+
|
|
8
|
+
Public surface (docs/CONTRACT.md §2.12):
|
|
9
|
+
|
|
10
|
+
def truncate(value: str, length: int, *, suffix: str = "…") -> str: ...
|
|
11
|
+
# Counts len() (codepoints) — the frontend's Array.from-based count must agree; see
|
|
12
|
+
# tests/fixtures/truncate-vectors.json.
|
|
13
|
+
|
|
14
|
+
def to_english_digits(value: str) -> str: ...
|
|
15
|
+
# Persian ۰۱۲۳۴۵۶۷۸۹ and Arabic-Indic ٠١٢٣٤٥٦٧٨٩ digit sets, normalised to ASCII.
|
|
16
|
+
|
|
17
|
+
def to_persian_digits(value: str) -> str: ...
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
__all__ = ["to_english_digits", "to_persian_digits", "truncate"]
|
|
23
|
+
|
|
24
|
+
# Persian and Arabic-Indic digit blocks, both normalised to ASCII by `to_english_digits` — a
|
|
25
|
+
# Persian-locale keyboard can emit either set depending on the input method, so both are
|
|
26
|
+
# accepted (docs/CONTRACT.md §2.12).
|
|
27
|
+
_PERSIAN_DIGITS = "۰۱۲۳۴۵۶۷۸۹"
|
|
28
|
+
_ARABIC_INDIC_DIGITS = "٠١٢٣٤٥٦٧٨٩"
|
|
29
|
+
_ASCII_DIGITS = "0123456789"
|
|
30
|
+
|
|
31
|
+
_TO_ENGLISH_TABLE = str.maketrans(_PERSIAN_DIGITS + _ARABIC_INDIC_DIGITS, _ASCII_DIGITS * 2)
|
|
32
|
+
_TO_PERSIAN_TABLE = str.maketrans(_ASCII_DIGITS, _PERSIAN_DIGITS)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def truncate(value: str, length: int, *, suffix: str = "…") -> str:
|
|
36
|
+
"""Truncates `value` to `length` characters (codepoints), suffix included in the count.
|
|
37
|
+
|
|
38
|
+
`truncate("hello world", 8)` -> `"hello w…"` (8 characters total). Never raises: a
|
|
39
|
+
`length <= len(suffix)` returns the suffix itself, clamped to `length` characters (a
|
|
40
|
+
negative `length` clamps to an empty string). Counts `len()` — plain codepoints, not
|
|
41
|
+
grapheme clusters — matching the frontend's `Array.from`-based count (docs/CONTRACT.md §18),
|
|
42
|
+
which is also codepoint-based, not grapheme-based.
|
|
43
|
+
"""
|
|
44
|
+
if length <= len(suffix):
|
|
45
|
+
return suffix[: max(length, 0)]
|
|
46
|
+
if len(value) <= length:
|
|
47
|
+
return value
|
|
48
|
+
return value[: length - len(suffix)] + suffix
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def to_english_digits(value: str) -> str:
|
|
52
|
+
"""Normalises Persian and Arabic-Indic digits to ASCII. Never raises; characters outside
|
|
53
|
+
both digit sets pass through unchanged.
|
|
54
|
+
"""
|
|
55
|
+
return value.translate(_TO_ENGLISH_TABLE)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def to_persian_digits(value: str) -> str:
|
|
59
|
+
"""Converts ASCII digits to Persian digits. Never raises; non-ASCII-digit characters pass
|
|
60
|
+
through unchanged.
|
|
61
|
+
"""
|
|
62
|
+
return value.translate(_TO_PERSIAN_TABLE)
|
appkit/throttling.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Mechanical construction of DRF throttle-scope strings from the app-namespace prefix
|
|
2
|
+
convention.
|
|
3
|
+
|
|
4
|
+
Enforces APP-DESIGN.md §1.3's namespacing rule for throttle scopes — every one is prefixed with
|
|
5
|
+
the app's own name, no exceptions, so two apps don't silently collide in one shared
|
|
6
|
+
REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] dict.
|
|
7
|
+
|
|
8
|
+
Public surface (docs/CONTRACT.md §2.15), implemented in a later phase:
|
|
9
|
+
|
|
10
|
+
def throttle_scope(app_namespace: str, action: str) -> str: ...
|
|
11
|
+
# e.g. throttle_scope("notifications", "list") -> "notifications_list"
|
|
12
|
+
# Raises ValueError if either argument is empty or contains an underscore itself.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
__all__ = ["throttle_scope"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def throttle_scope(app_namespace: str, action: str) -> str:
|
|
21
|
+
"""`throttle_scope("notifications", "list")` -> `"notifications_list"`.
|
|
22
|
+
|
|
23
|
+
Enforces naming at the point of declaration — the opt-in half of the prefix convention.
|
|
24
|
+
**Not** the same guarantee as `appkit.W004` (`appkit.checks.check_throttle_scopes`), which
|
|
25
|
+
checks the complementary, orthogonal property: that a declared `throttle_scope`, in whatever
|
|
26
|
+
format, has a matching `REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]` entry. Neither check
|
|
27
|
+
substitutes for the other.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
ValueError: if either argument is empty, or contains an underscore itself — which
|
|
31
|
+
would make the resulting scope ambiguous to split back apart, and more practically,
|
|
32
|
+
usually signals a caller passing an already-prefixed value by mistake.
|
|
33
|
+
"""
|
|
34
|
+
if not app_namespace or not action:
|
|
35
|
+
raise ValueError("throttle_scope() requires non-empty app_namespace and action.")
|
|
36
|
+
if "_" in app_namespace or "_" in action:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
"throttle_scope() arguments must not contain an underscore — "
|
|
39
|
+
f"got app_namespace={app_namespace!r}, action={action!r}."
|
|
40
|
+
)
|
|
41
|
+
return f"{app_namespace}_{action}"
|