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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,8 @@
1
+ requests>=2.25.0
2
+
3
+ [dev]
4
+ pytest>=7.0.0
5
+ mypy>=1.8.0
6
+ black>=24.0.0
7
+ build>=1.2.0
8
+ twine>=5.0.0
@@ -0,0 +1 @@
1
+ cascades_sdk
@@ -0,0 +1,5 @@
1
+ from cascades_sdk import CascadesClient, CascadeClient
2
+
3
+
4
+ def test_alias_types_match():
5
+ assert CascadeClient is CascadesClient
@@ -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)