superstep 0.1.0__tar.gz

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.
@@ -0,0 +1,32 @@
1
+ # deps / envs
2
+ node_modules/
3
+ .venv/
4
+ venv/
5
+ # build & caches
6
+ dist/
7
+ __pycache__/
8
+ *.pyc
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ *.tsbuildinfo
13
+ .vite/
14
+ # data
15
+ *.db
16
+ *.db-wal
17
+ *.db-shm
18
+ *.migrate.lock
19
+ backend/imports/
20
+ .imports/
21
+ # env/secrets
22
+ .env
23
+ .env.*
24
+ !.env.example
25
+ # misc
26
+ .DS_Store
27
+ *.log
28
+ *.egg-info/
29
+ frontend/test-results/
30
+ frontend/playwright-report/
31
+ backend/.venv
32
+ .coverage
@@ -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.
@@ -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,121 @@
1
+ # superstep (Python SDK)
2
+
3
+ Python client for the Superstep public API: start a workflow run from your own
4
+ service, poll it, and read its final state. Sync and async clients, typed
5
+ models and typed errors; the only dependency is `httpx`.
6
+
7
+ - Guide: <https://quinguyen1502.github.io/superstep/run/public-api/>
8
+ - API reference: <https://quinguyen1502.github.io/superstep/reference/public-api/>
9
+ - Spec: [`docs/api/openapi.public.json`](../../docs/api/openapi.public.json)
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install superstep
15
+ # from the repository, before the first release:
16
+ pip install ./sdk/python
17
+ ```
18
+
19
+ Python 3.9 or later.
20
+
21
+ ## Configure
22
+
23
+ A workflow token (`wft_...`) starts runs of one workflow. The workflow owner
24
+ creates it in the editor under **More (⋯) → API → Create token**; it is shown
25
+ once.
26
+
27
+ ```python
28
+ from superstep import Client
29
+
30
+ client = Client(
31
+ workflow_id="example",
32
+ token="wft_...",
33
+ base_url="https://superstep.example.com",
34
+ )
35
+ ```
36
+
37
+ Arguments left out are read from `SUPERSTEP_WORKFLOW_ID`, `SUPERSTEP_TOKEN`
38
+ and `SUPERSTEP_BASE_URL`. A missing value raises `ConfigError`.
39
+
40
+ ## Run a workflow
41
+
42
+ ```python
43
+ # Start and wait: polls with a growing interval (1 s, 1.5 s, ... up to 10 s).
44
+ run = client.run_and_wait({"message": "where is my order A100"}, timeout=120)
45
+ print(run.status, run.final_state)
46
+
47
+ # Or step by step.
48
+ started = client.run({"message": "hi"}) # status "queued", poll_url set
49
+ current = client.get_run(started.run_id) # status, final_state, metrics ...
50
+ done = client.wait(started.run_id, timeout=60, poll_interval=0.5)
51
+
52
+ # Let the server wait (up to STUDIO_SYNC_WAIT_MAX seconds, default 30).
53
+ run = client.run({"message": "hi"}, mode="sync")
54
+ ```
55
+
56
+ `Run.done` is true for every status except `queued` and `running`. Finished
57
+ statuses are `ok`, `error`, `cancelled`, `timeout`, `waiting` (paused at a
58
+ Human node, see `run.waiting`) and `expired`. `run_and_wait` returns the run
59
+ whatever its final status; check `run.status`.
60
+
61
+ `callback_url=` on `run()` and `run_and_wait()` asks the server to POST the
62
+ result to your URL when the run ends.
63
+
64
+ ## Async
65
+
66
+ ```python
67
+ import asyncio
68
+ from superstep import AsyncClient
69
+
70
+ async def main():
71
+ async with AsyncClient() as client: # configured from the environment
72
+ run = await client.run_and_wait({"message": "hi"})
73
+ print(run.final_state)
74
+
75
+ asyncio.run(main())
76
+ ```
77
+
78
+ ## Errors
79
+
80
+ Every API error raises a subclass of `SuperstepError` with `code` (the
81
+ API's stable error code), `status`, `message` and `details`.
82
+
83
+ | Exception | When |
84
+ | --- | --- |
85
+ | `AuthenticationError` | `401 TRIGGER_TOKEN_INVALID`: missing, wrong or revoked token, or a token of another workflow |
86
+ | `NotFoundError` | `404 RUN_NOT_FOUND` |
87
+ | `InvalidRequestError` | `400 RUN_INPUT_INVALID`, `CALLBACK_URL_INVALID`; `422 VALIDATION_ERROR` |
88
+ | `PayloadTooLargeError` | `413 RUN_INPUT_TOO_LARGE` |
89
+ | `RateLimitError` | `429 RATE_LIMITED`; `retry_after` holds the seconds to wait |
90
+ | `ServerError` | `5xx` |
91
+ | `APIConnectionError` | Network failure, or a response that is not the API envelope |
92
+ | `WaitTimeoutError` | `wait` / `run_and_wait` ran out of time; `err.run` is the last state |
93
+ | `ConfigError` | Missing base URL, token or workflow id |
94
+
95
+ ```python
96
+ from superstep import RateLimitError
97
+ import time
98
+
99
+ try:
100
+ run = client.run({"message": "hi"})
101
+ except RateLimitError as err:
102
+ time.sleep(err.retry_after or 1)
103
+ ```
104
+
105
+ ## Development
106
+
107
+ ```bash
108
+ cd sdk/python
109
+ pip install -e ".[dev]"
110
+ pytest # unit tests, mocked HTTP
111
+ ```
112
+
113
+ `tests/test_spec_sync.py` checks the models against the public spec. The live
114
+ test (`tests/test_integration.py`) runs when `SUPERSTEP_IT_BASE_URL`,
115
+ `SUPERSTEP_IT_TOKEN` and `SUPERSTEP_IT_WORKFLOW_ID` are set;
116
+ `scripts/sdk_integration.sh` at the repository root starts a local backend,
117
+ creates a token and runs it for both SDKs.
118
+
119
+ ## License
120
+
121
+ MIT
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "superstep"
7
+ dynamic = ["version"]
8
+ description = "Python client for the Superstep public API: start workflow runs and read their results."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Qui Nguyen" }]
13
+ dependencies = ["httpx>=0.25,<1"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Typing :: Typed",
17
+ "Framework :: AsyncIO",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ dev = ["pytest>=8", "pytest-asyncio>=0.23"]
22
+ # `factory deps apply` installs `.[test]`, so verification has pytest without it in base deps.
23
+ test = ["pytest>=8", "pytest-asyncio>=0.23"]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/quinguyen1502/superstep"
27
+ Documentation = "https://quinguyen1502.github.io/superstep/run/public-api/"
28
+
29
+ [tool.hatch.version]
30
+ path = "src/superstep/_version.py"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/superstep"]
34
+
35
+ [tool.pytest.ini_options]
36
+ asyncio_mode = "auto"
37
+ testpaths = ["tests"]
@@ -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__"]
@@ -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
@@ -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()
@@ -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)
@@ -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))
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,44 @@
1
+ import json
2
+ from typing import Callable, List
3
+
4
+ import httpx
5
+ import pytest
6
+
7
+ BASE, WID, TOKEN = "http://superstep.test", "wf1", "wft_test"
8
+
9
+
10
+ def env(data, status=200, headers=None):
11
+ return httpx.Response(status, json={"success": True, "data": data, "error": None}, headers=headers)
12
+
13
+
14
+ def err(code, status, message="boom", details=None, headers=None):
15
+ return httpx.Response(status, json={"success": False, "data": None, "error": {"code": code, "message": message, "details": details}},
16
+ headers=headers)
17
+
18
+
19
+ class Recorder:
20
+ """A scripted server: each request pops the next response (or calls it with the request)."""
21
+
22
+ def __init__(self, *responses):
23
+ self.responses: List = list(responses)
24
+ self.requests: List[httpx.Request] = []
25
+
26
+ def __call__(self, request: httpx.Request) -> httpx.Response:
27
+ self.requests.append(request)
28
+ nxt = self.responses.pop(0)
29
+ return nxt(request) if isinstance(nxt, Callable) else nxt
30
+
31
+ def body(self, i=0):
32
+ return json.loads(self.requests[i].content)
33
+
34
+
35
+ @pytest.fixture
36
+ def no_env(monkeypatch):
37
+ for k in ("SUPERSTEP_BASE_URL", "SUPERSTEP_TOKEN", "SUPERSTEP_WORKFLOW_ID"): monkeypatch.delenv(k, raising=False)
38
+
39
+
40
+ @pytest.fixture
41
+ def no_sleep(monkeypatch):
42
+ slept = []
43
+ monkeypatch.setattr("superstep._client.time.sleep", slept.append)
44
+ return slept
@@ -0,0 +1,33 @@
1
+ import httpx
2
+ import pytest
3
+
4
+ from superstep import AsyncClient, AuthenticationError, WaitTimeoutError
5
+ from conftest import BASE, TOKEN, WID, Recorder, env, err
6
+ from test_client import DONE, QUEUED, RUNNING
7
+
8
+
9
+ @pytest.fixture
10
+ def no_async_sleep(monkeypatch):
11
+ slept = []
12
+
13
+ async def fake(s): slept.append(s)
14
+ monkeypatch.setattr("superstep._client.asyncio.sleep", fake)
15
+ return slept
16
+
17
+
18
+ def make(rec):
19
+ return AsyncClient(WID, TOKEN, BASE, http_client=httpx.AsyncClient(transport=httpx.MockTransport(rec)))
20
+
21
+
22
+ async def test_async_run_and_wait(no_env, no_async_sleep):
23
+ rec = Recorder(env(QUEUED, 202), env(RUNNING), env(DONE))
24
+ async with make(rec) as c:
25
+ run = await c.run_and_wait({"x": 1}, poll_interval=0.5)
26
+ assert run.final_state == {"answer": 42} and no_async_sleep == [0.5] and rec.body() == {"input": {"x": 1}, "mode": "async"}
27
+
28
+
29
+ async def test_async_errors_and_timeout(no_env, no_async_sleep):
30
+ with pytest.raises(AuthenticationError):
31
+ await make(Recorder(err("TRIGGER_TOKEN_INVALID", 401))).get_run("r1")
32
+ with pytest.raises(WaitTimeoutError):
33
+ await make(Recorder(env(QUEUED, 202), env(RUNNING))).run_and_wait({}, timeout=0)
@@ -0,0 +1,100 @@
1
+ import httpx
2
+ import pytest
3
+
4
+ from superstep import (APIConnectionError, AuthenticationError, Client, ConfigError, InvalidRequestError, NotFoundError,
5
+ PayloadTooLargeError, RateLimitError, Run, ServerError, SuperstepError, WaitTimeoutError)
6
+ from superstep._base import poll_delays
7
+ from conftest import BASE, TOKEN, WID, Recorder, env, err
8
+
9
+ QUEUED = {"run_id": "r1", "status": "queued", "poll_url": f"{BASE}/api/hooks/workflows/{WID}/runs/r1"}
10
+ DONE = {"run_id": "r1", "workflow_id": WID, "status": "ok", "source": "api", "final_state": {"answer": 42}, "error": None, "waiting": None,
11
+ "metrics": {"duration_ms": 12.5, "tokens_in": 1, "tokens_out": 2, "cost_usd": 0.0, "error_count": 0}, "trace_url": None,
12
+ "started": 1.0, "ended": 2.0}
13
+ RUNNING = {**DONE, "status": "running", "final_state": None, "metrics": None, "ended": None}
14
+
15
+
16
+ def make(rec, **kw):
17
+ return Client(WID, TOKEN, BASE + "/", http_client=httpx.Client(transport=httpx.MockTransport(rec)), **kw)
18
+
19
+
20
+ def test_run_sends_token_body_and_parses(no_env):
21
+ rec = Recorder(env(QUEUED, 202))
22
+ run = make(rec).run({"q": 1}, callback_url="https://hook.example.com")
23
+ req = rec.requests[0]
24
+ assert req.method == "POST" and str(req.url) == f"{BASE}/api/hooks/workflows/{WID}/runs"
25
+ assert req.headers["x-workflow-token"] == TOKEN and req.headers["user-agent"].startswith("superstep-python/")
26
+ assert rec.body() == {"input": {"q": 1}, "mode": "async", "callback_url": "https://hook.example.com"}
27
+ assert isinstance(run, Run) and run.status == "queued" and not run.done and run.poll_url == QUEUED["poll_url"]
28
+
29
+
30
+ def test_get_run_typed_model(no_env):
31
+ rec = Recorder(env(DONE))
32
+ run = make(rec).get_run("r 1")
33
+ assert str(rec.requests[0].url).endswith("/runs/r%201")
34
+ assert run.done and run.final_state == {"answer": 42} and run.metrics.duration_ms == 12.5 and run.raw["source"] == "api"
35
+
36
+
37
+ def test_run_and_wait_polls_with_backoff(no_env, no_sleep):
38
+ rec = Recorder(env(QUEUED, 202), env(RUNNING), env(RUNNING), env(RUNNING), env(DONE))
39
+ run = make(rec).run_and_wait({}, poll_interval=1, backoff=2, max_poll_interval=3)
40
+ assert run.status == "ok" and no_sleep == [1, 2, 3] and len(rec.requests) == 5
41
+
42
+
43
+ def test_run_and_wait_times_out(no_env, monkeypatch):
44
+ clock = iter(range(0, 1000, 5))
45
+ monkeypatch.setattr("superstep._client.time.monotonic", lambda: next(clock))
46
+ monkeypatch.setattr("superstep._client.time.sleep", lambda s: None)
47
+ rec = Recorder(env(QUEUED, 202), *[env(RUNNING)] * 10)
48
+ with pytest.raises(WaitTimeoutError) as e:
49
+ make(rec).run_and_wait({}, timeout=12)
50
+ assert e.value.run.status == "running" and e.value.code == "WAIT_TIMEOUT"
51
+
52
+
53
+ def test_sync_mode_returns_the_result(no_env):
54
+ rec = Recorder(env(DONE))
55
+ assert make(rec).run({}, mode="sync").status == "ok" and rec.body()["mode"] == "sync"
56
+ with pytest.raises(ValueError): make(Recorder()).run({}, mode="later")
57
+
58
+
59
+ @pytest.mark.parametrize("code,status,cls", [
60
+ ("TRIGGER_TOKEN_INVALID", 401, AuthenticationError), ("RUN_NOT_FOUND", 404, NotFoundError), ("RUN_INPUT_INVALID", 400, InvalidRequestError),
61
+ ("CALLBACK_URL_INVALID", 400, InvalidRequestError), ("VALIDATION_ERROR", 422, InvalidRequestError),
62
+ ("RUN_INPUT_TOO_LARGE", 413, PayloadTooLargeError), ("INTERNAL_ERROR", 500, ServerError), ("SOMETHING_NEW", 503, ServerError),
63
+ ("SOMETHING_NEW", 418, SuperstepError)])
64
+ def test_errors_are_typed_by_code(no_env, code, status, cls):
65
+ with pytest.raises(cls) as e:
66
+ make(Recorder(err(code, status, details=[{"field": "mode"}]))).get_run("r1")
67
+ assert type(e.value) is cls and e.value.code == code and e.value.status == status
68
+
69
+
70
+ def test_rate_limit_exposes_retry_after(no_env):
71
+ with pytest.raises(RateLimitError) as e:
72
+ make(Recorder(err("RATE_LIMITED", 429, headers={"Retry-After": "7"}))).run({})
73
+ assert e.value.retry_after == 7.0
74
+
75
+
76
+ def test_non_envelope_and_network_errors(no_env):
77
+ with pytest.raises(APIConnectionError) as e:
78
+ make(Recorder(httpx.Response(502, text="<html>bad gateway</html>"))).get_run("r1")
79
+ assert e.value.code == "BAD_RESPONSE"
80
+
81
+ def down(request): raise httpx.ConnectError("refused", request=request)
82
+ with pytest.raises(APIConnectionError) as e:
83
+ make(Recorder(down)).get_run("r1")
84
+ assert e.value.code == "CONNECTION_ERROR"
85
+
86
+
87
+ def test_env_var_fallback(no_env, monkeypatch):
88
+ with pytest.raises(ConfigError) as e: Client()
89
+ assert "SUPERSTEP_TOKEN" in str(e.value)
90
+ monkeypatch.setenv("SUPERSTEP_BASE_URL", "http://env.test/"); monkeypatch.setenv("SUPERSTEP_TOKEN", "wft_env")
91
+ monkeypatch.setenv("SUPERSTEP_WORKFLOW_ID", "wenv")
92
+ with Client() as c:
93
+ assert c.config.runs_url("x") == "http://env.test/api/hooks/workflows/wenv/runs/x" and c.config.token == "wft_env"
94
+ assert Client(token="wft_arg").config.token == "wft_arg" # arguments win
95
+
96
+
97
+ def test_poll_delays():
98
+ d = poll_delays(0.5, 2, 2)
99
+ assert [next(d) for _ in range(5)] == [0.5, 1, 2, 2, 2]
100
+ with pytest.raises(ValueError): next(poll_delays(0, 1, 1))
@@ -0,0 +1,29 @@
1
+ """Live test against a running backend. scripts/sdk_integration.sh (repository root) starts one and sets the variables."""
2
+ import os
3
+
4
+ import pytest
5
+
6
+ from superstep import AsyncClient, AuthenticationError, Client, NotFoundError
7
+
8
+ IT = {k: os.environ.get(f"SUPERSTEP_IT_{k.upper()}") for k in ("base_url", "token", "workflow_id")}
9
+ pytestmark = pytest.mark.skipif(not all(IT.values()), reason="set SUPERSTEP_IT_BASE_URL, SUPERSTEP_IT_TOKEN, SUPERSTEP_IT_WORKFLOW_ID")
10
+ INPUT = {"message": "where is my order A100"}
11
+
12
+
13
+ def test_live_sync_client():
14
+ with Client(**IT) as c:
15
+ started = c.run(INPUT)
16
+ assert started.status == "queued" and started.poll_url.endswith(started.run_id)
17
+ run = c.wait(started.run_id, timeout=60, poll_interval=0.2)
18
+ assert run.status == "ok" and run.final_state["intent"] == "order_status" and run.source == "api"
19
+ assert c.run_and_wait(INPUT, timeout=60, poll_interval=0.2).status == "ok"
20
+ assert c.run(INPUT, mode="sync").status == "ok"
21
+ with pytest.raises(NotFoundError): c.get_run("does-not-exist")
22
+ with pytest.raises(AuthenticationError), Client(**{**IT, "token": "wft_wrong"}) as bad:
23
+ bad.run(INPUT)
24
+
25
+
26
+ async def test_live_async_client():
27
+ async with AsyncClient(**IT) as c:
28
+ run = await c.run_and_wait(INPUT, timeout=60, poll_interval=0.2)
29
+ assert run.status == "ok" and run.metrics is not None
@@ -0,0 +1,38 @@
1
+ """The hand-written models follow the public spec (docs/api/openapi.public.json)."""
2
+ import json
3
+ import pathlib
4
+ import typing
5
+ from dataclasses import fields
6
+
7
+ import pytest
8
+
9
+ from superstep import Run, RunMetrics, RunStatus, RunWaiting
10
+ from superstep._errors import BY_CODE
11
+
12
+ SPEC = pathlib.Path(__file__).resolve().parents[3] / "docs/api/openapi.public.json"
13
+ pytestmark = pytest.mark.skipif(not SPEC.exists(), reason="run inside the superstep repository")
14
+
15
+
16
+ def schemas():
17
+ return json.loads(SPEC.read_text())["components"]["schemas"]
18
+
19
+
20
+ def props(name):
21
+ return set(schemas()[name]["properties"])
22
+
23
+
24
+ def names(cls):
25
+ return {f.name for f in fields(cls)} - {"raw"}
26
+
27
+
28
+ def test_models_match_the_spec():
29
+ assert names(Run) == props("RunResult") | props("RunAccepted") | props("RunPending")
30
+ assert names(RunMetrics) == props("RunMetrics") and names(RunWaiting) == props("RunWaiting")
31
+ assert set(typing.get_args(RunStatus)) == set(schemas()["RunResult"]["properties"]["status"]["enum"])
32
+
33
+
34
+ def test_every_documented_error_code_is_mapped():
35
+ spec = json.loads(SPEC.read_text())
36
+ codes = {code for item in spec["paths"].values() for op in item.values() for r in op["responses"].values()
37
+ for code in r["content"]["application/json"].get("examples", {})}
38
+ assert codes and codes <= set(BY_CODE)