fluid-workflow-engine-sdk 0.2.1__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,6 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ dist/
6
+ build/
@@ -0,0 +1,22 @@
1
+ #MakeFile
2
+ PROTO_SRC := ../api/workflow/workflow_service.proto
3
+ PROTO_DIR := ../api/workflow
4
+ STUB_DIR := workflow_engine_sdk/_proto
5
+
6
+ gen-stubs:
7
+ python -m grpc_tools.protoc \
8
+ --proto_path=$(PROTO_DIR) \
9
+ --python_out=$(STUB_DIR) \
10
+ --grpc_python_out=$(STUB_DIR) \
11
+ workflow_service.proto
12
+ python -c "\
13
+ import re, pathlib; \
14
+ f = pathlib.Path('$(STUB_DIR)/workflow_service_pb2_grpc.py'); \
15
+ f.write_text(re.sub(r'^import workflow_service_pb2', \
16
+ 'from workflow_engine_sdk._proto import workflow_service_pb2', \
17
+ f.read_text(), flags=re.MULTILINE))"
18
+
19
+ test:
20
+ pytest tests/ -v
21
+
22
+ .PHONY: gen-stubs test
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.4
2
+ Name: fluid-workflow-engine-sdk
3
+ Version: 0.2.1
4
+ Summary: Python SDK for the Coredgeio Fluid workflow engine — worker registration, step execution, and workflow management
5
+ Project-URL: Homepage, https://github.com/coredgeio/fluid-workflow-engine
6
+ Project-URL: Repository, https://github.com/coredgeio/fluid-workflow-engine
7
+ Project-URL: Issues, https://github.com/coredgeio/fluid-workflow-engine/issues
8
+ Author-email: "Coredge.io" <ashok@coredge.io>
9
+ License: Apache-2.0
10
+ Keywords: fastapi,grpc,orchestration,saga,workflow
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Framework :: FastAPI
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3
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
+ Classifier: Topic :: System :: Distributed Computing
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.9
24
+ Requires-Dist: grpcio>=1.60
25
+ Requires-Dist: httpx>=0.27
26
+ Requires-Dist: protobuf>=4.25
27
+ Requires-Dist: pydantic>=2.0
28
+ Requires-Dist: pyyaml>=6.0
29
+ Provides-Extra: dev
30
+ Requires-Dist: grpcio-tools>=1.60; extra == 'dev'
31
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
32
+ Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
33
+ Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -0,0 +1,58 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "fluid-workflow-engine-sdk"
7
+ version = "0.2.1"
8
+ description = "Python SDK for the Coredgeio Fluid workflow engine — worker registration, step execution, and workflow management"
9
+ requires-python = ">=3.9"
10
+ license = { text = "Apache-2.0" }
11
+ authors = [
12
+ { name = "Coredge.io", email = "ashok@coredge.io" },
13
+ ]
14
+ keywords = ["workflow", "orchestration", "grpc", "fastapi", "saga"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: Apache Software License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ "Topic :: System :: Distributed Computing",
26
+ "Framework :: FastAPI",
27
+ "Typing :: Typed",
28
+ ]
29
+ dependencies = [
30
+ "grpcio>=1.60",
31
+ "protobuf>=4.25",
32
+ "httpx>=0.27",
33
+ "pyyaml>=6.0",
34
+ "pydantic>=2.0",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/coredgeio/fluid-workflow-engine"
39
+ Repository = "https://github.com/coredgeio/fluid-workflow-engine"
40
+ Issues = "https://github.com/coredgeio/fluid-workflow-engine/issues"
41
+
42
+ [project.optional-dependencies]
43
+ dev = [
44
+ "grpcio-tools>=1.60",
45
+ "pytest>=8.0",
46
+ "pytest-cov>=5.0",
47
+ "pytest-httpx>=0.30",
48
+ ]
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["fluid_workflow_engine_sdk"]
52
+
53
+ [tool.hatch.build.targets.sdist]
54
+ include = [
55
+ "workflow_engine_sdk/**",
56
+ "tests/**",
57
+ "Makefile",
58
+ ]
File without changes
@@ -0,0 +1,120 @@
1
+ import pytest
2
+ import yaml
3
+
4
+ from workflow_engine_sdk import RetryPolicy, Step, WorkflowDefinition
5
+
6
+
7
+ def test_minimal_workflow():
8
+ wf = WorkflowDefinition("hello").step(Step("greet", "sayHello"))
9
+ d = wf.to_dict()
10
+ assert d["apiVersion"] == "workflow/v1"
11
+ assert d["kind"] == "Workflow"
12
+ assert d["metadata"]["name"] == "hello"
13
+ assert d["spec"]["steps"][0]["name"] == "greet"
14
+ assert d["spec"]["steps"][0]["function"] == "sayHello"
15
+
16
+
17
+ def test_inputs_and_outputs():
18
+ wf = (
19
+ WorkflowDefinition("wf")
20
+ .input("region", type="string", required=True, description="AWS region")
21
+ .input("count", type="integer", default="1")
22
+ .step(Step("s1", "fn").input("r", "{{ inputs.region }}"))
23
+ .output("id", "{{ steps.s1.outputs.id }}")
24
+ )
25
+ d = wf.to_dict()
26
+ assert d["spec"]["inputs"]["region"]["required"] is True
27
+ assert d["spec"]["inputs"]["region"]["description"] == "AWS region"
28
+ assert "required" not in d["spec"]["inputs"]["count"]
29
+ assert d["spec"]["steps"][0]["inputs"]["r"] == "{{ inputs.region }}"
30
+ assert d["spec"]["outputs"]["id"] == "{{ steps.s1.outputs.id }}"
31
+
32
+
33
+ def test_step_full_options():
34
+ step = (
35
+ Step("deploy", "deployApp")
36
+ .depends_on("build", "test")
37
+ .dependency_mode("all")
38
+ .condition("{{ steps.build.outputs.ok }}")
39
+ .input("image", "{{ steps.build.outputs.image }}")
40
+ .execution_mode("grpc")
41
+ .timeout("5m")
42
+ .retry(RetryPolicy(max_retries=5, initial_backoff="2s"))
43
+ .compensation("rollbackDeploy", inputs={"image": "{{ inputs.image }}"})
44
+ .on_error("compensate")
45
+ )
46
+ d = step._to_dict()
47
+ assert d["dependsOn"] == ["build", "test"]
48
+ assert d["dependencyMode"] == "all"
49
+ assert d["condition"] == {"expression": "{{ steps.build.outputs.ok }}"}
50
+ assert d["executionMode"] == "grpc"
51
+ assert d["timeout"] == "5m"
52
+ assert d["retryPolicy"]["maxRetries"] == 5
53
+ assert d["retryPolicy"]["initialBackoff"] == "2s"
54
+ assert d["compensations"][0]["function"] == "rollbackDeploy"
55
+ assert d["onError"] == "compensate"
56
+
57
+
58
+ def test_duplicate_step_name_raises():
59
+ wf = WorkflowDefinition("wf").step(Step("s1", "fn"))
60
+ with pytest.raises(ValueError, match="Duplicate step name"):
61
+ wf.step(Step("s1", "fn2"))
62
+
63
+
64
+ def test_none_values_stripped_from_yaml():
65
+ wf = WorkflowDefinition("wf").step(Step("s1", "fn"))
66
+ y = wf.to_yaml()
67
+ assert "null" not in y
68
+ assert "None" not in y
69
+
70
+
71
+ def test_to_yaml_roundtrip():
72
+ wf = (
73
+ WorkflowDefinition("roundtrip")
74
+ .version("2.0")
75
+ .description("Test workflow")
76
+ .input("x", type="string", required=True)
77
+ .step(
78
+ Step("step1", "doWork")
79
+ .input("val", "{{ inputs.x }}")
80
+ .retry(RetryPolicy(max_retries=3, initial_backoff="500ms"))
81
+ )
82
+ .output("result", "{{ steps.step1.outputs.val }}")
83
+ )
84
+ parsed = yaml.safe_load(wf.to_yaml())
85
+ assert parsed["metadata"]["name"] == "roundtrip"
86
+ assert parsed["metadata"]["version"] == "2.0"
87
+ assert parsed["spec"]["steps"][0]["retryPolicy"]["maxRetries"] == 3
88
+
89
+
90
+ def test_retry_policy_defaults():
91
+ r = RetryPolicy()
92
+ d = r._to_dict()
93
+ assert d["maxRetries"] == 3
94
+ assert d["initialBackoff"] == "1s"
95
+ assert d["maxBackoff"] == "30s"
96
+ assert d["backoffMultiplier"] == 2.0
97
+
98
+
99
+ def test_workflow_level_retry_policy():
100
+ wf = (
101
+ WorkflowDefinition("wf")
102
+ .retry_policy(RetryPolicy(max_retries=10))
103
+ .step(Step("s1", "fn"))
104
+ )
105
+ d = wf.to_dict()
106
+ assert d["spec"]["retryPolicy"]["maxRetries"] == 10
107
+
108
+
109
+ def test_output_mapping():
110
+ step = Step("s", "fn").output_mapping("alias", "{{ outputs.raw }}")
111
+ d = step._to_dict()
112
+ assert d["outputs"]["alias"] == "{{ outputs.raw }}"
113
+
114
+
115
+ def test_save_writes_file(tmp_path):
116
+ wf = WorkflowDefinition("saved").step(Step("s", "fn"))
117
+ out = tmp_path / "wf.yaml"
118
+ wf.save(str(out))
119
+ loaded = yaml.safe_load(out.read_text())
120
+ assert loaded["metadata"]["name"] == "saved"
@@ -0,0 +1,100 @@
1
+ from datetime import datetime, timezone
2
+
3
+ from workflow_engine_sdk.models import (
4
+ ExecutionDetail,
5
+ StartWorkflowResult,
6
+ StepState,
7
+ WorkflowListItem,
8
+ WorkflowSummary,
9
+ )
10
+
11
+
12
+ def _now_iso() -> str:
13
+ return datetime.now(tz=timezone.utc).isoformat()
14
+
15
+
16
+ def test_execution_detail_camel_aliases():
17
+ data = {
18
+ "id": "abc-123",
19
+ "workflowName": "my-flow",
20
+ "state": "completed",
21
+ "inputs": {"x": 1},
22
+ "outputs": {"result": "ok"},
23
+ "steps": {
24
+ "step1": {
25
+ "name": "step1",
26
+ "state": "completed",
27
+ "outputs": {"val": 42},
28
+ "retryCount": 2,
29
+ "startedAt": _now_iso(),
30
+ "completedAt": _now_iso(),
31
+ }
32
+ },
33
+ "currentStep": "step1",
34
+ "failedStep": "",
35
+ "error": "",
36
+ "createdAt": _now_iso(),
37
+ "updatedAt": _now_iso(),
38
+ }
39
+ detail = ExecutionDetail.model_validate(data)
40
+ assert detail.id == "abc-123"
41
+ assert detail.workflow_name == "my-flow"
42
+ assert detail.state == "completed"
43
+ assert detail.steps["step1"].retry_count == 2
44
+ assert detail.steps["step1"].outputs["val"] == 42
45
+
46
+
47
+ def test_execution_detail_snake_case_access():
48
+ detail = ExecutionDetail.model_validate({
49
+ "id": "x",
50
+ "workflowName": "wf",
51
+ "state": "running",
52
+ "createdAt": _now_iso(),
53
+ "updatedAt": _now_iso(),
54
+ })
55
+ assert detail.workflow_name == "wf"
56
+ assert detail.current_step == ""
57
+ assert detail.completed_at is None
58
+
59
+
60
+ def test_step_state_defaults():
61
+ s = StepState(name="s1", state="pending")
62
+ assert s.outputs == {}
63
+ assert s.error == ""
64
+ assert s.retry_count == 0
65
+ assert s.started_at is None
66
+
67
+
68
+ def test_workflow_list_item():
69
+ item = WorkflowListItem.model_validate({"name": "wf", "version": "1.0"})
70
+ assert item.name == "wf"
71
+ assert item.description == ""
72
+
73
+
74
+ def test_workflow_summary_with_steps():
75
+ data = {
76
+ "name": "test-wf",
77
+ "steps": [
78
+ {
79
+ "name": "s1",
80
+ "function": "doWork",
81
+ "executionMode": "grpc",
82
+ "dependsOn": ["s0"],
83
+ "hasCompensation": True,
84
+ }
85
+ ],
86
+ }
87
+ summary = WorkflowSummary.model_validate(data)
88
+ assert summary.steps[0].execution_mode == "grpc"
89
+ assert summary.steps[0].depends_on == ["s0"]
90
+ assert summary.steps[0].has_compensation is True
91
+
92
+
93
+ def test_start_workflow_result():
94
+ r = StartWorkflowResult.model_validate({
95
+ "success": True,
96
+ "workflowId": "uuid-123",
97
+ "message": "started",
98
+ })
99
+ assert r.success is True
100
+ assert r.workflow_id == "uuid-123"
@@ -0,0 +1,236 @@
1
+ import base64
2
+ import json
3
+ import threading
4
+
5
+ import pytest
6
+
7
+ from workflow_engine_sdk import RetryableError, WorkerClient
8
+ from workflow_engine_sdk.worker import _StepWorkerServicer
9
+ from workflow_engine_sdk._proto import workflow_service_pb2 as pb
10
+
11
+
12
+ class _FakeContext:
13
+ """Minimal ServicerContext: carries optional invocation metadata."""
14
+
15
+ def __init__(self, metadata=None):
16
+ self._metadata = metadata or []
17
+
18
+ def invocation_metadata(self):
19
+ return self._metadata
20
+
21
+
22
+ def _userinfo_metadata(**fields):
23
+ """Build a ('userinfo', <b64>) pair like the engine forwards (base64
24
+ RawURLEncoding JSON, no padding)."""
25
+ raw = base64.urlsafe_b64encode(json.dumps(fields).encode()).rstrip(b"=").decode()
26
+ return [("userinfo", raw)]
27
+
28
+
29
+ def _make_request(**kwargs) -> pb.ExecuteStepRequest:
30
+ defaults = {
31
+ "execution_id": "exec-1",
32
+ "workflow_id": "wf-1",
33
+ "workflow_name": "test-wf",
34
+ "step_name": "step1",
35
+ "function_name": "myFn",
36
+ "is_compensation": False,
37
+ "retry_count": 0,
38
+ }
39
+ defaults.update(kwargs)
40
+ return pb.ExecuteStepRequest(**defaults)
41
+
42
+
43
+ def _make_servicer(steps: dict) -> _StepWorkerServicer:
44
+ return _StepWorkerServicer(steps, set(), threading.Lock())
45
+
46
+
47
+ def _drain(stream):
48
+ """Consume the ExecuteStep server-stream; return (logs, result) where
49
+ logs is the list of StepLog frames and result is the terminal
50
+ ExecuteStepResponse."""
51
+ logs, result = [], None
52
+ for event in stream:
53
+ kind = event.WhichOneof("body")
54
+ if kind == "log":
55
+ logs.append(event.log)
56
+ elif kind == "result":
57
+ result = event.result
58
+ return logs, result
59
+
60
+
61
+ def _result(stream) -> pb.ExecuteStepResponse:
62
+ return _drain(stream)[1]
63
+
64
+
65
+ def test_step_decorator_registers():
66
+ worker = WorkerClient("svc", "localhost:50052", "localhost")
67
+
68
+ @worker.step("myFn")
69
+ def my_fn(inputs, **_):
70
+ return {"out": inputs.get("x")}
71
+
72
+ assert "myFn" in worker.registered_steps()
73
+
74
+
75
+ def test_step_duplicate_raises():
76
+ worker = WorkerClient("svc", "localhost:50052", "localhost")
77
+
78
+ @worker.step("fn")
79
+ def fn1(inputs, **_):
80
+ return {}
81
+
82
+ with pytest.raises(ValueError, match="already registered"):
83
+ @worker.step("fn")
84
+ def fn2(inputs, **_):
85
+ return {}
86
+
87
+
88
+ def test_execute_step_success():
89
+ def my_fn(inputs, workflow_id, workflow_name, step_name, is_compensation, retry_count):
90
+ return {"result": inputs.get("val", 0) * 2}
91
+
92
+ servicer = _make_servicer({"myFn": my_fn})
93
+ req = _make_request()
94
+
95
+ from google.protobuf import json_format, struct_pb2
96
+ inputs_struct = struct_pb2.Struct()
97
+ json_format.ParseDict({"val": 5}, inputs_struct)
98
+ req = _make_request()
99
+ req.inputs.CopyFrom(inputs_struct)
100
+
101
+ resp = _result(servicer.ExecuteStep(req, _FakeContext()))
102
+ assert resp.success is True
103
+ assert resp.retry is False
104
+ out = json_format.MessageToDict(resp.outputs)
105
+ assert out["result"] == 10
106
+
107
+
108
+ def test_execute_step_unknown_function():
109
+ servicer = _make_servicer({})
110
+ req = _make_request(function_name="missing")
111
+ resp = _result(servicer.ExecuteStep(req, _FakeContext()))
112
+ assert resp.success is False
113
+ assert "not found" in resp.error
114
+ assert resp.retry is False
115
+
116
+
117
+ def test_execute_step_retryable_error():
118
+ def flaky(inputs, **_):
119
+ raise RetryableError("transient failure")
120
+
121
+ servicer = _make_servicer({"myFn": flaky})
122
+ req = _make_request()
123
+ resp = _result(servicer.ExecuteStep(req, _FakeContext()))
124
+ assert resp.success is False
125
+ assert resp.retry is True
126
+ assert "transient failure" in resp.error
127
+
128
+
129
+ def test_execute_step_unexpected_error():
130
+ def broken(inputs, **_):
131
+ raise ValueError("something broke")
132
+
133
+ servicer = _make_servicer({"myFn": broken})
134
+ req = _make_request()
135
+ resp = _result(servicer.ExecuteStep(req, _FakeContext()))
136
+ assert resp.success is False
137
+ assert resp.retry is False
138
+ assert "something broke" in resp.error
139
+
140
+
141
+ def test_execute_step_none_output():
142
+ def returns_none(inputs, **_):
143
+ return None
144
+
145
+ servicer = _make_servicer({"myFn": returns_none})
146
+ req = _make_request()
147
+ resp = _result(servicer.ExecuteStep(req, _FakeContext()))
148
+ assert resp.success is True
149
+
150
+
151
+ def test_execute_step_streams_log_frames():
152
+ def chatty(inputs, log, **_):
153
+ log("info", "starting", phase="begin")
154
+ log("WARN", "halfway")
155
+ return {"done": True}
156
+
157
+ servicer = _make_servicer({"myFn": chatty})
158
+ logs, result = _drain(servicer.ExecuteStep(_make_request(), _FakeContext()))
159
+ assert result.success is True
160
+ assert [lg.message for lg in logs] == ["starting", "halfway"]
161
+ assert logs[0].level == "INFO" and logs[0].attrs["phase"] == "begin"
162
+ assert logs[1].level == "WARN"
163
+
164
+
165
+ def test_execute_step_surfaces_auth_from_userinfo():
166
+ received = {}
167
+
168
+ def needs_auth(inputs, auth, **_):
169
+ received["auth"] = auth
170
+ return {}
171
+
172
+ servicer = _make_servicer({"myFn": needs_auth})
173
+ ctx = _FakeContext(_userinfo_metadata(preferred_username="svc", email="svc@x.com"))
174
+ _result(servicer.ExecuteStep(_make_request(), ctx))
175
+ assert received["auth"] == {"preferred_username": "svc", "email": "svc@x.com"}
176
+
177
+
178
+ def test_execute_step_auth_none_without_header():
179
+ received = {}
180
+
181
+ def needs_auth(inputs, auth, **_):
182
+ received["auth"] = auth
183
+ return {}
184
+
185
+ servicer = _make_servicer({"myFn": needs_auth})
186
+ _result(servicer.ExecuteStep(_make_request(), _FakeContext()))
187
+ assert received["auth"] is None
188
+
189
+
190
+ def test_execute_step_tracks_active_execution():
191
+ active: set[str] = set()
192
+ lock = threading.Lock()
193
+ started = threading.Event() # step has begun (so it's in `active`)
194
+ release = threading.Event() # let the step finish once we've snapshotted
195
+
196
+ def slow_fn(inputs, **_):
197
+ started.set()
198
+ release.wait()
199
+ return {}
200
+
201
+ servicer = _StepWorkerServicer({"myFn": slow_fn}, active, lock)
202
+ req = _make_request(execution_id="exec-tracking")
203
+
204
+ def run():
205
+ _result(servicer.ExecuteStep(req, _FakeContext()))
206
+
207
+ t = threading.Thread(target=run)
208
+ t.start()
209
+ started.wait()
210
+ with lock:
211
+ snapshot = set(active) # captured while the step is mid-flight
212
+ release.set()
213
+ t.join()
214
+
215
+ assert "exec-tracking" in snapshot
216
+ with lock:
217
+ assert "exec-tracking" not in active # discarded once the stream ends
218
+
219
+
220
+ def test_lifespan_returns_callable():
221
+ worker = WorkerClient("svc", "localhost:50052", "localhost")
222
+ lifespan = worker.lifespan
223
+ assert callable(lifespan)
224
+
225
+
226
+ def test_is_compensation_passed():
227
+ received = {}
228
+
229
+ def comp_fn(inputs, is_compensation, **_):
230
+ received["is_compensation"] = is_compensation
231
+ return {}
232
+
233
+ servicer = _make_servicer({"myFn": comp_fn})
234
+ req = _make_request(is_compensation=True)
235
+ _result(servicer.ExecuteStep(req, _FakeContext()))
236
+ assert received["is_compensation"] is True
@@ -0,0 +1,26 @@
1
+ from workflow_engine_sdk.client import WorkflowEngineClient
2
+ from workflow_engine_sdk.definition import RetryPolicy, Step, WorkflowDefinition
3
+ from workflow_engine_sdk.exceptions import EngineError, RetryableError
4
+ from workflow_engine_sdk.models import (
5
+ ExecutionDetail,
6
+ StartWorkflowResult,
7
+ StepState,
8
+ WorkflowListItem,
9
+ WorkflowSummary,
10
+ )
11
+ from workflow_engine_sdk.worker import WorkerClient
12
+
13
+ __all__ = [
14
+ "RetryableError",
15
+ "EngineError",
16
+ "WorkerClient",
17
+ "WorkflowEngineClient",
18
+ "WorkflowDefinition",
19
+ "Step",
20
+ "RetryPolicy",
21
+ "ExecutionDetail",
22
+ "StepState",
23
+ "WorkflowSummary",
24
+ "WorkflowListItem",
25
+ "StartWorkflowResult",
26
+ ]