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:
2026-10-31 23:54:55 +01:00
parent f8a8e905da
commit 31a75f4985
6 changed files with 546 additions and 515 deletions
+224 -109
View File
@@ -26,15 +26,7 @@ REFUSAL = """
from fastapi import HTTPException
def refused(status: int, reason: str, **params: object) -> HTTPException:
return HTTPException(status_code=status, detail={"code": "refused", "reason": reason, **params})
"""
# The same helper without a `**params` spread: what a call passes it is about the reason alone.
PLAIN = """
from fastapi import HTTPException
def refused(status: int, reason: str) -> HTTPException:
return HTTPException(status_code=status, detail={"code": "refused", "reason": reason})
return HTTPException(status_code=status, detail={**params, "code": "refused", "reason": reason})
"""
@@ -109,6 +101,12 @@ def test_a_helper_is_followed_to_every_call_however_it_is_written():
def i(x):
raise refused(409, "a" if x else "second_arm")
""",
routes__b="""
from other.lib import refused
def f():
refused("an outside library's, not the app's")
""",
)
assert _code(r, "refused")["reasons"] == [
"a",
@@ -122,7 +120,7 @@ def test_a_helper_is_followed_to_every_call_however_it_is_written():
def test_a_call_the_helper_cannot_be_followed_through_is_unread():
r = _scan(
refusal=PLAIN,
refusal=REFUSAL,
routes__a="""
from app.refusal import refused
@@ -267,6 +265,8 @@ def test_a_local_is_followed_only_through_plain_assignments():
def j(items):
LABELS[why] = "NotAReason"
raise HTTPException(409, {"code": "playlist", "reason": why})
def k(*why):
raise HTTPException(409, {"code": "playlist", "reason": why})
"""
)
assert _code(r, "edit")["reasons"] == ["first", "second", "third"]
@@ -276,7 +276,8 @@ def test_a_local_is_followed_only_through_plain_assignments():
"why",
"why",
"why",
] # unpacked, a loop, annotated, unbound
"why",
] # unpacked, a loop, annotated, unbound, `*args`
def test_a_call_answers_with_its_literal_return_type():
@@ -470,7 +471,7 @@ def test_a_map_lookup_is_its_values_and_its_keys_must_be_exactly_what_the_index_
_MAP: dict[SettingReason, str] = {{{keys}}}
def refused(status: int, reason: str, **params: object):
return HTTPException(status, {{"code": "refused", "reason": reason, **params}})
return HTTPException(status, {{**params, "code": "refused", "reason": reason}})
def setting_refused(exc: InvalidSetting):
return refused(400, _MAP[{index}], **exc.params)
@@ -527,115 +528,229 @@ def test_a_code_must_be_one_literal():
assert r["codes"]["busy"]["reasonless"] == ["a.py:4"]
assert set(r["codes"]) == {"busy"}
assert len(r["unreadCodes"]) == 5
assert r["unreadCodes"][-1].endswith("(a key the scan cannot read)")
assert r["unreadCodes"][-1].endswith(
"(a spread or a computed key the code and the reason do not follow)"
)
def test_a_spread_is_clean_only_from_an_untouched_kwargs_no_call_puts_a_code_in():
def unread_codes(helper: str, call: str) -> list[str]:
return _scan(a=helper + "\n" + f"def caller(x, more):\n raise {call}\n")["unreadCodes"]
def test_a_spread_or_a_computed_key_stands_before_both_the_code_and_the_reason():
def unread_codes(detail: str) -> list[str]:
return _scan(a=f"def f(x, more, key):\n raise HTTPException(409, {detail})\n")[
"unreadCodes"
]
helper = """
def deny(status, reason: str, **params):
return HTTPException(status, {"code": "refused", "reason": reason, **params})
assert unread_codes('{**more, "code": "refused", "reason": "a"}') == []
assert unread_codes('{key: 1, **more, "reason": "a", "code": "refused"}') == []
for late in [
'{"code": "refused", "reason": "a", **more}', # the spread may replace both
'{"code": "refused", **more, "reason": "a"}', # … the code
'{"reason": "a", **more, "code": "refused"}', # … the reason
'{**more, "code": "refused"}', # no reason after it: the spread may carry one
'{"code": "refused", "reason": "a", key: "x"}', # a key that may be either
'dict(**more, code="refused", reason="a")', # dict() raises on a repeated key
'dict(more, code="refused", reason="a")',
]:
assert unread_codes(late), late
def test_a_detail_stands_only_where_it_is_sent():
def result(body: str, extra: str = "") -> dict:
return _scan(a=f"def f(x, cached, fix):\n{textwrap.indent(body, ' ')}\n{extra}")
for body in [
'detail = {"code": "login", "reason": "a"}\nraise HTTPException(401, detail)',
'if (detail := {"code": "login", "reason": "a"}):\n raise HTTPException(401, detail)',
'raise HTTPException(401, {"code": "login", "reason": "a"}.copy())',
'raise HTTPException(401, fix({"code": "login", "reason": "a"}))',
'raise HTTPException(401, cached or {"code": "login", "reason": "a"})',
'return JSONResponse({"code": "login", "reason": "a"}, status_code=401)', # the whole body
'raise HTTPException(401, None, {"code": "login", "reason": "a"})', # the headers
'raise HTTPException(401, headers={"code": "login", "reason": "a"})',
'raise HTTPException(401, "a" if {"code": "login", "reason": "a"} else "b")',
]:
r = result(body)
assert r["unreadCodes"] and "login" not in r["codes"], body
assert "(a detail put where the scan cannot follow it)" in r["unreadCodes"][0], body
held = _scan(a='class C:\n detail = {"code": "login"}\n')
assert "(a detail put where the scan cannot follow it)" in held["unreadCodes"][0]
sent = result(
'raise HTTPException(401, {"code": "login", "reason": "a"} if x else {"code": "login"})\n'
'raise HTTPException(401, detail=dict(code="login", reason="b"))\n'
'return JSONResponse(content={"detail": {"code": "login", "reason": "c"}})'
)
assert sent["unreadCodes"] == [] and _code(sent, "login")["reasons"] == ["a", "b", "c"]
def test_a_factory_is_a_detail_only_when_each_return_is_one_and_each_call_is_sent():
factory = """
def quota(e) -> dict:
return {"code": "quota", "reason": "files"}
"""
assert unread_codes(helper, 'deny(409, "a", limit=3)') == []
assert unread_codes(helper, 'deny(409, "a", code=x)') # the spread puts its own code
assert unread_codes(helper, 'deny(409, "a", **more)') # a spread nothing vouches for
assert unread_codes(helper + "pick = deny\n", 'deny(409, "a")') # passed around
posonly = helper.replace("reason: str,", "reason: str, /,") # `reason=` lands in params
assert unread_codes(posonly, 'deny(409, "a")')
mutated = helper.replace(" return", ' params["reason"] = "zz"\n return')
assert unread_codes(mutated, 'deny(409, "a")')
renamed = helper.replace("**params})", "**{k.lstrip('_'): v for k, v in params.items()}})")
assert unread_codes(renamed, 'deny(409, "a")')
filtered = helper.replace("**params})", "**{k: v for k, v in params.items() if v}})")
assert unread_codes(filtered, 'deny(409, "a")') == []
sent = "def f(e):\n raise HTTPException(422, detail=quota(e))\n"
assert _scan(a=factory + sent)["codes"]["quota"]["reasons"] == ["files"]
for change in [
"def g(e):\n d = quota(e)\n d['code'] = 'other'\n raise HTTPException(422, d)\n",
"def g(e):\n raise HTTPException(422, detail=quota(e).copy())\n",
"pick = quota\n",
]:
r = _scan(a=factory + sent + change)
assert r["unreadCodes"] and "quota" not in r["codes"], change
twice = _scan(a="quota = None\n" + factory + sent) # the def is the last of two bindings
assert twice["unreadCodes"] and "quota" not in twice["codes"]
for other in [
" if e:\n return None\n",
" if e:\n return cached\n",
" if e:\n return {'code': 'quota', 'reason': 'x'} if e > 1 else None\n",
" log({'code': 'quota', 'reason': 'logged'})\n", # a detail it does not return
]:
bent = factory.replace("-> dict:\n", "-> dict:\n" + other)
assert _scan(a=bent + sent)["unreadCodes"], other
method = "class C:\n def quota(self):\n return {'code': 'quota', 'reason': 'm'}\n"
assert _scan(a=method)["unreadCodes"]
dotted = _scan(
a=factory,
routes__b="import app.a\n\ndef g(e):\n raise HTTPException(422, detail=app.a.quota(e))\n",
)
assert dotted["unreadCodes"] and "quota" not in dotted["codes"]
def test_a_caught_exceptions_params_are_clean_only_when_kept_from_its_init():
def unread_codes(errors: str, construct: str = 'Err("a", limit=1)') -> list[str]:
return _scan(
errors=errors,
a=f"""
from app.errors import Err
def f():
raise {construct}
def g():
try:
pass
except Err as e:
raise HTTPException(422, {{"code": "clip", "reason": e.reason, **e.params}})
""",
)["unreadCodes"]
kept = """
from typing import Literal
class Err(Exception):
def __init__(self, reason: Literal["a"], **params: float):
super().__init__(f"{reason} {params}")
self.reason: Literal["a"] = reason
self.params = params
"""
assert unread_codes(kept) == []
assert unread_codes(kept, 'Err("a", code="other")')
assert unread_codes(kept + "\n class Sub(Err):\n pass\n")
assert unread_codes(kept.replace("self.params = params", "self.params = {'code': 'x'}"))
assert unread_codes(kept + "\n def more(self):\n self.params = {}\n")
def test_a_detail_built_up_in_steps_is_read_through_its_variable():
def test_a_name_a_lambda_a_comprehension_a_nonlocal_or_a_global_binds_is_unread():
r = _scan(
a="""
def f(status: int, reason: str, user=None, **extra):
detail: dict = {"code": "login", "reason": "invalid"}
if user is not None:
detail |= {"email": user.email, "reason": "suspended"}
detail |= {k: v for k, v in extra.items() if v is not None}
detail["email"] = "x"
return HTTPException(status, detail)
def g(x):
detail = {"code": "login"} if x else {"code": "login", "reason": "other"}
detail["reason"] = "late"
return HTTPException(401, detail=detail)
def f(svc):
why = "invalid"
make = lambda why: HTTPException(401, {"code": "login", "reason": why})
raise make(svc.state)
def g(svc):
why = "invalid"
raise [HTTPException(401, {"code": "login", "reason": why}) for why in svc.all][0]
def h(svc):
why = "invalid"
def inner():
nonlocal why
why = svc.reason
inner()
raise HTTPException(401, {"code": "login", "reason": why})
def i(svc):
global why
why = "invalid"
raise HTTPException(401, {"code": "login", "reason": why})
"""
)
assert r["unreadCodes"] == []
assert _code(r, "login")["reasons"] == ["invalid", "late", "other", "suspended"]
def test_a_detail_changed_or_sent_any_other_way_is_an_unread_code():
changes = [
"detail.update(more)",
"detail |= more",
'detail |= {"code": "other"}',
'detail["code"] = "other"',
'del detail["reason"]',
"detail[key] = more",
'detail = {**detail, "reason": "b"}',
"detail = rebuilt()",
"copy = detail",
"add_extra(detail)",
"return detail",
'return {**detail, "reason": "b"}',
'return dict(detail, reason="b")',
assert _code(r, "login")["reasons"] == []
assert sorted(_unread(r, "login")) == [
"why (bound by a lambda or a comprehension)",
"why (bound by a lambda or a comprehension)",
"why (named by a nonlocal or a global)",
"why (named by a nonlocal or a global)",
]
for change in changes:
r = _scan(
a=f"""
def f(key, more):
detail = {{"code": "login", "reason": "a"}}
{change}
raise HTTPException(401, detail)
"""
)
assert r["unreadCodes"], change
assert "login" not in r["codes"], change
held = _scan(a='class C:\n detail = {"code": "login"}\n')
assert (
held["unreadCodes"]
and "a detail put where the scan cannot follow it" in held["unreadCodes"][0]
def test_a_local_that_reaches_itself_is_unread():
r = _scan(
a="""
def f(x):
why = "a"
if x:
why = why if x > 1 else "b"
raise HTTPException(409, {"code": "edit", "reason": why})
def g(x):
a = "z"
b = a
a = b
raise HTTPException(409, {"code": "playlist", "reason": a})
def h(x):
why = "c"
if x:
raise HTTPException(409, {"code": "busy", "reason": why})
raise HTTPException(409, {"code": "busy", "reason": why})
"""
)
assert _code(r, "busy")["reasons"] == ["c"] and _code(r, "busy")["unread"] == []
assert _code(r, "edit")["reasons"] == ["a", "b"]
assert _unread(r, "edit") == ["why (reached again while it is resolved)"]
assert _code(r, "playlist")["reasons"] == ["z"]
assert any("reached again while it is resolved" in u for u in _unread(r, "playlist"))
def test_a_module_constant_or_a_map_changed_elsewhere_is_unread():
def reasons(urlguard: str, route: str = "", **more: str) -> tuple[list[str], list[str]]:
r = _scan(
**more,
urlguard="REASON = 'malformed'\n_MAP = {'a': 'map_a'}\n" + urlguard,
routes__a="from app import urlguard\n"
+ route
+ "\ndef f(k: Literal['a']):\n"
+ " raise HTTPException(409, {'code': 'url_blocked', 'reason': urlguard.REASON})\n"
+ " raise HTTPException(409, {'code': 'url_blocked', 'reason': urlguard._MAP[k]})\n",
)
return _code(r, "url_blocked")["reasons"], _unread(r, "url_blocked")
assert reasons("def get(k):\n return _MAP.get(k)\n") == (["malformed", "map_a"], [])
other = "from app import other\ndef g():\n other.REASON = 'x'\n" # another module's REASON
assert reasons("", other, other="REASON = 'y'\n") == (["malformed", "map_a"], [])
shadow = (
"def h(*urlguard):\n"
" raise HTTPException(409, {'code': 'url_blocked', 'reason': urlguard.REASON})\n"
)
assert reasons("", shadow)[1] == ["urlguard.REASON"]
for urlguard, route in [
("def reset():\n global REASON\n REASON = 'x'\n", ""),
("", "def g():\n urlguard.REASON = 'x'\n"),
("", "def g():\n del urlguard.REASON\n"),
]:
assert reasons(urlguard, route)[0] == ["map_a"], (urlguard, route)
for urlguard in [
"_MAP['a'] = compute()\n",
"def init(x):\n _MAP['a'] = x\n",
"_MAP.update(a='other')\n",
"def drop():\n del _MAP['a']\n",
"register(_MAP)\n",
]:
got, unread = reasons(urlguard)
assert got == ["malformed"] and "(the map is changed after it is built)" in unread[0]
def test_a_helper_bound_twice_is_not_followed():
r = _scan(
refusal="refused = None\n" + textwrap.dedent(REFUSAL),
routes__a="from app.refusal import refused\n\ndef f():\n raise refused(409, 'lost')\n",
)
assert _code(r, "refused")["reasons"] == []
assert _unread(r, "refused")[0].endswith("refused(reason) is not a module-level helper")
def test_a_dotted_path_through_an_app_module_that_goes_nowhere_is_unread():
r = scan(
{
"__init__.py": "",
"refusal.py": textwrap.dedent(REFUSAL),
"routes/a.py": "import app as a\n\ndef f():\n raise a.refusal.refused(409, 'dotted')\n",
}
)
assert _unread(r, "refused") == ["a.refusal.refused (a reference the scan cannot resolve)"]
def test_a_helper_reached_through_an_app_module_that_does_not_bind_it_is_unread():
for shim in [
"from app.refusal import *\n",
"try:\n from app.refusal import refused\nexcept ImportError:\n refused = None\n",
]:
r = _scan(
refusal=REFUSAL,
shim=shim,
routes__a="from app.shim import refused\n\ndef f():\n raise refused(409, 'hidden')\n",
)
assert _unread(r, "refused") == ["refused (a reference the scan cannot resolve)"], shim
package = scan(
{
"pkg/refusal.py": textwrap.dedent(REFUSAL),
"pkg/__init__.py": "from .refusal import refused\n",
"routes/a.py": "from app.pkg import refused\n\ndef f():\n raise refused(409, 'hidden')\n",
}
)
assert _unread(package, "refused") == ["refused (a reference the scan cannot resolve)"]
def test_the_data_files_are_excluded_by_name_only():