Repository navigation
fix(cli): input and usage refusals exit 2, not 1 (#94 A2) #285
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
fe6ef4a
cbb6f85
6c54fdd
7b938bc
063f9de
0cdea25
5612e8d
060affe
5494c06
4e7947a
3d0497c
7927ec6
dedb46e
e4fa611
4f00e47
7f38c2b
fe8620d
cbdc415
a33186e
17052ca
98608b1
f3a6722
5bfdcbe
815d399
a35884e
1658728
fec6d60
d844403
f0ef4d0
f15201f
c6c8122
2025f74
96c04d4
d6ef7c0
0a039c0
3494320
0de1e84
ad1f2a6
a0c2e6a
660ea0d
aac7389
91c4bbd
19d515a
a4a204c
314c8a0
ef4b8ef
e375f50
1faa48c
bfa3164
ac6896f
a16e02d
0097947
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| ### Changed | ||
|
|
||
| - **Input and usage refusals exit 2, not 1** — the exit-code table (new in `docs/quickstart.md`, section Exit codes) gives 2 to a missing, unreadable, malformed or unusable input, a flag combination a command refuses, an output file that cannot be written, and a benchmark run or preflight with no provider credential. Exit 1 is left for a verdict or a gate that ran and failed, and for a runtime or model failure. The messages are unchanged. Scripts that branch on `$? -eq 1` for these cases must also accept 2. Affected: `personas` (approve, discover), `questions`, `eval` (score, ragas, grounding, validity, panel-score), `orchestrate`, `report` (eval, generate, model-compare), `baseline`, `novelty`, `catalog` (and `catalog probe`), `answers`, `arms`, `explore`, `tasks`, `subjects`, `repo` (`validate`, `scan`, `inspect`, `budget`, `fetch`, `reach`, `report`, `compare`), `library`, `tools`, `dashboard`, `run`, and `benchmark` (run, preflight, batch). Only the validation step of a command exits 2; a failure while it runs keeps 1. Specifically: `benchmark preflight` exits 2 when a provider credential, the output dir, the question set or the doc source is invalid, even when a failed check is also reported, and 1 when a check alone fails (an unreachable doc source); `benchmark batch` with nothing to run exits 2; `repo validate`, `scan`, `inspect`, `budget`, `fetch` and `reach` exit 2 for a malformed source string, an unpinned remote, a pinned local path that is not the repository root, a manifest path that leaves the tree, a manifest or profile error, or a bad scenario selection, and 1 for a clone, fetch or pin resolution that fails (including a pinned local source that is not a git repository, a pin absent locally, and a remote pin missing from origin) and for a harness run; `arms run` and `arms matrix-run` exit 2 for a bad spec or setup input, and 1 for a run failure or a failed sampling probe; a matrix probes every cell before its first answer, so a cell refused at the probe makes no answer call (probe calls are still model calls and can fail, exit 1); `catalog trigger-set` exits 2 for a duplicate question id, a question without text or an `@field` rule that labels none of its source's questions (refused before any `--rewrite` call), and 1 for a `--strict` refusal (a lint gate, as on main) and when a `--rewrite` returns nothing; a missing RAGAS extra stays 1. `repo run` and `repo tasks check` are not converted: their input refusals still exit 1. `tools preference` still exits 2 when every call failed or `--max-cost-usd` skipped calls. A `--static` file that cannot be read or is not JSON exits 2 in `repo scan`; a schema-invalid document exits 1. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,7 @@ | |
| from pathlib import Path | ||
| from typing import TYPE_CHECKING | ||
|
|
||
| from dokimi.exit_codes import EXIT_FAILURE, EXIT_USAGE | ||
| from dokimi.utils import question_id | ||
| from dokimi.eval.judge import JUDGE_PROVIDERS | ||
| from dokimi.defaults import ( | ||
|
|
@@ -53,21 +54,32 @@ def judge_panel_roles(value: str) -> str: | |
|
|
||
| def cmd_arms_run(args: argparse.Namespace) -> None: | ||
| """Run an N-arm treatment comparison and optionally judge it.""" | ||
| from dokimi.eval.arms_run import LibraryIdError, run_arms | ||
| from dokimi.eval.arms_run import LibraryIdError, ProbeError, prepare_arms, run_prepared | ||
|
|
||
| # Setup (inputs, arms, plugins, sampling, judge) exits 2; a failed sampling probe is a run failure (1). | ||
| try: | ||
| specs = _parse_arm_specs(args.arms) | ||
| questions = _load_questions(args.questions) | ||
| config = _with_named_cell(run_config_from_args(args), args.matrix_cells, args.matrix_cell) | ||
| output = run_arms(config, specs, questions) | ||
| prepared = prepare_arms(config, specs, questions) | ||
| except EmptyQuestionError: | ||
| raise # a bad input record, not an arm setup error: cli.main reports it (exit 2) | ||
| except ProbeError as exc: | ||
| print(f"Error running arms: {exc}", file=sys.stderr) | ||
| sys.exit(EXIT_FAILURE) | ||
| except LibraryIdError as exc: | ||
| print(f"Error: {exc}", file=sys.stderr) | ||
| sys.exit(1) | ||
| sys.exit(EXIT_USAGE) | ||
| except (ValueError, OSError, RecursionError) as exc: | ||
| print(f"Error building arms/plugins: {exc}", file=sys.stderr) | ||
| sys.exit(1) | ||
| sys.exit(EXIT_USAGE) | ||
| try: | ||
| output = run_prepared(prepared, config, questions) | ||
| except EmptyQuestionError: | ||
| raise # a bad input record, not a run error: cli.main reports it (exit 2) | ||
| except (ValueError, OSError, RecursionError) as exc: | ||
| print(f"Error running arms: {exc}", file=sys.stderr) | ||
| sys.exit(EXIT_FAILURE) | ||
|
|
||
| out_json = Path(args.out_json) if args.out_json else Path(f"results/arms/{args.product}.json") | ||
| out_md = Path(args.out_md) if args.out_md else Path(f"results/arms/{args.product}.md") | ||
|
|
@@ -95,6 +107,29 @@ def cmd_arms_matrix_run(args: argparse.Namespace) -> None: | |
| ) | ||
|
|
||
|
|
||
| def _load_matrix_inputs(config: ArmsRunConfig, arms: str, questions: str, matrix_cells: str): | ||
| """The arms, questions and cells of a matrix run, checked before any cell is set up. A refused | ||
| input exits 2; a failed sampling probe, a real call, exits 1.""" | ||
| from dokimi.eval import arms_run | ||
| from dokimi.eval.cells import load_matrix_cells | ||
|
|
||
| try: | ||
| specs = _parse_arm_specs(arms) | ||
| loaded_questions = _load_questions(questions) | ||
| cells = load_matrix_cells(matrix_cells) | ||
| _validate_unique_cell_filenames(cells) | ||
| arms_run.check_matrix(config, cells) | ||
| except EmptyQuestionError: | ||
| raise # a bad input record, not a cell error: cli.main reports it (exit 2) | ||
| except arms_run.ProbeError as exc: | ||
| print(f"Error loading matrix run inputs: {exc}", file=sys.stderr) | ||
| sys.exit(EXIT_FAILURE) | ||
| except (ValueError, OSError, RecursionError) as exc: | ||
| print(f"Error loading matrix run inputs: {exc}", file=sys.stderr) | ||
| sys.exit(EXIT_USAGE) | ||
| return specs, loaded_questions, cells | ||
|
|
||
|
|
||
| def run_matrix(config: ArmsRunConfig, *, arms: str, questions: str, matrix_cells: str, | ||
| out_dir: Path, out_json: Path | None = None, out_md: Path | None = None, | ||
| plugin_deltas: bool = True) -> None: | ||
|
|
@@ -105,32 +140,24 @@ def run_matrix(config: ArmsRunConfig, *, arms: str, questions: str, matrix_cells | |
| ``.md`` suffix. | ||
| """ | ||
| from dokimi.eval import arms_run | ||
| from dokimi.eval.cells import load_matrix_cells | ||
| from dokimi.eval.matrix_rollup import build_matrix_rollup, strip_internal_payloads | ||
| from dokimi.report.matrix_rollup_report import render_matrix_rollup_report | ||
| from dokimi.report.plugin_delta_report import render_plugin_delta_report | ||
|
|
||
| try: | ||
| specs = _parse_arm_specs(arms) | ||
| loaded_questions = _load_questions(questions) | ||
| cells = load_matrix_cells(matrix_cells) | ||
| _validate_unique_cell_filenames(cells) | ||
| arms_run.check_matrix(config, cells) | ||
| except (ValueError, OSError, RecursionError) as exc: | ||
| print(f"Error loading matrix run inputs: {exc}", file=sys.stderr) | ||
| sys.exit(1) | ||
| specs, loaded_questions, cells = _load_matrix_inputs(config, arms, questions, matrix_cells) | ||
|
|
||
| # The cells dir is made only when a cell finishes: a cell refused in setup writes nothing. | ||
| cells_dir = out_dir / "cells" | ||
| try: | ||
| cells_dir.mkdir(parents=True, exist_ok=True) | ||
| except OSError as exc: | ||
| print(f"Error: cannot write {cells_dir}: {exc}", file=sys.stderr) | ||
| sys.exit(1) | ||
| cell_artifacts = [] | ||
|
|
||
| def save_cell(cell, output: dict) -> None: | ||
| safe_name = _safe_name(cell.name) | ||
| cell_json = cells_dir / f"{safe_name}.json" | ||
| try: | ||
| cells_dir.mkdir(parents=True, exist_ok=True) | ||
| except OSError as exc: | ||
| print(f"Error: cannot write {cells_dir}: {exc}", file=sys.stderr) | ||
| sys.exit(EXIT_USAGE) | ||
|
Comment on lines
+156
to
+160
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| _save_arms_output(output, cell_json, cells_dir / f"{safe_name}.md") | ||
| cell_artifacts.append((cell.name, cell_json, output)) | ||
|
|
||
|
|
@@ -140,10 +167,11 @@ def save_cell(cell, output: dict) -> None: | |
| raise # a bad input record, not a cell error: cli.main reports it (exit 2) | ||
| except arms_run.LibraryIdError as exc: | ||
| print(f"Error: {exc}", file=sys.stderr) | ||
| sys.exit(1) | ||
| sys.exit(EXIT_USAGE) | ||
| except arms_run.MatrixCellError as exc: | ||
| print(f"Error running matrix cell {exc.cell!r}: {exc}", file=sys.stderr) | ||
| sys.exit(1) | ||
| verb = "building" if exc.usage else "running" | ||
| print(f"Error {verb} matrix cell {exc.cell!r}: {exc}", file=sys.stderr) | ||
| sys.exit(EXIT_USAGE if exc.usage else EXIT_FAILURE) | ||
|
|
||
| rollup = build_matrix_rollup( | ||
| library_name=config.product, | ||
|
|
@@ -185,7 +213,7 @@ def cmd_arms_plugin_delta(args: argparse.Namespace) -> None: | |
| output = compare_plugin_runs(baseline, plugin) | ||
| except (PluginDeltaError, ValueError, OSError, RecursionError) as exc: | ||
| print(f"Error computing plugin delta: {exc}", file=sys.stderr) | ||
| sys.exit(1) | ||
| sys.exit(EXIT_USAGE) | ||
|
|
||
| out_json = Path(args.out_json) if args.out_json else Path("results/arms/plugin_delta.json") | ||
| _save_artifact_or_exit("plugin_delta", output, out_json) | ||
|
|
@@ -488,7 +516,7 @@ def _write_report(path: Path, text: str) -> None: | |
| path.write_text(text, encoding="utf-8") | ||
| except OSError as exc: | ||
| print(f"Error: cannot write {path}: {exc}", file=sys.stderr) | ||
| sys.exit(1) | ||
| sys.exit(EXIT_USAGE) | ||
|
|
||
|
|
||
| def _save_artifact_or_exit(kind: str, payload: dict, path: Path) -> None: | ||
|
|
@@ -499,7 +527,7 @@ def _save_artifact_or_exit(kind: str, payload: dict, path: Path) -> None: | |
| save_artifact(kind, payload, path) | ||
| except OSError as exc: | ||
| print(f"Error: cannot write {path}: {exc}", file=sys.stderr) | ||
| sys.exit(1) | ||
| sys.exit(EXIT_USAGE) | ||
|
|
||
|
|
||
| def _save_arms_output(output: dict, out_json: Path, out_md: Path) -> None: | ||
|
|
@@ -510,7 +538,7 @@ def _save_arms_output(output: dict, out_json: Path, out_md: Path) -> None: | |
| ArmRunner.save(output, out_json) | ||
| except OSError as exc: | ||
| print(f"Error: cannot write {out_json}: {exc}", file=sys.stderr) | ||
| sys.exit(1) | ||
| sys.exit(EXIT_USAGE) | ||
| print(f"OK Saved arms comparison: {out_json}") | ||
| _write_report(out_md, render_arms_report(output)) | ||
| print(f"OK Saved arms report: {out_md}") | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,6 +12,7 @@ | |
| from typing import Any, Dict, List, Optional, Tuple | ||
|
|
||
| from dokimi.utils import normalize_model_ref | ||
| from dokimi.exit_codes import EXIT_USAGE | ||
|
|
||
| BASELINES_DIR = Path("baselines/eval") | ||
|
|
||
|
|
@@ -37,7 +38,7 @@ def _read_json_object(path: Path, code: int) -> Dict[str, Any]: | |
| return data | ||
|
|
||
|
|
||
| def _baseline_manifest(code: int = 1) -> Dict[str, Any]: | ||
| def _baseline_manifest(code: int = EXIT_USAGE) -> Dict[str, Any]: | ||
| """Load or create the baseline manifest; a corrupt one exits ``code``.""" | ||
| manifest_path = BASELINES_DIR / "manifest.json" | ||
| if not manifest_path.exists(): | ||
|
|
@@ -140,7 +141,7 @@ def cmd_baseline_save(args: argparse.Namespace) -> None: | |
|
|
||
| eval_path = Path(args.from_eval) | ||
| manifest = _baseline_manifest() # first: a corrupt manifest fails fast and leaves no orphan file | ||
| eval_data = _read_json_object(eval_path, 1) | ||
| eval_data = _read_json_object(eval_path, EXIT_USAGE) | ||
| product = args.product or eval_data.get("product", "unknown") | ||
| timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%f") | ||
| name = args.name or f"{product}-{timestamp}" | ||
|
|
@@ -151,13 +152,13 @@ def cmd_baseline_save(args: argparse.Namespace) -> None: | |
| summary = _compute_summary(eval_data) | ||
| except RecordError as exc: | ||
| print(f"❌ {eval_path}: {exc}", file=sys.stderr) | ||
| raise SystemExit(1) from exc | ||
| raise SystemExit(EXIT_USAGE) from exc | ||
|
|
||
| BASELINES_DIR.mkdir(parents=True, exist_ok=True) | ||
| dest = BASELINES_DIR / f"{name}.json" | ||
| if dest.exists(): | ||
| print(f"❌ Baseline already exists: {dest} (choose a different --name)", file=sys.stderr) | ||
| raise SystemExit(1) | ||
| raise SystemExit(EXIT_USAGE) | ||
| dest.write_text(json.dumps(eval_data, indent=2), encoding="utf-8") | ||
|
|
||
| manifest["baselines"].append({ | ||
|
|
@@ -205,7 +206,7 @@ def _pick_entry(args: argparse.Namespace, baselines: list, eval_data: dict) -> d | |
| entry = next((b for b in baselines if b["name"] == args.baseline), None) | ||
| if not entry: | ||
| print(f"❌ Baseline '{args.baseline}' not found.") | ||
| raise SystemExit(1) | ||
| raise SystemExit(EXIT_USAGE) | ||
|
Comment on lines
208
to
+209
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 1faa48c: "No baselines found" now exits 2 ( |
||
| else: | ||
| product = args.product or eval_data.get("product") | ||
| candidates = [b for b in baselines if b.get("product") == product] if product else baselines | ||
|
|
@@ -260,7 +261,7 @@ def cmd_baseline_compare(args: argparse.Namespace) -> None: | |
| if not baselines: | ||
| print("No baselines found. Save one first:") | ||
| print(" dokimi baseline save --from-eval eval/oneTBB.json --product oneTBB") | ||
| raise SystemExit(1) | ||
| raise SystemExit(EXIT_USAGE) | ||
|
|
||
| entry = _pick_entry(args, baselines, eval_data) | ||
| baseline_data = _read_json_object(Path(entry["path"]), 2) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This change makes the preflight refusals exit 2. The transcript note a few lines below (line 216) still says "It exited 1 here because no API keys were set". A missing credential is a refusal (
refuse(...)incmd_benchmark_preflight), so that run now exits 2. Update that sentence.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in c6c8122: the walkthrough now says it exited 2. A missing credential is a refusal at head (cmd_benchmark_preflight), so 2 is right.
A new test reads the exit number the page states and compares it with a real preflight run with no keys. It fails on the old text (2 != 1).