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.
- fluid_workflow_engine_sdk-0.2.1/.gitignore +6 -0
- fluid_workflow_engine_sdk-0.2.1/Makefile +22 -0
- fluid_workflow_engine_sdk-0.2.1/PKG-INFO +33 -0
- fluid_workflow_engine_sdk-0.2.1/pyproject.toml +58 -0
- fluid_workflow_engine_sdk-0.2.1/tests/__init__.py +0 -0
- fluid_workflow_engine_sdk-0.2.1/tests/test_definition.py +120 -0
- fluid_workflow_engine_sdk-0.2.1/tests/test_models.py +100 -0
- fluid_workflow_engine_sdk-0.2.1/tests/test_worker.py +236 -0
- fluid_workflow_engine_sdk-0.2.1/workflow_engine_sdk/__init__.py +26 -0
- fluid_workflow_engine_sdk-0.2.1/workflow_engine_sdk/_proto/__init__.py +0 -0
- fluid_workflow_engine_sdk-0.2.1/workflow_engine_sdk/_proto/workflow_service_pb2.py +113 -0
- fluid_workflow_engine_sdk-0.2.1/workflow_engine_sdk/_proto/workflow_service_pb2_grpc.py +636 -0
- fluid_workflow_engine_sdk-0.2.1/workflow_engine_sdk/client.py +151 -0
- fluid_workflow_engine_sdk-0.2.1/workflow_engine_sdk/definition.py +244 -0
- fluid_workflow_engine_sdk-0.2.1/workflow_engine_sdk/exceptions.py +11 -0
- fluid_workflow_engine_sdk-0.2.1/workflow_engine_sdk/models.py +82 -0
- fluid_workflow_engine_sdk-0.2.1/workflow_engine_sdk/worker.py +394 -0
|
@@ -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
|
+
]
|
|
File without changes
|