Skip to content

Commit 22ca1a4

Browse files
committed
Add graph CLI with export and execution output handling
1 parent 18794a7 commit 22ca1a4

7 files changed

Lines changed: 433 additions & 14 deletions

File tree

‎CONTRIBUTING.md‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Contributing
22

3-
Install the development dependencies using Python 3.8+ and a modern pip:
3+
To contribute to this Python client, install its development dependencies using
4+
Python 3.8+ and a modern pip:
45

56
```bash
67
python -m pip install --upgrade pip
@@ -9,11 +10,9 @@ python -m pip install -e '.[dev]'
910

1011
## Code quality
1112

12-
1313
Install `.[dev]` to get the pinned development tools. Black 24.8.0, isort 5.13.2,
14-
and Flake8 7.1.1 follow the conventions used in the sibling envstack and pyseq
15-
projects: 100-column formatting, isort's Black profile, and a Python 3.8 target.
16-
The tool versions also support running locally under Python 3.8.
14+
and Flake8 7.1.1 use 100-column formatting, isort's Black profile, and a Python
15+
3.8 target.
1716

1817
```bash
1918
make format # Sort imports and apply Black to src/ and tests/

‎README.md‎

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
<a href="https://subfork.com">
33
<img src="assets/subfork-banner.png" alt="Subfork" width="100%">
44
</a>
5-
<p>Create, publish, and run graphs from Python.</p>
65
</div>
76

