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:
+224
-109
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user