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/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}"