The scan followed many shapes and called the rest unread, but each shape it learned opened a silent branch: a parameter rebound in the body, a second visit of a followed helper, an `import app.x`, a name bound twice or under an `if`, a spread or a store that replaces the code, a detail rebuilt from its variable, a None reason. It now reads only what the app writes and reports everything else: a branch the app never takes is gone (an `or`, a default, a base class's attribute, string and union annotations, a bare module constant), a spread is clean only from an untouched `**kwargs` no call puts a `code` in, and the detail's variable may only grow by `|=` or `["reason"]`. The real tree reads exactly as before; every exit and every kept branch has a self-test that a mutant of it fails. The detail test also fails a dict without a `code`, a JSONResponse sentence and a detail-less HTTPException; the two that are no refusal (the setup gate's 503, an unmatched api path's 404) are listed with why. The contract's assertion that compared the reason map with itself is gone.
145 lines
6.1 KiB
Python
145 lines
6.1 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)
|
|
|
|
|
|
# Answers that are no refusal of the app's, by the function they are sent from: the client shows a
|
|
# detail only when it is an object (`lib/api/core.ts`), so these read as its generic line, and no screen
|
|
# needs more from them.
|
|
NO_REFUSAL = {
|
|
("main.py", "setup_gate"): "the 503 before setup: the setup screen is what the user sees",
|
|
("main.py", "spa_fallback"): "a bare 404 for an api path no route matches: no screen calls one",
|
|
}
|
|
|
|
|
|
def _detail_offenders(source: str, path: str) -> list[str]:
|
|
"""Each detail the client could not say: a dict without a `code`, a sentence, or none at all.
|
|
|
|
`HTTPException(status, detail)` and `JSONResponse({"detail": …})`; a variable or a call is the
|
|
refusal scan's to read (`refusal_sites.py`, which follows each `code` dict)."""
|
|
tree = ast.parse(source)
|
|
parents = {child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node)}
|
|
out = []
|
|
for node in ast.walk(tree):
|
|
name = isinstance(node, ast.Call) and getattr(
|
|
node.func, "id", getattr(node.func, "attr", "")
|
|
)
|
|
if name == "HTTPException":
|
|
kw = {k.arg: k.value for k in node.keywords}
|
|
detail = kw.get("detail", node.args[1] if len(node.args) > 1 else None)
|
|
elif name == "JSONResponse":
|
|
content = {k.arg: k.value for k in node.keywords}.get(
|
|
"content", node.args[0] if node.args else None
|
|
)
|
|
if not isinstance(content, ast.Dict) or not any(
|
|
isinstance(k, ast.Constant) and k.value == "detail" for k in content.keys
|
|
):
|
|
continue # an answer, not a refusal
|
|
detail = next(
|
|
v
|
|
for k, v in zip(content.keys, content.values, strict=True)
|
|
if isinstance(k, ast.Constant) and k.value == "detail"
|
|
)
|
|
else:
|
|
continue
|
|
fn = parents.get(node)
|
|
while fn is not None and not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
fn = parents.get(fn)
|
|
if (path, getattr(fn, "name", None)) in NO_REFUSAL:
|
|
continue
|
|
keys = (
|
|
[k.value for k in detail.keys if isinstance(k, ast.Constant)]
|
|
if isinstance(detail, ast.Dict)
|
|
else [k.arg for k in detail.keywords]
|
|
if isinstance(detail, ast.Call) and getattr(detail.func, "id", "") == "dict"
|
|
else None
|
|
)
|
|
if detail is None or _is_text(detail) or (keys is not None and "code" not in keys):
|
|
out.append(f"{path}:{node.lineno}")
|
|
return out
|
|
|
|
|
|
def test_every_detail_is_one_the_client_can_say():
|
|
offenders = [
|
|
o
|
|
for p in sorted(APP.rglob("*.py"))
|
|
for o in _detail_offenders(p.read_text(encoding="utf-8"), p.relative_to(APP).as_posix())
|
|
]
|
|
assert offenders == [], (
|
|
"a refusal the client can only answer with its generic line — send `{code, reason}` "
|
|
f"(`refused(status, reason)`), or list it in NO_REFUSAL with why: {offenders}"
|
|
)
|
|
|
|
|
|
def test_the_detail_scan_sees_each_shape_the_client_cannot_say():
|
|
src = """
|
|
def f(x):
|
|
raise HTTPException(409, detail={"reason": "view_name_taken"})
|
|
def g(x):
|
|
raise HTTPException(409, dict(reason="taken"))
|
|
def h(x):
|
|
raise HTTPException(status_code=404)
|
|
def i(x):
|
|
return JSONResponse({"detail": "This instance isn't set up yet."}, status_code=503)
|
|
def j(x):
|
|
return JSONResponse(content={"detail": {"reason": "x"}}, status_code=409)
|
|
def ok(x):
|
|
raise HTTPException(409, {"code": "refused", "reason": "x"})
|
|
raise HTTPException(409, detail(x))
|
|
return JSONResponse({"detail": {"code": "too_large"}}, status_code=413)
|
|
return JSONResponse({"ok": True})
|
|
def setup_gate(x):
|
|
return JSONResponse({"detail": "listed"}, status_code=503)
|
|
"""
|
|
assert _detail_offenders(src, "main.py") == [f"main.py:{n}" for n in (3, 5, 7, 9, 11)]
|