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.
626 lines
27 KiB
Python
626 lines
27 KiB
Python
"""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,
|
|
)
|