87
# Subfork Python
@@ -32,16 +31,40 @@ with Subfork() as client:
3231
graphs = client.graphs.list()
3332
```
3433

35-
The client reads `SUBFORK_API_KEY` and connects to `https://subfork.com` by default.
36-
Responses are dictionaries and lists matching the API's JSON responses.
34+
The client reads `SUBFORK_API_KEY` and connects to [subfork.com](https://subfork.com).
3735

38-
For another deployment, pass its origin without `/api/v1`:
36+
## Command line
3937

40-
```python
41-
with Subfork(base_url="http://subfork.localhost") as client:
42-
nodes = client.nodes.list()
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.
40+
41+
```bash
42+
subfork list
43+
subfork export GRAPH_ID --output graph.json
44+
subfork validate graph.json
45+
subfork create graph.json --name "My new graph"
46+
subfork interface GRAPH_ID
47+
subfork publish GRAPH_ID --version v1
48+
subfork execute GRAPH_ID --version v1 --inputs inputs.json
4349
```
4450

51+
Export writes a graph's draft definition, which can be passed directly to create.
52+
It refuses to overwrite an existing file; omit `--output` to print to stdout.
53+
Descriptions, tags, and published versions are not included in the export.
54+
JSON input files must contain an object; use `-` to read from stdin.
55+
Create makes a public draft, publish creates an immutable version, and execute
56+
waits for completion and prints the graph outputs. Runs consume account quota.
57+
Use `execute --no-wait` for a submission summary or `execute --raw` for the full
58+
execution snapshot. Waiting requires read and run scopes; submission alone requires
59+
run scope. `--wait-timeout` (default 120) and `--poll-interval` (default 2) are in
60+
seconds. A timeout stops waiting without canceling the remote run.
61+
62+
Other commands include `get`, `published`, and `versions`. Use `--help` on any
63+
command. Node discovery and execution monitoring are available through the Python API.
64+
65+
Errors go to stderr. Operational errors and failed validation return exit code 1;
66+
usage errors return 2. Failed executions and wait timeouts also return exit code 1.
67+
4568
## Create and run a graph
4669

4770
This example creates a text-producing graph, publishes `v1`, and runs that version.

‎pyproject.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ classifiers = [
1717
"Typing :: Typed",
1818
]
1919

20+
[project.scripts]
21+
subfork = "subfork.cli:main"
22+
2023
[project.optional-dependencies]
2124
dev = [
2225
"pytest>=8,<10",

‎src/subfork/__main__.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Support invocation with python -m subfork."""
2+
3+
from .cli import main
4+
5+
if __name__ == "__main__":
6+
raise SystemExit(main())

‎src/subfork/cli.py‎

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"""Command-line graph authoring and execution with JSON input and output."""
2+
3+
import argparse
4+
import json
5+
import math
6+
import os
7+
import sys
8+
from pathlib import Path
9+
from typing import Any, Dict, Optional, Sequence
10+
11+
from . import AuthenticationError, ExecutionTimeout, Subfork, SubforkError, __version__
12+
13+
14+
def build_parser() -> argparse.ArgumentParser:
15+
"""Build the public command tree without opening a connection."""
16+
parser = argparse.ArgumentParser(description=__doc__)
17+
parser.add_argument("--version", action="version", version=__version__)
18+
parser.add_argument("--base-url", default=os.getenv("SUBFORK_BASE_URL", "https://subfork.com"))
19+
parser.add_argument("--timeout", type=float, default=30, help="HTTP timeout in seconds")
20+
commands = parser.add_subparsers(dest="command", required=True)
21+
commands.add_parser("list", help="List account graphs")
22+
commands.add_parser("published", help="List published building blocks")
23+
for command in ("get", "versions", "interface", "export", "publish", "execute"):
24+
child = commands.add_parser(command)
25+
child.add_argument("graph_id")
26+
if command == "export":
27+
child.add_argument(
28+
"--output", default="-", help="Definition JSON path, or - for stdout"
29+
)
30+
elif command == "publish":
31+
child.add_argument("--version", required=True, help="New immutable version, e.g. v1")
32+
child.add_argument("--interface", help="Optional interface JSON file")
33+
child.add_argument("--comment", default="")
34+
elif command == "execute":
35+
child.add_argument("--version", default="draft")
36+
child.add_argument(
37+
"--no-wait", action="store_true", help="Return submission status immediately"
38+
)
39+
child.add_argument(
40+
"--raw", action="store_true", help="Print the full execution snapshot"
41+
)
42+
child.add_argument(
43+
"--wait-timeout",
44+
type=float,
45+
default=120,
46+
help="Maximum wait in seconds (default: 120)",
47+
)
48+
child.add_argument(
49+
"--poll-interval",
50+
type=float,
51+
default=2,
52+
help="Seconds between status checks (default: 2)",
53+
)
54+
child.add_argument("--inputs", help="Inputs JSON file, or - for stdin")
55+
for command in ("validate", "create"):
56+
child = commands.add_parser(command)
57+
child.add_argument("file", help="Graph definition JSON file, or - for stdin")
58+
child.add_argument("--name", help="Override the definition's name")
59+
if command == "create":
60+
child.add_argument("--description", default="")
61+
child.add_argument("--tag", action="append", default=[])
62+
return parser
63+
64+
65+
def read_object(filename: str) -> Dict[str, Any]:
66+
"""Read a JSON object from a UTF-8 file or standard input."""
67+
try:
68+
content = (
69+
sys.stdin.read() if filename == "-" else Path(filename).read_text(encoding="utf-8")
70+
)
71+
value = json.loads(content)
72+
except (ValueError, UnicodeError) as exc:
73+
raise ValueError("Input must contain valid UTF-8 JSON.") from exc
74+
if not isinstance(value, dict):
75+
raise ValueError("Input JSON must be an object.")
76+
return value
77+
78+
79+
def graph_command(client: Subfork, args: argparse.Namespace) -> Any:
80+
"""Dispatch graph operations through the same SDK used by Python callers."""
81+
graphs = client.graphs
82+
if args.command == "list":
83+
return graphs.list()
84+
if args.command == "published":
85+
return graphs.published()
86+
if args.command in {"create", "validate"}:
87+
definition = read_object(args.file)
88+
name = args.name if args.name is not None else definition.get("name", "Untitled Graph")
89+
if not isinstance(name, str) or not name.strip():
90+
raise ValueError("Graph name must be a nonempty string.")
91+
if args.command == "validate":
92+
return graphs.validate(definition, name=name)
93+
return graphs.create(
94+
name=name, definition=definition, description=args.description, tags=args.tag
95+
)
96+
if args.command == "get":
97+
return graphs.get(args.graph_id)
98+
if args.command == "export":
99+
graph = graphs.get(args.graph_id)
100+
exported = graph.get("definition")
101+
if not isinstance(exported, dict):
102+
raise ValueError("Graph response does not contain a definition object.")
103+
return exported
104+
if args.command == "versions":
105+
return graphs.versions(args.graph_id)
106+
if args.command == "interface":
107+
return graphs.interface(args.graph_id)
108+
if args.command == "publish":
109+
interface = read_object(args.interface) if args.interface else None
110+
return graphs.publish(
111+
args.graph_id, version=args.version, interface=interface, comment=args.comment
112+
)
113+
inputs = read_object(args.inputs) if args.inputs else None
114+
if any(
115+
not math.isfinite(value) or value <= 0 for value in (args.wait_timeout, args.poll_interval)
116+
):
117+
raise ValueError("Wait timeout and poll interval must be positive finite numbers.")
118+
result = graphs.execute(args.graph_id, version=args.version, inputs=inputs)
119+
if not args.no_wait and result.get("status") not in {
120+
"completed",
121+
"failed",
122+
"canceled",
123+
"cancelled",
124+
"outcome_unknown",
125+
}:
126+
execution_id = result.get("id") or result.get("execution_id")
127+
if not isinstance(execution_id, str) or not execution_id:
128+
raise ValueError(
129+
"Submission returned no execution ID; inspect remote state before retrying."
130+
)
131+
print("subfork: waiting for execution {}".format(execution_id), file=sys.stderr)
132+
result = client.executions.wait(
133+
execution_id, timeout=args.wait_timeout, poll_interval=args.poll_interval
134+
)
135+
return result
136+
137+
138+
def main(argv: Optional[Sequence[str]] = None) -> int:
139+
"""Run the CLI; return zero on success and one on operational failure.
140+
141+
Argparse reports usage errors with exit code two. Failed validation returns
142+
one, retaining its JSON diagnostics on stdout.
143+
"""
144+
args = build_parser().parse_args(argv)
145+
try:
146+
with Subfork(base_url=args.base_url, timeout=args.timeout) as client:
147+
result = graph_command(client, args)
148+
failed = args.command == "execute" and result.get("status") in {
149+
"failed",
150+
"canceled",
151+
"cancelled",
152+
"outcome_unknown",
153+
}
154+
display = result
155+
if args.command == "execute" and not args.raw:
156+
if args.no_wait or failed:
157+
display = {
158+
"execution_id": result.get("id") or result.get("execution_id"),
159+
"status": result.get("status"),
160+
}
161+
else:
162+
display = result.get("outputs", {})
163+
rendered = json.dumps(display, indent=2, ensure_ascii=False) + "\n"
164+
output = getattr(args, "output", "-")
165+
if output == "-":
166+
sys.stdout.write(rendered)
167+
else:
168+
# Exclusive creation avoids silently overwriting a local definition.
169+
with Path(output).open("x", encoding="utf-8", newline="\n") as stream:
170+
stream.write(rendered)
171+
if failed:
172+
print(
173+
"subfork: execution did not complete successfully; use --raw for details",
174+
file=sys.stderr,
175+
)
176+
return 1
177+
if args.command == "validate" and result.get("valid") is False:
178+
return 1
179+
return 0
180+
except ExecutionTimeout as exc:
181+
print(
182+
"subfork: execution {} timed out while waiting; the remote run is not canceled".format(
183+
exc.execution_id
184+
),
185+
file=sys.stderr,
186+
)
187+
return 1
188+
except AuthenticationError:
189+
print(
190+
"subfork: authentication failed (HTTP 401). Check that SUBFORK_API_KEY "
191+
"is an active key issued by the service selected with --base-url or "
192+
"SUBFORK_BASE_URL (default: https://subfork.com). "
193+
"The CLI reads exported environment variables; it does not load .env files.",
194+
file=sys.stderr,
195+
)
196+
return 1
197+
except (SubforkError, ValueError, OSError) as exc:
198+
print("subfork: {}".format(exc), file=sys.stderr)
199+
return 1
200+
except KeyboardInterrupt:
201+
print(
202+
"subfork: interrupted; remote execution is not canceled automatically", file=sys.stderr
203+
)
204+
return 130

‎src/subfork/client.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,12 @@ def __init__(
3737
timeout: float = 30,
3838
transport: Optional[httpx.BaseTransport] = None,
3939
) -> None:
40-
"""Create a client for an HTTPS origin or a local development server.
40+
"""Create an authenticated client for the Subfork API.
4141
4242
Args:
4343
api_key: Bearer secret, or None to read SUBFORK_API_KEY.
44-
base_url: Origin without an API path, query, or credentials.
44+
base_url: API origin, defaulting to https://subfork.com, without
45+
an API path, query, or credentials.
4546
timeout: Positive per-I/O-phase HTTP timeout in seconds.
4647
transport: Optional HTTPX transport, primarily for testing.
4748

0 commit comments

Comments
 (0)