e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
305 lines
12 KiB
Python
305 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")
|