Spaces:
Running on Zero
Running on Zero
File size: 7,505 Bytes
9010fc3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 | """SC-4 static guard: nothing on the turn path can reach a GPU.
The whole Phase 1 free-tier story rests on the turn loop completing with ``DISABLE_GPU=1``. The
deployed half of that proof is plan 01-09's; this file is the half that runs in the quick loop and
fails a build before it ever reaches the Space.
The scan is deliberately conservative. A false positive here costs one look at a diff; a false
negative is the exact thing SC-4 exists to prevent. So: any decorator whose dotted name ends in
``GPU`` anywhere under ``src/japanese_avatar/`` fails, full stop, and ``app.py`` may carry exactly
one such function - the ZeroGPU startup probe - only because the platform refuses to run a Space
without one (docs/HOSTING.md, first deploy record). That carve-out is spelled out by name rather
than by loosening the rule, and the call graph from ``blocks.turn`` is walked to show the probe is
unreachable from it.
"""
from __future__ import annotations
import ast
import importlib
import re
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
SRC = REPO_ROOT / "src" / "japanese_avatar"
APP = REPO_ROOT / "app.py"
#: The one @spaces.GPU function ZeroGPU insists on. 01-RESEARCH.md Open Question 2 called it
#: `gpu_healthcheck`; the name that actually shipped in plan 01-05 is below. Nothing on the turn
#: path may call it, import it, or be it.
GPU_PROBE_NAME = "zerogpu_probe"
BANNED_IMPORT_ROOTS = {"torch", "spaces"}
BANNED_IMPORT_PATTERN = re.compile(r"^cuda")
def _turn_path_modules() -> list[Path]:
modules = sorted(SRC.rglob("*.py"))
assert modules, f"no modules under {SRC}"
return modules
def _parse(path: Path) -> ast.Module:
return ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
def _decorator_name(node: ast.expr) -> str:
"""Dotted name of a decorator expression: `spaces.GPU(duration=1)` -> `spaces.GPU`."""
if isinstance(node, ast.Call):
node = node.func
parts: list[str] = []
while isinstance(node, ast.Attribute):
parts.append(node.attr)
node = node.value
if isinstance(node, ast.Name):
parts.append(node.id)
return ".".join(reversed(parts))
def _gpu_decorated(tree: ast.Module) -> list[str]:
hits = []
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
for dec in node.decorator_list:
name = _decorator_name(dec)
if name == "GPU" or name.endswith(".GPU"):
hits.append(node.name)
return hits
def _imports(tree: ast.Module) -> set[str]:
roots: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
roots.add(alias.name.split(".")[0])
elif isinstance(node, ast.ImportFrom) and node.module:
roots.add(node.module.split(".")[0])
return roots
def _called_names(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]:
"""Every bare or attribute name that is called inside ``fn``."""
names: set[str] = set()
for node in ast.walk(fn):
if isinstance(node, ast.Call):
target = node.func
if isinstance(target, ast.Name):
names.add(target.id)
elif isinstance(target, ast.Attribute):
names.add(target.attr)
return names
def _reachable_from_turn() -> tuple[set[str], dict[str, ast.FunctionDef]]:
"""Conservative call graph over every function defined in the package plus app.py.
Names are matched by simple identifier, ignoring module boundaries, so an attribute call
`tts.synthesize(...)` reaches every function called `synthesize` anywhere. Over-approximating
reachability is the safe direction for this guard.
"""
functions: dict[str, ast.FunctionDef] = {}
for path in [*_turn_path_modules(), APP]:
for node in ast.walk(_parse(path)):
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
functions.setdefault(node.name, node)
assert "turn" in functions, "blocks.turn is not defined"
reachable: set[str] = set()
frontier = ["turn"]
while frontier:
name = frontier.pop()
if name in reachable:
continue
reachable.add(name)
for called in _called_names(functions[name]):
if called in functions and called not in reachable:
frontier.append(called)
return reachable, functions
def test_no_spaces_gpu_decorator_anywhere_on_turn_path():
for path in _turn_path_modules():
hits = _gpu_decorated(_parse(path))
assert not hits, f"{path.relative_to(REPO_ROOT)} decorates {hits} with a GPU decorator"
app_hits = _gpu_decorated(_parse(APP))
assert app_hits in ([], [GPU_PROBE_NAME]), (
f"app.py carries GPU-decorated functions {app_hits}; only the ZeroGPU startup probe "
f"{GPU_PROBE_NAME!r} is permitted, and it must stay unreachable from the turn path"
)
reachable, _functions = _reachable_from_turn()
assert GPU_PROBE_NAME not in reachable, (
f"{GPU_PROBE_NAME} is reachable from blocks.turn: {sorted(reachable)}"
)
assert "turn" in reachable and "synthesize" in reachable, sorted(reachable)
def test_no_gpu_imports_on_turn_path():
for path in _turn_path_modules():
roots = _imports(_parse(path))
banned = {r for r in roots if r in BANNED_IMPORT_ROOTS or BANNED_IMPORT_PATTERN.match(r)}
assert not banned, f"{path.relative_to(REPO_ROOT)} imports {sorted(banned)}"
app_tree = _parse(APP)
app_roots = _imports(app_tree)
assert not {r for r in app_roots if r == "torch" or BANNED_IMPORT_PATTERN.match(r)}
if "spaces" in app_roots:
defined = {n.name for n in ast.walk(app_tree) if isinstance(n, ast.FunctionDef)}
assert GPU_PROBE_NAME in defined, (
"app.py imports spaces without defining the ZeroGPU probe that justifies the import"
)
reachable, _functions = _reachable_from_turn()
assert GPU_PROBE_NAME not in reachable
# The package must never import the entry point back, or the carve-out would leak inward.
for path in _turn_path_modules():
assert "app" not in _imports(_parse(path)), f"{path.name} imports app.py"
def test_disable_gpu_env_var_is_honoured(monkeypatch):
"""With DISABLE_GPU=1 every @spaces.GPU function in the codebase raises RuntimeError.
There is exactly one such function, the ZeroGPU startup probe in app.py. Off the platform
the decorator is a no-op wrapper, so calling it runs the body, and the body must refuse.
If the probe is ever removed this test passes trivially, which is the correct outcome.
"""
monkeypatch.setenv("DISABLE_GPU", "1")
monkeypatch.setenv("GRADIO_ANALYTICS_ENABLED", "False")
monkeypatch.syspath_prepend(str(REPO_ROOT))
sys.modules.pop("app", None)
app = importlib.import_module("app")
assert app.gpu_disabled() is True
probes = [
getattr(app, name)
for name in _gpu_decorated(_parse(APP))
if callable(getattr(app, name, None))
]
if not probes:
pytest.skip("no @spaces.GPU function exists; nothing to refuse")
for probe in probes:
with pytest.raises(RuntimeError):
probe()
monkeypatch.setenv("DISABLE_GPU", "0")
assert app.gpu_disabled() is False, "gpu_disabled() must read the variable per call"
|