Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions tests/test_output_root.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from __future__ import annotations

from pathlib import Path

import zwill.cli as cli
from zwill.cli import main, output_root, resolve_output_path


def test_relative_output_rebases_under_root(tmp_path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("ZWILL_OUT", raising=False)
resolved = resolve_output_path("reports/x.html")
assert resolved == Path("zwill_work") / "reports" / "x.html"
# The parent is created so a caller can write immediately.
assert resolved.parent.is_dir()


def test_absolute_output_passes_through_with_one_warning(tmp_path, monkeypatch, capsys) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("ZWILL_OUT", raising=False)
cli._warned_output_escapes.clear()
target = tmp_path / "elsewhere" / "r.html"
assert resolve_output_path(target) == target
assert resolve_output_path(target) == target # second call: no duplicate warning
err = capsys.readouterr().err
assert err.count("outside the") == 1


def test_managed_zwill_paths_are_never_rebased(tmp_path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("ZWILL_OUT", raising=False)
managed = Path(".zwill") / "projects" / "default" / "x.json"
assert resolve_output_path(managed) == managed


def test_paths_already_under_root_are_not_double_nested(tmp_path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("ZWILL_OUT", raising=False)
already = Path("zwill_work") / "demo_report"
assert resolve_output_path(already) == already


def test_env_var_overrides_root(tmp_path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("ZWILL_OUT", "custom_out")
assert output_root() == Path("custom_out")
assert resolve_output_path("r.html") == Path("custom_out") / "r.html"


def test_init_output_dir_is_persisted_and_used(tmp_path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("ZWILL_OUT", raising=False)
assert main(["init", "--output-dir", "deliverables"]) == 0
assert output_root() == Path("deliverables")
# Re-init without the flag preserves the configured directory.
assert main(["init"]) == 0
assert output_root() == Path("deliverables")
12 changes: 6 additions & 6 deletions zwill/agent_studies.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,15 +239,15 @@ def cmd_agent_study_report(args: argparse.Namespace) -> None:
if args.format == "json":
output = json.dumps(payload, indent=2)
if args.path:
Path(args.path).parent.mkdir(parents=True, exist_ok=True)
Path(args.path).write_text(output + "\n")
resolve_output_path(args.path).parent.mkdir(parents=True, exist_ok=True)
resolve_output_path(args.path).write_text(output + "\n")
print(output)
return
fieldnames = ["job_id", "agent_name", "question_name", "answer", "comment", "service", "model", "model_label", "instruction_present", "instruction_chars"]
if args.format == "csv":
if args.path:
Path(args.path).parent.mkdir(parents=True, exist_ok=True)
with Path(args.path).open("w", newline="") as output_file:
resolve_output_path(args.path).parent.mkdir(parents=True, exist_ok=True)
with resolve_output_path(args.path).open("w", newline="") as output_file:
writer = csv.DictWriter(output_file, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
Expand All @@ -261,8 +261,8 @@ def cmd_agent_study_report(args: argparse.Namespace) -> None:
if args.format == "html":
output = render_agent_study_report_html(payload)
if args.path:
Path(args.path).parent.mkdir(parents=True, exist_ok=True)
Path(args.path).write_text(output)
resolve_output_path(args.path).parent.mkdir(parents=True, exist_ok=True)
resolve_output_path(args.path).write_text(output)
else:
print(output)
return
Expand Down
87 changes: 82 additions & 5 deletions zwill/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,77 @@ def write_json(path: Path, value: Any) -> None:
os.replace(tmp_path, path)


DEFAULT_OUTPUT_DIR = "zwill_work"
_warned_output_escapes: set[str] = set()


def output_root() -> Path:
"""Directory that user-facing outputs are contained under.

Precedence: ``ZWILL_OUT`` env var, then ``output_dir`` in ``.zwill/config.json``,
then the default ``zwill_work/`` beside the ``.zwill/`` state dir. Reports,
bundles, and exports land here instead of sprawling across the working dir;
managed state stays in ``.zwill/``.
"""
env = os.environ.get("ZWILL_OUT")
if env:
return Path(env)
if ROOT.exists():
configured = read_json(ROOT / "config.json", {}).get("output_dir")
if configured:
return Path(configured)
return Path(DEFAULT_OUTPUT_DIR)


def _is_under(path: Path, base: Path) -> bool:
# Compare on absolute forms so a relative path and a relative base still
# resolve consistently (e.g. "zwill_work/x" under "zwill_work").
if path.is_relative_to(base):
return True
try:
return path.resolve().is_relative_to(base.resolve())
except (OSError, ValueError):
return False


def resolve_output_path(path: Any, *, create_parents: bool = True) -> Path | None:
"""Resolve a user-supplied output path against the output root.

Relative paths are rebased under :func:`output_root` so a bare ``--path
report.html`` (or a command's CWD-relative default) lands inside
``zwill_work/`` rather than the current directory. Absolute paths are left
as-is (an explicit escape hatch) but warn once if they fall outside the
root. Managed ``.zwill/`` state paths are never rebased.

When a path is rebased, its parent directory is created so callers that
assumed a CWD-relative parent still have a writable location. Pass
``create_parents=False`` to resolve a path for an existence check without
the side effect.
"""
if path is None:
return None
p = Path(path)
if _is_under(p, ROOT):
return p
root = output_root()
if p.is_absolute():
if not _is_under(p, root):
key = str(p)
if key not in _warned_output_escapes:
_warned_output_escapes.add(key)
print(
f"warning: output path {p} is outside the {root}/ output root; writing there anyway.",
file=sys.stderr,
)
return p
if _is_under(p, root):
return p
rebased = root / p
if create_parents:
rebased.parent.mkdir(parents=True, exist_ok=True)
return rebased


def read_json_or_gzip(path: Path) -> Any:
if path.suffix == ".gz":
with gzip.open(path, "rt") as f:
Expand Down Expand Up @@ -659,9 +730,14 @@ def add_quarantine_issue(sdir: Path, issue: dict[str, Any]) -> dict[str, Any]:
return full_issue


def cmd_init(_: argparse.Namespace) -> dict[str, Any]:
def cmd_init(args: argparse.Namespace) -> dict[str, Any]:
ROOT.mkdir(exist_ok=True)
write_json(ROOT / "config.json", {"schema_version": SCHEMA_VERSION})
# Preserve any existing config (e.g. a configured output_dir) across re-init.
config = read_json(ROOT / "config.json", {}) if (ROOT / "config.json").exists() else {}
config["schema_version"] = SCHEMA_VERSION
if getattr(args, "output_dir", None):
config["output_dir"] = args.output_dir
write_json(ROOT / "config.json", config)
pdir = ensure_project(DEFAULT_PROJECT_ID, title="Default")
if not head_path().exists() or not head_path().read_text().strip():
head_path().write_text(f"{DEFAULT_PROJECT_ID}\n")
Expand All @@ -671,6 +747,7 @@ def cmd_init(_: argparse.Namespace) -> dict[str, Any]:
{
"path": ".zwill",
"schema_version": SCHEMA_VERSION,
"output_dir": str(output_root()),
"active_project": active_project_id(),
"project_path": str(pdir),
},
Expand Down Expand Up @@ -800,7 +877,7 @@ def cmd_survey_report(args: argparse.Namespace) -> dict[str, Any] | None:
# The file holds the raw report payload (survey/summary/questions/...);
# stdout carries a parseable envelope pointing at it, so scripts get a
# consistent {command,status,data,...} shape on stdout.
Path(args.path).write_text(output + "\n")
resolve_output_path(args.path).write_text(output + "\n")
print_json(
envelope(
"zwill survey report",
Expand All @@ -814,7 +891,7 @@ def cmd_survey_report(args: argparse.Namespace) -> dict[str, Any] | None:
if args.format == "html":
output = render_survey_report_html(payload)
if args.path:
Path(args.path).write_text(output)
resolve_output_path(args.path).write_text(output)
# Mirror the json/csv branches: the file holds the rendered report and
# stdout carries a parseable {command,status,data,...} envelope.
print_json(
Expand All @@ -830,7 +907,7 @@ def cmd_survey_report(args: argparse.Namespace) -> dict[str, Any] | None:
if args.format == "csv":
if not args.path:
raise ZwillError("invalid_input", "--path is required for survey report --format csv.")
paths = write_survey_report_csvs(payload, Path(args.path))
paths = write_survey_report_csvs(payload, resolve_output_path(args.path))
return envelope("zwill survey report", "ok", {"survey": args.survey, **payload["summary"], **paths})
table = Table(title=f"Survey Report: {args.survey}")
for column in ["question", "type", "answers", "missing", "response", "options"]:
Expand Down
5 changes: 5 additions & 0 deletions zwill/cli_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ def build_parser() -> argparse.ArgumentParser:
subparsers = parser.add_subparsers(dest="command", required=True)

p = subparsers.add_parser("init")
p.add_argument(
"--output-dir",
help="Directory that reports, bundles, and exports are contained under (relative paths rebase here). "
"Persisted in .zwill/config.json. Defaults to zwill_work/; overridable per-run with the ZWILL_OUT env var.",
)
p.set_defaults(func=cmd_init)

p = subparsers.add_parser("status")
Expand Down
16 changes: 16 additions & 0 deletions zwill/guides/agent-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,22 @@ noise?" Do not report a positive result from a bare twin run.
- Do **not** pass `temperature` to models. Newer Anthropic/OpenAI models reject it
and error on every call; EDSL omits it automatically.

## Where outputs go

Reports, bundles, executive summaries, and exported CSVs are **contained under an
output root** so they do not sprawl across the working directory. A relative
`--path`/`--out` (and a command's CWD-relative default like `<survey>_report/`)
is rebased under that root; the default root is `zwill_work/` beside `.zwill/`.
Override it with the `ZWILL_OUT` environment variable or `zwill init
--output-dir <dir>` (persisted in `.zwill/config.json`). An **absolute** path is
written verbatim (an escape hatch) with a one-line warning that it left the root.

Two things are deliberately *not* rebased: managed state stays in `.zwill/`, and
intermediate EDSL plumbing that is read back by a later command — the job/results
files from `edsl-export`/`edsl-run` (`--path`) and `--job-path` — stays at the
current directory so `edsl-run --job <file>` still finds them. Input files you
pass with `--input-path` are reads and are never rebased.

## Non-negotiable guardrails

- Treat ingestion as a validation boundary, not a best-effort conversion. Convert
Expand Down
2 changes: 1 addition & 1 deletion zwill/numeric_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ def cmd_numeric_results_report(args: argparse.Namespace) -> dict[str, Any]:
payload = numeric_report_payload(rows, summary)
html = render_numeric_report_html(payload)
if getattr(args, "path", None):
Path(args.path).write_text(html)
resolve_output_path(args.path).write_text(html)
return envelope("zwill numeric-results report", "ok", {"job_id": args.job_id, "format": "html", "path": str(args.path), "models": list(summary.get("models", {}))})
print(html)
return None # type: ignore[return-value]
Expand Down
Loading
Loading