"""Managed-Python launcher for the fixed FreeCAD authoring worker.""" from __future__ import annotations import hashlib import json import os import re import shutil import subprocess import sys from datetime import datetime, timezone from pathlib import Path from typing import Any from preview_renderer import render from recipe import RecipeError, normalize_model ADAPTER_VERSION = "1.0.0" FREECAD_ENV = "ZCBOT_FREECAD_CMD" JOB_ENV = "ZCBOT_FREECAD_JOB_DIR" MIN_VERSION = (1, 1, 3) MAX_VERSION = (1, 2, 0) MAX_INPUT_BYTES = 256 * 1024 * 1024 OUTPUT_MEDIA = { "model.FCStd": ("project", "application/vnd.freecad"), "model.step": ("geometry_step", "model/step"), "model-preview.png": ("preview", "image/png"), "model-recipe.json": ("model_recipe", "application/json"), "model-manifest.json": ("model_manifest", "application/json"), "provenance.json": ("provenance", "application/json"), } class AdapterError(RuntimeError): def __init__(self, code: str, detail: str = "") -> None: super().__init__(detail or code) self.code = code def _atomic_json(path: Path, value: Any) -> None: path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_name(path.name + ".tmp-" + os.urandom(8).hex()) try: with temporary.open("w", encoding="utf-8", newline="\n") as handle: json.dump( value, handle, ensure_ascii=False, indent=2, sort_keys=True, allow_nan=False, ) handle.write("\n") handle.flush() os.fsync(handle.fileno()) os.replace(temporary, path) finally: temporary.unlink(missing_ok=True) def _sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def _candidate_paths() -> list[Path]: candidates: list[Path] = [] configured = os.environ.get(FREECAD_ENV) if configured: candidates.append(Path(configured)) discovered = shutil.which("FreeCADCmd.exe") if discovered: candidates.append(Path(discovered)) for variable in ("ProgramFiles", "ProgramW6432"): root = os.environ.get(variable) if not root: continue base = Path(root) candidates.extend( sorted(base.glob("FreeCAD 1.1*/bin/FreeCADCmd.exe"), reverse=True) ) candidates.extend( sorted(base.glob("FreeCAD 1.1*/FreeCADCmd.exe"), reverse=True) ) candidates.extend( sorted(base.glob("FreeCAD*/bin/FreeCADCmd.exe"), reverse=True) ) result: list[Path] = [] seen: set[str] = set() for candidate in candidates: if candidate.name.casefold() != "freecadcmd.exe": continue try: resolved = candidate.resolve(strict=True) except (OSError, RuntimeError): continue key = str(resolved).casefold() if resolved.is_file() and key not in seen: seen.add(key) result.append(resolved) return result def _version(executable: Path) -> str: result = subprocess.run( [str(executable), "--version"], check=False, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=7, env=_clean_environment(), ) output = (result.stdout + "\n" + result.stderr).strip() match = re.search( r"FreeCAD(?:Cmd)?\s+([0-9]+\.[0-9]+\.[0-9]+)", output, re.IGNORECASE ) if result.returncode != 0 or match is None: raise AdapterError("FREECAD_VERSION_PROBE_FAILED", output[:300]) lowered = output.casefold() if any( token in lowered for token in ( "rc", "weekly", "experimental", "development", "preview", "devr", ".dev", ) ) or re.search(r"\ddev(?:[.\dr]|$)", lowered): raise AdapterError("FREECAD_PRERELEASE_UNSUPPORTED", output[:300]) version = match.group(1) parts = tuple(int(item) for item in version.split(".")) if parts < MIN_VERSION or parts >= MAX_VERSION: raise AdapterError("FREECAD_VERSION_UNSUPPORTED", version) return version def _find_freecad() -> tuple[Path, str]: failures: list[str] = [] for candidate in _candidate_paths(): try: return candidate, _version(candidate) except (AdapterError, OSError, subprocess.SubprocessError) as exc: failures.append(f"{candidate}: {exc}") detail = "; ".join(failures) if failures else "FreeCADCmd.exe was not found" raise AdapterError("FREECAD_NOT_AVAILABLE", detail[:500]) def _clean_environment() -> dict[str, str]: environment = dict(os.environ) for name in tuple(environment): if name.casefold() in {"pythonpath", "pythonhome"}: environment.pop(name, None) environment["PYTHONNOUSERSITE"] = "1" environment["PYTHONUTF8"] = "1" environment["PYTHONIOENCODING"] = "utf-8" return environment def _record(job_dir: Path) -> dict[str, Any]: value = json.loads( (job_dir / "request" / "request.json").read_text(encoding="utf-8") ) if not isinstance(value, dict) or not isinstance(value.get("request"), dict): raise AdapterError("FREECAD_JOB_REQUEST_INVALID") return value def _input(job_dir: Path, bindings: list[dict[str, Any]]) -> Path | None: if not bindings: return None if len(bindings) != 1 or bindings[0].get("key") != "geometry": raise AdapterError("FREECAD_INPUT_BINDING_INVALID") input_root = (job_dir / "input").resolve(strict=True) directory = (input_root / "geometry").resolve(strict=True) if not directory.is_relative_to(input_root): raise AdapterError("FREECAD_INPUT_PATH_ESCAPES_JOB") files = [ item for item in directory.iterdir() if item.is_file() and not item.name.startswith(".") ] if len(files) != 1: raise AdapterError("FREECAD_INPUT_FILE_COUNT_INVALID") path = files[0].resolve(strict=True) if not path.is_relative_to(input_root) or path.suffix.casefold() not in { ".step", ".stp", }: raise AdapterError("FREECAD_STEP_INPUT_REQUIRED") if path.stat().st_size > MAX_INPUT_BYTES: raise AdapterError("FREECAD_STEP_INPUT_TOO_LARGE") return path def _artifact(path: Path) -> dict[str, Any]: artifact_id, media_type = OUTPUT_MEDIA[path.name] return { "artifact_id": artifact_id, "filename": path.name, "media_type": media_type, "size_bytes": path.stat().st_size, "sha256": _sha256(path), } def _terminal( record: dict[str, Any], status: str, artifacts: list[dict[str, Any]], code: str = "", detail: str = "", ) -> dict[str, Any]: return { "job_id": record.get("job_id", ""), "lease_id": record.get("lease_id", ""), "request_digest": record.get("request_digest", ""), "status": status, "artifacts": artifacts, "error": {} if not code else {"code": code, "detail": detail[:500]}, "finished_at": datetime.now(timezone.utc).isoformat(), } def probe() -> int: payload: dict[str, Any] = { "adapter_version": ADAPTER_VERSION, "software": "FreeCAD", "software_version": None, "health": "unavailable", "detail": "", } try: executable, version = _find_freecad() payload.update( { "software_version": version, "health": "ready", "detail": f"FreeCADCmd.exe {version} detected: {executable}", } ) except (AdapterError, OSError, subprocess.SubprocessError) as exc: payload["detail"] = str(exc)[:500] print(json.dumps(payload, ensure_ascii=False)) return 0 def run(job_dir: Path) -> list[dict[str, Any]]: job_dir = job_dir.resolve(strict=True) record = _record(job_dir) request = record["request"] if request.get("outputs"): raise AdapterError("FREECAD_OUTPUTS_MUST_BE_EMPTY") bindings = request.get("inputs") or [] input_path = _input(job_dir, bindings) try: normalized = normalize_model( request["operation"]["model"], input_path is not None ) except RecipeError as exc: raise AdapterError(exc.code, str(exc)) from exc executable, software_version = _find_freecad() output = (job_dir / "output").resolve() output.mkdir(exist_ok=True) if not output.is_relative_to(job_dir): raise AdapterError("FREECAD_OUTPUT_PATH_ESCAPES_JOB") metadata = output / ".meta" metadata.mkdir(exist_ok=True) _atomic_json( metadata / "resolved-job.json", { "recipe": request["operation"]["model"], "model": normalized, "input_path": str(input_path) if input_path else None, "software_version": software_version, "adapter_version": ADAPTER_VERSION, "request_digest": record.get("request_digest"), }, ) runtime = job_dir / ".freecad-runtime" runtime.mkdir(exist_ok=False) user_cfg = runtime / "user.cfg" system_cfg = runtime / "system.cfg" user_cfg.write_text("", encoding="utf-8") system_cfg.write_text("", encoding="utf-8") script = Path(__file__).with_name("freecad_worker.py").resolve(strict=True) environment = _clean_environment() environment.update( { JOB_ENV: str(job_dir), "FREECAD_USER_HOME": str(runtime / "home"), "FREECAD_USER_DATA": str(runtime / "data"), "FREECAD_USER_TEMP": str(runtime / "temp"), } ) for name in ("home", "data", "temp"): (runtime / name).mkdir() try: result = subprocess.run( [ str(executable), "--safe-mode", "--user-cfg", str(user_cfg), "--system-cfg", str(system_cfg), str(script), ], check=False, env=environment, ) finally: shutil.rmtree(runtime, ignore_errors=True) engine_result_path = metadata / "engine-result.json" if not engine_result_path.is_file(): raise AdapterError( "FREECAD_NO_ENGINE_RESULT", f"FreeCADCmd.exe exited with code {result.returncode}", ) engine_result = json.loads(engine_result_path.read_text(encoding="utf-8")) if engine_result.get("status") != "succeeded": raise AdapterError( str(engine_result.get("code") or "FREECAD_ENGINE_FAILED"), str(engine_result.get("detail") or "FreeCAD engine failed"), ) mesh_path = metadata / "preview-mesh.json" render(mesh_path, output / "model-preview.png", normalized.get("preview")) mesh_path.unlink(missing_ok=True) (metadata / "resolved-job.json").unlink(missing_ok=True) engine_result_path.unlink(missing_ok=True) paths = [ output / "model.FCStd", output / "model.step", output / "model-preview.png", output / "model-recipe.json", output / "model-manifest.json", metadata / "provenance.json", ] if any(not path.is_file() or path.stat().st_size == 0 for path in paths): raise AdapterError("FREECAD_OUTPUT_MISSING") return [_artifact(path) for path in paths] def main() -> int: if sys.argv[1:] == ["--probe"]: return probe() if len(sys.argv) != 2: print("[ERR] Usage: worker.py ", file=sys.stderr) return 2 job_dir = Path(sys.argv[1]) record: dict[str, Any] = {} try: record = _record(job_dir) artifacts = run(job_dir) _atomic_json(job_dir / "artifacts.json", artifacts) _atomic_json( job_dir / "terminal.json", _terminal(record, "succeeded", artifacts) ) print("[OK] FreeCAD model authored and verified.") return 0 except Exception as exc: # noqa: BLE001 - terminal must capture every launcher/engine failure code = exc.code if isinstance(exc, AdapterError) else "FREECAD_ADAPTER_FAILED" try: _atomic_json( job_dir / "terminal.json", _terminal(record, "failed", [], code, str(exc)), ) except OSError: pass print(f"[ERR] {code}: {exc}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())