Files
siftlode/backend/app/refusal.py
T
peter 31a75f4985 fix(i18n): write each refusal where it is sent, and read only that form
A refusal's detail now stands where it is sent (HTTPException's detail,
a JSONResponse's "detail") or is returned by a factory that only ever is,
with any spread before its code and reason, so no spread can replace
them. The scan follows no variable and no kwargs any more; what it cannot
read in a reason (a lambda's or comprehension's name, a nonlocal or
global, a local that reaches itself, a constant or map changed elsewhere,
an app import it cannot resolve) is reported. The detail guard reads the
same slots and fails on anything but a code dict or a factory's call.
2026-10-31 23:54:55 +01:00

48 lines
2.8 KiB
Python

"""A request the app refuses, said in a way the UI can put in the reader's language.
Every refusal the API answers carries a structured `detail` — `{code, reason, …params}` — and the client
translates it (`lib/api/refusalText.ts` `localizeDetail`). A bare English string reached the Hungarian UI
verbatim: the blocking error dialog, and every inline surface that printed `HttpError.detail`, showed
whatever sentence the route happened to hand-write. `refused` is the generic family
(`errors.refused.<reason>`, the params interpolated into the sentence); the older specific codes
(`not_found`, `youtube`, `playlist`, the admin refusals, …) stay, since each is already translated.
A test (`test_http_details.py`) reads every place an `HTTPException` or a `JSONResponse` sends a detail
and fails on one the client cannot say: a sentence, a dict without a `code`, a variable, none at all.
`tests/refusal_sites.py` reads every `{"code": …}` detail from the syntax tree, written where it is sent
(or returned by a factory that only ever is), with any `**` spread before its `code` and `reason`, and
follows each reason along a closed list of shapes: a literal, a `Literal` type on the parameter or
attribute it travels in, every call of a helper like `refused` below, a map's values; anything else is
reported, never guessed. The frontend's contract test
(`i18n/backendIds.contract.test.ts`) runs it and requires each code in one record of
`lib/api/refusalCodes.ts`, and each reason's sentence in both languages; a code or reason it cannot
follow fails it, unless no screen shows that code. So the rule is enforced, not described.
"""
from fastapi import HTTPException
from app.sysconfig import InvalidSetting, SettingReason
def refused(status: int, reason: str, **params: object) -> HTTPException:
"""The refusal to raise: `raise refused(409, "view_name_taken", name=name)`. `params` travel in
the detail for the sentence to interpolate (`{{name}}`); keep them to values the reader may see.
They are spread first, so a `code` or `reason` among them could never replace these two."""
return HTTPException(status_code=status, detail={**params, "code": "refused", "reason": reason})
# `sysconfig.InvalidSetting.reason` → the refusal it answers as. A dict of literals, so the refusal
# scan reads every reason this can produce, and holds its keys to `SettingReason`.
_SETTING_REASONS: dict[SettingReason, str] = {
"not_a_number": "setting_not_a_number",
"too_low": "setting_too_low",
"too_high": "setting_too_high",
"not_a_choice": "setting_not_a_choice",
"no_encryption_key": "no_encryption_key",
}
def setting_refused(exc: InvalidSetting) -> HTTPException:
"""A value the admin entered that `sysconfig.set_value` refused (`exc`: its `InvalidSetting`)."""
return refused(400, _SETTING_REASONS[exc.reason], key=exc.key, **exc.params)