cascades-sdk 0.2.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.
- cascades_sdk-0.2.0/PKG-INFO +77 -0
- cascades_sdk-0.2.0/README.md +47 -0
- cascades_sdk-0.2.0/pyproject.toml +54 -0
- cascades_sdk-0.2.0/setup.cfg +4 -0
- cascades_sdk-0.2.0/src/cascades_sdk/__init__.py +51 -0
- cascades_sdk-0.2.0/src/cascades_sdk/client/__init__.py +31 -0
- cascades_sdk-0.2.0/src/cascades_sdk/client/client.py +137 -0
- cascades_sdk-0.2.0/src/cascades_sdk/client/errors.py +42 -0
- cascades_sdk-0.2.0/src/cascades_sdk/client/polling.py +64 -0
- cascades_sdk-0.2.0/src/cascades_sdk/compiler/__init__.py +14 -0
- cascades_sdk-0.2.0/src/cascades_sdk/compiler/canonical.py +28 -0
- cascades_sdk-0.2.0/src/cascades_sdk/compiler/context.py +57 -0
- cascades_sdk-0.2.0/src/cascades_sdk/compiler/dag_builder.py +61 -0
- cascades_sdk-0.2.0/src/cascades_sdk/compiler/decorators.py +58 -0
- cascades_sdk-0.2.0/src/cascades_sdk/py.typed +0 -0
- cascades_sdk-0.2.0/src/cascades_sdk/types/__init__.py +32 -0
- cascades_sdk-0.2.0/src/cascades_sdk.egg-info/PKG-INFO +77 -0
- cascades_sdk-0.2.0/src/cascades_sdk.egg-info/SOURCES.txt +21 -0
- cascades_sdk-0.2.0/src/cascades_sdk.egg-info/dependency_links.txt +1 -0
- cascades_sdk-0.2.0/src/cascades_sdk.egg-info/requires.txt +8 -0
- cascades_sdk-0.2.0/src/cascades_sdk.egg-info/top_level.txt +1 -0
- cascades_sdk-0.2.0/tests/test_client_aliases.py +5 -0
- cascades_sdk-0.2.0/tests/test_compiler.py +34 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cascades-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python SDK for Cascades workflow orchestration
|
|
5
|
+
Author: Noir Stack LLC
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/no1rstack/cascades
|
|
8
|
+
Project-URL: Documentation, https://github.com/no1rstack/cascades/tree/main/sdk/python
|
|
9
|
+
Project-URL: Repository, https://github.com/no1rstack/cascades
|
|
10
|
+
Project-URL: Issues, https://github.com/no1rstack/cascades/issues
|
|
11
|
+
Keywords: cascades,workflow,orchestration,dag,task,flow
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: requests>=2.25.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
26
|
+
Requires-Dist: mypy>=1.8.0; extra == "dev"
|
|
27
|
+
Requires-Dist: black>=24.0.0; extra == "dev"
|
|
28
|
+
Requires-Dist: build>=1.2.0; extra == "dev"
|
|
29
|
+
Requires-Dist: twine>=5.0.0; extra == "dev"
|
|
30
|
+
|
|
31
|
+
# Cascades SDK (Python)
|
|
32
|
+
|
|
33
|
+
PyPI package for the Cascades workflow orchestration control plane.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install cascades-sdk
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quick start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from cascades_sdk import task, flow, CascadesClient, wait_for_completion
|
|
45
|
+
from cascades_sdk.compiler import build_dag_from_flow
|
|
46
|
+
|
|
47
|
+
@task
|
|
48
|
+
def add(a: int, b: int) -> int:
|
|
49
|
+
return a + b
|
|
50
|
+
|
|
51
|
+
@flow
|
|
52
|
+
def math_flow(a: int, b: int):
|
|
53
|
+
return add(a, b)
|
|
54
|
+
|
|
55
|
+
dag = build_dag_from_flow(math_flow, {"a": 1, "b": 2})
|
|
56
|
+
|
|
57
|
+
client = CascadesClient(base_url="http://localhost:3000", api_key="your_api_key")
|
|
58
|
+
flow_id = client.register_flow("math_flow", dag)
|
|
59
|
+
run_id = client.trigger_flow(flow_id, {"a": 1, "b": 2})
|
|
60
|
+
result = wait_for_completion(client, run_id)
|
|
61
|
+
print(result.get("result"))
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## What this SDK provides
|
|
65
|
+
|
|
66
|
+
- `@task` and `@flow` decorators
|
|
67
|
+
- Deterministic DAG capture/compilation
|
|
68
|
+
- Thin HTTP API client for flow registration and runs
|
|
69
|
+
- Polling helpers (sync + async)
|
|
70
|
+
|
|
71
|
+
## Publish to PyPI
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
python -m build
|
|
75
|
+
python -m twine check dist/*
|
|
76
|
+
python -m twine upload dist/*
|
|
77
|
+
```
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Cascades SDK (Python)
|
|
2
|
+
|
|
3
|
+
PyPI package for the Cascades workflow orchestration control plane.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install cascades-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from cascades_sdk import task, flow, CascadesClient, wait_for_completion
|
|
15
|
+
from cascades_sdk.compiler import build_dag_from_flow
|
|
16
|
+
|
|
17
|
+
@task
|
|
18
|
+
def add(a: int, b: int) -> int:
|
|
19
|
+
return a + b
|
|
20
|
+
|
|
21
|
+
@flow
|
|
22
|
+
def math_flow(a: int, b: int):
|
|
23
|
+
return add(a, b)
|
|
24
|
+
|
|
25
|
+
dag = build_dag_from_flow(math_flow, {"a": 1, "b": 2})
|
|
26
|
+
|
|
27
|
+
client = CascadesClient(base_url="http://localhost:3000", api_key="your_api_key")
|
|
28
|
+
flow_id = client.register_flow("math_flow", dag)
|
|
29
|
+
run_id = client.trigger_flow(flow_id, {"a": 1, "b": 2})
|
|
30
|
+
result = wait_for_completion(client, run_id)
|
|
31
|
+
print(result.get("result"))
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## What this SDK provides
|
|
35
|
+
|
|
36
|
+
- `@task` and `@flow` decorators
|
|
37
|
+
- Deterministic DAG capture/compilation
|
|
38
|
+
- Thin HTTP API client for flow registration and runs
|
|
39
|
+
- Polling helpers (sync + async)
|
|
40
|
+
|
|
41
|
+
## Publish to PyPI
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
python -m build
|
|
45
|
+
python -m twine check dist/*
|
|
46
|
+
python -m twine upload dist/*
|
|
47
|
+
```
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cascades-sdk"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Python SDK for Cascades workflow orchestration"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Noir Stack LLC" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["cascades", "workflow", "orchestration", "dag", "task", "flow"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.8",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules"
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"requests>=2.25.0"
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=7.0.0",
|
|
34
|
+
"mypy>=1.8.0",
|
|
35
|
+
"black>=24.0.0",
|
|
36
|
+
"build>=1.2.0",
|
|
37
|
+
"twine>=5.0.0"
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://github.com/no1rstack/cascades"
|
|
42
|
+
Documentation = "https://github.com/no1rstack/cascades/tree/main/sdk/python"
|
|
43
|
+
Repository = "https://github.com/no1rstack/cascades"
|
|
44
|
+
Issues = "https://github.com/no1rstack/cascades/issues"
|
|
45
|
+
|
|
46
|
+
[tool.setuptools]
|
|
47
|
+
package-dir = {"" = "src"}
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.packages.find]
|
|
50
|
+
where = ["src"]
|
|
51
|
+
include = ["cascades_sdk*"]
|
|
52
|
+
|
|
53
|
+
[tool.setuptools.package-data]
|
|
54
|
+
cascades_sdk = ["py.typed"]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Cascades SDK - Python client for Cascades workflow orchestration."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.2.0"
|
|
4
|
+
|
|
5
|
+
from .compiler import (
|
|
6
|
+
task,
|
|
7
|
+
flow,
|
|
8
|
+
build_dag_from_flow,
|
|
9
|
+
build_dag_from_flow_json,
|
|
10
|
+
canonical_json,
|
|
11
|
+
canonicalize_dag,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from .client import (
|
|
15
|
+
CascadesClient,
|
|
16
|
+
CascadeClient,
|
|
17
|
+
CascadesSDKError,
|
|
18
|
+
CascadeSDKError,
|
|
19
|
+
AuthenticationError,
|
|
20
|
+
ValidationError,
|
|
21
|
+
NotFoundError,
|
|
22
|
+
RateLimitError,
|
|
23
|
+
OrchestrationError,
|
|
24
|
+
NetworkError,
|
|
25
|
+
TimeoutError,
|
|
26
|
+
wait_for_completion,
|
|
27
|
+
wait_for_completion_async,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"__version__",
|
|
32
|
+
"task",
|
|
33
|
+
"flow",
|
|
34
|
+
"build_dag_from_flow",
|
|
35
|
+
"build_dag_from_flow_json",
|
|
36
|
+
"canonical_json",
|
|
37
|
+
"canonicalize_dag",
|
|
38
|
+
"CascadesClient",
|
|
39
|
+
"CascadeClient",
|
|
40
|
+
"wait_for_completion",
|
|
41
|
+
"wait_for_completion_async",
|
|
42
|
+
"CascadesSDKError",
|
|
43
|
+
"CascadeSDKError",
|
|
44
|
+
"AuthenticationError",
|
|
45
|
+
"ValidationError",
|
|
46
|
+
"NotFoundError",
|
|
47
|
+
"RateLimitError",
|
|
48
|
+
"OrchestrationError",
|
|
49
|
+
"NetworkError",
|
|
50
|
+
"TimeoutError",
|
|
51
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Cascades SDK client module."""
|
|
2
|
+
|
|
3
|
+
from .client import CascadesClient, CascadeClient
|
|
4
|
+
from .errors import (
|
|
5
|
+
CascadesSDKError,
|
|
6
|
+
CascadeSDKError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
ValidationError,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
RateLimitError,
|
|
11
|
+
OrchestrationError,
|
|
12
|
+
NetworkError,
|
|
13
|
+
TimeoutError,
|
|
14
|
+
)
|
|
15
|
+
from .polling import wait_for_completion, wait_for_completion_async
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"CascadesClient",
|
|
19
|
+
"CascadeClient",
|
|
20
|
+
"CascadesSDKError",
|
|
21
|
+
"CascadeSDKError",
|
|
22
|
+
"AuthenticationError",
|
|
23
|
+
"ValidationError",
|
|
24
|
+
"NotFoundError",
|
|
25
|
+
"RateLimitError",
|
|
26
|
+
"OrchestrationError",
|
|
27
|
+
"NetworkError",
|
|
28
|
+
"TimeoutError",
|
|
29
|
+
"wait_for_completion",
|
|
30
|
+
"wait_for_completion_async",
|
|
31
|
+
]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""HTTP client for Cascades control plane API."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from .errors import (
|
|
8
|
+
CascadesSDKError,
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
ValidationError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
OrchestrationError,
|
|
14
|
+
NetworkError,
|
|
15
|
+
TimeoutError as SDKTimeoutError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CascadesClient:
|
|
20
|
+
"""Thin HTTP client for Cascades flow registration and execution APIs."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
base_url: str,
|
|
25
|
+
api_key: str,
|
|
26
|
+
timeout: int = 30,
|
|
27
|
+
verify_ssl: bool = True,
|
|
28
|
+
task_output_path_template: str = "/api/runs/{run_id}/tasks/{task_id}/output",
|
|
29
|
+
):
|
|
30
|
+
self.base_url = base_url.rstrip("/")
|
|
31
|
+
self.api_key = api_key
|
|
32
|
+
self.timeout = timeout
|
|
33
|
+
self.verify_ssl = verify_ssl
|
|
34
|
+
self.task_output_path_template = task_output_path_template
|
|
35
|
+
|
|
36
|
+
self.session = requests.Session()
|
|
37
|
+
self.session.headers.update(
|
|
38
|
+
{
|
|
39
|
+
"X-API-Key": api_key,
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
"User-Agent": "cascades-sdk-python/0.2.0",
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def _request(
|
|
46
|
+
self,
|
|
47
|
+
method: str,
|
|
48
|
+
path: str,
|
|
49
|
+
json: Optional[Dict[str, Any]] = None,
|
|
50
|
+
params: Optional[Dict[str, Any]] = None,
|
|
51
|
+
headers: Optional[Dict[str, str]] = None,
|
|
52
|
+
) -> Dict[str, Any]:
|
|
53
|
+
url = f"{self.base_url}{path}"
|
|
54
|
+
try:
|
|
55
|
+
response = self.session.request(
|
|
56
|
+
method=method,
|
|
57
|
+
url=url,
|
|
58
|
+
json=json,
|
|
59
|
+
params=params,
|
|
60
|
+
headers=headers,
|
|
61
|
+
timeout=self.timeout,
|
|
62
|
+
verify=self.verify_ssl,
|
|
63
|
+
)
|
|
64
|
+
except requests.exceptions.Timeout as exc:
|
|
65
|
+
raise SDKTimeoutError(f"Request timeout after {self.timeout}s") from exc
|
|
66
|
+
except requests.exceptions.ConnectionError as exc:
|
|
67
|
+
raise NetworkError(f"Connection failed: {exc}") from exc
|
|
68
|
+
except requests.exceptions.RequestException as exc:
|
|
69
|
+
raise NetworkError(f"Network error: {exc}") from exc
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
response_body = response.json() if response.content else {}
|
|
73
|
+
except ValueError:
|
|
74
|
+
response_body = {"raw": response.text}
|
|
75
|
+
|
|
76
|
+
if response.status_code == 401:
|
|
77
|
+
raise AuthenticationError("Authentication failed - check API key", 401, response_body)
|
|
78
|
+
if response.status_code == 400:
|
|
79
|
+
raise ValidationError(response_body.get("title", "Validation failed"), 400, response_body)
|
|
80
|
+
if response.status_code == 404:
|
|
81
|
+
raise NotFoundError(response_body.get("title", "Resource not found"), 404, response_body)
|
|
82
|
+
if response.status_code == 429:
|
|
83
|
+
raise RateLimitError(response_body.get("title", "Rate limit exceeded"), 429, response_body)
|
|
84
|
+
if response.status_code >= 500:
|
|
85
|
+
raise OrchestrationError(response_body.get("title", "Server error"), response.status_code, response_body)
|
|
86
|
+
if not response.ok:
|
|
87
|
+
raise CascadesSDKError(f"HTTP {response.status_code}: {response.text}", response.status_code, response_body)
|
|
88
|
+
|
|
89
|
+
return response_body
|
|
90
|
+
|
|
91
|
+
def register_flow(self, flow_name: str, dag: Dict[str, Any], version: str = "1.0.0") -> str:
|
|
92
|
+
response = self._request(
|
|
93
|
+
"POST",
|
|
94
|
+
"/api/flows/register",
|
|
95
|
+
json={"name": flow_name, "version": version, "dag": dag},
|
|
96
|
+
)
|
|
97
|
+
return response["id"]
|
|
98
|
+
|
|
99
|
+
def trigger_flow(self, flow_id: str, inputs: Dict[str, Any]) -> str:
|
|
100
|
+
response = self._request("POST", "/api/runs", json={"flow_id": flow_id, "input": inputs})
|
|
101
|
+
return response["run_id"]
|
|
102
|
+
|
|
103
|
+
def get_run(self, run_id: str) -> Dict[str, Any]:
|
|
104
|
+
run = self._request("GET", f"/api/runs/{run_id}")
|
|
105
|
+
status = run.get("status")
|
|
106
|
+
if isinstance(status, str):
|
|
107
|
+
run["status"] = status.lower()
|
|
108
|
+
|
|
109
|
+
if "result" not in run and "output" in run:
|
|
110
|
+
run["result"] = run.get("output")
|
|
111
|
+
|
|
112
|
+
return run
|
|
113
|
+
|
|
114
|
+
def get_flow_graph(self, flow_id: str) -> Dict[str, Any]:
|
|
115
|
+
return self._request("GET", f"/api/flows/definitions/{flow_id}/graph")
|
|
116
|
+
|
|
117
|
+
def get_run_graph(self, run_id: str) -> Dict[str, Any]:
|
|
118
|
+
return self._request("GET", f"/api/flow-runs/{run_id}/graph")
|
|
119
|
+
|
|
120
|
+
def submit_task_output(
|
|
121
|
+
self,
|
|
122
|
+
run_id: str,
|
|
123
|
+
task_id: str,
|
|
124
|
+
output: Any,
|
|
125
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
126
|
+
path_template: Optional[str] = None,
|
|
127
|
+
) -> Dict[str, Any]:
|
|
128
|
+
template = path_template or self.task_output_path_template
|
|
129
|
+
path = template.format(run_id=run_id, task_id=task_id)
|
|
130
|
+
payload: Dict[str, Any] = {"output": output}
|
|
131
|
+
if metadata:
|
|
132
|
+
payload["metadata"] = metadata
|
|
133
|
+
return self._request("POST", path, json=payload)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# Backward-compatible alias
|
|
137
|
+
CascadeClient = CascadesClient
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Exception hierarchy for Cascades SDK."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CascadesSDKError(Exception):
|
|
5
|
+
"""Base exception for Cascades SDK errors."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str, status_code: int = None, response_body: dict = None):
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.status_code = status_code
|
|
10
|
+
self.response_body = response_body
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Backward-compatible alias
|
|
14
|
+
CascadeSDKError = CascadesSDKError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthenticationError(CascadesSDKError):
|
|
18
|
+
"""Authentication failed (HTTP 401)."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ValidationError(CascadesSDKError):
|
|
22
|
+
"""Request validation failed (HTTP 400)."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NotFoundError(CascadesSDKError):
|
|
26
|
+
"""Resource was not found (HTTP 404)."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RateLimitError(CascadesSDKError):
|
|
30
|
+
"""Rate limit exceeded (HTTP 429)."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OrchestrationError(CascadesSDKError):
|
|
34
|
+
"""Control plane orchestration error (HTTP 5xx)."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NetworkError(CascadesSDKError):
|
|
38
|
+
"""Network communication failed."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TimeoutError(CascadesSDKError):
|
|
42
|
+
"""Request or polling timeout."""
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Polling utilities for run completion."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Callable, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from .client import CascadesClient
|
|
8
|
+
from .errors import TimeoutError as SDKTimeoutError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def wait_for_completion(
|
|
12
|
+
client: CascadesClient,
|
|
13
|
+
run_id: str,
|
|
14
|
+
timeout: int = 300,
|
|
15
|
+
poll_interval: float = 1.0,
|
|
16
|
+
on_status_change: Optional[Callable[[str], None]] = None,
|
|
17
|
+
) -> Dict[str, Any]:
|
|
18
|
+
start_time = time.time()
|
|
19
|
+
last_status = None
|
|
20
|
+
|
|
21
|
+
while True:
|
|
22
|
+
elapsed = time.time() - start_time
|
|
23
|
+
if elapsed > timeout:
|
|
24
|
+
raise SDKTimeoutError(f"Run {run_id} did not complete within {timeout}s")
|
|
25
|
+
|
|
26
|
+
run = client.get_run(run_id)
|
|
27
|
+
status = run.get("status")
|
|
28
|
+
|
|
29
|
+
if status != last_status and on_status_change and isinstance(status, str):
|
|
30
|
+
on_status_change(status)
|
|
31
|
+
last_status = status
|
|
32
|
+
|
|
33
|
+
if status in ("completed", "failed", "canceled", "cancelled", "timedout", "crashed"):
|
|
34
|
+
return run
|
|
35
|
+
|
|
36
|
+
time.sleep(poll_interval)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def wait_for_completion_async(
|
|
40
|
+
client: CascadesClient,
|
|
41
|
+
run_id: str,
|
|
42
|
+
timeout: int = 300,
|
|
43
|
+
poll_interval: float = 1.0,
|
|
44
|
+
on_status_change: Optional[Callable[[str], None]] = None,
|
|
45
|
+
) -> Dict[str, Any]:
|
|
46
|
+
start_time = time.time()
|
|
47
|
+
last_status = None
|
|
48
|
+
|
|
49
|
+
while True:
|
|
50
|
+
elapsed = time.time() - start_time
|
|
51
|
+
if elapsed > timeout:
|
|
52
|
+
raise SDKTimeoutError(f"Run {run_id} did not complete within {timeout}s")
|
|
53
|
+
|
|
54
|
+
run = await asyncio.to_thread(client.get_run, run_id)
|
|
55
|
+
status = run.get("status")
|
|
56
|
+
|
|
57
|
+
if status != last_status and on_status_change and isinstance(status, str):
|
|
58
|
+
on_status_change(status)
|
|
59
|
+
last_status = status
|
|
60
|
+
|
|
61
|
+
if status in ("completed", "failed", "canceled", "cancelled", "timedout", "crashed"):
|
|
62
|
+
return run
|
|
63
|
+
|
|
64
|
+
await asyncio.sleep(poll_interval)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Compiler module for deterministic DAG generation from @flow functions."""
|
|
2
|
+
|
|
3
|
+
from .decorators import task, flow
|
|
4
|
+
from .dag_builder import build_dag_from_flow, build_dag_from_flow_json
|
|
5
|
+
from .canonical import canonical_json, canonicalize_dag
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"task",
|
|
9
|
+
"flow",
|
|
10
|
+
"build_dag_from_flow",
|
|
11
|
+
"build_dag_from_flow_json",
|
|
12
|
+
"canonical_json",
|
|
13
|
+
"canonicalize_dag",
|
|
14
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Deterministic JSON serialization for DAG definitions."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Dict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _normalize(obj: Any) -> Any:
|
|
8
|
+
if isinstance(obj, dict):
|
|
9
|
+
return {key: _normalize(obj[key]) for key in sorted(obj.keys())}
|
|
10
|
+
if isinstance(obj, list):
|
|
11
|
+
return [_normalize(item) for item in obj]
|
|
12
|
+
if isinstance(obj, (str, int, float, bool, type(None))):
|
|
13
|
+
return obj
|
|
14
|
+
return str(obj)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def canonical_json(obj: Any) -> str:
|
|
18
|
+
"""Serialize to canonical JSON string with deterministic key ordering."""
|
|
19
|
+
normalized = _normalize(obj)
|
|
20
|
+
return json.dumps(normalized, sort_keys=True, separators=(",", ":"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def canonicalize_dag(dag: Dict[str, Any]) -> Dict[str, Any]:
|
|
24
|
+
"""Normalize DAG for deterministic comparisons."""
|
|
25
|
+
normalized_dag: Dict[str, Any] = {}
|
|
26
|
+
for key in sorted(dag.keys()):
|
|
27
|
+
normalized_dag[key] = _normalize(dag[key])
|
|
28
|
+
return normalized_dag
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Flow execution context for DAG capture mode."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
import threading
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FlowContext:
|
|
8
|
+
"""Context manager that captures task calls to build a DAG."""
|
|
9
|
+
|
|
10
|
+
_thread_local = threading.local()
|
|
11
|
+
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self.nodes: List[Dict[str, Any]] = []
|
|
14
|
+
self.edges: List[Dict[str, str]] = []
|
|
15
|
+
self.return_node_id: Optional[str] = None
|
|
16
|
+
self.node_counter = 0
|
|
17
|
+
|
|
18
|
+
def add_node(self, task_func: Any, args: tuple, kwargs: dict, dependencies: List[str]) -> str:
|
|
19
|
+
node_id = f"node-{self.node_counter}"
|
|
20
|
+
self.node_counter += 1
|
|
21
|
+
|
|
22
|
+
task_name = getattr(task_func, "_task_name", task_func.__name__)
|
|
23
|
+
node = {
|
|
24
|
+
"id": node_id,
|
|
25
|
+
"task_name": task_name,
|
|
26
|
+
"dependencies": dependencies,
|
|
27
|
+
}
|
|
28
|
+
self.nodes.append(node)
|
|
29
|
+
|
|
30
|
+
for dep_id in dependencies:
|
|
31
|
+
self.add_edge(dep_id, node_id)
|
|
32
|
+
|
|
33
|
+
return node_id
|
|
34
|
+
|
|
35
|
+
def add_edge(self, from_node: str, to_node: str) -> None:
|
|
36
|
+
edge = {"from": from_node, "to": to_node}
|
|
37
|
+
if edge not in self.edges:
|
|
38
|
+
self.edges.append(edge)
|
|
39
|
+
|
|
40
|
+
def set_return_node(self, node_id: str) -> None:
|
|
41
|
+
self.return_node_id = node_id
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def get_current(cls) -> Optional["FlowContext"]:
|
|
45
|
+
return getattr(cls._thread_local, "context", None)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def set_current(cls, context: Optional["FlowContext"]) -> None:
|
|
49
|
+
cls._thread_local.context = context
|
|
50
|
+
|
|
51
|
+
def __enter__(self) -> "FlowContext":
|
|
52
|
+
FlowContext.set_current(self)
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
|
|
56
|
+
FlowContext.set_current(None)
|
|
57
|
+
return False
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""DAG builder - extract DAG from @flow functions in capture mode."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from typing import Any, Callable, Dict, Optional
|
|
5
|
+
|
|
6
|
+
from .context import FlowContext
|
|
7
|
+
from .decorators import _TaskPlaceholder
|
|
8
|
+
from .canonical import canonical_json
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _build_args_for_capture(flow_func: Callable, flow_inputs: Optional[Dict[str, Any]]) -> tuple:
|
|
12
|
+
sig = inspect.signature(flow_func)
|
|
13
|
+
if not sig.parameters:
|
|
14
|
+
return tuple()
|
|
15
|
+
|
|
16
|
+
if flow_inputs is not None:
|
|
17
|
+
bound = sig.bind_partial(**flow_inputs)
|
|
18
|
+
args = []
|
|
19
|
+
for name, param in sig.parameters.items():
|
|
20
|
+
if name in bound.arguments:
|
|
21
|
+
args.append(bound.arguments[name])
|
|
22
|
+
elif param.default is not inspect._empty:
|
|
23
|
+
args.append(param.default)
|
|
24
|
+
else:
|
|
25
|
+
args.append(None)
|
|
26
|
+
return tuple(args)
|
|
27
|
+
|
|
28
|
+
return tuple(None for _ in sig.parameters)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_dag_from_flow(flow_func: Callable, flow_inputs: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
32
|
+
"""Build deterministic DAG structure from a @flow-decorated function."""
|
|
33
|
+
if not getattr(flow_func, "_is_flow", False):
|
|
34
|
+
raise ValueError(f"{flow_func.__name__} is not decorated with @flow")
|
|
35
|
+
|
|
36
|
+
context = FlowContext()
|
|
37
|
+
args = _build_args_for_capture(flow_func, flow_inputs)
|
|
38
|
+
|
|
39
|
+
with context:
|
|
40
|
+
result = flow_func(*args)
|
|
41
|
+
if isinstance(result, _TaskPlaceholder):
|
|
42
|
+
context.set_return_node(result.node_id)
|
|
43
|
+
|
|
44
|
+
dag: Dict[str, Any] = {
|
|
45
|
+
"nodes": context.nodes,
|
|
46
|
+
"edges": context.edges,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if context.return_node_id:
|
|
50
|
+
dag["return_node"] = context.return_node_id
|
|
51
|
+
|
|
52
|
+
if context.nodes:
|
|
53
|
+
dag["entrypoints"] = {"default": {"node": context.nodes[0]["id"]}}
|
|
54
|
+
|
|
55
|
+
return dag
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_dag_from_flow_json(flow_func: Callable, flow_inputs: Optional[Dict[str, Any]] = None) -> str:
|
|
59
|
+
"""Build DAG and return canonical JSON payload."""
|
|
60
|
+
dag = build_dag_from_flow(flow_func, flow_inputs=flow_inputs)
|
|
61
|
+
return canonical_json(dag)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Decorators for task and flow definitions."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
from typing import Any, Callable, TypeVar
|
|
5
|
+
|
|
6
|
+
from .context import FlowContext
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T", bound=Callable[..., Any])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _TaskPlaceholder:
|
|
12
|
+
"""Placeholder returned by task wrappers during capture mode."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, node_id: str, task_name: str):
|
|
15
|
+
self.node_id = node_id
|
|
16
|
+
self.task_name = task_name
|
|
17
|
+
|
|
18
|
+
def __repr__(self) -> str:
|
|
19
|
+
return f"<TaskPlaceholder {self.task_name} @ {self.node_id}>"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def task(func: T) -> T:
|
|
23
|
+
"""Mark a function as a task in a flow DAG."""
|
|
24
|
+
|
|
25
|
+
@functools.wraps(func)
|
|
26
|
+
def wrapper(*args, **kwargs):
|
|
27
|
+
context = FlowContext.get_current()
|
|
28
|
+
if context is not None:
|
|
29
|
+
dependencies = []
|
|
30
|
+
for arg in args:
|
|
31
|
+
if isinstance(arg, _TaskPlaceholder):
|
|
32
|
+
dependencies.append(arg.node_id)
|
|
33
|
+
for value in kwargs.values():
|
|
34
|
+
if isinstance(value, _TaskPlaceholder):
|
|
35
|
+
dependencies.append(value.node_id)
|
|
36
|
+
|
|
37
|
+
node_id = context.add_node(func, args, kwargs, dependencies)
|
|
38
|
+
return _TaskPlaceholder(node_id, func.__name__)
|
|
39
|
+
|
|
40
|
+
return func(*args, **kwargs)
|
|
41
|
+
|
|
42
|
+
wrapper._is_task = True # type: ignore[attr-defined]
|
|
43
|
+
wrapper._task_name = func.__name__ # type: ignore[attr-defined]
|
|
44
|
+
wrapper._original_func = func # type: ignore[attr-defined]
|
|
45
|
+
return wrapper # type: ignore[return-value]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def flow(func: T) -> T:
|
|
49
|
+
"""Mark a function as a flow definition."""
|
|
50
|
+
|
|
51
|
+
@functools.wraps(func)
|
|
52
|
+
def wrapper(*args, **kwargs):
|
|
53
|
+
return func(*args, **kwargs)
|
|
54
|
+
|
|
55
|
+
wrapper._is_flow = True # type: ignore[attr-defined]
|
|
56
|
+
wrapper._flow_name = func.__name__ # type: ignore[attr-defined]
|
|
57
|
+
wrapper._original_func = func # type: ignore[attr-defined]
|
|
58
|
+
return wrapper # type: ignore[return-value]
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""TypedDict contracts for Cascades SDK."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, List, Optional, TypedDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Node(TypedDict, total=False):
|
|
7
|
+
id: str
|
|
8
|
+
task_name: str
|
|
9
|
+
dependencies: List[str]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Edge(TypedDict):
|
|
13
|
+
from_node: str
|
|
14
|
+
to_node: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DAGDefinition(TypedDict, total=False):
|
|
18
|
+
nodes: List[Node]
|
|
19
|
+
edges: List[dict]
|
|
20
|
+
return_node: Optional[str]
|
|
21
|
+
entrypoints: dict
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FlowRun(TypedDict, total=False):
|
|
25
|
+
run_id: str
|
|
26
|
+
flow_id: str
|
|
27
|
+
status: str
|
|
28
|
+
result: Optional[Any]
|
|
29
|
+
error: Optional[str]
|
|
30
|
+
created_at: str
|
|
31
|
+
started_at: Optional[str]
|
|
32
|
+
completed_at: Optional[str]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cascades-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python SDK for Cascades workflow orchestration
|
|
5
|
+
Author: Noir Stack LLC
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/no1rstack/cascades
|
|
8
|
+
Project-URL: Documentation, https://github.com/no1rstack/cascades/tree/main/sdk/python
|
|
9
|
+
Project-URL: Repository, https://github.com/no1rstack/cascades
|
|
10
|
+
Project-URL: Issues, https://github.com/no1rstack/cascades/issues
|
|
11
|
+
Keywords: cascades,workflow,orchestration,dag,task,flow
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: requests>=2.25.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
26
|
+
Requires-Dist: mypy>=1.8.0; extra == "dev"
|
|
27
|
+
Requires-Dist: black>=24.0.0; extra == "dev"
|
|
28
|
+
Requires-Dist: build>=1.2.0; extra == "dev"
|
|
29
|
+
Requires-Dist: twine>=5.0.0; extra == "dev"
|
|
30
|
+
|
|
31
|
+
# Cascades SDK (Python)
|
|
32
|
+
|
|
33
|
+
PyPI package for the Cascades workflow orchestration control plane.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install cascades-sdk
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quick start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from cascades_sdk import task, flow, CascadesClient, wait_for_completion
|
|
45
|
+
from cascades_sdk.compiler import build_dag_from_flow
|
|
46
|
+
|
|
47
|
+
@task
|
|
48
|
+
def add(a: int, b: int) -> int:
|
|
49
|
+
return a + b
|
|
50
|
+
|
|
51
|
+
@flow
|
|
52
|
+
def math_flow(a: int, b: int):
|
|
53
|
+
return add(a, b)
|
|
54
|
+
|
|
55
|
+
dag = build_dag_from_flow(math_flow, {"a": 1, "b": 2})
|
|
56
|
+
|
|
57
|
+
client = CascadesClient(base_url="http://localhost:3000", api_key="your_api_key")
|
|
58
|
+
flow_id = client.register_flow("math_flow", dag)
|
|
59
|
+
run_id = client.trigger_flow(flow_id, {"a": 1, "b": 2})
|
|
60
|
+
result = wait_for_completion(client, run_id)
|
|
61
|
+
print(result.get("result"))
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## What this SDK provides
|
|
65
|
+
|
|
66
|
+
- `@task` and `@flow` decorators
|
|
67
|
+
- Deterministic DAG capture/compilation
|
|
68
|
+
- Thin HTTP API client for flow registration and runs
|
|
69
|
+
- Polling helpers (sync + async)
|
|
70
|
+
|
|
71
|
+
## Publish to PyPI
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
python -m build
|
|
75
|
+
python -m twine check dist/*
|
|
76
|
+
python -m twine upload dist/*
|
|
77
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/cascades_sdk/__init__.py
|
|
4
|
+
src/cascades_sdk/py.typed
|
|
5
|
+
src/cascades_sdk.egg-info/PKG-INFO
|
|
6
|
+
src/cascades_sdk.egg-info/SOURCES.txt
|
|
7
|
+
src/cascades_sdk.egg-info/dependency_links.txt
|
|
8
|
+
src/cascades_sdk.egg-info/requires.txt
|
|
9
|
+
src/cascades_sdk.egg-info/top_level.txt
|
|
10
|
+
src/cascades_sdk/client/__init__.py
|
|
11
|
+
src/cascades_sdk/client/client.py
|
|
12
|
+
src/cascades_sdk/client/errors.py
|
|
13
|
+
src/cascades_sdk/client/polling.py
|
|
14
|
+
src/cascades_sdk/compiler/__init__.py
|
|
15
|
+
src/cascades_sdk/compiler/canonical.py
|
|
16
|
+
src/cascades_sdk/compiler/context.py
|
|
17
|
+
src/cascades_sdk/compiler/dag_builder.py
|
|
18
|
+
src/cascades_sdk/compiler/decorators.py
|
|
19
|
+
src/cascades_sdk/types/__init__.py
|
|
20
|
+
tests/test_client_aliases.py
|
|
21
|
+
tests/test_compiler.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cascades_sdk
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from cascades_sdk import flow, task
|
|
2
|
+
from cascades_sdk.compiler import build_dag_from_flow, canonical_json
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@task
|
|
6
|
+
def add(a, b):
|
|
7
|
+
return a + b
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@task
|
|
11
|
+
def mul(a, b):
|
|
12
|
+
return a * b
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@flow
|
|
16
|
+
def math_flow(a, b):
|
|
17
|
+
x = add(a, b)
|
|
18
|
+
return mul(x, 2)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_build_dag_from_flow_contains_nodes_edges_and_return_node():
|
|
22
|
+
dag = build_dag_from_flow(math_flow, {"a": 1, "b": 2})
|
|
23
|
+
|
|
24
|
+
assert "nodes" in dag
|
|
25
|
+
assert "edges" in dag
|
|
26
|
+
assert "return_node" in dag
|
|
27
|
+
assert len(dag["nodes"]) == 2
|
|
28
|
+
assert len(dag["edges"]) == 1
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_canonical_json_is_deterministic():
|
|
32
|
+
dag_1 = build_dag_from_flow(math_flow, {"a": 1, "b": 2})
|
|
33
|
+
dag_2 = build_dag_from_flow(math_flow, {"a": 3, "b": 4})
|
|
34
|
+
assert canonical_json(dag_1) == canonical_json(dag_2)
|