35 payloads move to tooling/scripts/blender/ and stay outside package scope. They run under Blender's bundled Python, which cannot see the repo venv, so they physically cannot import tooling.core — holding them to the D-263 contract would either fail the gate forever or force the contract to be weakened for everyone, and the second is how a gate stops meaning anything. Count verified by import rather than filename: 33 import bpy/bmesh directly, and the two that do not are still payloads per their own usage lines. garment-fit/make_logo.py is the one genuine non-payload and stays for T-1290. The bash wrapper is retired rather than kept. Keeping it would have put the install-resolution logic in two places, which is the duplication T-1286 had just finished collapsing three copies of. domains/blender/service.py owns the decisions — resolve_blender (native beats flatpak, ordering preserved), resolve_payload, absolutise — and only run_payload performs. test_blender.py pins all of them without launching Blender, which matters here more than usual: the thing being launched is a 200 MB GUI application that writes GLBs. `reach blender run` takes a registered payload name OR a path to any script, because the wrapper served both — the spikes and the glb-gen skill hand it one-off scripts of their own. An unknown name enumerates all 35 and exits 2. The exclusion now defends itself. check_carve_out_stays_carved fails if `scripts` is added to PACKAGE_ROOTS, if the payload directory empties (an empty exclusion proves nothing), or if an __init__.py appears there (which would make the payloads importable — the coupling the carve-out exists to prevent). All three arms mutation-proved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
134 lines
4.6 KiB
Python
134 lines
4.6 KiB
Python
"""Logic for the `blender` domain. Transport-agnostic (D-263).
|
|
|
|
Everything the bash wrapper decided is decided here, and every decision is a
|
|
pure function: which Blender install to use, which payload a name refers to,
|
|
which arguments are paths that need absolutising. Only `run_payload` performs.
|
|
|
|
That matters more than usual for this domain, because the thing being launched
|
|
is a 200 MB GUI application that opens files and writes GLBs. "Run it and see"
|
|
is not a test.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from tooling.core import config, console, process
|
|
from tooling.core.errors import ReachError, unknown_choice
|
|
|
|
PAYLOAD_DIR = ("tooling", "scripts", "blender")
|
|
|
|
# Flatpak is checked second: a native install is faster and has no sandbox, so
|
|
# it wins when both are present. This ordering is the bash wrapper's and is
|
|
# preserved deliberately.
|
|
FLATPAK_APP = "org.blender.Blender"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BlenderInvocation:
|
|
"""How Blender would be launched — decided without launching it."""
|
|
|
|
argv_prefix: list[str]
|
|
kind: str # "native" | "flatpak"
|
|
|
|
@property
|
|
def sandboxed(self) -> bool:
|
|
"""Flatpak cannot see paths outside its sandbox unless they are absolute."""
|
|
return self.kind == "flatpak"
|
|
|
|
|
|
def resolve_blender() -> BlenderInvocation:
|
|
"""Find Blender, preferring a native install over flatpak.
|
|
|
|
Raises rather than falling through to a bare `blender` that is not there —
|
|
the original printed its own error and exited 1, and losing that would turn
|
|
a missing dependency into a confusing FileNotFoundError.
|
|
"""
|
|
native = shutil.which("blender")
|
|
if native:
|
|
return BlenderInvocation([native], "native")
|
|
|
|
if shutil.which("flatpak"):
|
|
probe = process.run(["flatpak", "info", FLATPAK_APP], check=False)
|
|
if probe.returncode == 0:
|
|
return BlenderInvocation(["flatpak", "run", FLATPAK_APP], "flatpak")
|
|
|
|
raise ReachError(
|
|
"Blender is not installed (checked PATH and flatpak)",
|
|
fix="install Blender natively, or run: flatpak install "
|
|
f"{FLATPAK_APP} — reach prefers the native install when both exist",
|
|
)
|
|
|
|
|
|
def payload_dir() -> Path:
|
|
return config.path(*PAYLOAD_DIR)
|
|
|
|
|
|
def payloads() -> list[str]:
|
|
"""Every runnable payload, by the name `run` accepts."""
|
|
return sorted(p.stem for p in payload_dir().glob("*.py") if p.name != "__init__.py")
|
|
|
|
|
|
def resolve_payload(name: str) -> Path:
|
|
"""Map a payload name — or a path to any script — to a file to run.
|
|
|
|
Two forms, because the bash wrapper this replaces served both: our own 35
|
|
registered payloads BY NAME, and any other script BY PATH (the spikes and
|
|
the glb-gen skill hand it one-off scripts of their own). A registered name
|
|
wins; anything that resolves to an existing file is accepted as a path.
|
|
|
|
On a miss it enumerates, because a payload name is not guessable — the 35
|
|
names are the only place that vocabulary is written down.
|
|
"""
|
|
stem = name[:-3] if name.endswith(".py") else name
|
|
registered = payload_dir() / f"{stem}.py"
|
|
if registered.is_file():
|
|
return registered
|
|
|
|
given = Path(name)
|
|
if given.is_file():
|
|
return given.resolve()
|
|
|
|
raise unknown_choice("payload", stem, payloads())
|
|
|
|
|
|
def absolutise(args: list[str]) -> list[str]:
|
|
"""Resolve any argument that names an existing path.
|
|
|
|
The wrapper did this for every argument, not just `--python`, because
|
|
flatpak's sandbox resolves relative paths against a different root and the
|
|
failure is a file-not-found from inside Blender — far from the cause. An
|
|
argument that is not an existing path is passed through untouched, so flags
|
|
and values survive.
|
|
"""
|
|
return [str(Path(a).resolve()) if Path(a).exists() else a for a in args]
|
|
|
|
|
|
def run_payload(name: str, args: list[str], background: bool = True) -> None:
|
|
"""Launch a payload under Blender. The performing half."""
|
|
payload = resolve_payload(name)
|
|
invocation = resolve_blender()
|
|
|
|
argv = [*invocation.argv_prefix]
|
|
if background:
|
|
argv.append("--background")
|
|
argv += ["--python", str(payload)]
|
|
if args:
|
|
argv += ["--", *absolutise(args)]
|
|
|
|
console.event(
|
|
f"running {name} under {invocation.kind} Blender",
|
|
phase="blender",
|
|
)
|
|
# capture=False: Blender streams its own progress, and a payload that
|
|
# takes minutes with no output reads as a hang.
|
|
process.run(
|
|
argv,
|
|
cwd=config.repo_root(),
|
|
capture=False,
|
|
fix=f"run `reach blender run {name}` with --no-background to watch it in the GUI",
|
|
)
|
|
console.verdict(f"blender: {name} finished")
|