fix(i18n): read every refusal from the backend's syntax tree, not by regex

The contract read Python with regexes, and each guard added for a missed shape had shapes of its own
it missed: a reason after another key, a status passed by name, a second `e.reason` handler with the
same text, a pin naming a type nothing checked.

backend/tests/refusal_sites.py (stdlib `ast`, run by the contract on the host's Python) reads every
`{"code": ...}` detail and follows each reason to its values: literals, both arms of a conditional,
module constants, the `Literal` on the parameter or attribute it travels in (on the class an `except`
catches), every call of a `str` helper with its defaults, a map's values with its keys held to the
index's type. What it cannot follow fails the contract, unless no screen shows that code; no pins.

The reason attributes are typed with their Literal, so a subclass or an assignment cannot widen
them; InvalidSetting takes a SettingReason, and the settings map is held to it (a reason the map
lacked was a 500). The per-family reverse checks are one check by prefix (the admin actions
included), and the leaf-key filter applies only to the suffixed sign-in family.
This commit is contained in:
2026-10-31 15:07:38 +01:00
parent ceadf69e43
commit 5e8589bbf8
13 changed files with 1022 additions and 254 deletions
+1 -1
View File
@@ -78,7 +78,7 @@ class ClipError(Exception):
message names (the cap itself, so the client never says a stale one)."""
def __init__(self, reason: ClipReason, **params: float):
self.reason = reason
self.reason: ClipReason = reason
self.params = params
super().__init__(reason)
+1 -1
View File
@@ -174,7 +174,7 @@ class InvalidCookies(ValueError):
def __init__(self, reason: CookieReason):
super().__init__(reason)
self.reason = reason
self.reason: CookieReason = reason
def validate_cookies(raw: str) -> str:
+1 -1
View File
@@ -197,7 +197,7 @@ class FolderError(Exception):
def __init__(self, reason: FolderReason) -> None:
super().__init__(reason)
self.reason = reason
self.reason: FolderReason = reason
def own_folder(db: Session, user_id: int, folder_id: int, *, hold: bool = True) -> FileFolder:
+1 -1
View File
@@ -60,7 +60,7 @@ class QuotaExceeded(Exception):
"""Raised by check_enqueue; `reason` names the cap it hit (an i18n key on the client)."""
def __init__(self, reason: QuotaReason, limit: int, current: int):
self.reason = reason
self.reason: QuotaReason = reason
self.limit = limit
self.current = current
super().__init__(f"download quota exceeded: {reason} ({current}/{limit})")
+1 -1
View File
@@ -49,7 +49,7 @@ class EditError(Exception):
"""Bad edit request (empty spec / source not ready / not media); `reason` names the sentence."""
def __init__(self, reason: EditReason):
self.reason = reason
self.reason: EditReason = reason
super().__init__(reason)
+1 -1
View File
@@ -59,7 +59,7 @@ class BlockedUrl(Exception):
def __init__(self, reason: UrlBlockedReason):
super().__init__(reason)
self.reason = reason
self.reason: UrlBlockedReason = reason
def _is_forbidden(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: