import argparse import os import shutil import subprocess import sys import webbrowser from pathlib import Path from tempfile import TemporaryDirectory, mkdtemp from zipfile import ZipFile PROJECT_ROOT = Path(__file__).resolve().parents[2] def project_env_value(name, path=None): env_path = path or PROJECT_ROOT / ".env" if not env_path.is_file(): return None for raw_line in env_path.read_text(encoding="utf-8").splitlines(): line = raw_line.strip() if not line or line.startswith("#"): continue if line.startswith("export "): line = line.removeprefix("export ").lstrip() key, separator, value = line.partition("=") if separator and key.strip() == name: value = value.strip() if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": value = value[1:-1] return value or None return None def blender_path(explicit=None): candidate = ( explicit or os.environ.get("BLENDER_BIN") or project_env_value("BLENDER_BIN") or shutil.which("blender") ) if not candidate: raise RuntimeError( "Blender not found: pass --blender, set BLENDER_BIN, add it to .env, " "or add blender to PATH" ) path = Path(candidate).expanduser() if not path.is_file(): raise RuntimeError(f"Blender executable does not exist: {path}") return path def run(command): print("+", subprocess.list2cmdline([str(part) for part in command]), flush=True) subprocess.run(command, cwd=PROJECT_ROOT, check=True) def run_blender_modules(executable, modules, arguments=()): with TemporaryDirectory(prefix="anyimage-node-dependencies-") as directory: for wheel in (PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("scipy-*.whl"): with ZipFile(wheel) as archive: archive.extractall(Path(directory) / wheel.name.split("-")[2]) paths = [str(PROJECT_ROOT), str(PROJECT_ROOT / "src"), str(next((PROJECT_ROOT / "src/anyimage/wheels").glob("blendjob-*.whl")))] bootstrap = ( f"import sys, runpy; sys.path[:0] = {paths!r}; " f"sys.path.insert(0, {directory!r} + '/cp' + str(sys.version_info.major) + str(sys.version_info.minor)); " ) for module_name in modules: run( ( executable, "--factory-startup", "--background", "--python-exit-code", "1", "--python-expr", bootstrap + f"sys.argv = {[module_name, *arguments]!r}; " + f"runpy.run_module({module_name!r}, run_name='__main__')", ) ) def main(): parser = argparse.ArgumentParser(description="Build, check, or preview node groups.") commands = parser.add_subparsers(dest="command") for name, help_text in ( ("build", "Build assets, validate them, and run tests"), ("check", "Validate saved assets and run tests"), ("preview", "Preview node groups from current source"), ): command = commands.add_parser(name, help=help_text) command.add_argument("--blender", help="Blender executable; overrides BLENDER_BIN, .env, and PATH") if name == "preview": command.add_argument("--output", type=Path, help="Output HTML path; defaults to a temporary directory") command.add_argument("--no-open", action="store_true", help="Save without opening a browser") command.add_argument("--fragment", action="store_true", help="Save an HTML fragment without opening it") else: command.add_argument("--skip-tests", action="store_true", help="Skip pytest, keeping asset validation") options = parser.parse_args() if options.command is None: parser.print_help() return executable = blender_path(options.blender) if options.command == "preview": output = (options.output or Path(mkdtemp(prefix="anyimage-node-preview-")) / "nodes.html").resolve() arguments = ["--output", str(output)] if options.fragment: arguments.append("--fragment") run_blender_modules(executable, ("tools.nodes.preview.export",), arguments) print(output) if not options.no_open and not options.fragment: webbrowser.open(output.as_uri()) return modules = ("tools.nodes.build", "tools.nodes.check") if options.command == "build" else ("tools.nodes.check",) run_blender_modules(executable, modules) if not options.skip_tests: run((sys.executable, "-m", "pytest")) if __name__ == "__main__": main()