from __future__ import annotations
import ast
from supercov_probes import PROBE_NAMES, FileProbes, SiteProbes
def _key(node: ast.stmt) -> tuple[int, int]:
decorators = getattr(node, "decorator_list", None)
if decorators:
first = decorators[0]
return (first.lineno, first.col_offset - 1)
return (node.lineno, node.col_offset)
def _at(node: ast.AST, line: int, column: int) -> ast.AST:
for child in ast.walk(node):
child.lineno = line
child.col_offset = column
child.end_lineno = line
child.end_col_offset = column
return node
def _identifiers(tree: ast.AST) -> set[str]:
names: set[str] = set()
for node in ast.walk(tree):
for field in ("id", "arg", "name", "asname", "attr"):
value = getattr(node, field, None)
if isinstance(value, str):
names.add(value)
for value in getattr(node, "names", ()):
if isinstance(value, str):
names.add(value)
return names
def choose_alias(tree: ast.AST, stem: str) -> str:
taken = _identifiers(tree)
candidate = f"_scv_{stem}"
counter = 0
while candidate in taken:
counter += 1
candidate = f"_scv_{stem}{counter}"
return candidate
def _is_docstring(statement: ast.stmt) -> bool:
return (
isinstance(statement, ast.Expr)
and isinstance(statement.value, ast.Constant)
and isinstance(statement.value.value, str)
)
def _is_future_import(statement: ast.stmt) -> bool:
return isinstance(statement, ast.ImportFrom) and statement.module == "__future__"
def _exits(body: list, make_hit, k: int) -> None:
def walk(statements: list, loop_depth: int) -> None:
index = 0
while index < len(statements):
statement = statements[index]
leaves = isinstance(statement, ast.Return) or (
isinstance(statement, (ast.Break, ast.Continue)) and loop_depth == 0
)
if leaves:
statements.insert(index, make_hit(k, statement.lineno, statement.col_offset))
index += 1
elif not isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
nested_loop = isinstance(statement, (ast.For, ast.AsyncFor, ast.While))
for field, value in ast.iter_fields(statement):
if isinstance(value, list) and value and all(isinstance(item, ast.stmt) for item in value):
walk(value, loop_depth + (1 if nested_loop else 0))
elif isinstance(value, list):
for item in value:
for inner_field, inner in ast.iter_fields(item) if isinstance(item, ast.AST) else ():
if isinstance(inner, list) and inner and all(isinstance(x, ast.stmt) for x in inner):
walk(inner, loop_depth)
index += 1
walk(body, 0)
class _Inserter(ast.NodeTransformer):
def __init__(self, probes: FileProbes, names: dict[str, str]) -> None:
self.probes = probes
self.hits = names["hits_get"]
self.value = names["value_probe"]
self.names = names
self.inserted = 0
self.branch_tests: set[int] = set()
self.limitations: list = []
def _call(self, alias: str, arguments: list, node: ast.AST) -> ast.Call:
call = ast.Call(
func=ast.Name(id=self.names[alias], ctx=ast.Load()), args=arguments, keywords=[]
)
for argument in arguments:
if isinstance(argument, ast.Constant) and not hasattr(argument, "lineno"):
_at(argument, node.lineno, node.col_offset)
_at(call.func, node.lineno, node.col_offset)
call.lineno, call.col_offset = node.lineno, node.col_offset
call.end_lineno, call.end_col_offset = node.end_lineno, node.end_col_offset
self.inserted += 1
return call
@staticmethod
def _span(node: ast.AST):
return (node.lineno, node.col_offset, node.end_lineno, node.end_col_offset)
def visit(self, node: ast.AST) -> ast.AST:
branch_test = id(node) in self.branch_tests
node = super().visit(node)
if not isinstance(node, ast.expr) or not hasattr(node, "end_col_offset"):
return node
span = self._span(node)
leaf = self.probes.leaves.get(span)
if leaf is not None:
d, index, inverted = leaf
node = self._call(
"condition_probe",
[ast.Constant(value=d), ast.Constant(value=index), node, ast.Constant(value=inverted)],
node,
)
d = self.probes.decisions.get(span)
if d is not None:
node = self._call("decision_probe", [ast.Constant(value=d), node], node)
slot = None if branch_test else self.probes.singles.get(span)
if slot is not None:
node = self._call("single_probe", [ast.Constant(value=slot), node], node)
return node
def visit_BoolOp(self, node: ast.BoolOp) -> ast.AST:
g = self.probes.boolops.get(self._span(node))
node = self.generic_visit(node) if g is None:
return node
for index in range(1, len(node.values)):
operand = node.values[index]
node.values[index] = self._call(
"operand_probe", [ast.Constant(value=g), ast.Constant(value=index), operand], operand
)
return self._call("boolop_probe", [ast.Constant(value=g), node], node)
def _hit(self, k: int, line: int, column: int) -> ast.stmt:
node = ast.Assign(
targets=[
ast.Subscript(
value=ast.Call(func=ast.Name(id=self.hits, ctx=ast.Load()), args=[], keywords=[]),
slice=ast.Constant(value=k),
ctx=ast.Store(),
)
],
value=ast.Constant(value=1),
)
node._scv_k = k
self.inserted += 1
return _at(node, line, column)
def _hits(self, ks, anchor: ast.AST) -> list[ast.stmt]:
return [self._hit(k, anchor.lineno, anchor.col_offset) for k in ks]
def _body(self, statements: list[ast.stmt]) -> list[ast.stmt]:
out: list[ast.stmt] = []
for statement in statements:
k = self.probes.statements.get(_key(statement))
probe = None if k is None else self._hit(k, statement.lineno, statement.col_offset)
if probe is not None and not _is_future_import(statement):
out.append(probe)
visited = self.visit(statement)
if isinstance(visited, list):
out.extend(visited)
else:
out.append(visited)
if probe is not None and _is_future_import(statement):
out.append(probe)
return out
def _branches(self, node: ast.AST) -> ast.AST:
test = node.test slot = self.probes.singles.get(self._span(test))
if slot is None:
return self.generic_visit(node)
self.branch_tests.add(id(test))
try:
node = self.generic_visit(node)
finally:
self.branch_tests.discard(id(test))
if node.test is not test: return node
when_true, when_false = self.probes.single_heads.get(self._span(test), (None, None))
first = node.body[0] if when_true is None or getattr(first, "_scv_k", None) != when_true:
node.body.insert(0, self._hit(slot + 1, first.lineno, first.col_offset)) orelse = node.orelse if when_false is None or not orelse or getattr(orelse[0], "_scv_k", None) != when_false:
orelse.insert(0, self._hit(slot, test.lineno, test.col_offset))
return node
visit_If = _branches
visit_While = _branches
def _loop(self, node: ast.AST):
span = self._span(node.iter) plan = self.probes.loops.get(span)
node = self.generic_visit(node)
if plan is None:
return node
entered, zero = plan
flag = f"{self.hits}_f{entered}"
line, column = node.lineno, node.col_offset
reset = _at(ast.Assign(targets=[ast.Name(id=flag, ctx=ast.Store())], value=ast.Constant(value=0)), line, column)
first = node.body[0] head = self.probes.loop_heads.get(entered)
if head is not None and getattr(first, "_scv_k", None) == head:
node.body.pop(0) entered = head
mark = _at(
ast.Assign(
targets=[
ast.Name(id=flag, ctx=ast.Store()),
ast.Subscript(
value=ast.Call(func=ast.Name(id=self.hits, ctx=ast.Load()), args=[], keywords=[]),
slice=ast.Constant(value=entered),
ctx=ast.Store(),
),
],
value=ast.Constant(value=1),
),
first.lineno,
first.col_offset,
)
node.body.insert(0, mark) after = _at(
ast.If(
test=ast.UnaryOp(op=ast.Not(), operand=ast.Name(id=flag, ctx=ast.Load())),
body=[self._hit(zero, line, column)],
orelse=[],
),
line,
column,
)
self.inserted += 2
return [reset, node, after]
visit_For = _loop
visit_AsyncFor = _loop
def visit_comprehension(self, node: ast.comprehension) -> ast.AST:
span = self._span(node.iter)
node = self.generic_visit(node) plan = self.probes.loops.get(span)
if plan is not None:
entered, zero = plan
node.iter = self._call(
"aiter_probe" if node.is_async else "iter_probe",
[ast.Constant(value=entered), ast.Constant(value=zero), node.iter],
node.iter,
)
return node
def _try(self, node: ast.AST):
first = node.body[0] plan = self.probes.tries.get((first.lineno, first.col_offset))
star = type(node).__name__ == "TryStar"
node = self.generic_visit(node)
if plan is None:
return node
success, handlers, raised, has_finally = plan
body = node.body _exits(body, self._hit, success)
last = body[-1]
body.append(self._hit(success, last.end_lineno or last.lineno, last.col_offset))
for index, handler in enumerate(node.handlers): selected, _, _ = handlers[index]
anchor = handler.body[0]
probes = [raised, selected] + [
missed for selected_, missed, bare in handlers[:index] if not bare
]
handler.body[0:0] = self._hits(probes, anchor)
catch_all = node.handlers and ( node.handlers[-1].type is None or (isinstance(node.handlers[-1].type, ast.Name) and node.handlers[-1].type.id == "BaseException") )
if star:
for _, missed, bare in handlers:
if not bare:
self.limitations.append(
(
"python-try-star-miss-unobserved",
"an except* clause that matched no exception in the group is not observed",
self.probes.relative,
self.probes.ids[missed - self.probes.base].rsplit(":", 1)[0],
)
)
if not star and not catch_all:
probes = [missed for _, missed, bare in handlers if not bare]
if has_finally:
probes.append(raised)
anchor = node.handlers[-1] if node.handlers else node handler = ast.ExceptHandler(
type=ast.Name(id="BaseException", ctx=ast.Load()),
name=None,
body=self._hits(probes, anchor) + [ast.Raise(exc=None, cause=None)],
)
_at(handler, anchor.lineno, anchor.col_offset)
for probe in handler.body[:-1]:
_at(probe, anchor.lineno, anchor.col_offset)
node.handlers.append(handler) self.inserted += 1
return node
visit_Try = _try
visit_TryStar = _try
def visit_Match(self, node: ast.Match) -> ast.AST:
plan = self.probes.matches.get((node.lineno, node.col_offset))
node = self.generic_visit(node) if plan is None:
return node
cases, no_case = plan
for index, case in enumerate(node.cases):
selected, _, _ = cases[index]
anchor = case.body[0]
probes = [selected]
if no_case is not None:
probes.append(no_case[0])
probes += [missed for _, missed, irrefutable in cases[:index] if not irrefutable]
probes += [missed for _, missed, irrefutable in cases[index + 1 :] if irrefutable]
case.body[0:0] = self._hits(probes, anchor)
if not any(irrefutable for _, _, irrefutable in cases):
probes = [missed for _, missed, irrefutable in cases if not irrefutable]
if no_case is not None:
probes.append(no_case[1])
anchor = node.cases[-1].pattern
wildcard = ast.match_case(
pattern=ast.MatchAs(pattern=None, name=None),
guard=None,
body=self._hits(probes, anchor) or [ast.Pass()],
)
_at(wildcard.pattern, anchor.lineno, anchor.col_offset)
for statement in wildcard.body:
_at(statement, anchor.lineno, anchor.col_offset)
node.cases.append(wildcard)
self.inserted += 1
return node
def generic_visit(self, node: ast.AST) -> ast.AST:
for field, value in ast.iter_fields(node):
if isinstance(value, list):
if value and all(isinstance(item, ast.stmt) for item in value):
setattr(node, field, self._body(value))
else:
setattr(
node,
field,
[self.visit(item) if isinstance(item, ast.AST) else item for item in value],
)
elif isinstance(value, ast.AST):
setattr(node, field, self.visit(value))
return node
def _function(self, node: ast.AST) -> ast.AST:
k = self.probes.functions.get(_key(node)) node = self.generic_visit(node)
if k is not None:
body = node.body first = body[0]
position = 1 if _is_docstring(first) else 0
anchor = body[position] if position < len(body) else first
head = self.probes.entry_heads.get(k)
if head is None or getattr(anchor, "_scv_k", None) != head:
body.insert(position, self._hit(k, anchor.lineno, anchor.col_offset))
return node
visit_FunctionDef = _function
visit_AsyncFunctionDef = _function
def visit_Lambda(self, node: ast.Lambda) -> ast.AST:
node = self.generic_visit(node) k = self.probes.lambdas.get(
(node.lineno, node.col_offset, node.end_lineno, node.end_col_offset)
)
if k is not None:
node.body = _at(
ast.Call(
func=ast.Name(id=self.value, ctx=ast.Load()),
args=[ast.Constant(value=k), node.body],
keywords=[],
),
node.body.lineno,
node.body.col_offset,
) self.inserted += 1
return node
def instrument_sites(tree: ast.Module, sites: SiteProbes, alias: str) -> None:
seen: set[int] = set()
def walk(statements: list) -> None:
index = 0
while index < len(statements):
statement = statements[index]
if statement.lineno in sites.lines and statement.lineno not in seen:
seen.add(statement.lineno)
call = ast.Expr(
value=ast.Call(
func=ast.Name(id=alias, ctx=ast.Load()),
args=[ast.Constant(value=sites.relative), ast.Constant(value=statement.lineno)],
keywords=[],
)
)
statements.insert(index, _at(call, statement.lineno, statement.col_offset))
index += 1
for field, value in ast.iter_fields(statement):
if isinstance(value, list) and value and all(isinstance(item, ast.stmt) for item in value):
walk(value)
elif isinstance(value, list):
for item in value:
if isinstance(item, ast.AST):
for _, inner in ast.iter_fields(item):
if isinstance(inner, list) and inner and all(isinstance(x, ast.stmt) for x in inner):
walk(inner)
index += 1
walk(tree.body)
def instrument(tree: ast.Module, probes, report=None) -> ast.Module:
if isinstance(probes, SiteProbes):
alias = choose_alias(tree, "s")
instrument_sites(tree, probes, alias)
_import_aliases(tree, {"site_probe": alias})
return tree
taken = _identifiers(tree)
names = {}
for attribute, stem in PROBE_NAMES.items():
candidate = f"_scv_{stem}"
counter = 0
while candidate in taken:
counter += 1
candidate = f"_scv_{stem}{counter}"
taken.add(candidate)
names[attribute] = candidate
inserter = _Inserter(probes, names)
inserter.visit(tree)
_import_aliases(tree, names)
if report is not None:
for limitation in inserter.limitations:
report(*limitation)
return tree
def _import_aliases(tree: ast.Module, names: dict[str, str]) -> None:
position = 0
while position < len(tree.body) and (
_is_future_import(tree.body[position])
or (position == 0 and _is_docstring(tree.body[position]))
):
position += 1
anchor = tree.body[position] if position < len(tree.body) else None
line, column = (anchor.lineno, anchor.col_offset) if anchor is not None else (1, 0)
imports = ", ".join(f"{attribute} as {alias}" for attribute, alias in names.items())
fallbacks = {
"condition_probe": "lambda d, i, value, inverted: not not value",
"decision_probe": "lambda d, value: not not value",
"single_probe": "lambda k, value: not not value",
"site_probe": "lambda file, line: None",
}
sinks = "\n".join(
f" {alias} = {fallbacks.get(attribute, '_scv_sink')}"
for attribute, alias in names.items()
)
fallback = ast.parse(
f"try:\n from supercov_probes import {imports}\nexcept ImportError:\n"
f" class _scv_sink:\n def __setitem__(self, key, value):\n pass\n"
f" def __call__(self, *arguments):\n return arguments[-1] if arguments else self\n"
f" _scv_sink = _scv_sink()\n{sinks}\n"
).body
for statement in fallback:
_at(statement, line, column)
tree.body[position:position] = fallback