superstep 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- superstep/__init__.py +10 -0
- superstep/_base.py +64 -0
- superstep/_client.py +125 -0
- superstep/_errors.py +85 -0
- superstep/_models.py +59 -0
- superstep/_version.py +1 -0
- superstep/py.typed +0 -0
- superstep-0.1.0.dist-info/METADATA +143 -0
- superstep-0.1.0.dist-info/RECORD +11 -0
- superstep-0.1.0.dist-info/WHEEL +4 -0
- superstep-0.1.0.dist-info/licenses/LICENSE +21 -0
superstep/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Superstep Python SDK: start workflow runs over the public API and read their results."""
|
|
2
|
+
from ._client import AsyncClient, Client
|
|
3
|
+
from ._errors import (APIConnectionError, AuthenticationError, ConfigError, InvalidRequestError, NotFoundError, PayloadTooLargeError,
|
|
4
|
+
RateLimitError, ServerError, SuperstepError, WaitTimeoutError)
|
|
5
|
+
from ._models import Run, RunMetrics, RunStatus, RunWaiting
|
|
6
|
+
from ._version import __version__
|
|
7
|
+
|
|
8
|
+
__all__ = ["Client", "AsyncClient", "Run", "RunMetrics", "RunStatus", "RunWaiting", "SuperstepError", "ConfigError",
|
|
9
|
+
"APIConnectionError", "AuthenticationError", "NotFoundError", "InvalidRequestError", "PayloadTooLargeError",
|
|
10
|
+
"RateLimitError", "ServerError", "WaitTimeoutError", "__version__"]
|
superstep/_base.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""What the sync and async clients share: configuration, request building, envelope parsing and the poll schedule."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Dict, Iterator, Optional
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
from ._errors import APIConnectionError, ConfigError, error_for
|
|
10
|
+
from ._models import Run
|
|
11
|
+
from ._version import __version__
|
|
12
|
+
|
|
13
|
+
ENV_BASE_URL, ENV_TOKEN, ENV_WORKFLOW_ID = "SUPERSTEP_BASE_URL", "SUPERSTEP_TOKEN", "SUPERSTEP_WORKFLOW_ID"
|
|
14
|
+
DEFAULT_TIMEOUT = 60.0 # per HTTP request; a `sync` start may hold the connection up to STUDIO_SYNC_WAIT_MAX (30 s)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Config:
|
|
19
|
+
base_url: str
|
|
20
|
+
token: str
|
|
21
|
+
workflow_id: str
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def resolve(cls, base_url: Optional[str], token: Optional[str], workflow_id: Optional[str]) -> "Config":
|
|
25
|
+
values = {"base_url": base_url or os.environ.get(ENV_BASE_URL), "token": token or os.environ.get(ENV_TOKEN),
|
|
26
|
+
"workflow_id": workflow_id or os.environ.get(ENV_WORKFLOW_ID)}
|
|
27
|
+
env = {"base_url": ENV_BASE_URL, "token": ENV_TOKEN, "workflow_id": ENV_WORKFLOW_ID}
|
|
28
|
+
missing = [f"{k} (or {env[k]})" for k, v in values.items() if not v]
|
|
29
|
+
if missing: raise ConfigError("Missing " + ", ".join(missing), code="CONFIG_MISSING")
|
|
30
|
+
return cls(base_url=str(values["base_url"]).rstrip("/"), token=str(values["token"]), workflow_id=str(values["workflow_id"]))
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def headers(self) -> Dict[str, str]:
|
|
34
|
+
return {"X-Workflow-Token": self.token, "Accept": "application/json", "User-Agent": f"superstep-python/{__version__}"}
|
|
35
|
+
|
|
36
|
+
def runs_url(self, run_id: Optional[str] = None) -> str:
|
|
37
|
+
url = f"{self.base_url}/api/hooks/workflows/{quote(self.workflow_id, safe='')}/runs"
|
|
38
|
+
return f"{url}/{quote(run_id, safe='')}" if run_id else url
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def start_body(input: Optional[Dict[str, Any]], mode: str, callback_url: Optional[str]) -> Dict[str, Any]:
|
|
42
|
+
if mode not in ("async", "sync"): raise ValueError("mode must be 'async' or 'sync'")
|
|
43
|
+
body: Dict[str, Any] = {"input": input or {}, "mode": mode}
|
|
44
|
+
if callback_url: body["callback_url"] = callback_url
|
|
45
|
+
return body
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse(status: int, headers: Dict[str, str], payload: Any) -> Run:
|
|
49
|
+
"""Unwrap the envelope; raise the typed error for an error envelope."""
|
|
50
|
+
if not isinstance(payload, dict) or "success" not in payload:
|
|
51
|
+
raise APIConnectionError(f"Unexpected response (HTTP {status}); is base_url a Superstep server?", code="BAD_RESPONSE", status=status)
|
|
52
|
+
if not payload["success"]:
|
|
53
|
+
err = payload.get("error") or {}
|
|
54
|
+
raise error_for(status, err.get("code", "UNKNOWN"), err.get("message", f"HTTP {status}"), err.get("details"), headers)
|
|
55
|
+
return Run.from_dict(payload["data"])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def poll_delays(initial: float, maximum: float, factor: float) -> Iterator[float]:
|
|
59
|
+
"""initial, initial*factor, ... capped at maximum."""
|
|
60
|
+
if initial <= 0 or factor < 1: raise ValueError("poll_interval must be > 0 and backoff >= 1")
|
|
61
|
+
delay = initial
|
|
62
|
+
while True:
|
|
63
|
+
yield min(delay, maximum)
|
|
64
|
+
delay *= factor
|
superstep/_client.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Sync and async clients. Both send the same requests (`_base`); only the transport and sleeping differ."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._base import DEFAULT_TIMEOUT, Config, parse, poll_delays, start_body
|
|
11
|
+
from ._errors import APIConnectionError, WaitTimeoutError
|
|
12
|
+
from ._models import Run
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _json(r: httpx.Response) -> Any:
|
|
16
|
+
try: return r.json()
|
|
17
|
+
except ValueError: return None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _Common:
|
|
21
|
+
def __init__(self, workflow_id: Optional[str], token: Optional[str], base_url: Optional[str]):
|
|
22
|
+
self.config = Config.resolve(base_url, token, workflow_id)
|
|
23
|
+
|
|
24
|
+
def _deadline(self, timeout: Optional[float]) -> Optional[float]:
|
|
25
|
+
return None if timeout is None else time.monotonic() + timeout
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def _sleep_for(delay: float, deadline: Optional[float], run: Run) -> float:
|
|
29
|
+
if deadline is None: return delay
|
|
30
|
+
left = deadline - time.monotonic()
|
|
31
|
+
if left <= 0: raise WaitTimeoutError(f"Run {run.run_id} still {run.status} after the timeout", run)
|
|
32
|
+
return min(delay, left)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Client(_Common):
|
|
36
|
+
"""Synchronous client.
|
|
37
|
+
|
|
38
|
+
client = Client(workflow_id="example", token="wft_...", base_url="http://localhost:8080")
|
|
39
|
+
run = client.run_and_wait({"message": "hi"}, timeout=60)
|
|
40
|
+
|
|
41
|
+
Unset arguments fall back to SUPERSTEP_WORKFLOW_ID, SUPERSTEP_TOKEN and SUPERSTEP_BASE_URL.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, workflow_id: Optional[str] = None, token: Optional[str] = None, base_url: Optional[str] = None, *,
|
|
45
|
+
timeout: float = DEFAULT_TIMEOUT, http_client: Optional[httpx.Client] = None):
|
|
46
|
+
super().__init__(workflow_id, token, base_url)
|
|
47
|
+
self._http = http_client or httpx.Client(timeout=timeout)
|
|
48
|
+
self._owns_http = http_client is None
|
|
49
|
+
|
|
50
|
+
def _send(self, method: str, url: str, body: Optional[Dict[str, Any]] = None) -> Run:
|
|
51
|
+
try: r = self._http.request(method, url, json=body, headers=self.config.headers)
|
|
52
|
+
except httpx.HTTPError as e: raise APIConnectionError(str(e) or type(e).__name__, code="CONNECTION_ERROR") from e
|
|
53
|
+
return parse(r.status_code, dict(r.headers), _json(r))
|
|
54
|
+
|
|
55
|
+
def run(self, input: Optional[Dict[str, Any]] = None, *, mode: str = "async", callback_url: Optional[str] = None) -> Run:
|
|
56
|
+
"""Start a run. `async` returns at once (status `queued`); `sync` waits up to the server's limit."""
|
|
57
|
+
return self._send("POST", self.config.runs_url(), start_body(input, mode, callback_url))
|
|
58
|
+
|
|
59
|
+
def get_run(self, run_id: str) -> Run:
|
|
60
|
+
return self._send("GET", self.config.runs_url(run_id))
|
|
61
|
+
|
|
62
|
+
def wait(self, run_id: str, *, timeout: Optional[float] = 300.0, poll_interval: float = 1.0, max_poll_interval: float = 10.0,
|
|
63
|
+
backoff: float = 1.5) -> Run:
|
|
64
|
+
"""Poll until the run is done. Raises WaitTimeoutError after `timeout` seconds (None waits forever)."""
|
|
65
|
+
deadline, delays = self._deadline(timeout), poll_delays(poll_interval, max_poll_interval, backoff)
|
|
66
|
+
while True:
|
|
67
|
+
run = self.get_run(run_id)
|
|
68
|
+
if run.done: return run
|
|
69
|
+
time.sleep(self._sleep_for(next(delays), deadline, run))
|
|
70
|
+
|
|
71
|
+
def run_and_wait(self, input: Optional[Dict[str, Any]] = None, *, timeout: Optional[float] = 300.0, poll_interval: float = 1.0,
|
|
72
|
+
max_poll_interval: float = 10.0, backoff: float = 1.5, callback_url: Optional[str] = None) -> Run:
|
|
73
|
+
"""Start a run and poll (interval growing by `backoff` up to `max_poll_interval`) until it is done."""
|
|
74
|
+
started = self.run(input, callback_url=callback_url)
|
|
75
|
+
if started.done: return started
|
|
76
|
+
return self.wait(started.run_id, timeout=timeout, poll_interval=poll_interval, max_poll_interval=max_poll_interval, backoff=backoff)
|
|
77
|
+
|
|
78
|
+
def close(self) -> None:
|
|
79
|
+
if self._owns_http: self._http.close()
|
|
80
|
+
|
|
81
|
+
def __enter__(self) -> "Client": return self
|
|
82
|
+
|
|
83
|
+
def __exit__(self, *exc: Any) -> None: self.close()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AsyncClient(_Common):
|
|
87
|
+
"""Asynchronous client with the same methods as `Client`, as coroutines."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, workflow_id: Optional[str] = None, token: Optional[str] = None, base_url: Optional[str] = None, *,
|
|
90
|
+
timeout: float = DEFAULT_TIMEOUT, http_client: Optional[httpx.AsyncClient] = None):
|
|
91
|
+
super().__init__(workflow_id, token, base_url)
|
|
92
|
+
self._http = http_client or httpx.AsyncClient(timeout=timeout)
|
|
93
|
+
self._owns_http = http_client is None
|
|
94
|
+
|
|
95
|
+
async def _send(self, method: str, url: str, body: Optional[Dict[str, Any]] = None) -> Run:
|
|
96
|
+
try: r = await self._http.request(method, url, json=body, headers=self.config.headers)
|
|
97
|
+
except httpx.HTTPError as e: raise APIConnectionError(str(e) or type(e).__name__, code="CONNECTION_ERROR") from e
|
|
98
|
+
return parse(r.status_code, dict(r.headers), _json(r))
|
|
99
|
+
|
|
100
|
+
async def run(self, input: Optional[Dict[str, Any]] = None, *, mode: str = "async", callback_url: Optional[str] = None) -> Run:
|
|
101
|
+
return await self._send("POST", self.config.runs_url(), start_body(input, mode, callback_url))
|
|
102
|
+
|
|
103
|
+
async def get_run(self, run_id: str) -> Run:
|
|
104
|
+
return await self._send("GET", self.config.runs_url(run_id))
|
|
105
|
+
|
|
106
|
+
async def wait(self, run_id: str, *, timeout: Optional[float] = 300.0, poll_interval: float = 1.0, max_poll_interval: float = 10.0,
|
|
107
|
+
backoff: float = 1.5) -> Run:
|
|
108
|
+
deadline, delays = self._deadline(timeout), poll_delays(poll_interval, max_poll_interval, backoff)
|
|
109
|
+
while True:
|
|
110
|
+
run = await self.get_run(run_id)
|
|
111
|
+
if run.done: return run
|
|
112
|
+
await asyncio.sleep(self._sleep_for(next(delays), deadline, run))
|
|
113
|
+
|
|
114
|
+
async def run_and_wait(self, input: Optional[Dict[str, Any]] = None, *, timeout: Optional[float] = 300.0, poll_interval: float = 1.0,
|
|
115
|
+
max_poll_interval: float = 10.0, backoff: float = 1.5, callback_url: Optional[str] = None) -> Run:
|
|
116
|
+
started = await self.run(input, callback_url=callback_url)
|
|
117
|
+
if started.done: return started
|
|
118
|
+
return await self.wait(started.run_id, timeout=timeout, poll_interval=poll_interval, max_poll_interval=max_poll_interval, backoff=backoff)
|
|
119
|
+
|
|
120
|
+
async def aclose(self) -> None:
|
|
121
|
+
if self._owns_http: await self._http.aclose()
|
|
122
|
+
|
|
123
|
+
async def __aenter__(self) -> "AsyncClient": return self
|
|
124
|
+
|
|
125
|
+
async def __aexit__(self, *exc: Any) -> None: await self.aclose()
|
superstep/_errors.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Typed errors, mapped from the API's stable error codes (docs/api-errors.md), then from the HTTP status."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict, Optional, Type
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SuperstepError(Exception):
|
|
8
|
+
"""Base class. `code` is the API error code (or a client-side one such as CONNECTION_ERROR)."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, message: str, *, code: str = "UNKNOWN", status: Optional[int] = None, details: Any = None,
|
|
11
|
+
headers: Optional[Dict[str, str]] = None):
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.message, self.code, self.status, self.details, self.headers = message, code, status, details, headers or {}
|
|
14
|
+
|
|
15
|
+
def __repr__(self) -> str:
|
|
16
|
+
return f"{type(self).__name__}(code={self.code!r}, status={self.status!r}, message={self.message!r})"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConfigError(SuperstepError):
|
|
20
|
+
"""Missing base URL, token or workflow id."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class APIConnectionError(SuperstepError):
|
|
24
|
+
"""The request did not reach the server or the response was not the API's envelope."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthenticationError(SuperstepError):
|
|
28
|
+
"""401 TRIGGER_TOKEN_INVALID: missing, invalid or revoked token, or a token of another workflow."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NotFoundError(SuperstepError):
|
|
32
|
+
"""404, e.g. RUN_NOT_FOUND."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InvalidRequestError(SuperstepError):
|
|
36
|
+
"""400 / 422: RUN_INPUT_INVALID, CALLBACK_URL_INVALID, VALIDATION_ERROR (see `details`)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PayloadTooLargeError(SuperstepError):
|
|
40
|
+
"""413 RUN_INPUT_TOO_LARGE."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RateLimitError(SuperstepError):
|
|
44
|
+
"""429 RATE_LIMITED. `retry_after` is the number of seconds to wait."""
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def retry_after(self) -> Optional[float]:
|
|
48
|
+
value = self.headers.get("retry-after") or self.headers.get("Retry-After")
|
|
49
|
+
try: return float(value) if value is not None else None
|
|
50
|
+
except ValueError: return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ServerError(SuperstepError):
|
|
54
|
+
"""5xx, e.g. INTERNAL_ERROR."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class WaitTimeoutError(SuperstepError):
|
|
58
|
+
"""`run_and_wait` gave up before the run finished. `run` is the last state seen."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, message: str, run: Any):
|
|
61
|
+
super().__init__(message, code="WAIT_TIMEOUT")
|
|
62
|
+
self.run = run
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
BY_CODE: Dict[str, Type[SuperstepError]] = {
|
|
66
|
+
"TRIGGER_TOKEN_INVALID": AuthenticationError,
|
|
67
|
+
"RUN_NOT_FOUND": NotFoundError,
|
|
68
|
+
"WORKFLOW_NOT_FOUND": NotFoundError,
|
|
69
|
+
"NOT_FOUND": NotFoundError,
|
|
70
|
+
"RUN_INPUT_INVALID": InvalidRequestError,
|
|
71
|
+
"CALLBACK_URL_INVALID": InvalidRequestError,
|
|
72
|
+
"VALIDATION_ERROR": InvalidRequestError,
|
|
73
|
+
"BAD_REQUEST": InvalidRequestError,
|
|
74
|
+
"RUN_INPUT_TOO_LARGE": PayloadTooLargeError,
|
|
75
|
+
"PAYLOAD_TOO_LARGE": PayloadTooLargeError,
|
|
76
|
+
"RATE_LIMITED": RateLimitError,
|
|
77
|
+
"INTERNAL_ERROR": ServerError,
|
|
78
|
+
}
|
|
79
|
+
BY_STATUS: Dict[int, Type[SuperstepError]] = {401: AuthenticationError, 404: NotFoundError, 400: InvalidRequestError,
|
|
80
|
+
422: InvalidRequestError, 413: PayloadTooLargeError, 429: RateLimitError}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def error_for(status: int, code: str, message: str, details: Any = None, headers: Optional[Dict[str, str]] = None) -> SuperstepError:
|
|
84
|
+
cls = BY_CODE.get(code) or BY_STATUS.get(status) or (ServerError if status >= 500 else SuperstepError)
|
|
85
|
+
return cls(message, code=code, status=status, details=details, headers=headers)
|
superstep/_models.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Typed models of the public API payloads (docs/api/openapi.public.json; tests/test_spec_sync.py keeps them in step)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field, fields
|
|
5
|
+
from typing import Any, Dict, Literal, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
RunStatus = Literal["queued", "running", "ok", "error", "cancelled", "timeout", "waiting", "expired"]
|
|
8
|
+
PENDING: Tuple[str, ...] = ("queued", "running")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _pick(cls, data: Optional[Dict[str, Any]]):
|
|
12
|
+
if data is None: return None
|
|
13
|
+
names = {f.name for f in fields(cls)}
|
|
14
|
+
return cls(**{k: v for k, v in data.items() if k in names})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class RunMetrics:
|
|
19
|
+
duration_ms: Optional[float] = None
|
|
20
|
+
tokens_in: Optional[int] = None
|
|
21
|
+
tokens_out: Optional[int] = None
|
|
22
|
+
cost_usd: Optional[float] = None
|
|
23
|
+
error_count: Optional[int] = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class RunWaiting:
|
|
28
|
+
"""The Human node a `waiting` run is paused at."""
|
|
29
|
+
node: Optional[str] = None
|
|
30
|
+
prompt: Optional[str] = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Run:
|
|
35
|
+
"""A run as returned by `run()` and `get_run()`. A just-queued run has only `run_id`, `status` and `poll_url`."""
|
|
36
|
+
run_id: str
|
|
37
|
+
status: RunStatus
|
|
38
|
+
workflow_id: Optional[str] = None
|
|
39
|
+
source: Optional[str] = None
|
|
40
|
+
final_state: Optional[Dict[str, Any]] = None
|
|
41
|
+
error: Optional[Any] = None
|
|
42
|
+
waiting: Optional[RunWaiting] = None
|
|
43
|
+
metrics: Optional[RunMetrics] = None
|
|
44
|
+
trace_url: Optional[str] = None
|
|
45
|
+
started: Optional[float] = None
|
|
46
|
+
ended: Optional[float] = None
|
|
47
|
+
poll_url: Optional[str] = None
|
|
48
|
+
raw: Dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def done(self) -> bool:
|
|
52
|
+
"""True once the run has stopped changing (any status but `queued` / `running`)."""
|
|
53
|
+
return self.status not in PENDING
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Run":
|
|
57
|
+
plain = {f.name for f in fields(cls)} - {"waiting", "metrics", "raw"}
|
|
58
|
+
return cls(**{k: v for k, v in data.items() if k in plain}, waiting=_pick(RunWaiting, data.get("waiting")),
|
|
59
|
+
metrics=_pick(RunMetrics, data.get("metrics")), raw=dict(data))
|
superstep/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
superstep/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: superstep
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the Superstep public API: start workflow runs and read their results.
|
|
5
|
+
Project-URL: Homepage, https://github.com/quinguyen1502/superstep
|
|
6
|
+
Project-URL: Documentation, https://quinguyen1502.github.io/superstep/run/public-api/
|
|
7
|
+
Author: Qui Nguyen
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Framework :: AsyncIO
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Requires-Dist: httpx<1,>=0.25
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
20
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# superstep (Python SDK)
|
|
24
|
+
|
|
25
|
+
Python client for the Superstep public API: start a workflow run from your own
|
|
26
|
+
service, poll it, and read its final state. Sync and async clients, typed
|
|
27
|
+
models and typed errors; the only dependency is `httpx`.
|
|
28
|
+
|
|
29
|
+
- Guide: <https://quinguyen1502.github.io/superstep/run/public-api/>
|
|
30
|
+
- API reference: <https://quinguyen1502.github.io/superstep/reference/public-api/>
|
|
31
|
+
- Spec: [`docs/api/openapi.public.json`](../../docs/api/openapi.public.json)
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install superstep
|
|
37
|
+
# from the repository, before the first release:
|
|
38
|
+
pip install ./sdk/python
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Python 3.9 or later.
|
|
42
|
+
|
|
43
|
+
## Configure
|
|
44
|
+
|
|
45
|
+
A workflow token (`wft_...`) starts runs of one workflow. The workflow owner
|
|
46
|
+
creates it in the editor under **More (⋯) → API → Create token**; it is shown
|
|
47
|
+
once.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from superstep import Client
|
|
51
|
+
|
|
52
|
+
client = Client(
|
|
53
|
+
workflow_id="example",
|
|
54
|
+
token="wft_...",
|
|
55
|
+
base_url="https://superstep.example.com",
|
|
56
|
+
)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Arguments left out are read from `SUPERSTEP_WORKFLOW_ID`, `SUPERSTEP_TOKEN`
|
|
60
|
+
and `SUPERSTEP_BASE_URL`. A missing value raises `ConfigError`.
|
|
61
|
+
|
|
62
|
+
## Run a workflow
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
# Start and wait: polls with a growing interval (1 s, 1.5 s, ... up to 10 s).
|
|
66
|
+
run = client.run_and_wait({"message": "where is my order A100"}, timeout=120)
|
|
67
|
+
print(run.status, run.final_state)
|
|
68
|
+
|
|
69
|
+
# Or step by step.
|
|
70
|
+
started = client.run({"message": "hi"}) # status "queued", poll_url set
|
|
71
|
+
current = client.get_run(started.run_id) # status, final_state, metrics ...
|
|
72
|
+
done = client.wait(started.run_id, timeout=60, poll_interval=0.5)
|
|
73
|
+
|
|
74
|
+
# Let the server wait (up to STUDIO_SYNC_WAIT_MAX seconds, default 30).
|
|
75
|
+
run = client.run({"message": "hi"}, mode="sync")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`Run.done` is true for every status except `queued` and `running`. Finished
|
|
79
|
+
statuses are `ok`, `error`, `cancelled`, `timeout`, `waiting` (paused at a
|
|
80
|
+
Human node, see `run.waiting`) and `expired`. `run_and_wait` returns the run
|
|
81
|
+
whatever its final status; check `run.status`.
|
|
82
|
+
|
|
83
|
+
`callback_url=` on `run()` and `run_and_wait()` asks the server to POST the
|
|
84
|
+
result to your URL when the run ends.
|
|
85
|
+
|
|
86
|
+
## Async
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
import asyncio
|
|
90
|
+
from superstep import AsyncClient
|
|
91
|
+
|
|
92
|
+
async def main():
|
|
93
|
+
async with AsyncClient() as client: # configured from the environment
|
|
94
|
+
run = await client.run_and_wait({"message": "hi"})
|
|
95
|
+
print(run.final_state)
|
|
96
|
+
|
|
97
|
+
asyncio.run(main())
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Errors
|
|
101
|
+
|
|
102
|
+
Every API error raises a subclass of `SuperstepError` with `code` (the
|
|
103
|
+
API's stable error code), `status`, `message` and `details`.
|
|
104
|
+
|
|
105
|
+
| Exception | When |
|
|
106
|
+
| --- | --- |
|
|
107
|
+
| `AuthenticationError` | `401 TRIGGER_TOKEN_INVALID`: missing, wrong or revoked token, or a token of another workflow |
|
|
108
|
+
| `NotFoundError` | `404 RUN_NOT_FOUND` |
|
|
109
|
+
| `InvalidRequestError` | `400 RUN_INPUT_INVALID`, `CALLBACK_URL_INVALID`; `422 VALIDATION_ERROR` |
|
|
110
|
+
| `PayloadTooLargeError` | `413 RUN_INPUT_TOO_LARGE` |
|
|
111
|
+
| `RateLimitError` | `429 RATE_LIMITED`; `retry_after` holds the seconds to wait |
|
|
112
|
+
| `ServerError` | `5xx` |
|
|
113
|
+
| `APIConnectionError` | Network failure, or a response that is not the API envelope |
|
|
114
|
+
| `WaitTimeoutError` | `wait` / `run_and_wait` ran out of time; `err.run` is the last state |
|
|
115
|
+
| `ConfigError` | Missing base URL, token or workflow id |
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from superstep import RateLimitError
|
|
119
|
+
import time
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
run = client.run({"message": "hi"})
|
|
123
|
+
except RateLimitError as err:
|
|
124
|
+
time.sleep(err.retry_after or 1)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Development
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
cd sdk/python
|
|
131
|
+
pip install -e ".[dev]"
|
|
132
|
+
pytest # unit tests, mocked HTTP
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`tests/test_spec_sync.py` checks the models against the public spec. The live
|
|
136
|
+
test (`tests/test_integration.py`) runs when `SUPERSTEP_IT_BASE_URL`,
|
|
137
|
+
`SUPERSTEP_IT_TOKEN` and `SUPERSTEP_IT_WORKFLOW_ID` are set;
|
|
138
|
+
`scripts/sdk_integration.sh` at the repository root starts a local backend,
|
|
139
|
+
creates a token and runs it for both SDKs.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
superstep/__init__.py,sha256=0kWl8LmEknJg09fuoC6AwzV5mf47JfkmCK90fBD1r6g,763
|
|
2
|
+
superstep/_base.py,sha256=ehhUlq2JgixDp4fS7KE19KHIlRKeM8DtdX5--h63Zvw,3117
|
|
3
|
+
superstep/_client.py,sha256=tJT2g7tQ6EmR485Sf1vCKBwmjKAr7O2OeXddn58H7fY,6444
|
|
4
|
+
superstep/_errors.py,sha256=RtWlbElpSqsdkUibcHY9iMel1mA6Gha-UhsDI-Pi9cM,3278
|
|
5
|
+
superstep/_models.py,sha256=AdYV_oZPnb-90FSq9f1i7ucu25x4bFfgApqtYz17mvU,2174
|
|
6
|
+
superstep/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
7
|
+
superstep/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
superstep-0.1.0.dist-info/METADATA,sha256=-Md1hk3QIC7iqZNx4bLXQRa2aRLvD73VNywijhtG4zw,4655
|
|
9
|
+
superstep-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
superstep-0.1.0.dist-info/licenses/LICENSE,sha256=WWmmGBy5D7v1yYo4J74h1wAwOWyUzdfK7l9y_z5IFXs,1076
|
|
11
|
+
superstep-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thái Quí Nguyễn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|