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.
48 lines
2.8 KiB
Python
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)
|