"""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.`, 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 (`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, 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}) # `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)