"""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.__callbacks`` populated by ``add__callback`` and invoked via ``for cb in self.__callbacks: cb(...)`` (the call-site arity is canonical). Consumer: ``.add__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.__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 .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 .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= " "(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")