Skip to content

Commit fc1503c

Browse files
Report an unreadable input, rather than tracebacking out of the CLI (#73)
Four reads could raise a type their caller did not catch, so a directory, an unreadable file or a non-UTF-8 one exited 1 with a traceback where the contract promises exit 2 and a document: _existing_trace tested exists() while the handlers catch only TraceReadError, and two read_text calls sat outside their decoders' except (UnicodeDecodeError is a ValueError, so neither would ever have caught it). The config case is the worst of them, because grapharc.toml is picked up implicitly from the working directory. plan --approve also printed its park notice ahead of the JSON document, which made the whole output unparseable on the flag most likely to be driven unattended; the notice is now silent in JSON mode and unchanged for a human. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a8d5700 commit fc1503c

7 files changed

Lines changed: 122 additions & 2 deletions

File tree

‎grapharc/cli/config.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,12 @@ def load(explicit: Path | None = None, *, cwd: Path | None = None) -> Settings:
161161
return Settings()
162162

163163
try:
164+
# `UnicodeDecodeError` is a `ValueError`, so it belongs in this tuple
165+
# explicitly: without it a stray binary `grapharc.toml` in the working
166+
# directory tracebacks out of every configurable command, because this
167+
# file is picked up implicitly rather than named by the operator.
164168
document = tomllib.loads(path.read_text(encoding="utf-8"))
165-
except (OSError, tomllib.TOMLDecodeError) as exc:
169+
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc:
166170
raise ConfigError(f"{path}: {exc}") from exc
167171

168172
table = document.get(TABLE, document)

‎grapharc/cli/graphrun.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,13 @@ def load_topology(path: Path) -> dict[str, Any]:
7070
"""
7171
if not path.is_file():
7272
raise PlanSetupError(f"no such graph file: {path}")
73-
text = path.read_text(encoding="utf-8")
73+
try:
74+
text = path.read_text(encoding="utf-8")
75+
except (OSError, UnicodeDecodeError) as exc:
76+
# A topology saved as UTF-16, or truncated in transit, is a file we
77+
# cannot run — not a crash. `UnicodeDecodeError` is a `ValueError`, so
78+
# neither decoder below would ever have caught it.
79+
raise PlanSetupError(f"{path}: {exc}") from exc
7480
try:
7581
if path.suffix.lower() == ".toml":
7682
return tomllib.loads(text)

‎grapharc/cli/main.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,9 +242,23 @@ def _existing_trace(path: Path, *, command: str, as_json: bool) -> TraceRecorder
242242
Checked before constructing the recorder because `TraceRecorder.__init__`
243243
creates the parent directory: a typo in a read-only command should not leave
244244
a directory behind.
245+
246+
Existence is not enough. A directory, a file whose permissions forbid the
247+
read, or any other `OSError` used to escape as a traceback with exit 1,
248+
because the handlers below catch only `TraceReadError` — so the file is
249+
opened here, where the failure is still reportable as the exit-2 document
250+
the contract promises.
245251
"""
246252
if not path.exists():
247253
return fail(f"no such trace file: {path}", as_json=as_json, command=command)
254+
try:
255+
path.open("rb").close()
256+
except OSError as exc:
257+
return fail(
258+
f"unreadable trace file: {path}: {exc.strerror or exc}",
259+
as_json=as_json,
260+
command=command,
261+
)
248262
return TraceRecorder(path)
249263

250264

‎grapharc/cli/plan.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,10 @@ def plan(
251251
def _announce(message: str) -> None:
252252
# Printed *and flushed* before the run parks: a terminal user (or a
253253
# log tailer) must learn how to answer without waiting for the exit.
254+
# Silent in JSON mode: stdout there carries exactly one document, and
255+
# a notice printed ahead of it makes the whole output unparseable.
256+
if as_json:
257+
return
254258
print(message, flush=True, file=sys.stdout)
255259

256260
approval = file_approval(

‎tests/test_approval.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,3 +299,34 @@ def test_handshake_files_are_written_atomically(tmp_path):
299299
_write_atomically(target, {"fingerprint": "fp"})
300300
assert json.loads(target.read_text()) == {"fingerprint": "fp"}
301301
assert not list(tmp_path.glob("*.tmp"))
302+
303+
304+
def test_plan_approve_in_json_mode_emits_one_document(tmp_path, capsys):
305+
"""The park notice used to print ahead of the document, so nothing parsed.
306+
307+
`--approve` is the flag most likely to be driven unattended: a script starts
308+
a gated plan, a human answers out of band, the script reads the result. That
309+
is exactly the combination whose output could not be loaded.
310+
"""
311+
trace = tmp_path / "run" / "trace.jsonl"
312+
313+
code = main(
314+
["plan", "ship it", "--approve", "--approval-timeout", "0.2",
315+
"--trace", str(trace), "--json"]
316+
)
317+
captured = capsys.readouterr()
318+
319+
assert code == 1, "an unanswered gate is a negative answer, not a crash"
320+
assert captured.err == ""
321+
payload = json.loads(captured.out)
322+
assert payload["ok"] is False
323+
assert "not approved" in payload["detail"]
324+
325+
326+
def test_plan_approve_in_text_mode_still_announces_how_to_answer(tmp_path, capsys):
327+
"""Silencing the notice in JSON mode must not silence it for a human."""
328+
trace = tmp_path / "run" / "trace.jsonl"
329+
330+
main(["plan", "ship it", "--approve", "--approval-timeout", "0.2", "--trace", str(trace)])
331+
332+
assert "grapharc approve" in capsys.readouterr().out

‎tests/test_cli.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,37 @@ def test_a_malformed_trace_fails_as_one_json_document(argv, tmp_path, capsys):
373373
assert err == ""
374374

375375

376+
# A path that exists but cannot be read is the same class of failure as a
377+
# malformed one, and used to escape as a traceback with exit 1: `_existing_trace`
378+
# tested `exists()` and the handlers catch only `TraceReadError`, so every other
379+
# `OSError` went straight past both.
380+
@pytest.mark.parametrize("argv", READERS, ids=lambda argv: argv[0])
381+
def test_a_directory_where_a_trace_belongs_is_a_report_not_a_traceback(
382+
argv, tmp_path, capsys
383+
):
384+
directory = tmp_path / "adir"
385+
directory.mkdir()
386+
code, out, err = call([argv[0], str(directory), *argv[1:]], capsys)
387+
assert code == 2
388+
assert out == ""
389+
assert err.startswith(f"error: unreadable trace file: {directory}: ")
390+
assert "Traceback" not in err
391+
392+
393+
@pytest.mark.parametrize("argv", READERS, ids=lambda argv: argv[0])
394+
def test_a_directory_where_a_trace_belongs_fails_as_one_json_document(
395+
argv, tmp_path, capsys
396+
):
397+
directory = tmp_path / "adir"
398+
directory.mkdir()
399+
code, payload, err = call_json([argv[0], str(directory), *argv[1:]], capsys)
400+
assert code == 2
401+
assert payload["ok"] is False
402+
assert payload["command"] == argv[0]
403+
assert payload["error"].startswith(f"unreadable trace file: {directory}: ")
404+
assert err == ""
405+
406+
376407
# -- models -------------------------------------------------------------------
377408

378409

@@ -1395,6 +1426,23 @@ def test_run_says_which_file_is_missing(tmp_path, capsys):
13951426
assert "no such graph file" in err
13961427

13971428

1429+
def test_run_reports_a_graph_file_that_is_not_utf8(tmp_path, capsys):
1430+
"""`UnicodeDecodeError` is a `ValueError`, so neither decoder caught it.
1431+
1432+
A topology saved as UTF-16 or truncated in transit used to exit 1 with a
1433+
traceback and an empty document.
1434+
"""
1435+
binary = tmp_path / "bin.json"
1436+
binary.write_bytes(b"\xff\xfe\x00binary")
1437+
1438+
code, payload, err = call_json(["run", str(binary)], capsys)
1439+
1440+
assert code == 2
1441+
assert payload["ok"] is False
1442+
assert "utf-8" in payload["error"]
1443+
assert err == ""
1444+
1445+
13981446
def test_a_policy_document_gates_a_hand_written_graph_too(tmp_path, capsys):
13991447
"""§12.2 on the deterministic path: the TOML file decides here as well."""
14001448
graph = _write_graph(tmp_path, _DENIED_GRAPH)

‎tests/test_config.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,19 @@ def test_malformed_toml_names_the_file(tmp_path):
235235
load(path)
236236

237237

238+
def test_a_config_that_is_not_utf8_names_the_file(tmp_path):
239+
"""`UnicodeDecodeError` is a `ValueError`, so it was in neither except tuple.
240+
241+
This file is picked up implicitly from the working directory, so a stray
242+
binary `grapharc.toml` used to traceback out of every configurable command.
243+
"""
244+
path = tmp_path / CONFIG_NAME
245+
path.write_bytes(b"\xff\xfe")
246+
247+
with pytest.raises(ConfigError, match=CONFIG_NAME):
248+
load(path)
249+
250+
238251
# -- the resolver itself -----------------------------------------------------
239252

240253

0 commit comments

Comments
 (0)