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