checks / unit (pull_request) Successful in 8m44s
Checks and prints that indexed an empty list, exhausted next(), or formatted None killed the rest of their file (FCST-06). Read them through the new named_absence.py (nth/first/field/given/present) and conjoin a presence term. - every site in the brief, plus the family the new scan and a sweep found - test_named_absence.py: scans every test_*.py for unguarded filtered-list indexing, next() without a default and numeric formats on maybe-None names; proven to bite; prints what it cannot see, counted - test_price.py 16a: the `_T17_CAPS is _T17_CAPS` tautology now grades the two assemble() calls' arguments - test_control.py: coverage table re-measured on runs graded to the end Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
427 lines
19 KiB
Python
427 lines
19 KiB
Python
"""Runnable check for TEST-26 - a missing value must be a NAMED red. `python3 test_named_absence.py`
|
|
|
|
Every test file here is one script. A check or print that RAISES on a missing
|
|
value (an empty list indexed, `next()` exhausted, `:.0f` on None) kills every
|
|
check after it: the mutation it should name shows up as one unnamed traceback,
|
|
and the checks below it are never graded (MUTATION.md, FCST-06).
|
|
|
|
1. `named_absence.py`, the shared non-raising accessors.
|
|
2. A scan of every `test_*.py` for the shapes that raise, which must find none.
|
|
3. What the scan cannot see, counted on every run.
|
|
"""
|
|
import ast
|
|
import sys
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
|
|
from named_absence import field, first, given, nth, present
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
|
|
fails = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
# ==============================================================================
|
|
print("1. the accessors never raise, and an absence is never a pass")
|
|
# ==============================================================================
|
|
_a = nth([], 0)
|
|
_b = first(x for x in (1, 2) if x > 5)
|
|
_c = field({"a": 1}, "pv")
|
|
_d = given(None)
|
|
print(f" {_a!r} {_b!r} {_c!r} {_d!r}")
|
|
check("each accessor hands back the value when it is there",
|
|
nth([7, 8], -1) == 8 and first(iter([3])) == 3
|
|
and field({"pv": 0.0}, "pv") == 0.0 and given(0.0) == 0.0
|
|
and present(0.0, None, "", []))
|
|
check("...and a stand-in that says what was missing when it is not",
|
|
repr(_a) == "<ABSENT [0] of 0 item(s)>" and repr(_c) == "<ABSENT key 'pv'>"
|
|
and not present(_a) and not present(1, _b) and not present(_c, _d))
|
|
check("formatting, indexing, attributes and arithmetic do not raise",
|
|
f"{_a:.2f}|{_a[2]['x']:+.0f}|{_d.attr.get('k'):,}|{_d / 2 - 1:.1f}"
|
|
== "|".join(["<ABSENT [0] of 0 item(s)>"] * 2 + ["<ABSENT None>"] * 2))
|
|
check("an absence is never equal, unequal, ordered or truthy - so `==`, `!=` "
|
|
"and `<` grade it FAIL",
|
|
not (_a == 0) and not (_a != 0) and not (_a < 1) and not (_a >= 1)
|
|
and not bool(_a) and not (_a == _a))
|
|
|
|
|
|
# ==============================================================================
|
|
print()
|
|
print("2. no test file reads a possibly-missing value in a way that raises")
|
|
# ==============================================================================
|
|
# The scan flags, anywhere in a file:
|
|
# index `name[k]` (constant k) where `name` holds a FILTERED list - a
|
|
# comprehension with an `if`, `filter()`, or an accumulator started
|
|
# as `[]` - directly or as returned by a function in the same file;
|
|
# next `next(it)` without a default;
|
|
# format a numeric format spec on a name that may be None - assigned None,
|
|
# an `... else None`, a one-argument `.get()`, or tested `is None`.
|
|
# A site is guarded when an earlier operand of the same `and`/`or`, or the
|
|
# test of an enclosing `if`/`while`/conditional expression, names the value,
|
|
# or it sits in a `try`. A check's NAME is evaluated before its condition, so
|
|
# a guard in the condition does not guard the name.
|
|
_NUMFMT = set("fdeEgGn%xXbo")
|
|
_FUNCS = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)
|
|
_COMPS = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
|
|
|
|
|
|
def _filtered(rhs, returns):
|
|
if isinstance(rhs, (ast.ListComp, ast.GeneratorExp)):
|
|
return any(g.ifs for g in rhs.generators)
|
|
if isinstance(rhs, ast.List):
|
|
return not rhs.elts
|
|
if isinstance(rhs, ast.Call) and isinstance(rhs.func, ast.Name):
|
|
f = rhs.func.id
|
|
if f == "filter":
|
|
return True
|
|
if f in ("list", "sorted", "tuple") and rhs.args:
|
|
return _filtered(rhs.args[0], returns)
|
|
return "listy" in returns.get((f, None), ())
|
|
return False
|
|
|
|
|
|
def _maybe_none(rhs, returns):
|
|
if isinstance(rhs, ast.Constant):
|
|
return rhs.value is None
|
|
if isinstance(rhs, ast.IfExp):
|
|
return _maybe_none(rhs.body, returns) or _maybe_none(rhs.orelse, returns)
|
|
if isinstance(rhs, ast.Call):
|
|
if isinstance(rhs.func, ast.Attribute) and rhs.func.attr == "get":
|
|
return len(rhs.args) == 1 or (len(rhs.args) == 2 and _maybe_none(rhs.args[1], returns))
|
|
if isinstance(rhs.func, ast.Name):
|
|
return "noney" in returns.get((rhs.func.id, None), ())
|
|
return False
|
|
|
|
|
|
class _Scope:
|
|
def __init__(self, node, parent):
|
|
self.node, self.parent = node, parent
|
|
self.bound, self.listy, self.noney = set(), set(), set()
|
|
|
|
|
|
def _dump(n):
|
|
return ast.dump(n)
|
|
|
|
|
|
def _mentions(expr, base):
|
|
"""`base` appears in `expr` other than as the thing being indexed, so
|
|
`x[0] == 1 and x[1]` does not count as guarding `x[1]`."""
|
|
b = _dump(base)
|
|
inner = {id(n.value) for n in ast.walk(expr) if isinstance(n, ast.Subscript)}
|
|
return any(_dump(x) == b and id(x) not in inner for x in ast.walk(expr))
|
|
|
|
|
|
def _exits_on(st, base):
|
|
"""`if not x: return` (or continue/break/raise) - x is guarded below it."""
|
|
return (isinstance(st, ast.If) and _mentions(st.test, base) and st.body
|
|
and isinstance(st.body[-1], (ast.Return, ast.Continue, ast.Break, ast.Raise)))
|
|
|
|
|
|
def scan(src, census=None):
|
|
"""[(line, kind, text)] for every unguarded raising read in `src`."""
|
|
tree = ast.parse(src)
|
|
parent = {c: p for p in ast.walk(tree) for c in ast.iter_child_nodes(p)}
|
|
|
|
def scope_node(n):
|
|
p = parent.get(n)
|
|
while p is not None and not isinstance(p, _FUNCS + _COMPS + (ast.Module,)):
|
|
p = parent.get(p)
|
|
return p
|
|
|
|
scopes = {tree: _Scope(tree, None)}
|
|
for n in ast.walk(tree):
|
|
if isinstance(n, _FUNCS + _COMPS):
|
|
scopes[n] = None
|
|
for n in list(scopes):
|
|
if n is not tree:
|
|
scopes[n] = _Scope(n, None)
|
|
for n, s in scopes.items():
|
|
if n is not tree:
|
|
s.parent = scopes[scope_node(n)]
|
|
|
|
def targets(t):
|
|
if isinstance(t, ast.Name):
|
|
yield t
|
|
elif isinstance(t, (ast.Tuple, ast.List)):
|
|
for e in t.elts:
|
|
yield from targets(e)
|
|
elif isinstance(t, ast.Starred):
|
|
yield from targets(t.value)
|
|
|
|
for n in ast.walk(tree):
|
|
if isinstance(n, _FUNCS):
|
|
a = n.args
|
|
for arg in a.posonlyargs + a.args + a.kwonlyargs + [a.vararg, a.kwarg]:
|
|
if arg is not None:
|
|
scopes[n].bound.add(arg.arg)
|
|
elif isinstance(n, ast.comprehension):
|
|
for t in targets(n.target):
|
|
scopes[scope_node(n)].bound.add(t.id)
|
|
elif isinstance(n, (ast.For, ast.AsyncFor, ast.With, ast.AugAssign, ast.NamedExpr)):
|
|
ts = ([n.target] if not isinstance(n, ast.With)
|
|
else [i.optional_vars for i in n.items if i.optional_vars])
|
|
for t0 in ts:
|
|
for t in targets(t0):
|
|
scopes[scope_node(n)].bound.add(t.id)
|
|
|
|
# `x = <default> if x is None else x` makes x not-None: a scope that
|
|
# normalises x this way does not get x marked by its `is None` tests.
|
|
normalised = set()
|
|
for n in ast.walk(tree):
|
|
if (isinstance(n, ast.Assign) and isinstance(n.value, ast.IfExp)
|
|
and isinstance(n.value.test, ast.Compare)):
|
|
for t in n.targets:
|
|
if isinstance(t, ast.Name) and any(isinstance(x, ast.Name) and x.id == t.id
|
|
for x in ast.walk(n.value.test)):
|
|
normalised.add((scope_node(n), t.id))
|
|
|
|
# Two passes, so a function's return shape is known before its callers.
|
|
returns = {}
|
|
for _ in range(2):
|
|
for n in ast.walk(tree):
|
|
if isinstance(n, (ast.Assign, ast.AnnAssign)) and n.value is not None:
|
|
s = scopes[scope_node(n)]
|
|
for t0 in (n.targets if isinstance(n, ast.Assign) else [n.target]):
|
|
if isinstance(t0, ast.Name):
|
|
s.bound.add(t0.id)
|
|
if _filtered(n.value, returns):
|
|
s.listy.add(t0.id)
|
|
if _maybe_none(n.value, returns):
|
|
s.noney.add(t0.id)
|
|
elif (isinstance(t0, ast.Tuple) and isinstance(n.value, ast.Tuple)
|
|
and len(t0.elts) == len(n.value.elts)):
|
|
for e, v in zip(t0.elts, n.value.elts):
|
|
for t in targets(e):
|
|
s.bound.add(t.id)
|
|
if _filtered(v, returns):
|
|
s.listy.add(t.id)
|
|
if _maybe_none(v, returns):
|
|
s.noney.add(t.id)
|
|
elif (isinstance(t0, ast.Tuple) and isinstance(n.value, ast.Call)
|
|
and isinstance(n.value.func, ast.Name)):
|
|
for i, e in enumerate(t0.elts):
|
|
for t in targets(e):
|
|
s.bound.add(t.id)
|
|
got = returns.get((n.value.func.id, i), ())
|
|
if "listy" in got:
|
|
s.listy.add(t.id)
|
|
if "noney" in got:
|
|
s.noney.add(t.id)
|
|
else:
|
|
for t in targets(t0):
|
|
s.bound.add(t.id)
|
|
elif (isinstance(n, ast.Compare)
|
|
and any(isinstance(o, (ast.Is, ast.IsNot)) for o in n.ops)
|
|
and any(isinstance(c, ast.Constant) and c.value is None
|
|
for c in [n.left] + n.comparators)):
|
|
for x in [n.left] + n.comparators:
|
|
if isinstance(x, ast.Name) and (scope_node(n), x.id) not in normalised:
|
|
s = scopes[scope_node(n)]
|
|
while s.parent is not None and x.id not in s.bound:
|
|
s = s.parent
|
|
s.noney.add(x.id)
|
|
for fn in tree.body:
|
|
if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
continue
|
|
s = scopes[fn]
|
|
for r in ast.walk(fn):
|
|
if not isinstance(r, ast.Return) or r.value is None or scope_node(r) is not fn:
|
|
continue
|
|
vals = r.value.elts if isinstance(r.value, ast.Tuple) else [r.value]
|
|
for i, v in enumerate(vals):
|
|
key = (fn.name, i if isinstance(r.value, ast.Tuple) else None)
|
|
tags = returns.setdefault(key, set())
|
|
if isinstance(v, ast.Name) and v.id in s.listy or _filtered(v, returns):
|
|
tags.add("listy")
|
|
if isinstance(v, ast.Name) and v.id in s.noney or _maybe_none(v, returns):
|
|
tags.add("noney")
|
|
|
|
def resolve(name_node, attr):
|
|
s = scopes[scope_node(name_node)]
|
|
while s is not None:
|
|
if name_node.id in s.bound or name_node.id in getattr(s, attr):
|
|
return name_node.id in getattr(s, attr)
|
|
s = s.parent
|
|
return False
|
|
|
|
def guarded(node, base):
|
|
ch, p = node, parent.get(node)
|
|
while p is not None:
|
|
if isinstance(p, ast.BoolOp) and ch in p.values:
|
|
if any(_mentions(v, base) for v in p.values[:p.values.index(ch)]):
|
|
return True
|
|
elif isinstance(p, (ast.IfExp, ast.If, ast.While)):
|
|
if ch is not p.test and _mentions(p.test, base):
|
|
return True
|
|
elif isinstance(p, (ast.ListComp, ast.GeneratorExp, ast.SetComp)):
|
|
if ch is p.elt and any(_mentions(i, base) for g in p.generators for i in g.ifs):
|
|
return True
|
|
elif isinstance(p, ast.Try) and ch in p.body:
|
|
return True
|
|
for field_ in ("body", "orelse"):
|
|
block = getattr(p, field_, None)
|
|
if (isinstance(block, list) and ch in block
|
|
and any(_exits_on(st, base) for st in block[:block.index(ch)])):
|
|
return True
|
|
if isinstance(p, _FUNCS):
|
|
return False
|
|
ch, p = p, parent.get(p)
|
|
return False
|
|
|
|
out = []
|
|
for n in ast.walk(tree):
|
|
if (isinstance(n, ast.Subscript) and isinstance(n.ctx, ast.Load)
|
|
and isinstance(n.value, ast.Name)):
|
|
k = n.slice
|
|
if isinstance(k, ast.UnaryOp) and isinstance(k.op, ast.USub):
|
|
k = k.operand
|
|
if (isinstance(k, ast.Constant) and type(k.value) is int
|
|
and resolve(n.value, "listy") and not guarded(n, n.value)):
|
|
out.append((n.lineno, "index", ast.unparse(n)))
|
|
elif (isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
|
|
and n.func.id == "next" and len(n.args) == 1 and not n.keywords):
|
|
if not guarded(n, n.args[0]):
|
|
out.append((n.lineno, "next", ast.unparse(n)[:60]))
|
|
elif (isinstance(n, ast.FormattedValue) and n.format_spec is not None
|
|
and isinstance(n.value, ast.Name)):
|
|
spec = "".join(v.value for v in n.format_spec.values
|
|
if isinstance(v, ast.Constant))
|
|
if (spec and (spec[-1] in _NUMFMT or "," in spec)
|
|
and resolve(n.value, "noney") and not guarded(n, n.value)):
|
|
out.append((n.lineno, "format", f"{{{n.value.id}:{spec}}}"))
|
|
if census is not None:
|
|
_count_blind(tree, resolve, census)
|
|
return sorted(set(out))
|
|
|
|
|
|
def _count_blind(tree, resolve, census):
|
|
"""Count, inside check() and print() arguments, the forms part 2 skips."""
|
|
for call in ast.walk(tree):
|
|
if not (isinstance(call, ast.Call) and isinstance(call.func, ast.Name)
|
|
and call.func.id in ("check", "print")):
|
|
continue
|
|
for n in (x for a in call.args for x in ast.walk(a)):
|
|
if isinstance(n, ast.Subscript) and isinstance(n.ctx, ast.Load):
|
|
k = n.slice.operand if isinstance(n.slice, ast.UnaryOp) else n.slice
|
|
if not isinstance(k, ast.Constant):
|
|
continue
|
|
if isinstance(k.value, str):
|
|
census["key"] += 1
|
|
elif isinstance(n.value, ast.Name):
|
|
if not resolve(n.value, "listy"):
|
|
census["name-list"] += 1
|
|
elif isinstance(n.value, ast.Attribute):
|
|
census["attr-list"] += 1
|
|
else:
|
|
census["other-index"] += 1
|
|
elif isinstance(n, ast.FormattedValue) and n.format_spec is not None:
|
|
if not isinstance(n.value, ast.Name):
|
|
census["format-expr"] += 1
|
|
elif not resolve(n.value, "noney"):
|
|
census["format-name"] += 1
|
|
elif isinstance(n, ast.BinOp) and isinstance(n.op, (ast.Div, ast.FloorDiv)):
|
|
census["division"] += 1
|
|
|
|
|
|
# The scan must bite before its silence means anything: one planted site of
|
|
# each kind, and the guarded spelling of each, which must NOT be flagged.
|
|
_PLANTED = '''
|
|
def run():
|
|
seen = []
|
|
t_gate = None
|
|
return seen, t_gate
|
|
rows = [r for r in range(3) if r > 9]
|
|
seen, t_gate = run()
|
|
waits, n = [], 0
|
|
check(f"first {rows[0]}", bool(rows))
|
|
print(waits[0])
|
|
check("pair", rows[0] == 1 and rows[1] == 2)
|
|
print(seen[-1])
|
|
print(f"{t_gate:.2f}")
|
|
print(next(r for r in rows))
|
|
'''
|
|
_GUARDED = '''
|
|
def run():
|
|
seen = []
|
|
t_gate = None
|
|
return seen, t_gate
|
|
rows = [r for r in range(3) if r > 9]
|
|
seen, t_gate = run()
|
|
check("first", bool(rows) and rows[0] == 1)
|
|
print(seen[-1] if seen else "-")
|
|
check("pair", len(rows) == 2 and rows[0] < rows[1])
|
|
print(f"{t_gate:.2f}" if t_gate is not None else "-")
|
|
print(next((r for r in rows), None))
|
|
def show(rows):
|
|
print(rows[0])
|
|
def head():
|
|
if not rows:
|
|
return None
|
|
return rows[0]
|
|
'''
|
|
_hit = scan(_PLANTED)
|
|
print(f" planted: {_hit}")
|
|
check("the scan flags a planted site of each kind - a filtered list indexed in "
|
|
"a check NAME whose condition guards it, an accumulator bound in a tuple "
|
|
"and one returned from a function, a maybe-None formatted, and next() "
|
|
"without a default",
|
|
[(k, t) for _l, k, t in _hit] == [
|
|
("index", "rows[0]"), ("index", "waits[0]"), ("index", "rows[0]"),
|
|
("index", "rows[1]"), ("index", "seen[-1]"),
|
|
("format", "{t_gate:.2f}"),
|
|
("next", "next((r for r in rows))")])
|
|
check("...and flags none of the guarded spellings - `and`, a conditional, "
|
|
"an early return, a default - nor a parameter that shadows a filtered "
|
|
"name", scan(_GUARDED) == [])
|
|
|
|
_SUITES = sorted(HERE.glob("test_*.py"))
|
|
_found = {p.name: scan(p.read_text(encoding="utf-8")) for p in _SUITES}
|
|
_all = [(f, *h) for f, hs in _found.items() for h in hs]
|
|
for f, line, kind, text in _all:
|
|
print(f" {f}:{line} {kind:6} {text}")
|
|
check(f"no unguarded raising read in any of the {len(_SUITES)} test files "
|
|
f"({len(_all)} found) - read it through named_absence.py and conjoin "
|
|
f"present() in the check", _all == [] and len(_SUITES) >= 30)
|
|
|
|
|
|
# ==============================================================================
|
|
print()
|
|
print("3. what part 2 cannot see, counted - these can still raise")
|
|
# ==============================================================================
|
|
# The scan knows a list is filtered, or a name may be None, only from its own
|
|
# file's syntax. Each form below can still raise and is NOT graded; the counts
|
|
# are of every such site in check() and print() arguments, largest first.
|
|
_BLIND = {
|
|
"key": "`x['k']` - a dict key the code under test may omit",
|
|
"name-list": "`x[k]` on a list the scan cannot see is filtered - returned "
|
|
"by another module or a method, or of a fixed length",
|
|
"format-name": "a numeric format on a name the scan has no reason to think "
|
|
"is None - e.g. set from another module's function",
|
|
"format-expr": "a numeric format on an attribute, subscript or call",
|
|
"attr-list": "`obj.items[k]` - an attribute list",
|
|
"other-index": "`x[k]` on a call, a slice or a nested subscript",
|
|
"division": "`/` or `//` by a value that can be 0",
|
|
}
|
|
_census = Counter()
|
|
for p in _SUITES:
|
|
scan(p.read_text(encoding="utf-8"), _census)
|
|
for k, why in sorted(_BLIND.items(), key=lambda kv: -_census[kv[0]]):
|
|
print(f" {_census[k]:5d} {why}")
|
|
print(" never counted: these forms outside check()/print() arguments; "
|
|
"attribute access, iteration, int() or range() on a None or an ABSENT; "
|
|
"a raise inside a fixture function or inside app/.")
|
|
check("every counted form is in the printed table", set(_census) <= set(_BLIND))
|
|
|
|
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} CHECK(S) FAILED")
|
|
sys.exit(1)
|
|
print("all checks passed")
|