Skip to content

Commit a65f0eb

Browse files
committed
Scaffold subfork Python API client and package checks
1 parent 25adacd commit a65f0eb

10 files changed

Lines changed: 472 additions & 1 deletion

File tree

‎.github/workflows/tests.yml‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: Test and package
2+
on: [push, pull_request]
3+
permissions:
4+
contents: read
5+
jobs:
6+
test:
7+
runs-on: ubuntu-latest
8+
strategy:
9+
matrix:
10+
python: ['3.10', '3.12', '3.13']
11+
steps:
12+
- uses: actions/checkout@v4
13+
- uses: actions/setup-python@v5
14+
with:
15+
python-version: ${{ matrix.python }}
16+
- run: python -m pip install '.[dev]'
17+
- run: python -m pytest
18+
- run: python -m build
19+
- run: python -m twine check dist/*

‎.gitignore‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
__pycache__/
2+
*.py[cod]
3+
.pytest_cache/
4+
.venv/
5+
.env
6+
.env.*
7+
build/
8+
dist/
9+
*.egg-info/

‎README.md‎

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,100 @@
1-
hello world
1+
# subfork
2+
3+
Python API client for Subfork graph authoring, publication and execution.
4+
**Alpha scaffold; not yet published to PyPI.** Distribution and import name: `subfork`.
5+
BYO workers belong in the separate worker package, not this client.
6+
7+
## Development install
8+
9+
Requires Python 3.10+.
10+
11+
```bash
12+
python -m venv .venv
13+
source .venv/bin/activate
14+
pip install -e '.[dev]'
15+
pytest
16+
python -m build
17+
python -m twine check dist/*
18+
```
19+
20+
A package license must be selected before the first public release. CI builds and
21+
checks distributions but does not publish them.
22+
23+
## Usage
24+
25+
Create an API key under Account → API keys on a deployment with API-key support.
26+
Set `SUBFORK_API_KEY` in your process environment; never put it in source files,
27+
URLs, graph definitions, notebooks committed to git, or agent transcripts.
28+
29+
```python
30+
from subfork import Subfork
31+
32+
with Subfork() as client: # Reads SUBFORK_API_KEY; defaults to https://subfork.com
33+
nodes = client.nodes.list(include_all_versions=True)
34+
graphs = client.graphs.list()
35+
```
36+
37+
For a local deployment:
38+
39+
```python
40+
with Subfork(base_url="http://subfork.localhost") as client:
41+
nodes = client.nodes.list()
42+
```
43+
44+
`base_url` is an origin, without `/api/v1`. HTTPS is required except for loopback
45+
and `.localhost` hosts. Credentials are sent in the Authorization bearer header.
46+
Redirects are not followed. Use a context manager or call `client.close()`.
47+
48+
## Author, publish, execute
49+
50+
The following deliberately creates and publishes a public graph and consumes
51+
execution quota. Grant read/write/run/publish scopes only when needed.
52+
Responses are dictionaries preserving the server's JSON shape.
53+
54+
```python
55+
from subfork import Subfork
56+
57+
name = "Python greeting"
58+
definition = {
59+
"name": name,
60+
"nodes": [{
61+
"node_instance_id": "hello", "node_id": "n_text_value",
62+
"node_version": "1.0.0", "title": "Hello",
63+
"params": {"text": "Hello from Python"},
64+
}],
65+
"edges": [],
66+
"graph_outputs": {"text": {"node_instance_id": "hello", "output_name": "text"}},
67+
}
68+
with Subfork() as client:
69+
client.graphs.validate(definition)
70+
graph = client.graphs.create(name=name, definition=definition)
71+
client.graphs.publish(graph["id"], version="v1")
72+
execution = client.graphs.execute(graph["id"], version="v1")
73+
result = client.executions.wait(execution["id"], timeout=120)
74+
print(result["status"], result.get("outputs"))
75+
```
76+
77+
Discover published blocks with `graphs.published()` and retrieve their exact
78+
interfaces with `graphs.published_version(graph_id, version)`. Compose their
79+
returned node IDs with `composite_graph` parameters and explicit pinned versions.
80+
`graphs.interface(graph_id)` previews the server-inferred interface.
81+
`graphs.update()` replaces the submitted draft fields, including description;
82+
pass the description to preserve it. Existing server ownership and quotas apply.
83+
84+
## Errors and limits
85+
86+
Catch `AuthenticationError`, `PermissionDeniedError`, `ValidationError`,
87+
`RateLimitError`, or the base `APIError`; HTTP errors expose `status_code` and
88+
`retry_after`. Messages intentionally do not echo raw response bodies or secrets.
89+
`TransportError` means the outcome of a write may be unknown. There are no automatic
90+
retries: inspect remote state before retrying create, publish or execute operations.
91+
92+
`executions.wait()` polls until a terminal status and returns failed executions
93+
as results too. `ExecutionTimeout` does **not** cancel the remote execution;
94+
use `executions.cancel(id)` explicitly. Polling uses a monotonic deadline and caps
95+
individual HTTP timeout settings by remaining time. HTTPX timeouts apply per I/O
96+
phase, not a strict wall-clock deadline for the entire network exchange.
97+
98+
The first scaffold is synchronous. Async support, rich response models, artifact
99+
streaming, idempotent write retries and worker support are not included. Mock HTTP
100+
tests do not certify any particular server deployment.

‎pyproject.toml‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
[build-system]
2+
requires = ["setuptools>=77", "wheel"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "subfork"
7+
version = "0.1.0a1"
8+
description = "Python API client for Subfork graph authoring and execution"
9+
readme = "README.md"
10+
requires-python = ">=3.10"
11+
dependencies = ["httpx>=0.28,<1"]
12+
classifiers = [
13+
"Development Status :: 3 - Alpha",
14+
"Intended Audience :: Developers",
15+
"Programming Language :: Python :: 3",
16+
"Typing :: Typed",
17+
]
18+
19+
[project.optional-dependencies]
20+
dev = ["pytest>=8,<10", "build>=1,<2", "twine>=6,<7"]
21+
22+
[tool.setuptools.packages.find]
23+
where = ["src"]
24+
25+
[tool.setuptools.package-data]
26+
subfork = ["py.typed"]
27+
28+
[tool.pytest.ini_options]
29+
testpaths = ["tests"]

‎src/subfork/__init__.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""Subfork's Python API client. BYO worker support is a separate distribution."""
2+
from .client import Subfork
3+
from .errors import APIError, AuthenticationError, ExecutionTimeout, InvalidResponseError, NotFoundError, PermissionDeniedError, RateLimitError, SubforkError, TransportError, ValidationError
4+
5+
__version__ = '0.1.0a1'
6+
__all__ = ['Subfork', 'SubforkError', 'APIError', 'AuthenticationError', 'PermissionDeniedError',
7+
'NotFoundError', 'ValidationError', 'RateLimitError', 'TransportError',
8+
'InvalidResponseError', 'ExecutionTimeout']

‎src/subfork/client.py‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Synchronous, explicitly closed HTTP client. No automatic write retries."""
2+
from __future__ import annotations
3+
4+
import math
5+
import os
6+
from typing import Any
7+
8+
import httpx
9+
10+
from .errors import APIError, AuthenticationError, InvalidResponseError, NotFoundError, PermissionDeniedError, RateLimitError, TransportError, ValidationError
11+
from .resources import Executions, Graphs, Nodes
12+
13+
14+
class Subfork:
15+
def __init__(self, api_key: str | None = None, *, base_url: str = 'https://subfork.com',
16+
timeout: float = 30, transport: httpx.BaseTransport | None = None):
17+
key = os.environ.get('SUBFORK_API_KEY') if api_key is None else api_key
18+
if not key or any(char.isspace() for char in key):
19+
raise ValueError('Provide an API key or set SUBFORK_API_KEY; keys cannot contain whitespace.')
20+
if not math.isfinite(timeout) or timeout <= 0:
21+
raise ValueError('timeout must be a positive finite number.')
22+
origin = httpx.URL(base_url)
23+
if not origin.host or origin.userinfo or origin.query or origin.fragment or origin.path not in {'', '/'}:
24+
raise ValueError('base_url must be an origin without credentials, path, query or fragment.')
25+
if origin.scheme != 'https' and not (origin.scheme == 'http' and (origin.host in {'localhost', '127.0.0.1', '::1'} or origin.host.endswith('.localhost'))):
26+
raise ValueError('Use HTTPS; HTTP is allowed only for localhost.')
27+
self.timeout = timeout
28+
self._client = httpx.Client(base_url=str(origin).rstrip('/') + '/api/v1/', timeout=timeout,
29+
headers={'Authorization': 'Bearer ' + key, 'Accept': 'application/json'},
30+
follow_redirects=False, transport=transport)
31+
self.nodes = Nodes(self)
32+
self.graphs = Graphs(self)
33+
self.executions = Executions(self)
34+
35+
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
36+
try:
37+
response = self._client.request(method, path.lstrip('/'), **kwargs)
38+
except httpx.TransportError:
39+
raise TransportError('API request could not be completed; inspect remote state before retrying a write.') from None
40+
if not response.is_success:
41+
errors = {401: AuthenticationError, 403: PermissionDeniedError, 404: NotFoundError,
42+
422: ValidationError, 429: RateLimitError}
43+
# Fixed messages avoid reflecting secrets from an untrusted response.
44+
raise errors.get(response.status_code, APIError)(
45+
'Subfork API returned HTTP %s.' % response.status_code, status_code=response.status_code,
46+
retry_after=response.headers.get('retry-after'))
47+
if response.status_code == 204:
48+
return None
49+
try:
50+
return response.json()
51+
except ValueError:
52+
raise InvalidResponseError('API returned invalid JSON.') from None
53+
54+
def close(self) -> None:
55+
self._client.close()
56+
57+
def __enter__(self) -> Subfork:
58+
return self
59+
60+
def __exit__(self, *_args: Any) -> None:
61+
self.close()

‎src/subfork/errors.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Public client errors; credentials and raw responses are never attached."""
2+
3+
4+
class SubforkError(Exception):
5+
"""Base class for client errors."""
6+
7+
8+
class TransportError(SubforkError):
9+
"""The request could not be completed; a write's outcome may be unknown."""
10+
11+
12+
class APIError(SubforkError):
13+
def __init__(self, message: str, *, status_code: int, retry_after: str | None = None):
14+
super().__init__(message)
15+
self.status_code = status_code
16+
self.retry_after = retry_after
17+
18+
19+
class AuthenticationError(APIError):
20+
pass
21+
22+
23+
class PermissionDeniedError(APIError):
24+
pass
25+
26+
27+
class NotFoundError(APIError):
28+
pass
29+
30+
31+
class ValidationError(APIError):
32+
pass
33+
34+
35+
class RateLimitError(APIError):
36+
pass
37+
38+
39+
class InvalidResponseError(SubforkError):
40+
pass
41+
42+
43+
class ExecutionTimeout(SubforkError):
44+
"""Polling expired. The remote execution was not cancelled."""
45+
46+
def __init__(self, execution_id: str):
47+
super().__init__("Execution polling timed out; the remote execution may still be running.")
48+
self.execution_id = execution_id

‎src/subfork/py.typed‎

Whitespace-only changes.

‎src/subfork/resources.py‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Small resource wrappers over the existing versioned HTTP API."""
2+
from __future__ import annotations
3+
4+
import math
5+
import time
6+
from typing import TYPE_CHECKING, Any
7+
from urllib.parse import quote
8+
9+
from .errors import ExecutionTimeout
10+
11+
if TYPE_CHECKING:
12+
from .client import Subfork
13+
14+
15+
def segment(value: str) -> str:
16+
if not value or value in {'.', '..'}:
17+
raise ValueError('Resource identifiers must be non-empty and cannot be dot segments.')
18+
return quote(value, safe='')
19+
20+
21+
class Resource:
22+
def __init__(self, client: Subfork):
23+
self._client = client
24+
25+
26+
class Nodes(Resource):
27+
def list(self, *, include_all_versions: bool = False) -> list[dict[str, Any]]:
28+
return self._client._request('GET', '/nodes', params={'include_all_versions': include_all_versions})
29+
30+
def get(self, node_id: str) -> dict[str, Any]:
31+
return self._client._request('GET', '/nodes/' + segment(node_id))
32+
33+
34+
class Graphs(Resource):
35+
def list(self) -> list[dict[str, Any]]:
36+
return self._client._request('GET', '/graphs')
37+
38+
def get(self, graph_id: str) -> dict[str, Any]:
39+
return self._client._request('GET', '/graphs/' + segment(graph_id))
40+
41+
def validate(self, definition: dict[str, Any], *, name: str | None = None) -> dict[str, Any]:
42+
return self._client._request('POST', '/graphs/validate', json={
43+
'name': name if name is not None else definition.get('name', 'Untitled Graph'), 'definition': definition})
44+
45+
def create(self, *, name: str, definition: dict[str, Any], description: str = '', tags: list[str] | None = None) -> dict[str, Any]:
46+
return self._client._request('POST', '/graphs', json={
47+
'name': name, 'description': description, 'definition': definition, 'tags': tags or []})
48+
49+
def update(self, graph_id: str, *, name: str, definition: dict[str, Any], description: str = '') -> dict[str, Any]:
50+
return self._client._request('PUT', '/graphs/' + segment(graph_id), json={
51+
'name': name, 'description': description, 'definition': definition})
52+
53+
def published(self) -> list[dict[str, Any]]:
54+
return self._client._request('GET', '/graphs/published')
55+
56+
def versions(self, graph_id: str) -> list[dict[str, Any]]:
57+
return self._client._request('GET', '/graphs/' + segment(graph_id) + '/versions')
58+
59+
def published_version(self, graph_id: str, version: str) -> dict[str, Any]:
60+
return self._client._request('GET', '/graphs/published/' + segment(graph_id) + '/versions/' + segment(version))
61+
62+
def interface(self, graph_id: str) -> dict[str, Any]:
63+
return self._client._request('GET', '/graphs/' + segment(graph_id) + '/interface-preview')
64+
65+
def publish(self, graph_id: str, *, version: str, interface: dict[str, Any] | None = None, comment: str = '') -> dict[str, Any]:
66+
payload: dict[str, Any] = {'version': version, 'comment': comment}
67+
if interface is not None:
68+
payload['interface'] = interface
69+
return self._client._request('POST', '/graphs/' + segment(graph_id) + '/versions', json=payload)
70+
71+
def execute(self, graph_id: str, *, version: str = 'draft', inputs: dict[str, Any] | None = None) -> dict[str, Any]:
72+
return self._client._request('POST', '/graphs/' + segment(graph_id) + '/execute',
73+
params={'version': version}, json={'inputs': inputs or {}})
74+
75+
76+
class Executions(Resource):
77+
def get(self, execution_id: str) -> dict[str, Any]:
78+
return self._client._request('GET', '/executions/' + segment(execution_id))
79+
80+
def cancel(self, execution_id: str) -> dict[str, Any]:
81+
return self._client._request('POST', '/executions/' + segment(execution_id) + '/cancel')
82+
83+
def wait(self, execution_id: str, *, timeout: float = 120, poll_interval: float = 2) -> dict[str, Any]:
84+
if not math.isfinite(timeout) or timeout <= 0 or not math.isfinite(poll_interval) or poll_interval <= 0:
85+
raise ValueError('timeout and poll_interval must be positive finite numbers.')
86+
deadline = time.monotonic() + timeout
87+
while True:
88+
remaining = deadline - time.monotonic()
89+
if remaining <= 0:
90+
raise ExecutionTimeout(execution_id)
91+
result = self._client._request('GET', '/executions/' + segment(execution_id),
92+
timeout=min(self._client.timeout, remaining))
93+
if result['status'] in {'completed', 'failed', 'canceled', 'cancelled', 'outcome_unknown'}:
94+
return result
95+
time.sleep(min(poll_interval, max(0, deadline - time.monotonic())))

0 commit comments

Comments
 (0)