"""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 scan that tries to follow every shape opens a silent gap with each one it learns, and following how a value flows through Python (variables, scopes, aliases, a dict changed in place) never closes. So the CODE keeps to a narrow form, and this reads a CLOSED list of it, reporting everything else, never guessing: - where a detail stands: where it is sent — `HTTPException`'s second argument or `detail=`, the `"detail"` value of a `JSONResponse`'s content dict — through the arms of `a if c else b`, or as what a module-level factory returns when every return of it is a detail and every reference to it is a call sent as one. A detail held in a variable is not followed; - the code: one literal. A `**` spread, or a key that is not a literal, stands BEFORE a literal `code` and `reason`, both of which the detail then carries: the later key wins, so no spread can change them; - a reason: a string literal (`None` sends none: the site counts as reasonless); both arms of `a if c else b`; a function's local bound only by plain `x = …`; a module constant reached as `mod.NAME`; a call's `Literal` return annotation; - a parameter typed with a `Literal` (basedpyright holds every value passed in), or a `str` one of a module-level helper (`refused`, `not_found`), followed to every call, each of which must pass it by position or by name; a parameter its body binds again is unread; - an attribute: `e.x` of the exception an `except C as e` catches, or of a parameter typed `C`, when a method of `C` declares `self.x: `; - a map lookup (`_SETTING_REASONS[exc.reason]`): the map's values, and its keys must be exactly what the index can be. A name bound twice at module level, inside a module-level `if`/`try`/loop, named by a `global`, or imported from two places is not looked up; nor is a local a lambda or a comprehension binds, or one a `nonlocal` or `global` names. A module constant or map that anything stores into, deletes from or (a map) uses but by `map[k]` or a read method is unread. A reference named like a followed helper that the scan cannot resolve (`import app.x`, a relative import, an app module that does not bind it) is unread. Anything else is UNREAD, or an unread code, 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 collections.abc import Iterator 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", } # What a map may be used by besides `map[k]`: a method that only reads it. READS = {"get", "items", "keys", "values"} Node = ast.AST FUNCS = (ast.FunctionDef, ast.AsyncFunctionDef) SCOPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef) COMPREHENSIONS = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp) COMPOUND = ( ast.If, ast.Try, ast.TryStar, ast.For, ast.AsyncFor, ast.While, ast.With, ast.AsyncWith, ast.Match, ) @dataclass class Res: values: set[str] = field(default_factory=set) unread: list[str] = field(default_factory=list) none: bool = False # an arm sends no reason def __or__(self, other: "Res") -> "Res": return Res(self.values | other.values, self.unread + other.unread, self.none or other.none) @dataclass class Module: path: str # relative to app/, posix name: str # dotted, `app.…` tree: ast.Module parents: dict[Node, Node] # local name → (module, symbol or None for the module) imports: dict[str, tuple[str, str | None]] top: dict[str, Node] # module-level bindings: def, class, or the assignment ambiguous: set[ str ] # bound more than once, conditionally, by a `global`, or imported from two places slots: set[int] # the ids of the nodes sent as a detail def _own_scope(node: Node) -> Iterator[Node]: """`node` and what it holds in its own scope: a nested def, class or lambda is itself, not its body.""" stack = [node] while stack: n = stack.pop() yield n if n is not node and isinstance(n, SCOPES): continue stack.extend(ast.iter_child_nodes(n)) def _stored(target: Node) -> set[str]: """The names an assignment target binds, not the ones it reads (`LABELS[k] = v` binds none).""" return { n.id for n in ast.walk(target) if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store) } def _binds(node: Node) -> set[str]: """The names one node binds in the scope it sits in.""" if isinstance(node, ast.Assign): return set().union(*(_stored(t) for t in node.targets)) if isinstance(node, (ast.AnnAssign, ast.AugAssign, ast.For, ast.AsyncFor, ast.NamedExpr)): return _stored(node.target) if isinstance(node, (ast.With, ast.AsyncWith)): return set().union(*(_stored(i.optional_vars) for i in node.items if i.optional_vars)) if isinstance(node, (ast.Import, ast.ImportFrom)): return {(a.asname or a.name).split(".")[0] for a in node.names} if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): return {node.name} if isinstance(node, (ast.MatchAs, ast.MatchStar, ast.ExceptHandler)) and node.name: return {node.name} return set() def _sender(call: ast.Call) -> str | None: return getattr(call.func, "id", None) or getattr(call.func, "attr", None) def detail_slots(tree: Node) -> Iterator[tuple[ast.Call, Node | None]]: """Every place a detail is sent from, with what it holds: `HTTPException`'s second argument or `detail=` (None: sent without one), and the `"detail"` value of a `JSONResponse`'s content dict (a content without one is an answer, not a refusal).""" for node in ast.walk(tree): if not isinstance(node, ast.Call): continue kw = {k.arg: k.value for k in node.keywords} if _sender(node) == "HTTPException": yield node, kw.get("detail", node.args[1] if len(node.args) > 1 else None) elif _sender(node) == "JSONResponse": content = kw.get("content", node.args[0] if node.args else None) if isinstance(content, ast.Dict): for k, v in zip(content.keys, content.values, strict=True): if isinstance(k, ast.Constant) and k.value == "detail": yield node, v def _code_dict(node: Node) -> bool: """A `{"code": …}` dict, or a `dict(code=…)` call.""" if isinstance(node, ast.Dict): return any(isinstance(k, ast.Constant) and k.value == "code" for k in node.keys) return ( isinstance(node, ast.Call) and getattr(node.func, "id", None) == "dict" and any(k.arg == "code" for k in node.keywords) ) 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)} origins: dict[str, set[tuple[str, str | None]]] = {} ambiguous: set[str] = set() for node in ast.walk(tree): if isinstance(node, ast.ImportFrom) and node.module and node.level == 0: for a in node.names: origins.setdefault(a.asname or a.name, set()).add((node.module, a.name)) elif isinstance(node, ast.Import): for a in node.names: if a.asname: origins.setdefault(a.asname, set()).add((a.name, None)) elif isinstance(node, ast.Global): ambiguous |= set(node.names) top: dict[str, Node] = {} count: dict[str, int] = {} ambiguous |= {name for name, o in origins.items() if len(o) > 1} 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 for n in [stmt] if isinstance(stmt, SCOPES) else _own_scope(stmt): for name in _binds(n): count[name] = count.get(name, 0) + 1 if isinstance(stmt, COMPOUND): ambiguous.add(name) ambiguous |= {name for name, c in count.items() if c > 1} imports = {name: next(iter(o)) for name, o in origins.items() if name not in ambiguous} slots = {id(d) for _, d in detail_slots(tree) if d is not None} name = _module_name(path) mods[name] = Module(path, name, tree, parents, imports, top, ambiguous, slots) 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() self._followed: dict[tuple[str, str, str], Res] = {} self._resolving: set[int] = set() self._factory: dict[int, bool] = {} self._changed: dict[int, bool] = {} # --- names --------------------------------------------------------------------------------------- def lookup( self, m: Module, name: str, seen: frozenset[tuple[str, str]] = frozenset() ) -> 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.ambiguous: return 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 and (mod, sym) not in seen: return self.lookup(self.mods[mod], sym, seen | {(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 None if self.binder(m, e) is not None else 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 binder(self, m: Module, e: ast.Name) -> Node | None: """The innermost scope `e` is bound in: a function (a parameter, or a binding in its own body), a lambda (a parameter), a comprehension (its target); None: a module-level name.""" node = m.parents.get(e) while node is not None: if isinstance(node, (*FUNCS, ast.Lambda)): a = node.args params = [*a.posonlyargs, *a.args, *a.kwonlyargs, a.vararg, a.kwarg] if e.id in {p.arg for p in params if p}: return node if isinstance(node, FUNCS) and self.assignments(node, e.id): return node elif isinstance(node, COMPREHENSIONS) and any( e.id in _stored(g.target) for g in node.generators ): return node node = m.parents.get(node) 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 def assignments(self, fn: Node, name: str) -> list[Node]: """What binds `name` in this function's own body (not in a nested one).""" return [n for n in _own_scope(fn) if n is not fn and name in _binds(n)] 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, FUNCS): if isinstance(node, ast.ExceptHandler) and node.name == name: return node node = m.parents.get(node) return None def references( self, m: Module, target: Node ) -> Iterator[tuple[Module, Node, ast.Call | None, bool]]: """Every reference to a module-level def or class: the call it makes (None: not a call), and whether it resolved. A reference named like it that the scan cannot resolve is yielded too.""" name = getattr(target, "name", None) for cm in self.mods.values(): for node in ast.walk(cm.tree): if not isinstance(node, (ast.Name, ast.Attribute)) or not isinstance( node.ctx, ast.Load ): continue if (node.id if isinstance(node, ast.Name) else node.attr) != name: continue parent = cm.parents.get(node) if isinstance(parent, ast.Attribute) and parent.value is node: continue found = self.lookup_expr(cm, node) if found: if not (found[0] is m and found[1] is target): continue elif not self.unresolved(cm, node): continue call = parent if isinstance(parent, ast.Call) and parent.func is node else None yield cm, node, call, found is not None def unresolved(self, m: Module, node: Node) -> bool: """A name, or a dotted path, the app could mean that the scan cannot resolve: not a local, nor an outside module's (an attribute of an object the function holds is not one).""" root = node while isinstance(root, ast.Attribute): root = root.value if not isinstance(root, ast.Name) or self.binder(m, root) is not None: return False if root.id in m.ambiguous or (root.id not in m.top and root.id not in m.imports): return True if root.id in m.top: return False if root is node: # imported from an app module that does not bind it where the scan can see mod = m.imports[root.id][0] return (mod == "app" or mod.startswith("app.")) and self.lookup(m, root.id) is None found = self.lookup(m, root.id) return ( found is not None and found[1] is None ) # an app module whose dotted path went nowhere def changed(self, m: Module, stmt: Node, name: str, *, only_read: bool) -> bool: """Something in the app stores into, deletes or rebinds the module-level `name` that `stmt` binds, or (`only_read`, a map) uses it but by `map[k]` or a method that only reads it.""" key = id(stmt) * 2 + only_read if key in self._changed: return self._changed[key] own = stmt.targets if isinstance(stmt, ast.Assign) else [getattr(stmt, "target", None)] out = False for cm in self.mods.values(): for node in ast.walk(cm.tree): if out: break if not ( isinstance(node, ast.Name) and node.id == name or isinstance(node, ast.Attribute) and node.attr == name ) or (cm is m and node in own): continue found = self.lookup_expr(cm, node) if not (found and found[0] is m and found[1] is stmt): continue if not isinstance(node.ctx, ast.Load): out = True elif only_read: p = cm.parents.get(node) out = not ( isinstance(p, ast.Subscript) and p.value is node and isinstance(p.ctx, ast.Load) or isinstance(p, ast.Attribute) and p.attr in READS ) self._changed[key] = out return out # --- types --------------------------------------------------------------------------------------- def ann_values(self, m: Module, ann: Node | None, depth: int = 0) -> set[str] | None: """The values a `Literal[…]` annotation, or an alias of one, allows; None when it is not one.""" 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.Name, ast.Attribute)) and depth < 8: found = self.lookup_expr(m, ann) if found and isinstance(found[0], Module) and isinstance(found[1], ast.Assign): return self.ann_values(found[0], found[1].value, depth + 1) return None def classes(self, m: Module, e: Node | None) -> list[tuple[Module, ast.ClassDef]] | None: found = self.lookup_expr(m, e) if e is not None else None if found and isinstance(found[0], Module) and isinstance(found[1], ast.ClassDef): return [(found[0], found[1])] return None def typed(self, m: Module, at: Node, name: str) -> list[tuple[Module, ast.ClassDef]] | None: """The class `name` holds where `at` sits: the one an `except C as name` catches, or a parameter's annotation (a parameter its body binds again holds no known class).""" handler = self.caught_as(m, at, name) if handler is not None: return self.classes(m, handler.type) fn = self.enclosing(m, at, FUNCS) if fn is None: return None params = {a.arg: a for a in [*fn.args.posonlyargs, *fn.args.args, *fn.args.kwonlyargs]} if name in params and not self.assignments(fn, name): return self.classes(m, params[name].annotation) return None def attr_values(self, cm: Module, cls: ast.ClassDef, attr: str) -> set[str] | None: """An attribute's `Literal` values, from `self.attr: T` in a method of the class.""" for node in ast.walk(cls): if ( isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Attribute) and node.target.attr == attr and getattr(node.target.value, "id", None) == "self" ): return self.ann_values(cm, node.annotation) return None # --- reasons ------------------------------------------------------------------------------------- def resolve(self, m: Module, e: Node) -> Res: if isinstance(e, ast.Constant): if e.value is None: return Res(none=True) 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.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], FUNCS): 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: if self.caught_as(m, e, e.id) is not None: return Res(unread=[self.at(m, e)]) # the exception itself, not a reason fn = self.binder(m, e) if fn is None: return Res(unread=[self.at(m, e)]) if not isinstance(fn, FUNCS): return Res(unread=[f"{self.at(m, e)} (bound by a lambda or a comprehension)"]) if any(isinstance(n, (ast.Nonlocal, ast.Global)) and e.id in n.names for n in ast.walk(fn)): return Res(unread=[f"{self.at(m, e)} (named by a nonlocal or a global)"]) a = fn.args params = {p.arg: p for p in [*a.posonlyargs, *a.args, *a.kwonlyargs]} assigned = self.assignments(fn, e.id) if e.id in params: if assigned: return Res(unread=[f"{self.at(m, e)} (a parameter its body binds again)"]) return self.resolve_param(m, fn, params[e.id], e) out = Res(unread=[] if assigned else [self.at(m, e)]) # `*args`, `**kwargs` for s in assigned: if not (isinstance(s, ast.Assign) and all(isinstance(t, ast.Name) for t in s.targets)): out = out | Res(unread=[self.at(m, e)]) elif id(s) in self._resolving: out = out | Res(unread=[f"{self.at(m, e)} (reached again while it is resolved)"]) else: self._resolving.add(id(s)) out = out | self.resolve(m, s.value) self._resolving.discard(id(s)) return out 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, FUNCS) key = (m.name, fn.name, param.arg) if key in self._followed: return self._followed[key] where = f"{m.path}:{fn.lineno}: {fn.name}({param.arg})" if key in self._following: return Res(unread=[f"{where} reached again while it is followed"]) if m.top.get(fn.name) is not fn or fn.name in m.ambiguous: return Res(unread=[f"{where} is not a module-level helper"]) self._following.add(key) positional = [a.arg for a in [*fn.args.posonlyargs, *fn.args.args]] by_name = param not in fn.args.posonlyargs out = Res() for cm, node, call, resolved in self.references(m, fn): if not resolved: out = out | Res( unread=[f"{self.at(cm, node)} (a reference the scan cannot resolve)"] ) continue if call is None: 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 by_name and 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: out = out | self.resolve(cm, call.args[positional.index(param.arg)]) else: out = out | Res(unread=[f"{self.at(cm, call)} (does not pass {param.arg})"]) self._followed[key] = out # once followed, its key answers from here: never reached again return out def resolve_attr(self, m: Module, e: ast.Attribute) -> Res: base = e.value if isinstance(base, ast.Name): typed = self.typed(m, e, base.id) 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)) and isinstance(found[1].value, ast.Constant) ): if self.changed(found[0], found[1], e.attr, only_read=False): return Res(unread=[f"{self.at(m, e)} (bound again or deleted elsewhere)"]) return self.resolve(found[0], found[1].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)]) name = e.value.attr if isinstance(e.value, ast.Attribute) else getattr(e.value, "id", "") if self.changed(found[0], found[1], name, only_read=True): return Res(unread=[f"{self.at(m, e)} (the map is changed after it is built)"]) 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 isinstance(k, ast.Constant)} if index.unread or index.none 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 is_detail(self, node: Node | None) -> bool: if isinstance(node, ast.IfExp): return self.is_detail(node.body) and self.is_detail(node.orelse) return _code_dict(node) def factory(self, m: Module, fn: Node) -> bool: """A module-level function each return of which is a detail, and each reference to which is a call sent as one (`quota_detail`).""" assert isinstance(fn, FUNCS) if id(fn) in self._factory: return self._factory[id(fn)] returns = [n for n in _own_scope(fn) if isinstance(n, ast.Return)] ok = ( m.top.get(fn.name) is fn # a name bound twice: its calls are unresolved below and bool(returns) and all(self.is_detail(r.value) for r in returns) ) for cm, _, call, resolved in self.references(m, fn) if ok else []: ok = resolved and id(call) in cm.slots # a bare reference is no call: never sent if not ok: break self._factory[id(fn)] = ok return ok def placed(self, m: Module, d: Node) -> bool: """`d` is sent as a detail: from a sender's slot, through the arms of `a if c else b`, or as what a factory returns.""" holder, p = d, m.parents.get(d) while isinstance(p, ast.IfExp) and holder is not p.test: holder, p = p, m.parents.get(p) if id(holder) in m.slots: return True fn = self.enclosing(m, p, FUNCS) if isinstance(p, ast.Return) else None return fn is not None and self.factory(m, fn) def sayable(self, m: Module, d: Node | None) -> bool: """What a slot holds is a detail the client can say: a code dict, or a factory's call.""" if isinstance(d, ast.IfExp): return self.sayable(m, d.body) and self.sayable(m, d.orelse) if _code_dict(d): return True found = self.lookup_expr(m, d.func) if isinstance(d, ast.Call) else None return bool( found and isinstance(found[0], Module) and isinstance(found[1], FUNCS) and self.factory(found[0], found[1]) ) def entries(self, d: Node) -> list[tuple[str | None, Node]]: """A dict's (key, value) pairs: None for a `**` spread, "?" for a key that is not a literal.""" if isinstance(d, ast.Dict): return [ ( k.value if isinstance(k, ast.Constant) and isinstance(k.value, str) 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 [(None, a) for a in d.args] + [(k.arg, k.value) for k in d.keywords] def site(self, m: Module, d: Node, out: dict) -> None: items = self.entries(d) code = Res() for v in (v for k, v in items if k == "code"): code = code | self.resolve(m, v) problems = [ f"{self.at(m, v)} (a spread in dict())" for k, v in items if k is None and isinstance(d, ast.Call) ] + [ f"{self.at(m, v)} (a spread or a computed key the code and the reason do not follow)" for i, (k, v) in enumerate(items) if k in (None, "?") and not {"code", "reason"} <= {k2 for k2, _ in items[i + 1 :]} ] if not self.placed(m, d): problems.append(f"{self.at(m, d)} (a detail put where the scan cannot follow it)") if code.unread or code.none or len(code.values) != 1 or problems: out["unreadCodes"] += problems or [self.at(m, d)] 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 if not has_reason or res.none: entry["reasonless"].append(f"{m.path}:{d.lineno}") entry["reasons"] |= res.values entry["unread"] += res.unread def other_details(self) -> list[tuple[str, int, str | None]]: """Each slot that sends what the client cannot say — nothing, or anything but a code dict or a factory's call: (its file, its line, the function it is sent from).""" out = [] for m in self.mods.values(): for call, d in detail_slots(m.tree): if self.sayable(m, d): continue fn = self.enclosing(m, call, FUNCS) out.append((m.path, call.lineno, getattr(fn, "name", None))) return sorted(out) 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): if not _code_dict(node): 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 app_sources(app: pathlib.Path = APP) -> dict[str, str]: return { 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 } def scan_app(app: pathlib.Path = APP) -> dict: return scan(app_sources(app)) 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, )