Route cargo lint through repository timeout validation and strict Clippy, reject unregistered timing primitives with compiler-resolved call budgets, bound Nextest concurrency, and replace polling waits in test infrastructure.
198 lines
6.2 KiB
Python
Executable file
198 lines
6.2 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Reject unregistered Python timeout and sleep call sites."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import sys
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SOURCE_ROOTS = ("apps", "crates", "src", "tests", "tools", "xtask")
|
|
SKIPPED_PARTS = {".venv", "__pycache__", "target"}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Allowance:
|
|
path: str
|
|
caller: str
|
|
callee: str
|
|
calls: int
|
|
purpose: str
|
|
|
|
|
|
ALLOWANCES = (
|
|
Allowance(
|
|
"apps/myelin/node-image/tinygrad_worker.py",
|
|
"fetch_whole",
|
|
"urllib.request.urlopen",
|
|
1,
|
|
"model download request deadline",
|
|
),
|
|
Allowance(
|
|
"apps/myelin/node-image/tinygrad_worker.py",
|
|
"CpuLineSampler._run",
|
|
"self._stop.wait",
|
|
1,
|
|
"cpu sampler poll interval",
|
|
),
|
|
Allowance(
|
|
"crates/bindings/python/tests/test_bootstrap.py",
|
|
"test_real_exec_attachment_and_blob_mapping",
|
|
"subprocess.run",
|
|
1,
|
|
"child-process test completion fuse",
|
|
),
|
|
)
|
|
|
|
|
|
class TimingVisitor(ast.NodeVisitor):
|
|
def __init__(self, path: str) -> None:
|
|
self.path = path
|
|
self.aliases: dict[str, str] = {}
|
|
self.scopes: list[str] = []
|
|
self.occurrences: defaultdict[tuple[str, str], int] = defaultdict(int)
|
|
self.violations: list[str] = []
|
|
|
|
def visit_Import(self, node: ast.Import) -> None:
|
|
for name in node.names:
|
|
if name.asname:
|
|
self.aliases[name.asname] = name.name
|
|
else:
|
|
root = name.name.split(".", 1)[0]
|
|
self.aliases[root] = root
|
|
|
|
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
if node.module is not None:
|
|
for name in node.names:
|
|
self.aliases[name.asname or name.name] = f"{node.module}.{name.name}"
|
|
|
|
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
self.scopes.append(node.name)
|
|
self.generic_visit(node)
|
|
self.scopes.pop()
|
|
|
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
self.scopes.append(node.name)
|
|
self.generic_visit(node)
|
|
self.scopes.pop()
|
|
|
|
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
self.scopes.append(node.name)
|
|
self.generic_visit(node)
|
|
self.scopes.pop()
|
|
|
|
def visit_Call(self, node: ast.Call) -> None:
|
|
callee = self._callee(node.func)
|
|
if callee is not None and self._is_guarded(node, callee):
|
|
caller = ".".join(self.scopes) or "<module>"
|
|
key = (caller, callee)
|
|
self.occurrences[key] += 1
|
|
occurrence = self.occurrences[key]
|
|
allowance = next(
|
|
(
|
|
item
|
|
for item in ALLOWANCES
|
|
if item.path == self.path
|
|
and item.caller == caller
|
|
and item.callee == callee
|
|
),
|
|
None,
|
|
)
|
|
if allowance is None:
|
|
self.violations.append(
|
|
f"{self.path}:{node.lineno}: unapproved timing primitive "
|
|
f"`{callee}` in `{caller}`"
|
|
)
|
|
elif occurrence > allowance.calls:
|
|
self.violations.append(
|
|
f"{self.path}:{node.lineno}: timing occurrence {occurrence} exceeds "
|
|
f"the {allowance.calls} audited call(s) in `{caller}` "
|
|
f"(`{allowance.purpose}`)"
|
|
)
|
|
self.generic_visit(node)
|
|
|
|
def _callee(self, node: ast.expr) -> str | None:
|
|
parts: list[str] = []
|
|
while isinstance(node, ast.Attribute):
|
|
parts.append(node.attr)
|
|
node = node.value
|
|
if not isinstance(node, ast.Name):
|
|
return None
|
|
root = self.aliases.get(node.id, node.id)
|
|
return ".".join((root, *reversed(parts)))
|
|
|
|
@staticmethod
|
|
def _is_guarded(node: ast.Call, callee: str) -> bool:
|
|
if any(keyword.arg == "timeout" for keyword in node.keywords):
|
|
return True
|
|
return callee in {"asyncio.sleep", "asyncio.wait_for", "time.sleep"} or callee.endswith(
|
|
(".set_read_timeout", ".set_write_timeout", ".settimeout", ".wait")
|
|
)
|
|
|
|
|
|
def python_sources() -> list[Path]:
|
|
sources: list[Path] = []
|
|
for source_root in SOURCE_ROOTS:
|
|
for path in (ROOT / source_root).rglob("*.py"):
|
|
if not SKIPPED_PARTS.intersection(path.parts):
|
|
sources.append(path)
|
|
return sorted(sources)
|
|
|
|
|
|
def check_source(path: Path) -> list[str]:
|
|
relative = path.relative_to(ROOT).as_posix()
|
|
source = path.read_text(encoding="utf-8")
|
|
syntax = ast.parse(source, filename=relative)
|
|
visitor = TimingVisitor(relative)
|
|
visitor.visit(syntax)
|
|
return visitor.violations
|
|
|
|
|
|
def self_test() -> None:
|
|
visitor = TimingVisitor("probe.py")
|
|
visitor.visit(ast.parse("import time as clock\ndef work():\n clock.sleep(1)\n"))
|
|
assert visitor.violations == [
|
|
"probe.py:3: unapproved timing primitive `time.sleep` in `work`"
|
|
]
|
|
|
|
overage = TimingVisitor("crates/bindings/python/tests/test_bootstrap.py")
|
|
overage.visit(
|
|
ast.parse(
|
|
"import subprocess\n"
|
|
"def test_real_exec_attachment_and_blob_mapping():\n"
|
|
" subprocess.run([], timeout=1)\n"
|
|
" subprocess.run([], timeout=1)\n"
|
|
)
|
|
)
|
|
assert overage.violations == [
|
|
"crates/bindings/python/tests/test_bootstrap.py:4: timing occurrence 2 "
|
|
"exceeds the 1 audited call(s) in "
|
|
"`test_real_exec_attachment_and_blob_mapping` "
|
|
"(`child-process test completion fuse`)"
|
|
]
|
|
|
|
|
|
def main() -> int:
|
|
if sys.argv[1:] == ["--self-test"]:
|
|
self_test()
|
|
return 0
|
|
if sys.argv[1:]:
|
|
print("usage: timeout-policy.py [--self-test]", file=sys.stderr)
|
|
return 2
|
|
|
|
self_test()
|
|
|
|
violations = [violation for path in python_sources() for violation in check_source(path)]
|
|
if violations:
|
|
print("Python timeout policy violations:", file=sys.stderr)
|
|
print("\n".join(violations), file=sys.stderr)
|
|
return 1
|
|
print("python-timeout-policy: OK")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|