* Reduce and tighten comments and docstrings in tests Shorten verbose comments and docstrings across the test suite without changing any test logic. Remove narration that restates the next line, collapse long module and test docstrings to a single line, and drop banner separators. Keep regression context (issue and PR references, run ids), skip reasons, mocking and timing rationale, license headers, lint and type directives, and commented-out code. Comments and docstrings only: an AST signature check confirms no code, assertions, or string literals changed, and the suite byte-compiles cleanly. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
304 lines
12 KiB
Python
304 lines
12 KiB
Python
"""Static-analysis regression test: callback signature drift.
|
|
|
|
Catches a producer (e.g. unsloth_zoo's MLXTrainer) changing the arity it passes to a registered
|
|
callback while consumers still declare the old arity; the producer's try/except swallows the
|
|
TypeError so the callback silently never fires. Pure AST so it runs on every CI OS/Python.
|
|
|
|
Producer: a class with ``self._<name>_callbacks`` populated by ``add_<name>_callback`` and invoked
|
|
via ``for cb in self._<name>_callbacks: cb(...)`` (the call-site arity is canonical).
|
|
Consumer: ``<obj>.add_<name>_callback(fn)`` where ``fn`` is a def/async def in the same file; its
|
|
arity must equal canonical (or be variadic). ``*args``/``**kwargs`` accept any arity; methods and
|
|
unresolved Name targets are skipped with a note.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import importlib.util
|
|
import os
|
|
import pathlib
|
|
import sys
|
|
|
|
|
|
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
# Skip noisy paths during file discovery.
|
|
SKIP_PARTS = {
|
|
".git",
|
|
".out",
|
|
"temp",
|
|
"node_modules",
|
|
"build",
|
|
"dist",
|
|
".venv",
|
|
"venv",
|
|
".pytest_cache",
|
|
"__pycache__",
|
|
# studio frontend is JS/TS plus a few stub .py files; skip.
|
|
"frontend",
|
|
}
|
|
|
|
|
|
def _iter_py(root: pathlib.Path):
|
|
root = pathlib.Path(root).resolve()
|
|
for p in root.rglob("*.py"):
|
|
try:
|
|
rel_parts = p.resolve().relative_to(root).parts
|
|
except ValueError:
|
|
rel_parts = p.parts
|
|
if any(part.startswith(".") and part not in (".", "..") for part in rel_parts):
|
|
continue
|
|
if any(part in SKIP_PARTS for part in rel_parts):
|
|
continue
|
|
yield p
|
|
|
|
|
|
# Parse cache so each file is parsed once across the run.
|
|
_PARSE_CACHE: dict[pathlib.Path, ast.AST | None] = {}
|
|
|
|
|
|
def _safe_parse(path: pathlib.Path):
|
|
key = path.resolve()
|
|
if key in _PARSE_CACHE:
|
|
return _PARSE_CACHE[key]
|
|
try:
|
|
import warnings as _w
|
|
with _w.catch_warnings():
|
|
# Suppress SyntaxWarning from third-party files with invalid escape sequences.
|
|
_w.simplefilter("ignore", SyntaxWarning)
|
|
tree = ast.parse(path.read_text(encoding = "utf-8"))
|
|
except (SyntaxError, UnicodeDecodeError):
|
|
tree = None
|
|
_PARSE_CACHE[key] = tree
|
|
return tree
|
|
|
|
|
|
def _callback_list_attrs_in_class(cls: ast.ClassDef) -> set[str]:
|
|
"""Find self._<name>_callbacks attributes assigned or appended-to inside cls."""
|
|
found = set()
|
|
for node in ast.walk(cls):
|
|
# self._x_callbacks = [...]
|
|
if isinstance(node, ast.Assign):
|
|
for t in node.targets:
|
|
if (
|
|
isinstance(t, ast.Attribute)
|
|
and isinstance(t.value, ast.Name)
|
|
and t.value.id == "self"
|
|
and t.attr.startswith("_")
|
|
and t.attr.endswith("_callbacks")
|
|
):
|
|
found.add(t.attr)
|
|
# self._x_callbacks.append(fn)
|
|
if (
|
|
isinstance(node, ast.Call)
|
|
and isinstance(node.func, ast.Attribute)
|
|
and node.func.attr == "append"
|
|
and isinstance(node.func.value, ast.Attribute)
|
|
and isinstance(node.func.value.value, ast.Name)
|
|
and node.func.value.value.id == "self"
|
|
and node.func.value.attr.startswith("_")
|
|
and node.func.value.attr.endswith("_callbacks")
|
|
):
|
|
found.add(node.func.value.attr)
|
|
return found
|
|
|
|
|
|
def _producer_arities(tree: ast.AST) -> dict[str, int]:
|
|
"""Return {cb_list_attr: max_arity} over all ``for cb in self._x_callbacks: cb(...)`` sites."""
|
|
out: dict[str, int] = {}
|
|
for cls in [n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)]:
|
|
cb_lists = _callback_list_attrs_in_class(cls)
|
|
for cb_list in cb_lists:
|
|
for node in ast.walk(cls):
|
|
if not isinstance(node, ast.For):
|
|
continue
|
|
if not (
|
|
isinstance(node.iter, ast.Attribute)
|
|
and isinstance(node.iter.value, ast.Name)
|
|
and node.iter.value.id == "self"
|
|
and node.iter.attr == cb_list
|
|
):
|
|
continue
|
|
if not isinstance(node.target, ast.Name):
|
|
continue
|
|
cb_name = node.target.id
|
|
for inner in ast.walk(node):
|
|
if (
|
|
isinstance(inner, ast.Call)
|
|
and isinstance(inner.func, ast.Name)
|
|
and inner.func.id == cb_name
|
|
):
|
|
arity = len(inner.args)
|
|
out[cb_list] = max(out.get(cb_list, 0), arity)
|
|
return out
|
|
|
|
|
|
def _registration_attr_to_list(attr: str) -> str | None:
|
|
"""add_step_callback -> _step_callbacks. Returns None if pattern doesn't match."""
|
|
if attr.startswith("add_") and attr.endswith("_callback"):
|
|
middle = attr[len("add_") : -len("_callback")]
|
|
if middle:
|
|
return f"_{middle}_callbacks"
|
|
if attr.startswith("register_") and attr.endswith("_callback"):
|
|
middle = attr[len("register_") : -len("_callback")]
|
|
if middle:
|
|
return f"_{middle}_callbacks"
|
|
return None
|
|
|
|
|
|
def _func_arity(node: ast.AST) -> tuple[int, bool] | None:
|
|
"""Return (positional_arity, accepts_var_positional). None if not a function def."""
|
|
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
|
return None
|
|
args = node.args
|
|
arity = len(args.posonlyargs) + len(args.args)
|
|
accepts_var = args.vararg is not None
|
|
# Don't subtract self: we can't tell statically if this is a method, and the
|
|
# consumer check skips `self.fn` registrations anyway.
|
|
return arity, accepts_var
|
|
|
|
|
|
def discover_producers(roots: list[pathlib.Path]) -> dict[str, list[tuple[pathlib.Path, int]]]:
|
|
"""Walk every .py under each root and return {cb_list_attr: [(file, arity), ...]}."""
|
|
producers: dict[str, list[tuple[pathlib.Path, int]]] = {}
|
|
for root in roots:
|
|
if not root or not root.exists():
|
|
continue
|
|
for src in _iter_py(root):
|
|
tree = _safe_parse(src)
|
|
if tree is None:
|
|
continue
|
|
for cb_list, arity in _producer_arities(tree).items():
|
|
producers.setdefault(cb_list, []).append((src, arity))
|
|
return producers
|
|
|
|
|
|
def check_registrations(
|
|
roots: list[pathlib.Path], producers: dict[str, list[tuple[pathlib.Path, int]]]
|
|
):
|
|
"""Assert each in-file <x>.add_*_callback(fn) arity matches the producer's canonical arity.
|
|
|
|
Returns (issues, skipped, ok_count).
|
|
"""
|
|
issues: list[str] = []
|
|
skipped: list[str] = []
|
|
ok_count = 0
|
|
for root in roots:
|
|
if not root or not root.exists():
|
|
continue
|
|
for src in _iter_py(root):
|
|
tree = _safe_parse(src)
|
|
if tree is None:
|
|
continue
|
|
# All function/lambda defs in this file, keyed by name.
|
|
defs_by_name: dict[str, ast.AST] = {}
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
defs_by_name[node.name] = node
|
|
if isinstance(node, ast.Assign):
|
|
if (
|
|
isinstance(node.value, ast.Lambda)
|
|
and len(node.targets) == 1
|
|
and isinstance(node.targets[0], ast.Name)
|
|
):
|
|
defs_by_name[node.targets[0].id] = node.value
|
|
# Find <x>.add_*_callback(fn) sites.
|
|
for call in ast.walk(tree):
|
|
if not isinstance(call, ast.Call):
|
|
continue
|
|
if not isinstance(call.func, ast.Attribute):
|
|
continue
|
|
cb_list = _registration_attr_to_list(call.func.attr)
|
|
if cb_list is None:
|
|
continue
|
|
if cb_list not in producers:
|
|
skipped.append(
|
|
f"{src}:{call.lineno}: {call.func.attr}(...) but no producer "
|
|
f"defines {cb_list} (third-party API?)"
|
|
)
|
|
continue
|
|
# Only bare-Name registrations; bound methods/partials skipped.
|
|
if not (len(call.args) == 1 and isinstance(call.args[0], ast.Name)):
|
|
skipped.append(
|
|
f"{src}:{call.lineno}: {call.func.attr}(...) registers a "
|
|
f"non-Name callback (lambda/method/partial); arity not statically checkable"
|
|
)
|
|
continue
|
|
cb_name = call.args[0].id
|
|
fn = defs_by_name.get(cb_name)
|
|
if fn is None:
|
|
skipped.append(
|
|
f"{src}:{call.lineno}: {call.func.attr}({cb_name}) but {cb_name} "
|
|
f"is not defined as a function/lambda in this file (imported?)"
|
|
)
|
|
continue
|
|
arity_info = _func_arity(fn)
|
|
if arity_info is None:
|
|
continue
|
|
consumer_arity, accepts_var = arity_info
|
|
expected_arity = max(a for _, a in producers[cb_list])
|
|
if accepts_var:
|
|
ok_count += 1
|
|
continue
|
|
if consumer_arity != expected_arity:
|
|
issues.append(
|
|
f"{src}:{call.lineno}: {cb_name} declared with {consumer_arity} "
|
|
f"positional arg(s), but producer calls {cb_list} entries with "
|
|
f"{expected_arity} arg(s) "
|
|
f"({', '.join(str(p) for p, _ in producers[cb_list])})"
|
|
)
|
|
else:
|
|
ok_count += 1
|
|
return issues, skipped, ok_count
|
|
|
|
|
|
def _zoo_roots() -> list[pathlib.Path]:
|
|
"""unsloth_zoo source roots, in order: UNSLOTH_ZOO_SRC env, ../unsloth-zoo sibling, pip package.
|
|
|
|
(The pip wheel may strip submodules like mlx/, missing MLX producers.) All existing roots scanned.
|
|
"""
|
|
roots: list[pathlib.Path] = []
|
|
env_src = os.environ.get("UNSLOTH_ZOO_SRC")
|
|
if env_src:
|
|
p = pathlib.Path(env_src).expanduser().resolve()
|
|
if p.exists():
|
|
roots.append(p)
|
|
sibling = (REPO_ROOT.parent / "unsloth-zoo").resolve()
|
|
if sibling.exists():
|
|
roots.append(sibling)
|
|
spec = importlib.util.find_spec("unsloth_zoo")
|
|
if spec is not None and spec.origin is not None:
|
|
# Use the unsloth_zoo dir itself (parent of __init__.py), not the site-packages root.
|
|
roots.append(pathlib.Path(spec.origin).resolve().parent)
|
|
return roots
|
|
|
|
|
|
def test_no_callback_signature_drift():
|
|
roots = [REPO_ROOT, *_zoo_roots()]
|
|
producers = discover_producers(roots)
|
|
if not producers:
|
|
import pytest
|
|
pytest.skip(
|
|
"no callback producer pattern (self._*_callbacks + cb(...)) found in "
|
|
"unsloth or unsloth_zoo. Set UNSLOTH_ZOO_SRC=<path-to-unsloth-zoo-git-checkout> "
|
|
"(the pip wheel strips platform-specific submodules like mlx/) to enable "
|
|
"the detector locally."
|
|
)
|
|
issues, skipped, ok_count = check_registrations(roots, producers)
|
|
msg_parts = [
|
|
f"producers discovered: {len(producers)} ({sorted(producers)})",
|
|
f"registrations matched: {ok_count}",
|
|
f"registrations skipped: {len(skipped)}",
|
|
]
|
|
if issues:
|
|
msg_parts.append("")
|
|
msg_parts.append("Callback signature drift detected:")
|
|
msg_parts.extend(" " + i for i in issues)
|
|
raise AssertionError("\n".join(msg_parts))
|
|
if "-v" in sys.argv or "--verbose" in sys.argv:
|
|
print("\n".join(msg_parts))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.argv.append("-v")
|
|
test_no_callback_signature_drift()
|
|
print("PASS")
|