From d641279166f1cb8759b13a115c5dfa1326445bf6 Mon Sep 17 00:00:00 2001 From: Ahmed Hindy Date: Sun, 31 May 2026 15:48:26 +0300 Subject: [PATCH] Add top-level CLI workflows --- pyproject.toml | 1 + src/materials_processor/cli.py | 157 +++++++++ src/materials_processor/dcc/blender/cli.py | 380 ++++++++++++++++++--- tests/test_blender_cli.py | 169 +++++++++ tests/test_cli.py | 107 ++++++ tests/test_public_imports.py | 2 + 6 files changed, 776 insertions(+), 40 deletions(-) create mode 100644 src/materials_processor/cli.py create mode 100644 tests/test_cli.py diff --git a/pyproject.toml b/pyproject.toml index 1a161b4..3f7525d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ requires-python = ">=3.11" dependencies = [] [project.scripts] +materials-processor = "materials_processor.cli:main" materials-processor-blender = "materials_processor.dcc.blender.cli:main" [build-system] diff --git a/src/materials_processor/cli.py b/src/materials_processor/cli.py new file mode 100644 index 0000000..d608436 --- /dev/null +++ b/src/materials_processor/cli.py @@ -0,0 +1,157 @@ +"""Top-level command line interface for Materials Processor.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from materials_processor.dcc.blender import cli as blender_cli + + +def _default_package_src() -> Path: + return Path(__file__).resolve().parents[1] + + +def _print_json(payload: dict) -> None: + print(json.dumps(payload, indent=2, sort_keys=True)) + + +def _add_runtime_parser(subparsers) -> argparse.ArgumentParser: + runtime_parser = subparsers.add_parser("runtime", help="Runtime discovery and validation commands.") + runtime_subparsers = runtime_parser.add_subparsers(dest="runtime_command", required=True) + + validate_parser = runtime_subparsers.add_parser("validate", help="Validate an installed DCC runtime.") + validate_parser.add_argument( + "--dcc", + choices=("blender", "maya"), + required=True, + help="DCC runtime to validate.", + ) + validate_parser.add_argument("--timeout", type=int, default=120, help="Validation timeout in seconds.") + validate_parser.add_argument( + "--package-src", + default=None, + help="Source directory to expose to the DCC. Defaults to this checkout's src directory.", + ) + validate_parser.add_argument("--material-smoke", action="store_true", help="Run material traversal/recreation smoke.") + + blender_group = validate_parser.add_argument_group("Blender") + blender_group.add_argument("--blender-exe", help="Explicit path to blender.exe.") + blender_group.add_argument("--blender-root", help="Explicit Blender install root.") + blender_group.add_argument("--blender-version", help="Blender version to discover, e.g. 4.5.") + + maya_group = validate_parser.add_argument_group("Maya") + maya_group.add_argument("--maya-root", help="Explicit Maya install root.") + maya_group.add_argument("--maya-version", default="2024", help="Maya version to resolve. Default: 2024.") + + return runtime_parser + + +def _add_blender_parser(subparsers) -> argparse.ArgumentParser: + blender_parser = subparsers.add_parser("blender", help="Blender scene inspection and export commands.") + blender_subparsers = blender_parser.add_subparsers(dest="blender_command", required=True) + blender_cli.add_blender_export_parser(blender_subparsers) + blender_cli.add_blender_inspect_parser(blender_subparsers) + return blender_parser + + +def build_parser() -> argparse.ArgumentParser: + """Build the top-level argument parser.""" + parser = argparse.ArgumentParser(prog="materials-processor") + subparsers = parser.add_subparsers(dest="command", required=True) + _add_blender_parser(subparsers) + _add_runtime_parser(subparsers) + return parser + + +def _validate_blender_runtime(args) -> dict: + from materials_processor.dcc.blender.runtime import ( + resolve_blender_runtime, + validate_blender_material_smoke, + validate_blender_runtime, + ) + + runtime = resolve_blender_runtime( + version=args.blender_version, + root=args.blender_root, + blender_exe=args.blender_exe, + ) + package_src = args.package_src or _default_package_src() + validated = validate_blender_runtime(runtime=runtime, package_src=package_src, timeout=args.timeout) + result = { + "dcc": "blender", + "root": str(validated.root), + "blender_exe": str(validated.blender_exe), + "version": validated.version, + "python_version": validated.python_version, + "api_version": validated.api_version, + } + if args.material_smoke: + result["material_smoke"] = validate_blender_material_smoke( + runtime=validated, + package_src=package_src, + timeout=args.timeout, + ) + return result + + +def _validate_maya_runtime(args) -> dict: + from materials_processor.dcc.maya.runtime import ( + resolve_maya_runtime, + validate_maya_material_smoke, + validate_maya_runtime, + ) + + runtime = resolve_maya_runtime(version=args.maya_version, root=args.maya_root) + package_src = args.package_src or _default_package_src() + validated = validate_maya_runtime(runtime=runtime, package_src=package_src, timeout=args.timeout) + result = { + "dcc": "maya", + "root": str(validated.root), + "maya_exe": str(validated.maya_exe), + "mayapy_exe": str(validated.mayapy_exe), + "version": validated.version, + "api_version": validated.api_version, + } + if args.material_smoke: + result["material_smoke"] = validate_maya_material_smoke( + runtime=validated, + package_src=package_src, + timeout=args.timeout, + ) + return result + + +def main(argv: list[str] | None = None) -> int: + """Run the top-level command line interface.""" + parser = build_parser() + args = parser.parse_args(argv) + + try: + if args.command == "blender": + if args.blender_command == "export-usd": + _print_json(blender_cli.run_export_from_args(args)) + return 0 + if args.blender_command == "inspect": + _print_json(blender_cli.run_inspect_from_args(args)) + return 0 + + if args.command == "runtime" and args.runtime_command == "validate": + if args.dcc == "blender": + _print_json(_validate_blender_runtime(args)) + return 0 + if args.dcc == "maya": + _print_json(_validate_maya_runtime(args)) + return 0 + + parser.error("Unsupported command.") + return 2 + except (FileNotFoundError, RuntimeError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/materials_processor/dcc/blender/cli.py b/src/materials_processor/dcc/blender/cli.py index 6c7961a..69c83de 100644 --- a/src/materials_processor/dcc/blender/cli.py +++ b/src/materials_processor/dcc/blender/cli.py @@ -7,7 +7,7 @@ import sys import tempfile from dataclasses import asdict -from pathlib import Path +from pathlib import Path, PureWindowsPath from typing import Any from materials_processor.core.graph import MaterialGraph, NodeConnection, NodeInfo, NodeParameter, OutputConnection @@ -24,6 +24,7 @@ "mtlx": "materialx", "openpbr": "openpbr", } +MISSING_TEXTURE_POLICIES = ("warn", "error") def _default_package_src() -> Path: @@ -67,6 +68,120 @@ def _iter_nodeinfos(nodes: list[NodeInfo]): yield from _iter_nodeinfos(node.children_list) +def _texture_remaps_from_args(values: list[str] | None) -> tuple[tuple[str, str], ...]: + """Parse ``OLD=NEW`` texture remap arguments.""" + remaps = [] + for value in values or []: + if "=" not in value: + raise ValueError(f"Texture remap must be in OLD=NEW form: {value}") + old, new = value.split("=", 1) + if not old or not new: + raise ValueError(f"Texture remap must include both OLD and NEW paths: {value}") + remaps.append((old, new)) + return tuple(remaps) + + +def _apply_texture_remaps_to_path( + texture_path: str, + *, + texture_root: str | Path | None = None, + remap_prefixes: tuple[tuple[str, str], ...] = (), +) -> str: + """Apply prefix and search-root remaps to one texture path.""" + remapped = texture_path + for old_prefix, new_prefix in remap_prefixes: + old_norm = old_prefix.replace("\\", "/").rstrip("/") + current_norm = remapped.replace("\\", "/") + if current_norm == old_norm or current_norm.startswith(f"{old_norm}/"): + suffix = current_norm[len(old_norm):].lstrip("/") + remapped = str(Path(new_prefix) / Path(suffix.replace("/", "\\"))) + break + + if texture_root is not None and remapped == texture_path and not Path(remapped.replace("", "1001")).exists(): + texture_name = PureWindowsPath(texture_path).name or Path(texture_path).name + candidate = Path(texture_root) / texture_name + if candidate.exists(): + remapped = str(candidate) + else: + recursive_match = next(Path(texture_root).rglob(texture_name), None) + if recursive_match is not None: + remapped = str(recursive_match) + + return remapped + + +def _apply_texture_remaps( + graph_payload: dict[str, Any], + *, + texture_root: str | Path | None = None, + remap_prefixes: tuple[tuple[str, str], ...] = (), +) -> dict[str, Any]: + """Apply texture remap options directly to extracted graph payload data.""" + if not texture_root and not remap_prefixes: + return graph_payload + + remapped_textures = [] + for graph in graph_payload.get("graphs") or []: + for node in _walk_node_dicts(graph.get("nodeinfo_list") or []): + for parameter in node.get("parameters") or []: + if parameter.get("generic_name") != "filename" or not parameter.get("value"): + continue + original = str(parameter["value"]) + remapped = _apply_texture_remaps_to_path( + original, + texture_root=texture_root, + remap_prefixes=remap_prefixes, + ) + if remapped != original: + parameter["value"] = remapped + remapped_textures.append( + { + "material": graph["material_name"], + "original": original, + "remapped": remapped, + } + ) + + graph_payload["remapped_texture_paths"] = remapped_textures + graph_payload["missing_texture_paths"] = _find_missing_texture_paths(graph_payload) + return graph_payload + + +def _walk_node_dicts(nodes: list[dict[str, Any]]): + """Yield node dictionaries recursively from JSON-compatible graph data.""" + for node in nodes: + yield node + yield from _walk_node_dicts(node.get("children_list") or []) + + +def _find_missing_texture_paths(graph_payload: dict[str, Any]) -> list[dict[str, str]]: + """Return missing texture paths from JSON-compatible graph data.""" + missing = [] + for graph in graph_payload.get("graphs") or []: + for node in _walk_node_dicts(graph.get("nodeinfo_list") or []): + for parameter in node.get("parameters") or []: + if parameter.get("generic_name") != "filename" or not parameter.get("value"): + continue + texture_path = str(parameter["value"]) + normalized = texture_path.replace("", "1001") + if "" not in texture_path and not Path(normalized).exists(): + missing.append({"material": graph["material_name"], "path": texture_path}) + return missing + + +def _enforce_report_policies( + report: dict[str, Any], + *, + fail_on_unsupported: bool = False, + missing_textures: str = "warn", +) -> None: + """Raise when report policy flags request hard failures.""" + if fail_on_unsupported and report.get("unsupported_nodes"): + raise RuntimeError(f"Unsupported Blender nodes were found: {json.dumps(report['unsupported_nodes'], sort_keys=True)}") + if missing_textures == "error" and report.get("missing_texture_paths"): + raise RuntimeError(f"Missing texture paths were found: {json.dumps(report['missing_texture_paths'], sort_keys=True)}") + + def _extract_code(scene_path: Path, graph_json_path: Path) -> str: """Return the Python script executed inside Blender to extract material graphs.""" return f""" @@ -222,6 +337,7 @@ def build_usd_material_files( "read_failures": graph_payload.get("read_failures", []), "unsupported_nodes": graph_payload.get("unsupported_nodes", {}), "missing_texture_paths": graph_payload.get("missing_texture_paths", []), + "remapped_texture_paths": graph_payload.get("remapped_texture_paths", []), "usd_files": {}, } @@ -275,29 +391,117 @@ def export_blender_scene_to_usd( runtime: BlenderRuntime | None = None, package_src: str | Path | None = None, timeout: int = 300, + texture_root: str | Path | None = None, + remap_prefixes: tuple[tuple[str, str], ...] = (), + missing_textures: str = "warn", + fail_on_unsupported: bool = False, + report_json: str | Path | None = None, + graph_json: str | Path | None = None, ) -> dict[str, Any]: """Export Blender scene materials to USD MaterialX/OpenPBR files.""" output_dir = Path(out_dir).expanduser().resolve() output_dir.mkdir(parents=True, exist_ok=True) - graph_json = output_dir / "blender_material_graphs.json" + graph_json_path = Path(graph_json).expanduser().resolve() if graph_json else output_dir / "blender_material_graphs.json" + graph_json_path.parent.mkdir(parents=True, exist_ok=True) extract_blender_material_graphs( scene_path, - graph_json, + graph_json_path, runtime=runtime, package_src=package_src, timeout=timeout, ) - graph_payload = json.loads(graph_json.read_text(encoding="utf-8")) - report = build_usd_material_files(graph_payload, output_dir, targets=targets) - report["graph_json"] = str(graph_json) + graph_payload = json.loads(graph_json_path.read_text(encoding="utf-8")) + graph_payload = _apply_texture_remaps( + graph_payload, + texture_root=texture_root, + remap_prefixes=remap_prefixes, + ) + graph_json_path.write_text(json.dumps(graph_payload, indent=2, sort_keys=True), encoding="utf-8") - report_json = output_dir / "export_report.json" - report["report_json"] = str(report_json) - report_json.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8") + report = build_usd_material_files(graph_payload, output_dir, targets=targets) + report["graph_json"] = str(graph_json_path) + + report_json_path = Path(report_json).expanduser().resolve() if report_json else output_dir / "export_report.json" + report_json_path.parent.mkdir(parents=True, exist_ok=True) + report["report_json"] = str(report_json_path) + report_json_path.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8") + _enforce_report_policies( + report, + fail_on_unsupported=fail_on_unsupported, + missing_textures=missing_textures, + ) return report +def inspect_blender_scene( + scene_path: str | Path, + *, + runtime: BlenderRuntime | None = None, + package_src: str | Path | None = None, + timeout: int = 300, + texture_root: str | Path | None = None, + remap_prefixes: tuple[tuple[str, str], ...] = (), + graph_json: str | Path | None = None, + report_json: str | Path | None = None, + missing_textures: str = "warn", + fail_on_unsupported: bool = False, +) -> dict[str, Any]: + """Inspect a Blender scene's materials without writing USD files.""" + if graph_json: + graph_json_path = Path(graph_json).expanduser().resolve() + graph_json_path.parent.mkdir(parents=True, exist_ok=True) + cleanup_dir = None + else: + cleanup_dir = tempfile.TemporaryDirectory(prefix="materials_processor_blender_inspect_") + graph_json_path = Path(cleanup_dir.name) / "blender_material_graphs.json" + + try: + extract_blender_material_graphs( + scene_path, + graph_json_path, + runtime=runtime, + package_src=package_src, + timeout=timeout, + ) + graph_payload = json.loads(graph_json_path.read_text(encoding="utf-8")) + graph_payload = _apply_texture_remaps( + graph_payload, + texture_root=texture_root, + remap_prefixes=remap_prefixes, + ) + if graph_json: + graph_json_path.write_text(json.dumps(graph_payload, indent=2, sort_keys=True), encoding="utf-8") + + graphs = graph_payload.get("graphs") or [] + report = { + "scene": graph_payload.get("scene"), + "material_count": graph_payload.get("material_count", 0), + "node_material_count": graph_payload.get("node_material_count", 0), + "graph_count": len(graphs), + "read_failures": graph_payload.get("read_failures", []), + "unsupported_nodes": graph_payload.get("unsupported_nodes", {}), + "missing_texture_paths": graph_payload.get("missing_texture_paths", []), + "remapped_texture_paths": graph_payload.get("remapped_texture_paths", []), + } + if graph_json: + report["graph_json"] = str(graph_json_path) + if report_json: + report_json_path = Path(report_json).expanduser().resolve() + report_json_path.parent.mkdir(parents=True, exist_ok=True) + report["report_json"] = str(report_json_path) + report_json_path.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8") + _enforce_report_policies( + report, + fail_on_unsupported=fail_on_unsupported, + missing_textures=missing_textures, + ) + return report + finally: + if cleanup_dir is not None: + cleanup_dir.cleanup() + + def _targets_from_args(values: list[str]) -> tuple[str, ...]: targets = [] values = values or ["all"] @@ -309,10 +513,48 @@ def _targets_from_args(values: list[str]) -> tuple[str, ...]: return tuple(dict.fromkeys(targets)) -def _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="materials-processor-blender") - subparsers = parser.add_subparsers(dest="command", required=True) +def add_blender_runtime_arguments(parser: argparse.ArgumentParser) -> None: + """Add common Blender runtime options to an argument parser.""" + parser.add_argument("--blender-exe", help="Explicit path to blender.exe.") + parser.add_argument("--blender-root", help="Explicit Blender install root.") + parser.add_argument("--blender-version", help="Blender version to discover, e.g. 4.5.") + parser.add_argument("--timeout", type=int, default=300, help="Headless Blender timeout in seconds.") + parser.add_argument( + "--package-src", + default=None, + help="Source directory to expose to Blender. Defaults to this checkout's src directory.", + ) + +def add_texture_arguments(parser: argparse.ArgumentParser) -> None: + """Add texture reporting/remap options to an argument parser.""" + parser.add_argument( + "--texture-root", + default=None, + help="Directory to search by filename for missing texture paths.", + ) + parser.add_argument( + "--remap-prefix", + action="append", + default=None, + metavar="OLD=NEW", + help="Remap texture paths with the given prefix replacement. Can be passed more than once.", + ) + parser.add_argument( + "--missing-textures", + choices=MISSING_TEXTURE_POLICIES, + default="warn", + help="Whether missing textures should warn in the report or fail the command.", + ) + parser.add_argument( + "--fail-on-unsupported", + action="store_true", + help="Fail when unsupported Blender nodes are found.", + ) + + +def add_blender_export_parser(subparsers) -> argparse.ArgumentParser: + """Add the Blender ``export-usd`` subcommand to a subparser collection.""" export_parser = subparsers.add_parser( "export-usd", help="Export node materials from a .blend scene to USD material files.", @@ -330,15 +572,77 @@ def _build_parser() -> argparse.ArgumentParser: default=None, help="USD material target to export. Can be passed more than once. Default: all.", ) - export_parser.add_argument("--blender-exe", help="Explicit path to blender.exe.") - export_parser.add_argument("--blender-root", help="Explicit Blender install root.") - export_parser.add_argument("--blender-version", help="Blender version to discover, e.g. 4.5.") - export_parser.add_argument("--timeout", type=int, default=300, help="Headless Blender timeout in seconds.") - export_parser.add_argument( - "--package-src", - default=None, - help="Source directory to expose to Blender. Defaults to this checkout's src directory.", + export_parser.add_argument("--report-json", default=None, help="Explicit path for the export report JSON.") + export_parser.add_argument("--graph-json", default=None, help="Explicit path for the extracted material graph JSON.") + add_blender_runtime_arguments(export_parser) + add_texture_arguments(export_parser) + return export_parser + + +def add_blender_inspect_parser(subparsers) -> argparse.ArgumentParser: + """Add the Blender ``inspect`` subcommand to a subparser collection.""" + inspect_parser = subparsers.add_parser( + "inspect", + help="Inspect node materials in a .blend scene without writing USD files.", ) + inspect_parser.add_argument("scene", help="Path to the .blend scene.") + inspect_parser.add_argument("--report-json", default=None, help="Optional path for the inspection report JSON.") + inspect_parser.add_argument("--graph-json", default=None, help="Optional path for the extracted material graph JSON.") + add_blender_runtime_arguments(inspect_parser) + add_texture_arguments(inspect_parser) + return inspect_parser + + +def _runtime_from_args(args) -> BlenderRuntime: + """Resolve Blender runtime from parsed arguments.""" + return resolve_blender_runtime( + version=args.blender_version, + root=args.blender_root, + blender_exe=args.blender_exe, + ) + + +def run_export_from_args(args) -> dict[str, Any]: + """Run Blender USD export from parsed CLI arguments.""" + out_dir = Path(args.out_dir) if args.out_dir else Path(tempfile.mkdtemp(prefix="materials_processor_blender_usd_")) + return export_blender_scene_to_usd( + args.scene, + out_dir, + targets=_targets_from_args(args.target), + runtime=_runtime_from_args(args), + package_src=args.package_src, + timeout=args.timeout, + texture_root=args.texture_root, + remap_prefixes=_texture_remaps_from_args(args.remap_prefix), + missing_textures=args.missing_textures, + fail_on_unsupported=args.fail_on_unsupported, + report_json=args.report_json, + graph_json=args.graph_json, + ) + + +def run_inspect_from_args(args) -> dict[str, Any]: + """Run Blender material inspection from parsed CLI arguments.""" + return inspect_blender_scene( + args.scene, + runtime=_runtime_from_args(args), + package_src=args.package_src, + timeout=args.timeout, + texture_root=args.texture_root, + remap_prefixes=_texture_remaps_from_args(args.remap_prefix), + graph_json=args.graph_json, + report_json=args.report_json, + missing_textures=args.missing_textures, + fail_on_unsupported=args.fail_on_unsupported, + ) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="materials-processor-blender") + subparsers = parser.add_subparsers(dest="command", required=True) + + add_blender_export_parser(subparsers) + add_blender_inspect_parser(subparsers) return parser @@ -348,26 +652,22 @@ def main(argv: list[str] | None = None) -> int: parser = _build_parser() args = parser.parse_args(argv) - if args.command == "export-usd": - out_dir = Path(args.out_dir) if args.out_dir else Path(tempfile.mkdtemp(prefix="materials_processor_blender_usd_")) - runtime = resolve_blender_runtime( - version=args.blender_version, - root=args.blender_root, - blender_exe=args.blender_exe, - ) - report = export_blender_scene_to_usd( - args.scene, - out_dir, - targets=_targets_from_args(args.target), - runtime=runtime, - package_src=args.package_src, - timeout=args.timeout, - ) - print(json.dumps(report, indent=2, sort_keys=True)) - return 0 - - parser.error(f"Unsupported command: {args.command}") - return 2 + try: + if args.command == "export-usd": + report = run_export_from_args(args) + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 + + if args.command == "inspect": + report = run_inspect_from_args(args) + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 + + parser.error(f"Unsupported command: {args.command}") + return 2 + except (FileNotFoundError, RuntimeError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 if __name__ == "__main__": diff --git a/tests/test_blender_cli.py b/tests/test_blender_cli.py index f3042af..41a7bf9 100644 --- a/tests/test_blender_cli.py +++ b/tests/test_blender_cli.py @@ -71,6 +71,21 @@ def _graph_payload(material_name="Cli Material"): } +def _texture_graph_payload(texture_path): + payload = _graph_payload("Textured Material") + payload["graphs"][0]["nodeinfo_list"][0]["node_type"] = "GENERIC::image" + payload["graphs"][0]["nodeinfo_list"][0]["parameters"] = [ + { + "generic_name": "filename", + "generic_type": "string1", + "direction": "input", + "value": texture_path, + } + ] + payload["missing_texture_paths"] = [{"material": "Textured Material", "path": texture_path}] + return payload + + def test_build_usd_material_files_writes_materialx_and_openpbr(tmp_path): report = cli.build_usd_material_files(_graph_payload(), tmp_path) @@ -110,6 +125,138 @@ def fake_extract(scene_path, graph_json_path, **kwargs): assert Path(report["usd_files"]["mtlx"]["path"]).is_file() +def test_export_blender_scene_to_usd_creates_explicit_graph_json_parent(tmp_path, monkeypatch): + scene = tmp_path / "scene.blend" + scene.write_text("fake blend", encoding="utf-8") + graph_json = tmp_path / "nested" / "graphs" / "materials.json" + + def fake_extract(scene_path, graph_json_path, **kwargs): + Path(graph_json_path).write_text(json.dumps(_graph_payload()), encoding="utf-8") + return {"graph_count": 1} + + monkeypatch.setattr(cli, "extract_blender_material_graphs", fake_extract) + + report = cli.export_blender_scene_to_usd( + scene, + tmp_path / "export", + targets=("mtlx",), + graph_json=graph_json, + ) + + assert report["graph_json"] == str(graph_json.resolve()) + assert graph_json.is_file() + + +def test_export_blender_scene_to_usd_applies_texture_prefix_remap(tmp_path, monkeypatch): + scene = tmp_path / "scene.blend" + scene.write_text("fake blend", encoding="utf-8") + texture_root = tmp_path / "textures" + texture_root.mkdir() + fixed_texture = texture_root / "basecolor.png" + fixed_texture.write_text("fake image", encoding="utf-8") + + def fake_extract(scene_path, graph_json_path, **kwargs): + payload = _texture_graph_payload(r"C:\PROJECT\textures\basecolor.png") + Path(graph_json_path).write_text(json.dumps(payload), encoding="utf-8") + return {"graph_count": 1} + + monkeypatch.setattr(cli, "extract_blender_material_graphs", fake_extract) + + report = cli.export_blender_scene_to_usd( + scene, + tmp_path / "export", + targets=("mtlx",), + remap_prefixes=((r"C:\PROJECT\textures", str(texture_root)),), + ) + + assert report["missing_texture_paths"] == [] + assert report["remapped_texture_paths"] == [ + { + "material": "Textured Material", + "original": r"C:\PROJECT\textures\basecolor.png", + "remapped": str(fixed_texture), + } + ] + + +def test_export_blender_scene_to_usd_applies_texture_root_recursive_remap(tmp_path, monkeypatch): + scene = tmp_path / "scene.blend" + scene.write_text("fake blend", encoding="utf-8") + nested_texture = tmp_path / "textures" / "asset" / "basecolor.png" + nested_texture.parent.mkdir(parents=True) + nested_texture.write_text("fake image", encoding="utf-8") + + def fake_extract(scene_path, graph_json_path, **kwargs): + payload = _texture_graph_payload(r"C:\missing\basecolor.png") + Path(graph_json_path).write_text(json.dumps(payload), encoding="utf-8") + return {"graph_count": 1} + + monkeypatch.setattr(cli, "extract_blender_material_graphs", fake_extract) + + report = cli.export_blender_scene_to_usd( + scene, + tmp_path / "export", + targets=("mtlx",), + texture_root=tmp_path / "textures", + ) + + assert report["missing_texture_paths"] == [] + assert report["remapped_texture_paths"][0]["remapped"] == str(nested_texture) + + +def test_inspect_blender_scene_reports_without_writing_usd(tmp_path, monkeypatch): + scene = tmp_path / "scene.blend" + scene.write_text("fake blend", encoding="utf-8") + report_json = tmp_path / "inspect_report.json" + + def fake_extract(scene_path, graph_json_path, **kwargs): + Path(graph_json_path).write_text(json.dumps(_graph_payload()), encoding="utf-8") + return {"graph_count": 1} + + monkeypatch.setattr(cli, "extract_blender_material_graphs", fake_extract) + + report = cli.inspect_blender_scene(scene, report_json=report_json) + + assert report["graph_count"] == 1 + assert report["report_json"] == str(report_json.resolve()) + assert report_json.is_file() + assert "usd_files" not in report + + +def test_inspect_blender_scene_can_fail_on_unsupported_nodes(tmp_path, monkeypatch): + scene = tmp_path / "scene.blend" + scene.write_text("fake blend", encoding="utf-8") + payload = _graph_payload() + payload["unsupported_nodes"] = {"Mat": [{"node_name": "Group", "node_path": "/mat/Mat/Group"}]} + + def fake_extract(scene_path, graph_json_path, **kwargs): + Path(graph_json_path).write_text(json.dumps(payload), encoding="utf-8") + return {"graph_count": 1} + + monkeypatch.setattr(cli, "extract_blender_material_graphs", fake_extract) + + with pytest.raises(RuntimeError, match="Unsupported Blender nodes"): + cli.inspect_blender_scene(scene, fail_on_unsupported=True) + + +def test_inspect_blender_scene_writes_report_before_missing_texture_failure(tmp_path, monkeypatch): + scene = tmp_path / "scene.blend" + scene.write_text("fake blend", encoding="utf-8") + report_json = tmp_path / "inspect_report.json" + + def fake_extract(scene_path, graph_json_path, **kwargs): + Path(graph_json_path).write_text(json.dumps(_texture_graph_payload(r"C:\missing\basecolor.png")), encoding="utf-8") + return {"graph_count": 1} + + monkeypatch.setattr(cli, "extract_blender_material_graphs", fake_extract) + + with pytest.raises(RuntimeError, match="Missing texture paths"): + cli.inspect_blender_scene(scene, report_json=report_json, missing_textures="error") + + assert report_json.is_file() + assert json.loads(report_json.read_text(encoding="utf-8"))["missing_texture_paths"] + + def test_build_usd_material_files_honors_single_target_alias(tmp_path): report = cli.build_usd_material_files(_graph_payload(), tmp_path, targets=("materialx",)) @@ -160,9 +307,31 @@ def fake_export(scene_path, out_dir, **kwargs): assert "blender_scene_openpbr.usda" in capsys.readouterr().out +def test_blender_cli_reports_runtime_errors_without_traceback(tmp_path, monkeypatch, capsys): + scene = tmp_path / "scene.blend" + scene.write_text("fake blend", encoding="utf-8") + + def fail(args): + raise RuntimeError("unsupported nodes") + + monkeypatch.setattr(cli, "run_inspect_from_args", fail) + + exit_code = cli.main(["inspect", str(scene)]) + captured = capsys.readouterr() + + assert exit_code == 1 + assert captured.err == "error: unsupported nodes\n" + assert "Traceback" not in captured.err + + def test_blender_cli_targets_all_expands_without_duplicates(): assert cli._targets_from_args(["materialx", "all", "mtlx"]) == ("mtlx", "openpbr") def test_blender_cli_targets_default_to_all(): assert cli._targets_from_args([]) == ("mtlx", "openpbr") + + +def test_texture_remaps_require_old_equals_new_form(): + with pytest.raises(ValueError, match="OLD=NEW"): + cli._texture_remaps_from_args(["C:/old"]) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..4fc6db1 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,107 @@ +"""Tests for the top-level Materials Processor CLI.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from materials_processor import cli + + +def test_top_level_cli_blender_export_dispatches(monkeypatch, tmp_path, capsys): + scene = tmp_path / "scene.blend" + scene.write_text("fake blend", encoding="utf-8") + captured = {} + + def fake_export(args): + captured["args"] = args + return { + "scene": args.scene, + "usd_files": {"mtlx": {"path": "out.usda"}}, + } + + monkeypatch.setattr(cli.blender_cli, "run_export_from_args", fake_export) + + exit_code = cli.main([ + "blender", + "export-usd", + str(scene), + "--target", + "materialx", + "--out-dir", + str(tmp_path / "out"), + ]) + + assert exit_code == 0 + assert captured["args"].blender_command == "export-usd" + assert captured["args"].target == ["materialx"] + assert json.loads(capsys.readouterr().out)["usd_files"]["mtlx"]["path"] == "out.usda" + + +def test_top_level_cli_blender_inspect_dispatches(monkeypatch, tmp_path, capsys): + scene = tmp_path / "scene.blend" + scene.write_text("fake blend", encoding="utf-8") + captured = {} + + def fake_inspect(args): + captured["args"] = args + return {"scene": args.scene, "graph_count": 1} + + monkeypatch.setattr(cli.blender_cli, "run_inspect_from_args", fake_inspect) + + exit_code = cli.main(["blender", "inspect", str(scene), "--missing-textures", "error"]) + + assert exit_code == 0 + assert captured["args"].blender_command == "inspect" + assert captured["args"].missing_textures == "error" + assert json.loads(capsys.readouterr().out)["graph_count"] == 1 + + +def test_top_level_cli_runtime_validate_blender_dispatches(monkeypatch, capsys): + monkeypatch.setattr( + cli, + "_validate_blender_runtime", + lambda args: {"dcc": "blender", "version": "4.5.0", "material_smoke": args.material_smoke}, + ) + + exit_code = cli.main(["runtime", "validate", "--dcc", "blender", "--material-smoke"]) + + assert exit_code == 0 + assert json.loads(capsys.readouterr().out) == { + "dcc": "blender", + "material_smoke": True, + "version": "4.5.0", + } + + +def test_top_level_cli_runtime_validate_maya_dispatches(monkeypatch, capsys): + monkeypatch.setattr(cli, "_validate_maya_runtime", lambda args: {"dcc": "maya", "version": args.maya_version}) + + exit_code = cli.main(["runtime", "validate", "--dcc", "maya", "--maya-version", "2024"]) + + assert exit_code == 0 + assert json.loads(capsys.readouterr().out) == {"dcc": "maya", "version": "2024"} + + +def test_top_level_cli_reports_runtime_errors_without_traceback(monkeypatch, tmp_path, capsys): + scene = tmp_path / "scene.blend" + scene.write_text("fake blend", encoding="utf-8") + + def fail(args): + raise RuntimeError("missing textures") + + monkeypatch.setattr(cli.blender_cli, "run_inspect_from_args", fail) + + exit_code = cli.main(["blender", "inspect", str(scene)]) + captured = capsys.readouterr() + + assert exit_code == 1 + assert captured.err == "error: missing textures\n" + assert "Traceback" not in captured.err + + +def test_top_level_parser_exposes_expected_commands(): + help_text = cli.build_parser().format_help() + + assert "blender" in help_text + assert "runtime" in help_text diff --git a/tests/test_public_imports.py b/tests/test_public_imports.py index c213307..2662e99 100644 --- a/tests/test_public_imports.py +++ b/tests/test_public_imports.py @@ -6,6 +6,7 @@ def test_public_core_and_houdini_modules_import(): modules = [ "materials_processor", + "materials_processor.cli", "materials_processor.core", "materials_processor.core.adapters", "materials_processor.core.conversion", @@ -14,6 +15,7 @@ def test_public_core_and_houdini_modules_import(): "materials_processor.dcc.blender", "materials_processor.dcc.blender.addon", "materials_processor.dcc.blender.adapters", + "materials_processor.dcc.blender.cli", "materials_processor.dcc.blender.recreator", "materials_processor.dcc.blender.runtime", "materials_processor.dcc.blender.traverser",