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.
This commit is contained in:
2026-10-31 23:54:55 +01:00
parent f8a8e905da
commit 31a75f4985
6 changed files with 546 additions and 515 deletions
+10 -8
View File
@@ -7,11 +7,13 @@ whatever sentence the route happened to hand-write. `refused` is the generic fam
(`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 `HTTPException` and `JSONResponse` detail in the app and
fails on a sentence, a dict without a `code`, or none at all. `tests/refusal_sites.py` reads every
`{"code": …}` detail from the syntax tree 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
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.
@@ -24,9 +26,9 @@ 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,
and never a `code`: it would replace this one (the refusal scan reads such a call as an unread code)."""
return HTTPException(status_code=status, detail={"code": "refused", "reason": reason, **params})
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
+9 -9
View File
@@ -1001,15 +1001,15 @@ def _login_refused(
`email_verified` is false, and vice versa — the SPA renders whichever is actually outstanding
rather than assuming a sequence.
"""
detail: dict = {"code": "login", "reason": reason}
if user is not None:
detail |= {
"email": user.email,
"email_verified": user.email_verified,
"approved": user.is_active,
}
detail |= {k: v for k, v in extra.items() if v is not None}
return HTTPException(status_code=status, detail=detail)
account = (
{"email": user.email, "email_verified": user.email_verified, "approved": user.is_active}
if user is not None
else {}
)
given = {k: v for k, v in extra.items() if v is not None}
return HTTPException(
status_code=status, detail={**account, **given, "code": "login", "reason": reason}
)
@router.post("/password-login")
+3 -2
View File
@@ -359,8 +359,9 @@ def enqueue_clip(
job = service.enqueue_clip(db, user.id, spec, display_name=display_name)
except clipmod.ClipError as e:
status = 404 if e.reason == "not_found" else 422
detail = {"code": "clip", "reason": e.reason, **e.params}
raise HTTPException(status_code=status, detail=detail) from e
raise HTTPException(
status_code=status, detail={**e.params, "code": "clip", "reason": e.reason}
) from e
except quota.QuotaExceeded as e:
raise HTTPException(status_code=422, detail=quota_detail(e)) from e
db.commit()