feat(s53): --dimension-config/--outbox-dir/--run-id CLI flags + full-run structured refusal

main() single-project path now parses --dimension-config (fail-fast via load_dimension),
--outbox-dir, and --run-id (deliberate 7th companion flag: determinism invariant forbids a
wall-clock run_id default). All three threaded into BOTH run_project call sites. Full-run
branch wrapped in a structured-refusal (catch ValueError/FileNotFoundError/ValidationError ->
'run refused: {exc}' on stderr, rc 1, no traceback); the EXISTING dry-run handler widened to
the same tuple (pydantic ValidationError is not a ValueError subclass; load_dimension's
FileNotFoundError would otherwise traceback — Pass-2 #1). --outbox-dir carries a loud help=
note it must differ from --verdict-dir (self-contamination footgun; documented, not enforced).
RED-first: 4 CLI tests failed on unrecognized args, green after. 7 passed (incl. live-dry-run
regression). ruff + mypy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNNiJRk1sSwxgVLS5AobT1
This commit is contained in:
Kjell Tore Guttormsen 2026-07-23 21:36:21 +02:00
commit 663639d376
2 changed files with 173 additions and 15 deletions

121
tests/test_run_cli.py Normal file
View file

@ -0,0 +1,121 @@
"""S5.3 CLI-parity tests for ``run.main()`` single-project flags (Steps 2/4/5).
In-process ``run.main([argv])`` + rc + ``capsys`` substring asserts (never subprocess),
mirroring ``tests/test_live_dry_run.py``. Every arm is offline it stops before the first
model call (``debate.run``), so no socket/network is exercised (brief NFR). The bundle
fixture ``shared/examples/bygg-energi-mikro`` (project ``BYGG-KONTOR-NORD``) supplies citable
content so the dry-run reaches its offline return.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from portfolio_optimiser import run
from portfolio_optimiser.dimension import Dimension
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
_PID = "BYGG-KONTOR-NORD"
@pytest.fixture(autouse=True)
def _isolate_model_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Hermetic env (verbatim from ``test_live_dry_run.py``): clear the S4.1 out-of-tree overrides
so these CLI assertions read the BUNDLED map/config, not the operator's Foundry environment."""
monkeypatch.delenv("PORTFOLIO_MODEL_MAP", raising=False)
monkeypatch.delenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", raising=False)
def _write_dimension(tmp_path: Path) -> Path:
"""A valid dimension scope config on disk (loaded fail-fast by ``load_dimension``)."""
dim = Dimension(
id="energi",
label="Energi",
allowed_measure_types=frozenset({"energy_efficiency"}),
)
p = tmp_path / "dim.json"
p.write_text(dim.model_dump_json(), encoding="utf-8")
return p
# --- Step 2: --dimension-config / --outbox-dir / --run-id wiring + structured refusal -------------
def test_dimension_config_flag_parses_offline(tmp_path, capsys) -> None:
"""(a) ``--dimension-config <valid>`` + ``--live-dry-run`` → rc 0 (flag parsed, loader invoked,
offline stops before any model call)."""
rc = run.main(
[
_PID,
"--docs-dir",
str(BUNDLE_DIR),
"--bundle-dir",
str(BUNDLE_DIR),
"--dimension-config",
str(_write_dimension(tmp_path)),
"--live-dry-run",
]
)
assert rc == 0
assert "LIVE-DRY-RUN OK" in capsys.readouterr().out
def test_outbox_dir_with_run_id_writes_runconfig_offline(tmp_path) -> None:
"""(b) ``--outbox-dir`` + ``--run-id`` + ``--live-dry-run`` → rc 0 AND ``<tmp>/r1-runconfig.json``
written offline (via ``write_run_config``, before the dry-run return)."""
outbox = tmp_path / "out"
outbox.mkdir()
rc = run.main(
[
_PID,
"--docs-dir",
str(BUNDLE_DIR),
"--bundle-dir",
str(BUNDLE_DIR),
"--outbox-dir",
str(outbox),
"--run-id",
"r1",
"--live-dry-run",
]
)
assert rc == 0
assert (outbox / "r1-runconfig.json").is_file()
def test_outbox_dir_without_run_id_refuses(tmp_path, capsys) -> None:
"""(c) RED guard: ``--outbox-dir`` WITHOUT ``--run-id`` → rc 1 structured refusal. ``run_project``'s
step-0 fail-fast (no wall-clock default) surfaces through the CLI refusal wrapper, no traceback."""
outbox = tmp_path / "out"
outbox.mkdir()
rc = run.main(
[
_PID,
"--docs-dir",
str(BUNDLE_DIR),
"--outbox-dir",
str(outbox),
"--live-dry-run",
]
)
assert rc == 1
assert "refused" in capsys.readouterr().err.lower()
def test_dimension_config_missing_file_refuses(capsys) -> None:
"""(d) ``--dimension-config <nonexistent>`` → rc 1 structured refusal. ``load_dimension`` raises
``FileNotFoundError``, caught by the WIDENED dry-run handler (not just ``ValueError`` Pass-2 #1)."""
rc = run.main(
[
_PID,
"--docs-dir",
str(BUNDLE_DIR),
"--dimension-config",
"/nonexistent-dim-config.json",
"--live-dry-run",
]
)
assert rc == 1
assert "refused" in capsys.readouterr().err.lower()