diff --git a/pyproject.toml b/pyproject.toml index 648a7e3..d45a07d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ pythonpath = ["src"] markers = [ "hython: local Houdini integration tests that run through hython when available", "maya: local Maya integration tests that run through mayapy when available", + "blender: local Blender integration tests that run through blender.exe when available", ] [tool.ruff] diff --git a/scripts/validate_blender_runtime.py b/scripts/validate_blender_runtime.py new file mode 100644 index 0000000..7fc09b3 --- /dev/null +++ b/scripts/validate_blender_runtime.py @@ -0,0 +1,50 @@ +"""Validate that Materials Processor can load in Blender headless mode.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from materials_processor.dcc.blender.runtime import ( + resolve_blender_runtime, + validate_blender_material_smoke, + validate_blender_runtime, +) + + +def main() -> int: + """Run Blender runtime validation.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", help="Blender version used for default install discovery.") + parser.add_argument("--root", help="Blender installation root.") + parser.add_argument("--exe", help="Direct path to blender.exe.") + parser.add_argument("--src", default=str(Path(__file__).resolve().parents[1] / "src"), help="Package src path.") + parser.add_argument("--timeout", default=120, type=int, help="Validation timeout in seconds.") + parser.add_argument( + "--smoke-material", + action="store_true", + help="Also run a tiny traversal/standardization/recreation smoke test inside Blender.", + ) + args = parser.parse_args() + + runtime = resolve_blender_runtime(version=args.version, root=args.root, blender_exe=args.exe) + validated = validate_blender_runtime(runtime=runtime, package_src=args.src, timeout=args.timeout) + print( + "Blender " + f"{validated.version} runtime OK: {validated.blender_exe} " + f"(Python {validated.python_version}, API {validated.api_version})" + ) + + if args.smoke_material: + result = validate_blender_material_smoke(runtime=validated, package_src=args.src, timeout=args.timeout) + print( + "Blender material smoke OK: " + f"{result['node_count']} standardized node(s), " + f"{result['output_count']} output connection(s)" + ) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/materials_processor/dcc/blender/runtime.py b/src/materials_processor/dcc/blender/runtime.py new file mode 100644 index 0000000..f70125a --- /dev/null +++ b/src/materials_processor/dcc/blender/runtime.py @@ -0,0 +1,351 @@ +"""Discover and validate the local Blender runtime.""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path + +DEFAULT_BLENDER_VERSION = "4.0" +BLENDER_ROOT_ENV_VAR = "MATERIALS_PROCESSOR_BLENDER_ROOT" +BLENDER_EXE_ENV_VAR = "MATERIALS_PROCESSOR_BLENDER_EXE" +VALIDATION_RESULT_PREFIX = "MATERIALS_PROCESSOR_BLENDER_RUNTIME=" +MATERIAL_SMOKE_RESULT_PREFIX = "MATERIALS_PROCESSOR_BLENDER_MATERIAL_SMOKE=" + + +@dataclass(frozen=True) +class BlenderRuntime: + """Resolved Blender executable and version metadata.""" + + root: Path + blender_exe: Path + version: str + python_version: str + api_version: str + + +def _default_blender_root(version: str = DEFAULT_BLENDER_VERSION) -> Path: + return Path("C:/Program Files/Blender Foundation") / f"Blender {version}" + + +def _version_from_root(root: Path) -> str: + match = re.match(r"Blender\s+(.+)$", root.name) + return match.group(1) if match else "" + + +def _version_sort_key(path: Path) -> tuple[int, ...]: + version = _version_from_root(path) + parts = [] + for part in re.split(r"[^0-9]+", version): + if part: + parts.append(int(part)) + return tuple(parts) + + +def _candidate_roots(version: str | None) -> list[Path]: + if version: + return [_default_blender_root(version)] + + blender_foundation = Path("C:/Program Files/Blender Foundation") + if not blender_foundation.is_dir(): + return [_default_blender_root()] + + candidates = [path for path in blender_foundation.glob("Blender *") if path.is_dir()] + sorted_candidates = sorted(candidates, key=_version_sort_key, reverse=True) + blender_4_candidates = [path for path in sorted_candidates if _version_from_root(path).startswith("4.")] + other_candidates = [path for path in sorted_candidates if path not in blender_4_candidates] + return blender_4_candidates + other_candidates + + +def _require_file(path: Path, label: str) -> Path: + resolved = path.expanduser().resolve() + if not resolved.is_file(): + raise FileNotFoundError(f"{label} was not found: {resolved}") + return resolved + + +def _resolve_blender_exe( + version: str | None, + root: str | os.PathLike[str] | None, + blender_exe: str | os.PathLike[str] | None, +) -> Path: + if blender_exe is not None: + return _require_file(Path(blender_exe), "Blender executable") + + env_exe = os.environ.get(BLENDER_EXE_ENV_VAR) + if env_exe: + return _require_file(Path(env_exe), "Blender executable") + + if root is not None: + return _require_file(Path(root) / "blender.exe", "Blender executable") + + env_root = os.environ.get(BLENDER_ROOT_ENV_VAR) + if env_root: + return _require_file(Path(env_root) / "blender.exe", "Blender executable") + + for candidate_root in _candidate_roots(version): + candidate_exe = candidate_root / "blender.exe" + if candidate_exe.is_file(): + return candidate_exe.resolve() + + discovered = shutil.which("blender") + if discovered: + return _require_file(Path(discovered), "Blender executable") + + searched = ", ".join(str(path / "blender.exe") for path in _candidate_roots(version)) + raise FileNotFoundError( + "Blender executable was not found. Set " + f"{BLENDER_EXE_ENV_VAR}, {BLENDER_ROOT_ENV_VAR}, or install Blender in one of: {searched}" + ) + + +def resolve_blender_runtime( + version: str | None = None, + root: str | os.PathLike[str] | None = None, + blender_exe: str | os.PathLike[str] | None = None, +) -> BlenderRuntime: + """Resolve the Blender runtime executable path. + + Args: + version: Optional Blender version used for the default Windows install + path. Pass ``None`` to search installed ``Blender *`` directories + and then ``PATH``. + root: Optional Blender installation root. When omitted, + ``MATERIALS_PROCESSOR_BLENDER_ROOT`` is checked. + blender_exe: Optional direct path to ``blender.exe``. When omitted, + ``MATERIALS_PROCESSOR_BLENDER_EXE`` is checked. + + Returns: + Resolved Blender runtime metadata. Version fields are populated from + the requested/root version until ``validate_blender_runtime`` can ask + Blender itself. + + Raises: + FileNotFoundError: If ``blender.exe`` is missing. + """ + resolved_exe = _resolve_blender_exe(version, root, blender_exe) + resolved_root = resolved_exe.parent.resolve() + requested_version = version or _version_from_root(resolved_root) + + return BlenderRuntime( + root=resolved_root, + blender_exe=resolved_exe, + version=requested_version or "", + python_version="", + api_version="", + ) + + +def _runtime_validation_code() -> str: + return f""" +import json +import sys + +import bpy +import materials_processor + +result = {{ + "api_version": ".".join(str(part) for part in bpy.app.version), + "package_file": materials_processor.__file__, + "python_version": ".".join(str(part) for part in sys.version_info[:3]), + "version": bpy.app.version_string, +}} +print({VALIDATION_RESULT_PREFIX!r} + json.dumps(result, sort_keys=True)) +""".strip() + + +def _material_smoke_code() -> str: + return f""" +import json + +import bpy + +from materials_processor.dcc.blender.recreator import BlenderNodeRecreator +from materials_processor.dcc.blender.traverser import BlenderNodeTraverser +from materials_processor.standardizer import NodeStandardizer + + +def socket(collection, name): + return collection[name] if name in collection else collection[0] + + +source = bpy.data.materials.new("materials_processor_smoke_source") +source.use_nodes = True +source_tree = source.node_tree +source_tree.nodes.clear() + +output_node = source_tree.nodes.new(type="ShaderNodeOutputMaterial") +output_node.name = "Material Output" +bsdf_node = source_tree.nodes.new(type="ShaderNodeBsdfPrincipled") +bsdf_node.name = "Principled BSDF" +socket(bsdf_node.inputs, "Base Color").default_value = (0.8, 0.2, 0.1, 1.0) +source_tree.links.new(socket(bsdf_node.outputs, "BSDF"), socket(output_node.inputs, "Surface")) + +traversed_nodes, output_nodes = BlenderNodeTraverser(source).run() +nodeinfo_list, output_connections = NodeStandardizer( + traversed_nodes_dict=traversed_nodes, + output_nodes_dict=output_nodes, + material_type="blender", + source_type="blender_shader_nodes", +).run() + +target = bpy.data.materials.new("materials_processor_smoke_target") +target.use_nodes = True +recreated = BlenderNodeRecreator( + nodeinfo_list=nodeinfo_list, + output_connections=output_connections, + target_material=target, +).run() + +target_node_types = sorted(node.bl_idname for node in target.node_tree.nodes) +result = {{ + "node_count": len(nodeinfo_list), + "output_count": len(output_connections), + "recreated": recreated, + "target_node_types": target_node_types, +}} +print({MATERIAL_SMOKE_RESULT_PREFIX!r} + json.dumps(result, sort_keys=True)) +""".strip() + + +def _with_pythonpath(env: dict[str, str], package_src: Path) -> dict[str, str]: + current_pythonpath = env.get("PYTHONPATH") + src_path = str(package_src.resolve()) + env["PYTHONPATH"] = src_path if not current_pythonpath else f"{src_path}{os.pathsep}{current_pythonpath}" + return env + + +def _parse_prefixed_output(stdout: str, stderr: str, prefix: str, label: str) -> dict: + for line in stdout.splitlines(): + if line.startswith(prefix): + return json.loads(line[len(prefix):]) + raise RuntimeError(f"Blender {label} did not produce a runtime result.\nstdout:\n{stdout}\nstderr:\n{stderr}") + + +def _default_package_src() -> Path: + return Path(__file__).resolve().parents[3] + + +def _matches_requested_version(requested_version: str, reported_version: str) -> bool: + if not requested_version: + return True + return reported_version == requested_version or reported_version.startswith(f"{requested_version}.") + + +def _run_blender_python(runtime: BlenderRuntime, code: str, package_src: Path, timeout: int) -> subprocess.CompletedProcess: + env = _with_pythonpath(os.environ.copy(), package_src) + with tempfile.TemporaryDirectory(prefix="materials_processor_blender_user_") as blender_user_dir: + user_dir = Path(blender_user_dir) + script_path = user_dir / "validate_materials_processor.py" + script_path.write_text( + "import sys\n" + f"sys.path.insert(0, {str(package_src.resolve())!r})\n\n" + f"{code}\n", + encoding="utf-8", + ) + env["BLENDER_USER_CONFIG"] = str(user_dir / "config") + env["BLENDER_USER_SCRIPTS"] = str(user_dir / "scripts") + env["BLENDER_USER_DATAFILES"] = str(user_dir / "datafiles") + try: + return subprocess.run( + [str(runtime.blender_exe), "--background", "--factory-startup", "--python", str(script_path)], + check=False, + capture_output=True, + env=env, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired as exc: + raise RuntimeError(f"Blender validation timed out after {timeout} seconds.") from exc + + +def validate_blender_runtime( + runtime: BlenderRuntime | None = None, + package_src: str | os.PathLike[str] | None = None, + timeout: int = 120, +) -> BlenderRuntime: + """Validate that Materials Processor imports inside Blender headless mode. + + Args: + runtime: Optional pre-resolved Blender runtime. + package_src: Source directory to prepend to ``PYTHONPATH`` for the + validation process. Defaults to this checkout's ``src`` directory. + timeout: Maximum seconds to wait for Blender. + + Returns: + Runtime metadata populated from Blender itself. + + Raises: + RuntimeError: If Blender fails, times out, or reports an unexpected + version. + """ + runtime = runtime or resolve_blender_runtime() + package_src_path = Path(package_src) if package_src is not None else _default_package_src() + + completed = _run_blender_python(runtime, _runtime_validation_code(), package_src_path, timeout) + if completed.returncode != 0: + raise RuntimeError( + "Blender validation failed with exit code " + f"{completed.returncode}.\nstdout:\n{completed.stdout}\nstderr:\n{completed.stderr}" + ) + + result = _parse_prefixed_output(completed.stdout, completed.stderr, VALIDATION_RESULT_PREFIX, "validation") + if not _matches_requested_version(runtime.version, result["version"]): + raise RuntimeError(f"Expected Blender {runtime.version}, but Blender reported {result['version']}.") + + return BlenderRuntime( + root=runtime.root, + blender_exe=runtime.blender_exe, + version=result["version"], + python_version=result["python_version"], + api_version=result["api_version"], + ) + + +def validate_blender_material_smoke( + runtime: BlenderRuntime | None = None, + package_src: str | os.PathLike[str] | None = None, + timeout: int = 120, +) -> dict: + """Validate Blender traversal/standardization/recreation in headless mode. + + Args: + runtime: Optional pre-resolved Blender runtime. + package_src: Source directory to prepend to ``PYTHONPATH`` for the + validation process. Defaults to this checkout's ``src`` directory. + timeout: Maximum seconds to wait for Blender. + + Returns: + Summary data from the material smoke test. + + Raises: + RuntimeError: If Blender fails, times out, or the smoke graph cannot be + traversed and recreated. + """ + runtime = runtime or resolve_blender_runtime() + package_src_path = Path(package_src) if package_src is not None else _default_package_src() + + completed = _run_blender_python(runtime, _material_smoke_code(), package_src_path, timeout) + if completed.returncode != 0: + raise RuntimeError( + "Blender material smoke validation failed with exit code " + f"{completed.returncode}.\nstdout:\n{completed.stdout}\nstderr:\n{completed.stderr}" + ) + + result = _parse_prefixed_output( + completed.stdout, + completed.stderr, + MATERIAL_SMOKE_RESULT_PREFIX, + "material smoke validation", + ) + if not result["recreated"]: + raise RuntimeError(f"Blender material smoke validation did not recreate the material: {result}") + if "ShaderNodeBsdfPrincipled" not in result["target_node_types"]: + raise RuntimeError(f"Blender material smoke validation did not create a Principled BSDF: {result}") + + return result diff --git a/src/materials_processor/dcc/blender/traverser.py b/src/materials_processor/dcc/blender/traverser.py index d5b4de3..218a23d 100644 --- a/src/materials_processor/dcc/blender/traverser.py +++ b/src/materials_processor/dcc/blender/traverser.py @@ -157,10 +157,12 @@ def _convert_parms_to_dict(node): continue val = socket.default_value - # Convert math-types like Vector or Color/RGBA (which are sequences) to standard lists + # Convert math-types like Vector, Color, RGBA, and Blender arrays to standard lists. if hasattr(val, "copy") or isinstance(val, (list, tuple, bytes, set)): val = list(val) - elif type(val).__name__ in ("Vector", "Color"): + elif type(val).__name__ in ("Vector", "Color", "bpy_prop_array"): + val = list(val) + elif not isinstance(val, str) and hasattr(val, "__iter__"): val = list(val) # Map Blender socket type names to generic types diff --git a/tests/test_blender_runtime.py b/tests/test_blender_runtime.py new file mode 100644 index 0000000..b3d4179 --- /dev/null +++ b/tests/test_blender_runtime.py @@ -0,0 +1,164 @@ +import json +import os +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from materials_processor.dcc.blender.runtime import ( + BLENDER_EXE_ENV_VAR, + BLENDER_ROOT_ENV_VAR, + MATERIAL_SMOKE_RESULT_PREFIX, + VALIDATION_RESULT_PREFIX, + BlenderRuntime, + _default_package_src, + _default_blender_root, + resolve_blender_runtime, + validate_blender_material_smoke, + validate_blender_runtime, +) + +ROOT = Path(__file__).resolve().parents[1] + + +def _fake_blender_root(tmp_path): + tmp_path.mkdir(parents=True, exist_ok=True) + blender_exe = tmp_path / "blender.exe" + blender_exe.write_text("", encoding="utf-8") + return tmp_path + + +def test_default_blender_root_uses_standard_windows_install_path(): + assert _default_blender_root("4.0") == Path("C:/Program Files/Blender Foundation/Blender 4.0") + + +def test_resolve_blender_runtime_uses_explicit_root(tmp_path): + blender_root = _fake_blender_root(tmp_path / "Blender 4.0") + + runtime = resolve_blender_runtime(root=blender_root) + + assert runtime == BlenderRuntime( + root=blender_root.resolve(), + blender_exe=(blender_root / "blender.exe").resolve(), + version="4.0", + python_version="", + api_version="", + ) + + +def test_resolve_blender_runtime_uses_env_executable_override(tmp_path, monkeypatch): + blender_root = _fake_blender_root(tmp_path / "PortableBlender") + monkeypatch.setenv(BLENDER_EXE_ENV_VAR, str(blender_root / "blender.exe")) + + runtime = resolve_blender_runtime() + + assert runtime.root == blender_root.resolve() + assert runtime.blender_exe == (blender_root / "blender.exe").resolve() + + +def test_resolve_blender_runtime_uses_env_root_override(tmp_path, monkeypatch): + blender_root = _fake_blender_root(tmp_path / "Blender 4.2") + monkeypatch.setenv(BLENDER_ROOT_ENV_VAR, str(blender_root)) + + runtime = resolve_blender_runtime(version=None) + + assert runtime.root == blender_root.resolve() + assert runtime.blender_exe == (blender_root / "blender.exe").resolve() + + +def test_resolve_blender_runtime_reports_missing_executable(tmp_path): + with pytest.raises(FileNotFoundError, match="Blender executable"): + resolve_blender_runtime(root=tmp_path) + + +def test_validate_blender_runtime_uses_headless_blender_pythonpath_and_isolated_prefs(tmp_path, monkeypatch): + blender_root = _fake_blender_root(tmp_path / "Blender 4.0") + runtime = resolve_blender_runtime(root=blender_root) + package_src = tmp_path / "src" + package_src.mkdir() + captured = {} + + def fake_run(command, check, capture_output, env, text, timeout): + captured["command"] = command + captured["check"] = check + captured["capture_output"] = capture_output + captured["env"] = env + captured["text"] = text + captured["timeout"] = timeout + result = { + "api_version": "4.0.0", + "package_file": str(package_src / "materials_processor" / "__init__.py"), + "python_version": "3.11.7", + "version": "4.0.2", + } + return SimpleNamespace( + returncode=0, + stdout=f"{VALIDATION_RESULT_PREFIX}{json.dumps(result)}\n", + stderr="", + ) + + monkeypatch.setattr("materials_processor.dcc.blender.runtime.subprocess.run", fake_run) + + validated = validate_blender_runtime(runtime=runtime, package_src=package_src, timeout=3) + + assert validated.version == "4.0.2" + assert validated.python_version == "3.11.7" + assert validated.api_version == "4.0.0" + assert captured["command"][:3] == [str(runtime.blender_exe), "--background", "--factory-startup"] + assert "--python" in captured["command"] + assert captured["command"][-1].endswith("validate_materials_processor.py") + assert captured["check"] is False + assert captured["capture_output"] is True + assert captured["text"] is True + assert captured["timeout"] == 3 + assert captured["env"]["PYTHONPATH"].split(os.pathsep)[0] == str(package_src.resolve()) + assert "materials_processor_blender_user_" in captured["env"]["BLENDER_USER_CONFIG"] + assert "materials_processor_blender_user_" in captured["env"]["BLENDER_USER_SCRIPTS"] + assert "materials_processor_blender_user_" in captured["env"]["BLENDER_USER_DATAFILES"] + + +def test_validate_blender_material_smoke_parses_recreation_result(tmp_path, monkeypatch): + blender_root = _fake_blender_root(tmp_path / "Blender 4.0") + runtime = resolve_blender_runtime(root=blender_root) + package_src = tmp_path / "src" + package_src.mkdir() + + def fake_run(command, check, capture_output, env, text, timeout): + result = { + "node_count": 1, + "output_count": 1, + "recreated": True, + "target_node_types": ["ShaderNodeBsdfPrincipled", "ShaderNodeOutputMaterial"], + } + return SimpleNamespace( + returncode=0, + stdout=f"{MATERIAL_SMOKE_RESULT_PREFIX}{json.dumps(result)}\n", + stderr="", + ) + + monkeypatch.setattr("materials_processor.dcc.blender.runtime.subprocess.run", fake_run) + + result = validate_blender_material_smoke(runtime=runtime, package_src=package_src, timeout=3) + + assert result["recreated"] is True + assert result["target_node_types"] == ["ShaderNodeBsdfPrincipled", "ShaderNodeOutputMaterial"] + + +def test_validate_blender_runtime_default_package_src_points_to_src(): + assert _default_package_src() == ROOT / "src" + + +@pytest.mark.blender +def test_validate_local_blender_runtime_when_available(): + try: + runtime = resolve_blender_runtime(version=None) + except FileNotFoundError as exc: + pytest.skip(str(exc)) + + validated = validate_blender_runtime(runtime=runtime, package_src=ROOT / "src", timeout=120) + smoke = validate_blender_material_smoke(runtime=validated, package_src=ROOT / "src", timeout=120) + + assert validated.version + assert validated.python_version + assert smoke["recreated"] is True + assert "ShaderNodeBsdfPrincipled" in smoke["target_node_types"] diff --git a/tests/test_public_imports.py b/tests/test_public_imports.py index acd2711..500f56a 100644 --- a/tests/test_public_imports.py +++ b/tests/test_public_imports.py @@ -14,6 +14,7 @@ def test_public_core_and_houdini_modules_import(): "materials_processor.dcc.blender", "materials_processor.dcc.blender.addon", "materials_processor.dcc.blender.recreator", + "materials_processor.dcc.blender.runtime", "materials_processor.dcc.blender.traverser", "materials_processor.dcc.houdini.commands", "materials_processor.dcc.houdini.recreator",