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:
@@ -0,0 +1,625 @@
|
||||
"""Every refusal the backend can answer, read from its syntax tree: each `{"code": …, "reason": …}` detail,
|
||||
with every reason its code can be sent with.
|
||||
|
||||
The frontend's contract (`i18n/backendIds.contract.test.ts`) runs this and requires a sentence for each
|
||||
code and reason in both languages. A regex over the source read only the shapes it was written for: a
|
||||
reason after another key, a status passed by name, a second handler with the same text all went past it.
|
||||
Here a reason is followed to where it comes from, and what cannot be followed is reported, never guessed:
|
||||
|
||||
- a literal; both arms of `a if c else b`, every operand of `a or b`;
|
||||
- a module constant (`urlguard.REASON_MALFORMED`), a local variable's assignments;
|
||||
- a parameter or attribute typed with a `Literal` (basedpyright holds every value passed in), the
|
||||
attribute found on the class an `except … as e` catches, or the class a parameter is typed with;
|
||||
- a `str` parameter of a helper (`refused`, `not_found`): followed to every call of it, its default
|
||||
included, and a helper passed around as a value is unread;
|
||||
- a call: its return annotation; a map lookup (`_SETTING_REASONS[exc.reason]`): the map's values, and
|
||||
its keys must be exactly what the index can be.
|
||||
|
||||
Anything else is UNREAD, and the contract fails on it unless no screen shows that code. Stdlib only: it
|
||||
runs on the host, beside the frontend's tests (`python -I backend/tests/refusal_sites.py` → JSON).
|
||||
"""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||
|
||||
# Files whose `"code"` dicts are data, not refusals: a subtitle track's status payload, TMDB country codes.
|
||||
NOT_REFUSALS = {
|
||||
"plex/subtitles.py": "a subtitle track's status payload (its own `code`)",
|
||||
"plex/tmdb.py": "a production country's ISO code",
|
||||
}
|
||||
|
||||
Node = ast.AST
|
||||
|
||||
|
||||
@dataclass
|
||||
class Res:
|
||||
values: set[str] = field(default_factory=set)
|
||||
unread: list[str] = field(default_factory=list)
|
||||
|
||||
def __or__(self, other: "Res") -> "Res":
|
||||
return Res(self.values | other.values, self.unread + other.unread)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Module:
|
||||
path: str # relative to app/, posix
|
||||
name: str # dotted, `app.…`
|
||||
tree: ast.Module
|
||||
parents: dict[Node, Node]
|
||||
imports: dict[
|
||||
str, tuple[str, str | None]
|
||||
] # local name → (module, symbol or None for the module)
|
||||
top: dict[str, Node] # module-level bindings: def, class, or the assigned value
|
||||
|
||||
|
||||
def _module_name(path: str) -> str:
|
||||
parts = path.removesuffix(".py").split("/")
|
||||
if parts[-1] == "__init__":
|
||||
parts = parts[:-1]
|
||||
return ".".join(["app", *parts])
|
||||
|
||||
|
||||
def _load(sources: dict[str, str]) -> dict[str, Module]:
|
||||
mods: dict[str, Module] = {}
|
||||
for path, src in sources.items():
|
||||
tree = ast.parse(src)
|
||||
parents = {child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node)}
|
||||
imports: dict[str, tuple[str, str | None]] = {}
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
||||
for a in node.names:
|
||||
imports[a.asname or a.name] = (node.module, a.name)
|
||||
elif isinstance(node, ast.Import):
|
||||
for a in node.names:
|
||||
if a.asname:
|
||||
imports[a.asname] = (a.name, None)
|
||||
top: dict[str, Node] = {}
|
||||
for stmt in tree.body:
|
||||
if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||
top[stmt.name] = stmt
|
||||
elif isinstance(stmt, ast.Assign):
|
||||
for t in stmt.targets:
|
||||
if isinstance(t, ast.Name):
|
||||
top[t.id] = stmt
|
||||
elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
|
||||
top[stmt.target.id] = stmt
|
||||
name = _module_name(path)
|
||||
mods[name] = Module(path, name, tree, parents, imports, top)
|
||||
return mods
|
||||
|
||||
|
||||
class Scan:
|
||||
def __init__(self, sources: dict[str, str]):
|
||||
self.mods = _load(sources)
|
||||
self.errors: list[str] = []
|
||||
self._following: set[tuple[str, str, str]] = set()
|
||||
|
||||
# --- names ---------------------------------------------------------------------------------------
|
||||
|
||||
def lookup(self, m: Module, name: str) -> tuple[Module, Node] | tuple[str, None] | None:
|
||||
"""What a module-level name is: (its module, its binding), or (a module's name, None)."""
|
||||
if name in m.top:
|
||||
return m, m.top[name]
|
||||
if name in m.imports:
|
||||
mod, sym = m.imports[name]
|
||||
if sym is None:
|
||||
return (mod, None) if mod in self.mods else None
|
||||
if f"{mod}.{sym}" in self.mods:
|
||||
return f"{mod}.{sym}", None
|
||||
if mod in self.mods:
|
||||
return self.lookup(self.mods[mod], sym)
|
||||
return None
|
||||
|
||||
def lookup_expr(self, m: Module, e: Node) -> tuple[Module, Node] | tuple[str, None] | None:
|
||||
if isinstance(e, ast.Name):
|
||||
return self.lookup(m, e.id)
|
||||
if isinstance(e, ast.Attribute):
|
||||
base = self.lookup_expr(m, e.value)
|
||||
if base and base[1] is None and isinstance(base[0], str):
|
||||
return self.lookup(self.mods[base[0]], e.attr)
|
||||
return None
|
||||
|
||||
def at(self, m: Module, e: Node) -> str:
|
||||
return f"{m.path}:{getattr(e, 'lineno', '?')}: {ast.unparse(e)}"
|
||||
|
||||
def enclosing(self, m: Module, e: Node, kind: type | tuple[type, ...]) -> Node | None:
|
||||
node = m.parents.get(e)
|
||||
while node is not None and not isinstance(node, kind):
|
||||
node = m.parents.get(node)
|
||||
return node
|
||||
|
||||
# --- types ---------------------------------------------------------------------------------------
|
||||
|
||||
def ann_values(self, m: Module, ann: Node | None) -> set[str] | None:
|
||||
"""The values a `Literal` annotation allows, or None when it is not one (`str`, a class, …)."""
|
||||
if ann is None:
|
||||
return None
|
||||
if isinstance(ann, ast.Constant):
|
||||
if ann.value is None:
|
||||
return set()
|
||||
if isinstance(ann.value, str):
|
||||
return self.ann_values(m, ast.parse(ann.value, mode="eval").body)
|
||||
return None
|
||||
if isinstance(ann, ast.Subscript) and getattr(ann.value, "id", None) == "Literal":
|
||||
items = ann.slice.elts if isinstance(ann.slice, ast.Tuple) else [ann.slice]
|
||||
if all(isinstance(i, ast.Constant) and isinstance(i.value, str) for i in items):
|
||||
return {i.value for i in items}
|
||||
return None
|
||||
if isinstance(ann, ast.BinOp) and isinstance(ann.op, ast.BitOr):
|
||||
left, right = self.ann_values(m, ann.left), self.ann_values(m, ann.right)
|
||||
return None if left is None or right is None else left | right
|
||||
found = self.lookup_expr(m, ann)
|
||||
if found and isinstance(found[1], ast.Assign) and isinstance(found[0], Module):
|
||||
if isinstance(found[1].value, (ast.Subscript, ast.BinOp, ast.Name, ast.Attribute)):
|
||||
return self.ann_values(found[0], found[1].value)
|
||||
return None
|
||||
|
||||
def classes(self, m: Module, e: Node | None) -> list[tuple[Module, ast.ClassDef]] | None:
|
||||
if e is None:
|
||||
return None
|
||||
if isinstance(e, ast.Tuple):
|
||||
out = [self.classes(m, x) for x in e.elts]
|
||||
return None if any(o is None for o in out) else [c for o in out for c in o]
|
||||
found = self.lookup_expr(m, e)
|
||||
if found and isinstance(found[0], Module) and isinstance(found[1], ast.ClassDef):
|
||||
return [(found[0], found[1])]
|
||||
return None
|
||||
|
||||
def attr_values(self, cm: Module, cls: ast.ClassDef, attr: str) -> set[str] | None:
|
||||
"""An attribute's `Literal` values, from its annotation on the class or `self.attr: T` in a method,
|
||||
else from a base class."""
|
||||
for node in ast.walk(cls):
|
||||
if isinstance(node, ast.AnnAssign):
|
||||
t = node.target
|
||||
if (isinstance(t, ast.Name) and t.id == attr and node in cls.body) or (
|
||||
isinstance(t, ast.Attribute)
|
||||
and t.attr == attr
|
||||
and getattr(t.value, "id", None) == "self"
|
||||
):
|
||||
return self.ann_values(cm, node.annotation)
|
||||
for base in cls.bases:
|
||||
for bm, bc in self.classes(cm, base) or []:
|
||||
v = self.attr_values(bm, bc, attr)
|
||||
if v is not None:
|
||||
return v
|
||||
return None
|
||||
|
||||
# --- reasons -------------------------------------------------------------------------------------
|
||||
|
||||
def resolve(self, m: Module, e: Node) -> Res:
|
||||
if isinstance(e, ast.Constant):
|
||||
if e.value is None:
|
||||
return Res()
|
||||
if isinstance(e.value, str):
|
||||
return Res({e.value})
|
||||
if isinstance(e, ast.IfExp):
|
||||
return self.resolve(m, e.body) | self.resolve(m, e.orelse)
|
||||
if isinstance(e, ast.BoolOp) and isinstance(e.op, ast.Or):
|
||||
out = Res()
|
||||
for v in e.values:
|
||||
out = out | self.resolve(m, v)
|
||||
return out
|
||||
if isinstance(e, ast.Name):
|
||||
return self.resolve_name(m, e)
|
||||
if isinstance(e, ast.Attribute):
|
||||
return self.resolve_attr(m, e)
|
||||
if isinstance(e, ast.Call):
|
||||
found = self.lookup_expr(m, e.func)
|
||||
if (
|
||||
found
|
||||
and isinstance(found[0], Module)
|
||||
and isinstance(found[1], (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
):
|
||||
v = self.ann_values(found[0], found[1].returns)
|
||||
if v is not None:
|
||||
return Res(v)
|
||||
if isinstance(e, ast.Subscript):
|
||||
return self.resolve_lookup(m, e)
|
||||
return Res(unread=[self.at(m, e)])
|
||||
|
||||
def resolve_name(self, m: Module, e: ast.Name) -> Res:
|
||||
fn = self.enclosing(m, e, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
while fn is not None:
|
||||
assert isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
if self.caught_as(m, e, e.id) is not None:
|
||||
return Res(unread=[self.at(m, e)]) # the exception itself, not a reason
|
||||
params = {a.arg: a for a in [*fn.args.posonlyargs, *fn.args.args, *fn.args.kwonlyargs]}
|
||||
if e.id in params:
|
||||
return self.resolve_param(m, fn, params[e.id], e)
|
||||
if (
|
||||
fn.args.vararg
|
||||
and fn.args.vararg.arg == e.id
|
||||
or fn.args.kwarg
|
||||
and fn.args.kwarg.arg == e.id
|
||||
):
|
||||
return Res(unread=[self.at(m, e)])
|
||||
assigned = self.assignments(fn, e.id)
|
||||
if assigned:
|
||||
out = Res()
|
||||
for a in assigned:
|
||||
if (
|
||||
isinstance(a, ast.AnnAssign)
|
||||
and (v := self.ann_values(m, a.annotation)) is not None
|
||||
):
|
||||
out = out | Res(v)
|
||||
elif (
|
||||
isinstance(a, (ast.Assign, ast.AnnAssign))
|
||||
and a.value is not None
|
||||
and not any(
|
||||
isinstance(t, (ast.Tuple, ast.List)) for t in getattr(a, "targets", [])
|
||||
)
|
||||
):
|
||||
out = out | self.resolve(m, a.value)
|
||||
else:
|
||||
out = out | Res(unread=[self.at(m, e)])
|
||||
return out
|
||||
fn = self.enclosing(m, fn, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
found = self.lookup(m, e.id)
|
||||
if (
|
||||
found
|
||||
and isinstance(found[0], Module)
|
||||
and isinstance(found[1], (ast.Assign, ast.AnnAssign))
|
||||
):
|
||||
stmt = found[1]
|
||||
if stmt.value is not None and isinstance(stmt.value, ast.Constant):
|
||||
return self.resolve(found[0], stmt.value)
|
||||
return Res(unread=[self.at(m, e)])
|
||||
|
||||
def assignments(self, fn: Node, name: str) -> list[Node]:
|
||||
"""The statements that bind `name` in this function's own body (not in a nested one)."""
|
||||
out: list[Node] = []
|
||||
stack = list(ast.iter_child_nodes(fn))
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
|
||||
continue
|
||||
if isinstance(node, ast.Assign):
|
||||
if any(
|
||||
name in {n.id for n in ast.walk(t) if isinstance(n, ast.Name)}
|
||||
for t in node.targets
|
||||
):
|
||||
out.append(node)
|
||||
elif isinstance(node, ast.AnnAssign) and getattr(node.target, "id", None) == name:
|
||||
out.append(node)
|
||||
elif isinstance(
|
||||
node, (ast.For, ast.With, ast.AugAssign, ast.NamedExpr, ast.comprehension)
|
||||
):
|
||||
targets = [getattr(node, "target", None)] + [
|
||||
i.optional_vars
|
||||
for i in getattr(node, "items", [])
|
||||
if i.optional_vars is not None
|
||||
]
|
||||
if any(
|
||||
t is not None and name in {n.id for n in ast.walk(t) if isinstance(n, ast.Name)}
|
||||
for t in targets
|
||||
):
|
||||
out.append(
|
||||
node
|
||||
) # bound by a loop, a `with`, an augmented or a walrus: not followed
|
||||
stack.extend(ast.iter_child_nodes(node))
|
||||
return out
|
||||
|
||||
def caught_as(self, m: Module, e: Node, name: str) -> ast.ExceptHandler | None:
|
||||
node = m.parents.get(e)
|
||||
while node is not None and not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
if isinstance(node, ast.ExceptHandler) and node.name == name:
|
||||
return node
|
||||
node = m.parents.get(node)
|
||||
return None
|
||||
|
||||
def resolve_param(self, m: Module, fn: Node, param: ast.arg, e: Node) -> Res:
|
||||
v = self.ann_values(m, param.annotation)
|
||||
if v is not None:
|
||||
return Res(v)
|
||||
if param.annotation is not None and getattr(param.annotation, "id", None) != "str":
|
||||
return Res(unread=[self.at(m, e)])
|
||||
return self.follow(m, fn, param)
|
||||
|
||||
def follow(self, m: Module, fn: Node, param: ast.arg) -> Res:
|
||||
"""A `str` parameter of a helper: what every call of the helper passes it."""
|
||||
assert isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
key = (m.name, fn.name, param.arg)
|
||||
if key in self._following:
|
||||
return Res()
|
||||
if m.top.get(fn.name) is not fn:
|
||||
return Res(
|
||||
unread=[
|
||||
f"{m.path}:{fn.lineno}: {fn.name}({param.arg}) is not a module-level helper"
|
||||
]
|
||||
)
|
||||
self._following.add(key)
|
||||
positional = [a.arg for a in [*fn.args.posonlyargs, *fn.args.args]]
|
||||
defaults = dict(
|
||||
zip(
|
||||
positional[len(positional) - len(fn.args.defaults) :], fn.args.defaults, strict=True
|
||||
)
|
||||
)
|
||||
defaults |= {
|
||||
a.arg: d
|
||||
for a, d in zip(fn.args.kwonlyargs, fn.args.kw_defaults, strict=True)
|
||||
if d is not None
|
||||
}
|
||||
out = Res()
|
||||
for cm in self.mods.values():
|
||||
for node in ast.walk(cm.tree):
|
||||
if not isinstance(node, (ast.Name, ast.Attribute)) or isinstance(
|
||||
cm.parents.get(node), ast.Attribute
|
||||
):
|
||||
continue
|
||||
found = self.lookup_expr(cm, node)
|
||||
if not (found and found[0] is m and found[1] is fn):
|
||||
continue
|
||||
call = cm.parents.get(node)
|
||||
if not (isinstance(call, ast.Call) and call.func is node):
|
||||
out = out | Res(unread=[f"{self.at(cm, node)} (the helper used as a value)"])
|
||||
continue
|
||||
kw = {k.arg: k.value for k in call.keywords}
|
||||
given = param.arg in positional and positional.index(param.arg) < len(call.args)
|
||||
if param.arg in kw:
|
||||
out = out | self.resolve(cm, kw[param.arg])
|
||||
elif any(isinstance(a, ast.Starred) for a in call.args):
|
||||
out = out | Res(unread=[self.at(cm, call)])
|
||||
elif given: # given by position, a `**` spread cannot pass it again (a TypeError)
|
||||
out = out | self.resolve(cm, call.args[positional.index(param.arg)])
|
||||
elif None in kw:
|
||||
out = out | Res(unread=[self.at(cm, call)])
|
||||
elif param.arg in defaults:
|
||||
out = out | self.resolve(m, defaults[param.arg])
|
||||
return out
|
||||
|
||||
def resolve_attr(self, m: Module, e: ast.Attribute) -> Res:
|
||||
base = e.value
|
||||
if isinstance(base, ast.Name):
|
||||
handler = self.caught_as(m, e, base.id)
|
||||
fn = self.enclosing(m, e, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
typed = None
|
||||
if handler is not None:
|
||||
typed = self.classes(m, handler.type)
|
||||
elif fn is not None:
|
||||
params = {
|
||||
a.arg: a for a in [*fn.args.posonlyargs, *fn.args.args, *fn.args.kwonlyargs]
|
||||
}
|
||||
if base.id in params:
|
||||
typed = self.classes(m, params[base.id].annotation)
|
||||
if typed is not None:
|
||||
out = Res()
|
||||
for cm, cls in typed:
|
||||
v = self.attr_values(cm, cls, e.attr)
|
||||
out = out | (
|
||||
Res(v)
|
||||
if v is not None
|
||||
else Res(
|
||||
unread=[f"{self.at(m, e)} ({cls.name}.{e.attr} has no Literal type)"]
|
||||
)
|
||||
)
|
||||
return out
|
||||
found = self.lookup_expr(m, e)
|
||||
if (
|
||||
found
|
||||
and isinstance(found[0], Module)
|
||||
and isinstance(found[1], (ast.Assign, ast.AnnAssign))
|
||||
):
|
||||
stmt = found[1]
|
||||
if isinstance(stmt.value, ast.Constant):
|
||||
return self.resolve(found[0], stmt.value)
|
||||
return Res(unread=[self.at(m, e)])
|
||||
|
||||
def resolve_lookup(self, m: Module, e: ast.Subscript) -> Res:
|
||||
found = self.lookup_expr(m, e.value)
|
||||
if not (
|
||||
found
|
||||
and isinstance(found[0], Module)
|
||||
and isinstance(found[1], (ast.Assign, ast.AnnAssign))
|
||||
):
|
||||
return Res(unread=[self.at(m, e)])
|
||||
table = found[1].value
|
||||
if not isinstance(table, ast.Dict) or not all(
|
||||
isinstance(k, ast.Constant) and isinstance(k.value, str) for k in table.keys
|
||||
):
|
||||
return Res(unread=[self.at(m, e)])
|
||||
out = Res()
|
||||
for v in table.values:
|
||||
out = out | self.resolve(found[0], v)
|
||||
index = self.resolve(m, e.slice)
|
||||
keys = {k.value for k in table.keys}
|
||||
if index.unread or index.values != keys:
|
||||
self.errors.append(
|
||||
f"{self.at(m, e)}: the map's keys {sorted(keys)} are not what its index can be "
|
||||
f"{sorted(index.values)}{' + unread ' + str(index.unread) if index.unread else ''}"
|
||||
)
|
||||
return out
|
||||
|
||||
# --- sites ---------------------------------------------------------------------------------------
|
||||
|
||||
def spread_is_safe(self, m: Module, e: Node, src: Node) -> bool:
|
||||
"""A `**x` in a detail, or `detail |= {…}` from it, cannot carry a reason when `x` is the `**kwargs`
|
||||
of a function that takes `reason` by name: Python binds that one to the parameter."""
|
||||
if isinstance(src, ast.Attribute):
|
||||
return self.spread_is_safe_attr(m, e, src)
|
||||
fn = self.enclosing(m, e, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
if fn is None or fn.args.kwarg is None:
|
||||
return False
|
||||
names = {a.arg for a in [*fn.args.posonlyargs, *fn.args.args, *fn.args.kwonlyargs]}
|
||||
kwarg = fn.args.kwarg.arg
|
||||
if isinstance(src, ast.Name):
|
||||
return src.id == kwarg and "reason" in names
|
||||
|
||||
if isinstance(src, ast.DictComp):
|
||||
it = src.generators[0].iter
|
||||
return (
|
||||
isinstance(it, ast.Call)
|
||||
and isinstance(it.func, ast.Attribute)
|
||||
and it.func.attr == "items"
|
||||
and getattr(it.func.value, "id", None) == kwarg
|
||||
and "reason" in names
|
||||
)
|
||||
return False
|
||||
|
||||
def spread_is_safe_attr(self, m: Module, e: Node, src: ast.Attribute) -> bool:
|
||||
"""`e.params` of a caught exception whose `__init__(self, reason, **params)` keeps `self.params = params`."""
|
||||
if not isinstance(src.value, ast.Name):
|
||||
return False
|
||||
handler = self.caught_as(m, e, src.value.id)
|
||||
typed = self.classes(m, handler.type) if handler else None
|
||||
return bool(typed) and all(self.keeps_kwargs(cls, src.attr) for _, cls in typed or [])
|
||||
|
||||
def keeps_kwargs(self, cls: ast.ClassDef, attr: str) -> bool:
|
||||
init = next(
|
||||
(s for s in cls.body if isinstance(s, ast.FunctionDef) and s.name == "__init__"), None
|
||||
)
|
||||
if init is None or init.args.kwarg is None:
|
||||
return False
|
||||
names = {a.arg for a in [*init.args.posonlyargs, *init.args.args, *init.args.kwonlyargs]}
|
||||
kwarg = init.args.kwarg.arg
|
||||
return "reason" in names and any(
|
||||
isinstance(n, ast.Assign)
|
||||
and any(
|
||||
isinstance(t, ast.Attribute)
|
||||
and t.attr == attr
|
||||
and getattr(t.value, "id", None) == "self"
|
||||
for t in n.targets
|
||||
)
|
||||
and getattr(n.value, "id", None) == kwarg
|
||||
for n in ast.walk(init)
|
||||
)
|
||||
|
||||
def entries(self, d: Node) -> list[tuple[str | None, Node]]:
|
||||
if isinstance(d, ast.Dict):
|
||||
return [
|
||||
(k.value if isinstance(k, ast.Constant) else None if k is None else "?", v)
|
||||
for k, v in zip(d.keys, d.values, strict=True)
|
||||
]
|
||||
assert isinstance(d, ast.Call)
|
||||
return [(k.arg, k.value) for k in d.keywords]
|
||||
|
||||
def site(self, m: Module, d: Node, out: dict) -> None:
|
||||
items = self.entries(d)
|
||||
code_expr = next(v for k, v in items if k == "code")
|
||||
code = self.resolve(m, code_expr)
|
||||
if code.unread or len(code.values) != 1:
|
||||
out["unreadCodes"].append(self.at(m, code_expr))
|
||||
return
|
||||
entry = out["codes"].setdefault(
|
||||
next(iter(code.values)), {"reasons": set(), "unread": [], "sites": 0, "reasonless": []}
|
||||
)
|
||||
entry["sites"] += 1
|
||||
res, has_reason = Res(), False
|
||||
for k, v in items:
|
||||
if k == "reason":
|
||||
res, has_reason = res | self.resolve(m, v), True
|
||||
elif k is None and not self.spread_is_safe(m, d, v):
|
||||
res = res | Res(unread=[f"{self.at(m, v)} (a spread that may carry a reason)"])
|
||||
res, more = self.mutations(m, d, res)
|
||||
has_reason = has_reason or more
|
||||
if not has_reason:
|
||||
entry["reasonless"].append(f"{m.path}:{d.lineno}")
|
||||
entry["reasons"] |= res.values
|
||||
entry["unread"] += res.unread
|
||||
|
||||
def mutations(self, m: Module, d: Node, res: Res) -> tuple[Res, bool]:
|
||||
"""`detail |= {…}`, `detail["reason"] = …`, `detail.update(…)` on the variable a detail is put in."""
|
||||
stmt = m.parents.get(d)
|
||||
if not (isinstance(stmt, (ast.Assign, ast.AnnAssign)) and stmt.value is d):
|
||||
return res, False
|
||||
target = stmt.targets[0] if isinstance(stmt, ast.Assign) else stmt.target
|
||||
fn = self.enclosing(m, d, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
if not isinstance(target, ast.Name) or fn is None:
|
||||
return res, False
|
||||
found = False
|
||||
for node in ast.walk(fn):
|
||||
if isinstance(node, ast.AugAssign) and getattr(node.target, "id", None) == target.id:
|
||||
v = node.value
|
||||
if isinstance(v, ast.Dict):
|
||||
for k, val in self.entries(v):
|
||||
if k == "reason":
|
||||
res, found = res | self.resolve(m, val), True
|
||||
elif k is None and not self.spread_is_safe(m, node, val):
|
||||
res = res | Res(
|
||||
unread=[f"{self.at(m, val)} (a spread that may carry a reason)"]
|
||||
)
|
||||
elif not self.spread_is_safe(m, node, v):
|
||||
res = res | Res(unread=[f"{self.at(m, node)} (may carry a reason)"])
|
||||
elif (
|
||||
isinstance(node, ast.Subscript)
|
||||
and getattr(node.value, "id", None) == target.id
|
||||
and isinstance(node.ctx, ast.Store)
|
||||
):
|
||||
if isinstance(node.slice, ast.Constant) and node.slice.value != "reason":
|
||||
continue
|
||||
assign = m.parents.get(node)
|
||||
if isinstance(node.slice, ast.Constant) and isinstance(assign, ast.Assign):
|
||||
res, found = res | self.resolve(m, assign.value), True
|
||||
else:
|
||||
res = res | Res(unread=[f"{self.at(m, node)} (may carry a reason)"])
|
||||
elif (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Attribute)
|
||||
and getattr(node.func.value, "id", None) == target.id
|
||||
and node.func.attr in {"update", "setdefault"}
|
||||
):
|
||||
res = res | Res(unread=[f"{self.at(m, node)} (may carry a reason)"])
|
||||
return res, found
|
||||
|
||||
def run(self) -> dict:
|
||||
out: dict = {
|
||||
"codes": {},
|
||||
"unreadCodes": [],
|
||||
"errors": self.errors,
|
||||
"excluded": {},
|
||||
"aliases": {},
|
||||
"notRefusals": sorted(NOT_REFUSALS),
|
||||
}
|
||||
for m in self.mods.values():
|
||||
for name, stmt in m.top.items():
|
||||
if (
|
||||
isinstance(stmt, ast.Assign)
|
||||
and isinstance(stmt.value, ast.Subscript)
|
||||
and (v := self.ann_values(m, stmt.value))
|
||||
):
|
||||
out["aliases"].setdefault(m.path, {})[name] = sorted(v)
|
||||
for node in ast.walk(m.tree):
|
||||
is_dict = isinstance(node, ast.Dict) and any(
|
||||
isinstance(k, ast.Constant) and k.value == "code" for k in node.keys
|
||||
)
|
||||
is_call = (
|
||||
isinstance(node, ast.Call)
|
||||
and getattr(node.func, "id", None) == "dict"
|
||||
and any(k.arg == "code" for k in node.keywords)
|
||||
)
|
||||
if not (is_dict or is_call):
|
||||
continue
|
||||
if m.path in NOT_REFUSALS:
|
||||
out["excluded"][m.path] = out["excluded"].get(m.path, 0) + 1
|
||||
continue
|
||||
self.site(m, node, out)
|
||||
for entry in out["codes"].values():
|
||||
entry["reasons"] = sorted(entry["reasons"])
|
||||
return out
|
||||
|
||||
|
||||
def scan(sources: dict[str, str]) -> dict:
|
||||
return Scan(sources).run()
|
||||
|
||||
|
||||
def scan_app(app: pathlib.Path = APP) -> dict:
|
||||
return scan(
|
||||
{
|
||||
p.relative_to(app).as_posix(): p.read_text(encoding="utf-8")
|
||||
for p in sorted(app.rglob("*.py"))
|
||||
if "__pycache__" not in p.parts
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
json.dump(
|
||||
scan_app(pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else APP),
|
||||
sys.stdout,
|
||||
indent=1,
|
||||
sort_keys=True,
|
||||
)
|
||||
Reference in New Issue
Block a user