91 lines
3.3 KiB
Python
91 lines
3.3 KiB
Python
"""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())
|