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:
+10
-8
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user