Files
settled-reach/tooling/domains/blender/service.py
T
jpmschweitzerandClaude Opus 5 201dabd19b refactor(tooling): T-1273 — the Blender carve-out, and a guard that keeps it carved
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>
2026-09-02 20:55:52 +02:00

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")