~150 HTTPExceptions carried a hand-written English sentence as their detail,
shown verbatim under the Hungarian UI — in the blocking error dialog and on
every inline surface that printed HttpError.detail (sign-in, setup wizard,
change password, the passphrase gate, live search). Four also quoted an
upstream exception's text back to the user.
- app/refusal.py: `refused(status, reason, **params)` → {code: "refused",
reason, …params}; localizeDetail translates errors.refused.<reason> with the
params interpolated. "Unknown X" 404s use the existing not_found(what).
- sysconfig.set_value raises InvalidSetting (reason + bounds) instead of an
English ValueError; the three writers answer it through setting_refused.
- The Plex connection/write/image failures log what the upstream said and
answer a reason; the search scraper's failure is logged and answered as
youtube/unavailable (it crashed refusal_reason before).
- HttpError.detail now only ever holds a translated reason: a string detail
(only the framework's own can remain) is left out, so reading .detail is
safe everywhere — including destructured, the hole the raw-detail source
scan could not see. That scan and its five-file exception list are gone.
- Enforced: test_http_details.py fails on any HTTPException with a sentence
for a detail; the contract test requires every refused reason and every
not_found "what" in both languages, and no key for one that is gone.
- local/jsx-comment-text: a // inside JSX children renders as text; the rule
is ten lines in eslint.config.js rather than eslint-plugin-react's ~1800
lockfile lines.
54 lines
2.3 KiB
Python
54 lines
2.3 KiB
Python
"""No refusal answers in a hand-written English sentence.
|
|
|
|
The UI is bilingual and translates a structured `detail` (`{code, reason, …}`), but a string detail it
|
|
can only print as it came — so ~150 of them reached the Hungarian UI verbatim, in the blocking error
|
|
dialog and on every inline surface that shows `HttpError.detail`. `app/refusal.py` is the family they
|
|
became; this test is what keeps a new one from being written the old way. Pure: it reads the sources.
|
|
"""
|
|
|
|
import ast
|
|
import pathlib
|
|
|
|
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
|
|
|
|
|
def _is_text(node: ast.AST) -> bool:
|
|
"""A detail that is a sentence: a literal, an f-string, `str(exc)`, or `"…" + x`."""
|
|
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
return True
|
|
if isinstance(node, ast.JoinedStr):
|
|
return True
|
|
if isinstance(node, ast.Call) and getattr(node.func, "id", "") == "str":
|
|
return True
|
|
if isinstance(node, ast.BoolOp):
|
|
return any(_is_text(v) for v in node.values)
|
|
if isinstance(node, ast.BinOp):
|
|
return _is_text(node.left) or _is_text(node.right)
|
|
return False
|
|
|
|
|
|
def test_no_http_exception_carries_a_sentence():
|
|
offenders = []
|
|
for path in sorted(APP.rglob("*.py")):
|
|
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
|
|
if not (
|
|
isinstance(node, ast.Call)
|
|
and getattr(node.func, "id", getattr(node.func, "attr", "")) == "HTTPException"
|
|
):
|
|
continue
|
|
kw = {k.arg: k.value for k in node.keywords}
|
|
detail = kw.get("detail", node.args[1] if len(node.args) > 1 else None)
|
|
if detail is not None and _is_text(detail):
|
|
offenders.append(f"{path.relative_to(APP).as_posix()}:{node.lineno}")
|
|
assert offenders == [], (
|
|
"a refusal with an English sentence for a detail — raise `refused(status, reason)` "
|
|
f"(app/refusal.py) and name the reason in both locales: {offenders}"
|
|
)
|
|
|
|
|
|
def test_the_scan_sees_a_sentence_when_there_is_one():
|
|
"""A guard that matches nothing is green over nothing."""
|
|
sample = ast.parse('HTTPException(status_code=400, detail=f"bad {x}")').body[0].value
|
|
assert _is_text(sample.keywords[1].value)
|
|
assert not _is_text(ast.parse('{"code": "refused", "reason": "x"}').body[0].value)
|