384 lines
13 KiB
Python
384 lines
13 KiB
Python
"""Run every current example model in an isolated process and export scenes.
|
|
|
|
This is intentionally a disposable harness for the example tagging migration.
|
|
It uses the model list from ``test_example_model_contract`` so the execution
|
|
set stays aligned with the repository's structural contract.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ast
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from datetime import datetime, timezone
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from typing import Any
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
EXAMPLES = ROOT / "examples"
|
|
DEFAULT_TIMEOUT_SECONDS = 900
|
|
DEFAULT_HEAVY_TIMEOUT_SECONDS = 1800
|
|
HEAVY_CASES = {
|
|
"examples/16_compact_two_stage_planetary_reducer/main.py",
|
|
"examples/20_integrated_bldc_joint_actuator/main.py",
|
|
}
|
|
|
|
|
|
def _is_model_decorator(node: ast.expr) -> bool:
|
|
if isinstance(node, ast.Call):
|
|
node = node.func
|
|
return (
|
|
isinstance(node, ast.Attribute)
|
|
and isinstance(node.value, ast.Name)
|
|
and node.value.id == "scad"
|
|
and node.attr == "model"
|
|
) or (isinstance(node, ast.Name) and node.id == "model")
|
|
|
|
|
|
def _builder_name(path: Path) -> str:
|
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
model_functions = [
|
|
node.name
|
|
for node in tree.body
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
and any(_is_model_decorator(decorator) for decorator in node.decorator_list)
|
|
]
|
|
if len(model_functions) != 1:
|
|
raise RuntimeError(f"expected one @model entry in {path}, got {model_functions}")
|
|
return model_functions[0]
|
|
|
|
|
|
def _model_cases() -> list[tuple[str, str]]:
|
|
contract_path = ROOT / "test" / "test_example_model_contract.py"
|
|
spec = importlib.util.spec_from_file_location(
|
|
"simplecadapi_example_model_contract", contract_path
|
|
)
|
|
if spec is None or spec.loader is None:
|
|
raise RuntimeError(f"could not load model contract: {contract_path}")
|
|
contract = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(contract)
|
|
|
|
cases = []
|
|
for path in contract._model_files():
|
|
relative_path = path.resolve().relative_to(ROOT).as_posix()
|
|
cases.append((relative_path, _builder_name(path)))
|
|
return cases
|
|
|
|
|
|
def _child_main(relative_path: str, builder_name: str, output_dir: Path) -> None:
|
|
path = (ROOT / relative_path).resolve()
|
|
sys.path.insert(0, str(path.parent))
|
|
spec = importlib.util.spec_from_file_location(
|
|
f"simplecadapi_scene_case_{os.getpid()}", path
|
|
)
|
|
if spec is None or spec.loader is None:
|
|
raise RuntimeError(f"could not load example module: {path}")
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
builder = getattr(module, builder_name)
|
|
result = builder()
|
|
exported = result.export_artifacts(output_dir=output_dir)
|
|
print(
|
|
"SCENE_RUN_RESULT="
|
|
+ json.dumps(
|
|
{
|
|
"graph_id": exported.session.graph.graph_id,
|
|
"artifact_paths": {
|
|
key: str(value) for key, value in exported.artifact_paths.items()
|
|
},
|
|
"result_node_count": len(exported.result_node_ids),
|
|
},
|
|
sort_keys=True,
|
|
),
|
|
flush=True,
|
|
)
|
|
|
|
|
|
def _kill_process_group(process: subprocess.Popen[str]) -> None:
|
|
try:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
pass
|
|
|
|
|
|
def _run_case(
|
|
*,
|
|
relative_path: str,
|
|
builder_name: str,
|
|
run_dir: Path,
|
|
timeout_seconds: int,
|
|
heavy_timeout_seconds: int,
|
|
) -> dict[str, Any]:
|
|
case_name = relative_path.removesuffix(".py").replace("/", "__")
|
|
case_dir = run_dir / case_name
|
|
case_dir.mkdir(parents=True, exist_ok=True)
|
|
attempt = 1
|
|
while (case_dir / f"stdout.attempt-{attempt}.log").exists():
|
|
attempt += 1
|
|
for log_name in ("stdout.log", "stderr.log"):
|
|
log_path = case_dir / log_name
|
|
if log_path.exists():
|
|
log_path.replace(
|
|
case_dir / f"{log_path.stem}.attempt-{attempt}{log_path.suffix}"
|
|
)
|
|
timeout = (
|
|
heavy_timeout_seconds
|
|
if relative_path in HEAVY_CASES
|
|
else timeout_seconds
|
|
)
|
|
command = [
|
|
sys.executable,
|
|
str(Path(__file__).resolve()),
|
|
"--child",
|
|
relative_path,
|
|
builder_name,
|
|
str(case_dir),
|
|
]
|
|
environment = dict(os.environ)
|
|
environment["PYTHONHASHSEED"] = "0"
|
|
started_at = datetime.now(timezone.utc)
|
|
started = time.monotonic()
|
|
print(
|
|
f"[scene-export] starting {relative_path}::{builder_name} "
|
|
f"timeout={timeout}s",
|
|
flush=True,
|
|
)
|
|
stdout = ""
|
|
stderr = ""
|
|
returncode: int | None = None
|
|
timed_out = False
|
|
error: str | None = None
|
|
try:
|
|
process = subprocess.Popen(
|
|
command,
|
|
cwd=ROOT,
|
|
env=environment,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
errors="replace",
|
|
start_new_session=True,
|
|
)
|
|
try:
|
|
stdout, stderr = process.communicate(timeout=timeout)
|
|
returncode = process.returncode
|
|
except subprocess.TimeoutExpired as exc:
|
|
timed_out = True
|
|
_kill_process_group(process)
|
|
stdout, stderr = process.communicate()
|
|
if exc.stdout:
|
|
stdout = str(exc.stdout)
|
|
if exc.stderr:
|
|
stderr = str(exc.stderr)
|
|
except Exception as exc: # pragma: no cover - harness failure path
|
|
error = f"{exc.__class__.__name__}: {exc}"
|
|
|
|
elapsed = time.monotonic() - started
|
|
(case_dir / "stdout.log").write_text(stdout, encoding="utf-8")
|
|
(case_dir / "stderr.log").write_text(stderr, encoding="utf-8")
|
|
scene_paths = sorted(case_dir.glob("*.scene.zip"))
|
|
child_result: dict[str, Any] | None = None
|
|
for line in stdout.splitlines():
|
|
if line.startswith("SCENE_RUN_RESULT="):
|
|
try:
|
|
child_result = json.loads(line.removeprefix("SCENE_RUN_RESULT="))
|
|
except json.JSONDecodeError:
|
|
error = "invalid SCENE_RUN_RESULT payload"
|
|
|
|
status = "passed"
|
|
if timed_out:
|
|
status = "timeout"
|
|
elif error is not None or returncode != 0:
|
|
status = "failed"
|
|
elif len(scene_paths) != 1:
|
|
status = "failed"
|
|
error = f"expected one scene zip, found {len(scene_paths)}"
|
|
|
|
record = {
|
|
"case": relative_path,
|
|
"builder": builder_name,
|
|
"case_dir": str(case_dir.relative_to(ROOT)),
|
|
"status": status,
|
|
"returncode": returncode,
|
|
"timed_out": timed_out,
|
|
"timeout_seconds": timeout,
|
|
"elapsed_seconds": round(elapsed, 3),
|
|
"started_at": started_at.isoformat(),
|
|
"stdout_log": str((case_dir / "stdout.log").relative_to(ROOT)),
|
|
"stderr_log": str((case_dir / "stderr.log").relative_to(ROOT)),
|
|
"scene_paths": [str(path.relative_to(ROOT)) for path in scene_paths],
|
|
"child_result": child_result,
|
|
"error": error,
|
|
"attempt": attempt,
|
|
}
|
|
print(
|
|
f"[scene-export] {status} {relative_path} elapsed={elapsed:.1f}s",
|
|
flush=True,
|
|
)
|
|
return record
|
|
|
|
|
|
def _publish_scenes(
|
|
run_dir: Path,
|
|
records: list[dict[str, Any]],
|
|
) -> list[dict[str, Any]]:
|
|
scene_dir = run_dir / "scenes"
|
|
scene_dir.mkdir(parents=True, exist_ok=True)
|
|
for existing in scene_dir.glob("*.scene.zip"):
|
|
existing.unlink()
|
|
published: list[dict[str, Any]] = []
|
|
for record in records:
|
|
scene_paths = record.get("scene_paths", [])
|
|
if record.get("status") != "passed" or len(scene_paths) != 1:
|
|
continue
|
|
case_path = Path(record["case"])
|
|
case_name = (
|
|
case_path.parent.name if case_path.name == "main.py" else case_path.stem
|
|
)
|
|
source_path = ROOT / scene_paths[0]
|
|
destination = scene_dir / f"{case_name}.scene.zip"
|
|
shutil.copy2(source_path, destination)
|
|
published.append(
|
|
{
|
|
"case": record["case"],
|
|
"path": str(destination.relative_to(ROOT)),
|
|
}
|
|
)
|
|
return published
|
|
|
|
|
|
def _parent_main(args: argparse.Namespace) -> int:
|
|
all_cases = _model_cases()
|
|
previous_records: dict[str, dict[str, Any]] = {}
|
|
if args.resume is not None:
|
|
run_dir = args.resume.resolve()
|
|
report_path = run_dir / "execution_report.json"
|
|
previous_report = json.loads(report_path.read_text(encoding="utf-8"))
|
|
run_id = str(previous_report["run_id"])
|
|
started_at = datetime.fromisoformat(previous_report["started_at"])
|
|
previous_records = {
|
|
record["case"]: record for record in previous_report["cases"]
|
|
}
|
|
cases = [
|
|
case
|
|
for case in all_cases
|
|
if previous_records.get(case[0], {}).get("status") != "passed"
|
|
]
|
|
print(
|
|
f"[scene-export] resuming {run_id}; retrying {len(cases)} cases",
|
|
flush=True,
|
|
)
|
|
else:
|
|
cases = all_cases
|
|
run_id = datetime.now(timezone.utc).strftime("scene_export_%Y%m%dT%H%M%SZ")
|
|
run_dir = EXAMPLES / "out" / run_id
|
|
run_dir.mkdir(parents=True, exist_ok=False)
|
|
started_at = datetime.now(timezone.utc)
|
|
if args.case:
|
|
requested = set(args.case)
|
|
cases = [case for case in cases if case[0] in requested]
|
|
unknown = requested - {case[0] for case in all_cases}
|
|
if unknown:
|
|
raise ValueError(f"unknown example cases: {sorted(unknown)}")
|
|
if args.publish_only:
|
|
cases = []
|
|
records: list[dict[str, Any]] = []
|
|
with ThreadPoolExecutor(max_workers=args.workers) as executor:
|
|
futures = {
|
|
executor.submit(
|
|
_run_case,
|
|
relative_path=relative_path,
|
|
builder_name=builder_name,
|
|
run_dir=run_dir,
|
|
timeout_seconds=args.timeout,
|
|
heavy_timeout_seconds=args.heavy_timeout,
|
|
): (relative_path, builder_name)
|
|
for relative_path, builder_name in cases
|
|
}
|
|
for future in as_completed(futures):
|
|
relative_path, builder_name = futures[future]
|
|
try:
|
|
records.append(future.result())
|
|
except Exception as exc: # pragma: no cover - harness failure path
|
|
records.append(
|
|
{
|
|
"case": relative_path,
|
|
"builder": builder_name,
|
|
"status": "failed",
|
|
"error": f"runner worker {exc.__class__.__name__}: {exc}",
|
|
}
|
|
)
|
|
|
|
records_by_case = dict(previous_records)
|
|
records_by_case.update({record["case"]: record for record in records})
|
|
records = sorted(records_by_case.values(), key=lambda record: record["case"])
|
|
report = {
|
|
"run_id": run_id,
|
|
"started_at": started_at.isoformat(),
|
|
"finished_at": datetime.now(timezone.utc).isoformat(),
|
|
"root": str(ROOT),
|
|
"workers": args.workers,
|
|
"timeout_seconds": args.timeout,
|
|
"heavy_timeout_seconds": args.heavy_timeout,
|
|
"case_count": len(all_cases),
|
|
"passed": sum(record.get("status") == "passed" for record in records),
|
|
"failed": sum(record.get("status") == "failed" for record in records),
|
|
"timed_out": sum(record.get("status") == "timeout" for record in records),
|
|
"cases": records,
|
|
}
|
|
report_path = run_dir / "execution_report.json"
|
|
manifest_path = run_dir / "manifest.json"
|
|
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
|
|
published_scenes = _publish_scenes(run_dir, records)
|
|
manifest = {
|
|
"run_id": run_id,
|
|
"case_count": len(all_cases),
|
|
"successful_scene_count": len(published_scenes),
|
|
"standalone_scene_packages": published_scenes,
|
|
}
|
|
manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
|
|
print(f"[scene-export] report={report_path}", flush=True)
|
|
print(f"[scene-export] scenes={run_dir / 'scenes'}", flush=True)
|
|
print(
|
|
f"[scene-export] passed={report['passed']} failed={report['failed']} "
|
|
f"timed_out={report['timed_out']}",
|
|
flush=True,
|
|
)
|
|
return 0 if report["failed"] == 0 and report["timed_out"] == 0 else 1
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--child", nargs=3, metavar=("PATH", "BUILDER", "OUTPUT"))
|
|
parser.add_argument("--workers", type=int, default=3)
|
|
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_SECONDS)
|
|
parser.add_argument("--resume", type=Path)
|
|
parser.add_argument("--case", action="append")
|
|
parser.add_argument("--publish-only", action="store_true")
|
|
parser.add_argument(
|
|
"--heavy-timeout", type=int, default=DEFAULT_HEAVY_TIMEOUT_SECONDS
|
|
)
|
|
args = parser.parse_args()
|
|
if args.child:
|
|
relative_path, builder_name, output = args.child
|
|
_child_main(relative_path, builder_name, Path(output).resolve())
|
|
return 0
|
|
if args.workers < 1:
|
|
parser.error("--workers must be at least 1")
|
|
if args.publish_only and args.resume is None:
|
|
parser.error("--publish-only requires --resume")
|
|
return _parent_main(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|