"""Command-line interface for planning, running, and validating studies.""" from __future__ import annotations import argparse import importlib import json from pathlib import Path from typing import Any from .io import atomic_write_json from .plan import build_trial_plan, load_document, validate_trial_plan from .runner import run_trial_plan from .validate import validate_batch def _load_executor(specification: str): if ":" not in specification: raise ValueError("executor must use module:function syntax") module_name, attribute_name = specification.split(":", 1) module = importlib.import_module(module_name) executor = getattr(module, attribute_name) if not callable(executor): raise TypeError(f"{specification} is not callable") return executor def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Reproducible experiment runner") subparsers = parser.add_subparsers(dest="command", required=True) plan_parser = subparsers.add_parser("plan", help="expand a study specification") plan_parser.add_argument("--spec", type=Path, required=True) plan_parser.add_argument("--output", type=Path, required=True) run_parser = subparsers.add_parser("run", help="execute an immutable plan") run_parser.add_argument("--plan", type=Path, required=True) run_parser.add_argument("--batch-dir", type=Path, required=True) run_parser.add_argument("--executor", default=None) run_parser.add_argument("--dry-run", action="store_true") run_parser.add_argument("--no-resume", action="store_true") run_parser.add_argument("--stop-on-error", action="store_true") run_parser.add_argument("--max-trials", type=int, default=None) validate_parser = subparsers.add_parser("validate", help="validate a batch") validate_parser.add_argument("--batch-dir", type=Path, required=True) validate_parser.add_argument("--allow-incomplete", action="store_true") return parser def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) result: dict[str, Any] if args.command == "plan": plan = build_trial_plan(load_document(args.spec)) atomic_write_json(args.output, plan) result = { "kind": "plan_summary", "schema_version": plan["schema_version"], "study_id": plan["study_id"], "split": plan["split"], "pair_count": plan["pair_count"], "trial_count": plan["trial_count"], "plan_hash": plan["plan_hash"], "output": str(args.output.resolve()), } elif args.command == "run": plan = load_document(args.plan) validate_trial_plan(plan) executor = None if args.executor is None else _load_executor(args.executor) result = run_trial_plan( plan, args.batch_dir, executor, resume=not args.no_resume, dry_run=args.dry_run, continue_on_error=not args.stop_on_error, max_trials=args.max_trials, ) else: result = validate_batch( args.batch_dir, require_complete=not args.allow_incomplete, ) print(json.dumps(result, indent=2, ensure_ascii=False, sort_keys=True)) return 0 if result.get("valid", True) else 1 if __name__ == "__main__": raise SystemExit(main())