Skip to content

Commit 94a8a99

Browse files
committed
Clarify API errors and add explicit output overwrite support
1 parent afd5a60 commit 94a8a99

5 files changed

Lines changed: 165 additions & 41 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -35,44 +35,22 @@ The client reads `SUBFORK_API_KEY` and connects to [subfork.com](https://subfork
3535

3636
## Command line
3737

38-
The package also installs `subfork` (or use `python -m subfork`). It reads
39-
`SUBFORK_API_KEY` from your environment and prints JSON for use in scripts.
38+
The package includes a `subfork` CLI that uses your `SUBFORK_API_KEY`.
4039

4140
```bash
4241
subfork list
4342
subfork export GRAPH_ID --output graph.json
4443
subfork validate graph.json
4544
subfork create graph.json --name "My new graph"
46-
subfork interface GRAPH_ID
4745
subfork publish GRAPH_ID --version v1
48-
subfork execute GRAPH_ID --version v1 --inputs inputs.json
46+
subfork execute GRAPH_ID --version v1
4947
subfork execute GRAPH_ID -o results.json
5048
```
5149

52-
Export writes a graph's draft definition, which can be passed directly to create.
53-
It refuses to overwrite an existing file; omit `--output` to print to stdout.
54-
Descriptions, tags, and published versions are not included in the export.
55-
JSON input files must contain an object; use `-` to read from stdin.
56-
Create makes a public draft, publish creates an immutable version, and execute
57-
waits for completion and prints the graph outputs. Runs consume account quota.
58-
Use `-o results.json` or `--out results.json` to write result JSON to a new file
59-
instead of stdout. Progress still goes to stderr. Existing files are not overwritten.
60-
Use `execute --no-wait` for a submission summary or `execute --raw` for the full
61-
execution snapshot. Waiting requires read and run scopes; submission alone requires
62-
run scope. `--wait-timeout` (default 120) and `--poll-interval` (default 2) are in
63-
seconds. A timeout stops waiting without canceling the remote run.
64-
A yellow spinner precedes `Graph <name> .......... Running`, with `Running` in
65-
green. As status snapshots arrive, the line shows the currently running node titles
66-
(or multiple titles for parallel nodes). Short-lived nodes may finish between polls.
67-
Set `NO_COLOR` to disable colors. Progress goes to stderr; stdout remains JSON.
68-
Finished nodes remain on separate stderr lines with their final status before
69-
the JSON results appear. Redirected progress uses plain text without animation.
70-
71-
Other commands include `get`, `published`, and `versions`. Use `--help` on any
72-
command. Node discovery and execution monitoring are available through the Python API.
73-
74-
Errors go to stderr. Operational errors and failed validation return exit code 1;
75-
usage errors return 2. Failed executions and wait timeouts also return exit code 1.
50+
`execute` waits for completion and returns JSON. Use `-o` to save results to a
51+
file; progress stays on stderr. Add `-f` / `--force` to overwrite existing output
52+
files with `execute` or `export`. Run `subfork --help` or `subfork execute --help`
53+
for more options.
7654

7755
## Create and run a graph
7856

‎src/subfork/cli.py‎

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,15 @@
1111
from pathlib import Path
1212
from typing import Any, Callable, Dict, Iterator, Optional, Sequence
1313

14-
from . import AuthenticationError, ExecutionTimeout, Subfork, SubforkError, __version__
14+
from . import APIError, AuthenticationError, ExecutionTimeout, Subfork, SubforkError, __version__
15+
16+
17+
def print_api_error(status_code: int, message: str) -> None:
18+
"""Write a concise API diagnostic, coloring only the status code on terminals."""
19+
code = str(status_code)
20+
if sys.stderr.isatty() and os.environ.get("TERM") != "dumb" and not os.environ.get("NO_COLOR"):
21+
code = "\033[33m" + code + "\033[0m"
22+
print("{}: {}".format(code, message), file=sys.stderr)
1523

1624

1725
@contextmanager
@@ -161,6 +169,10 @@ def build_parser() -> argparse.ArgumentParser:
161169
for command in ("get", "versions", "interface", "export", "publish", "execute"):
162170
child = commands.add_parser(command)
163171
child.add_argument("graph_id")
172+
if command in {"export", "execute"}:
173+
child.add_argument(
174+
"-f", "--force", action="store_true", help="Overwrite an existing output file"
175+
)
164176
if command == "export":
165177
child.add_argument(
166178
"--output", default="-", help="Definition JSON path, or - for stdout"
@@ -171,9 +183,7 @@ def build_parser() -> argparse.ArgumentParser:
171183
child.add_argument("--comment", default="")
172184
elif command == "execute":
173185
child.add_argument("--version", default="draft")
174-
child.add_argument(
175-
"-o", "--out", help="Write result JSON to a new file instead of stdout"
176-
)
186+
child.add_argument("-o", "--out", help="Write result JSON to a file instead of stdout")
177187
child.add_argument(
178188
"--no-wait", action="store_true", help="Return submission status immediately"
179189
)
@@ -297,8 +307,15 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
297307
args = build_parser().parse_args(argv)
298308
try:
299309
result_file = getattr(args, "out", None)
300-
if result_file is not None and Path(result_file).exists():
301-
raise ValueError("Output file already exists; choose a new path.")
310+
force = getattr(args, "force", False)
311+
export_file = getattr(args, "output", "-")
312+
destination = (
313+
result_file
314+
if result_file is not None
315+
else (export_file if export_file != "-" else None)
316+
)
317+
if destination is not None and Path(destination).exists() and not force:
318+
raise ValueError("Output file already exists; use --force to overwrite.")
302319
with Subfork(base_url=args.base_url, timeout=args.timeout) as client:
303320
result = graph_command(client, args)
304321
failed = args.command == "execute" and result.get("status") in {
@@ -321,9 +338,9 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
321338
if result_file is None and output == "-":
322339
sys.stdout.write(rendered)
323340
else:
324-
# Exclusive creation avoids silently overwriting an existing file.
341+
# Open only after the request and serialization succeed.
325342
with Path(result_file if result_file is not None else output).open(
326-
"x", encoding="utf-8", newline="\n"
343+
"w" if force else "x", encoding="utf-8", newline="\n"
327344
) as stream:
328345
stream.write(rendered)
329346
if failed:
@@ -344,14 +361,21 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
344361
)
345362
return 1
346363
except AuthenticationError:
347-
print(
348-
"subfork: authentication failed (HTTP 401). Check that SUBFORK_API_KEY "
364+
print_api_error(
365+
401,
366+
"Authentication failed. Check that SUBFORK_API_KEY "
349367
"is an active key issued by the service selected with --base-url or "
350368
"SUBFORK_BASE_URL (default: https://subfork.com). "
351369
"The CLI reads exported environment variables; it does not load .env files.",
352-
file=sys.stderr,
353370
)
354371
return 1
372+
except APIError as exc:
373+
message = str(exc)
374+
prefix = "Subfork API returned HTTP {}.".format(exc.status_code)
375+
if message.startswith(prefix):
376+
message = message[len(prefix) :].strip() or "API request failed."
377+
print_api_error(exc.status_code, message)
378+
return 1
355379
except (SubforkError, ValueError, OSError) as exc:
356380
print("subfork: {}".format(exc), file=sys.stderr)
357381
return 1

‎src/subfork/client.py‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import math
66
import os
7+
import re
78
from types import TracebackType
89
from typing import Any, Optional, Type
910

@@ -22,6 +23,29 @@
2223
from .resources import Executions, Graphs, Nodes
2324

2425

26+
def _error_message(response: httpx.Response) -> str:
27+
"""Translate recognized server errors into fixed, credential-safe guidance."""
28+
message = "Subfork API returned HTTP %s." % response.status_code
29+
if response.status_code != 409:
30+
return message
31+
try:
32+
payload = response.json()
33+
except ValueError:
34+
return message
35+
detail = payload.get("detail") if isinstance(payload, dict) else None
36+
if isinstance(detail, str) and re.fullmatch(
37+
r"Graph secret '[^\r\n]*' could not be decrypted\. The server encryption "
38+
r"key may have changed; re-enter this secret in Graph Settings\.",
39+
detail,
40+
):
41+
return (
42+
message + " A stored graph secret could not be decrypted. "
43+
"The server encryption key may have changed. "
44+
"Re-enter and save the affected secret in Graph Settings > Secrets, then retry."
45+
)
46+
return message
47+
48+
2549
class Subfork:
2650
"""Manage authenticated HTTP requests and graph resources.
2751
@@ -110,7 +134,7 @@ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
110134
}
111135
# Fixed messages avoid reflecting secrets from an untrusted response.
112136
raise errors.get(response.status_code, APIError)(
113-
"Subfork API returned HTTP %s." % response.status_code,
137+
_error_message(response),
114138
status_code=response.status_code,
115139
retry_after=response.headers.get("retry-after"),
116140
)

‎tests/test_cli.py‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,3 +349,72 @@ def test_execute_result_file(
349349
captured = capsys.readouterr()
350350
assert captured.out == ""
351351
assert "already exists" in captured.err
352+
353+
354+
@pytest.mark.parametrize(
355+
"terminal,no_color,colored", [(True, False, True), (True, True, False), (False, False, False)]
356+
)
357+
def test_api_error_display(
358+
monkeypatch: pytest.MonkeyPatch, terminal: bool, no_color: bool, colored: bool
359+
) -> None:
360+
"""Color only the error code and omit redundant API and program prefixes."""
361+
362+
class ErrorStream(io.StringIO):
363+
"""Capture diagnostics with configurable terminal detection."""
364+
365+
def isatty(self) -> bool:
366+
"""Return the selected terminal mode."""
367+
return terminal
368+
369+
def factory(**kwargs: Any) -> Subfork:
370+
"""Raise the SDK's safe conflict explanation."""
371+
from subfork import APIError
372+
373+
raise APIError(
374+
"Subfork API returned HTTP 409. A stored graph secret could not be decrypted.",
375+
status_code=409,
376+
)
377+
378+
stream = ErrorStream()
379+
monkeypatch.setattr("sys.stderr", stream)
380+
monkeypatch.setattr(cli, "Subfork", factory)
381+
monkeypatch.setenv("TERM", "xterm")
382+
monkeypatch.setenv("NO_COLOR", "1" if no_color else "")
383+
assert cli.main(["list"]) == 1
384+
code = "\033[33m409\033[0m" if colored else "409"
385+
assert stream.getvalue() == code + ": A stored graph secret could not be decrypted.\n"
386+
387+
388+
@pytest.mark.parametrize("flag", ["-f", "--force"])
389+
@pytest.mark.parametrize("command,option", [("execute", "-o"), ("export", "--output")])
390+
def test_force_output(
391+
requests: list, tmp_path: Path, capsys: Any, flag: str, command: str, option: str
392+
) -> None:
393+
"""Replace the whole output file only when explicitly requested."""
394+
output = tmp_path / "existing.json"
395+
output.write_text("previous result with extra trailing content")
396+
assert cli.main([command, "g_test", option, str(output), flag]) == 0
397+
assert json.loads(output.read_text()) == (
398+
{} if command == "execute" else {"name": "Greeting", "nodes": [], "edges": []}
399+
)
400+
assert capsys.readouterr().out == ""
401+
402+
403+
def test_force_preserves_output_on_api_failure(
404+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
405+
) -> None:
406+
"""Keep the previous result if execution submission fails."""
407+
408+
def factory(**kwargs: Any) -> Subfork:
409+
"""Construct a client that receives a server conflict."""
410+
return Subfork(
411+
"synthetic-key",
412+
transport=httpx.MockTransport(lambda request: httpx.Response(409)),
413+
**kwargs,
414+
)
415+
416+
monkeypatch.setattr(cli, "Subfork", factory)
417+
output = tmp_path / "existing.json"
418+
output.write_text("previous result")
419+
assert cli.main(["execute", "g_test", "-o", str(output), "--force"]) == 1
420+
assert output.read_text() == "previous result"

‎tests/test_client.py‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Exercise client contracts without network access or real credentials."""
22

33
import json
4-
from typing import Type
4+
from typing import Any, Type
55

66
import httpx
77
import pytest
@@ -68,6 +68,7 @@ def test_credentials_required(monkeypatch: pytest.MonkeyPatch) -> None:
6868
[
6969
(401, AuthenticationError),
7070
(403, PermissionDeniedError),
71+
(409, APIError),
7172
(422, ValidationError),
7273
(429, RateLimitError),
7374
(503, APIError),
@@ -200,3 +201,31 @@ def test_wait_reports_snapshots() -> None:
200201
result = client.executions.wait("e_test", poll_interval=0.001, on_update=snapshots.append)
201202
assert [snapshot["status"] for snapshot in snapshots] == ["running", "completed"]
202203
assert result == snapshots[-1]
204+
205+
206+
@pytest.mark.parametrize(
207+
"detail,recognized",
208+
[
209+
(
210+
"Graph secret '"
211+
+ KEY
212+
+ "' could not be decrypted. The server encryption key may have changed; re-enter this secret in Graph Settings.",
213+
True,
214+
),
215+
(KEY, False),
216+
({"secret": KEY}, False),
217+
(None, False),
218+
],
219+
)
220+
def test_decryption_error_guidance(detail: Any, recognized: bool) -> None:
221+
"""Explain known conflicts without echoing even the server-provided secret name."""
222+
with Subfork(
223+
KEY,
224+
transport=httpx.MockTransport(lambda request: httpx.Response(409, json={"detail": detail})),
225+
) as client:
226+
with pytest.raises(APIError) as caught:
227+
client.graphs.execute("g_test")
228+
message = str(caught.value)
229+
assert caught.value.status_code == 409
230+
assert KEY not in message
231+
assert ("Graph Settings > Secrets" in message) is recognized

0 commit comments

Comments
 (0)