grctl-sdk-python 0.1.2__tar.gz → 0.1.4__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.
Files changed (48) hide show
  1. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/PKG-INFO +13 -13
  2. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/README.md +5 -5
  3. grctl_sdk_python-0.1.4/grctl/client/__init__.py +16 -0
  4. grctl_sdk_python-0.1.4/grctl/client/client.py +165 -0
  5. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/models/errors.py +4 -0
  6. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/models/history.py +3 -0
  7. grctl_sdk_python-0.1.4/grctl/nats/history_fetch.py +78 -0
  8. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/nats/kv_store.py +2 -1
  9. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/nats/publisher.py +1 -1
  10. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/nats/subscriber.py +2 -2
  11. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/worker/context.py +7 -2
  12. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/worker/errors.py +0 -7
  13. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/worker/runner.py +1 -1
  14. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/worker/task.py +2 -1
  15. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/worker/worker.py +41 -21
  16. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/workflow/future.py +12 -1
  17. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/workflow/handle.py +2 -2
  18. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/pyproject.toml +9 -8
  19. grctl_sdk_python-0.1.2/grctl/client/__init__.py +0 -7
  20. grctl_sdk_python-0.1.2/grctl/client/client.py +0 -111
  21. grctl_sdk_python-0.1.2/grctl/nats/history_fetch.py +0 -44
  22. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/LICENSE +0 -0
  23. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/__init__.py +0 -0
  24. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/logging_config.py +0 -0
  25. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/models/__init__.py +0 -0
  26. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/models/api.py +0 -0
  27. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/models/command.py +0 -0
  28. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/models/common.py +0 -0
  29. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/models/directive.py +0 -0
  30. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/models/run_info.py +0 -0
  31. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/models/run_info_helper.py +0 -0
  32. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/models/worker.py +0 -0
  33. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/nats/__init__.py +0 -0
  34. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/nats/connection.py +0 -0
  35. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/nats/history_sub.py +0 -0
  36. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/nats/manifest.py +0 -0
  37. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/nats/nats_client.py +0 -0
  38. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/nats/nats_manifest.yaml +0 -0
  39. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/py.typed +0 -0
  40. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/settings.py +0 -0
  41. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/worker/__init__.py +0 -0
  42. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/worker/codec.py +0 -0
  43. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/worker/logger.py +0 -0
  44. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/worker/run_manager.py +0 -0
  45. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/worker/runtime.py +0 -0
  46. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/worker/store.py +0 -0
  47. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/workflow/__init__.py +0 -0
  48. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.4}/grctl/workflow/workflow.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: grctl-sdk-python
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: The Python SDK for the Ground Control
5
- Author: cemevren
6
- Author-email: cemevren <cemevren@gmail.com>
5
+ Author: Cem Evren Ates
6
+ Author-email: Cem Evren Ates <cemevre@gmail.com>
7
7
  License-Expression: Apache-2.0
8
8
  License-File: LICENSE
9
9
  Classifier: Development Status :: 3 - Alpha
@@ -25,18 +25,18 @@ Requires-Dist: pydantic-settings>=2.11.0
25
25
  Requires-Dist: pyyaml>=6.0.3
26
26
  Requires-Dist: pydantic>=2.12.5
27
27
  Requires-Python: >=3.13
28
- Project-URL: Homepage, https://github.com/cemevren/grctl
29
- Project-URL: Documentation, https://cemevren.github.io/grctl/
30
- Project-URL: Repository, https://github.com/cemevren/grctl
31
- Project-URL: Issues, https://github.com/cemevren/grctl/issues
32
- Project-URL: Changelog, https://github.com/cemevren/grctl/blob/main/sdk_python/CHANGELOG.md
28
+ Project-URL: Homepage, https://github.com/grctl/sdk-python
29
+ Project-URL: Documentation, https://grctl.github.io/grctl/
30
+ Project-URL: Repository, https://github.com/grctl/sdk-python
31
+ Project-URL: Issues, https://github.com/grctl/sdk-python/issues
32
+ Project-URL: Changelog, https://github.com/grctl/sdk-python/blob/main/CHANGELOG.md
33
33
  Description-Content-Type: text/markdown
34
34
 
35
35
  # Ground Control Python SDK
36
36
 
37
- The official Python SDK for [Ground Control](https://github.com/cemevren/grctl) — a lightweight workflow orchestration engine built for fail-safe execution.
37
+ The official Python SDK for [Ground Control](https://github.com/grctl/grctl) — a lightweight workflow orchestration engine built for fail-safe execution.
38
38
 
39
- **[Documentation](https://cemevren.github.io/grctl/)**
39
+ **[Documentation](https://grctl.github.io/grctl/)**
40
40
 
41
41
  > [!WARNING]
42
42
  > **Status: Pre-alpha**
@@ -55,12 +55,12 @@ pip install grctl-sdk-python
55
55
 
56
56
  ## Contributing
57
57
 
58
- Contributions are welcome! Please read our [Contributing Guide](https://github.com/cemevren/grctl/blob/main/CONTRIBUTING.md) for more information.
58
+ Contributions are welcome! Please read our [Contributing Guide](https://github.com/grctl/grctl/blob/main/CONTRIBUTING.md) for more information.
59
59
 
60
60
  ## Security
61
61
 
62
- Please see our [Security Policy](https://github.com/cemevren/grctl/blob/main/SECURITY.md) for reporting vulnerabilities.
62
+ Please see our [Security Policy](https://github.com/grctl/grctl/blob/main/SECURITY.md) for reporting vulnerabilities.
63
63
 
64
64
  ## License
65
65
 
66
- This project is licensed under the Apache License 2.0 - see the [LICENSE](https://github.com/cemevren/grctl/blob/main/LICENSE) file for details.
66
+ This project is licensed under the Apache License 2.0 - see the [LICENSE](https://github.com/grctl/grctl/blob/main/LICENSE) file for details.
@@ -1,8 +1,8 @@
1
1
  # Ground Control Python SDK
2
2
 
3
- The official Python SDK for [Ground Control](https://github.com/cemevren/grctl) — a lightweight workflow orchestration engine built for fail-safe execution.
3
+ The official Python SDK for [Ground Control](https://github.com/grctl/grctl) — a lightweight workflow orchestration engine built for fail-safe execution.
4
4
 
5
- **[Documentation](https://cemevren.github.io/grctl/)**
5
+ **[Documentation](https://grctl.github.io/grctl/)**
6
6
 
7
7
  > [!WARNING]
8
8
  > **Status: Pre-alpha**
@@ -21,12 +21,12 @@ pip install grctl-sdk-python
21
21
 
22
22
  ## Contributing
23
23
 
24
- Contributions are welcome! Please read our [Contributing Guide](https://github.com/cemevren/grctl/blob/main/CONTRIBUTING.md) for more information.
24
+ Contributions are welcome! Please read our [Contributing Guide](https://github.com/grctl/grctl/blob/main/CONTRIBUTING.md) for more information.
25
25
 
26
26
  ## Security
27
27
 
28
- Please see our [Security Policy](https://github.com/cemevren/grctl/blob/main/SECURITY.md) for reporting vulnerabilities.
28
+ Please see our [Security Policy](https://github.com/grctl/grctl/blob/main/SECURITY.md) for reporting vulnerabilities.
29
29
 
30
30
  ## License
31
31
 
32
- This project is licensed under the Apache License 2.0 - see the [LICENSE](https://github.com/cemevren/grctl/blob/main/LICENSE) file for details.
32
+ This project is licensed under the Apache License 2.0 - see the [LICENSE](https://github.com/grctl/grctl/blob/main/LICENSE) file for details.
@@ -0,0 +1,16 @@
1
+ """Ground Control Python SDK client package."""
2
+
3
+ from grctl.client.client import Client
4
+ from grctl.logging_config import get_logger, setup_logging
5
+ from grctl.models.errors import WorkflowAlreadyRunningError, WorkflowError, WorkflowNotFoundError
6
+ from grctl.nats.connection import Connection
7
+
8
+ __all__ = [
9
+ "Client",
10
+ "Connection",
11
+ "WorkflowAlreadyRunningError",
12
+ "WorkflowError",
13
+ "WorkflowNotFoundError",
14
+ "get_logger",
15
+ "setup_logging",
16
+ ]
@@ -0,0 +1,165 @@
1
+ """Workflow Engine Client.
2
+
3
+ Provides a simple interface for interacting with workflows.
4
+ """
5
+
6
+ import asyncio
7
+ import logging
8
+ from datetime import UTC, datetime, timedelta
9
+ from typing import Any, TypeVar, overload
10
+
11
+ import msgspec
12
+ from ulid import ULID
13
+
14
+ from grctl.models import DescribeCmd, GrctlAPIResponse, HistoryEvent, RunInfo
15
+ from grctl.models.command import CmdKind, Command
16
+ from grctl.models.errors import WorkflowAlreadyRunningError, WorkflowError, WorkflowNotFoundError
17
+ from grctl.nats.connection import Connection
18
+ from grctl.nats.history_fetch import fetch_run_history
19
+ from grctl.worker.codec import CodecRegistry
20
+ from grctl.workflow.handle import WorkflowHandle
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ _T = TypeVar("_T")
25
+
26
+ ErrWorkflowAlreadyRunningCode = 4001
27
+ ErrWorkflowRunNotFoundCode = 4002
28
+
29
+
30
+ class Client:
31
+ """Client for interacting with the Workflow Engine."""
32
+
33
+ def __init__(self, connection: Connection, codec: CodecRegistry | None = None) -> None:
34
+ self._connection = connection
35
+ self._codec = codec or CodecRegistry()
36
+
37
+ async def describe(self, wf_id: str) -> RunInfo:
38
+ """Describe the latest run for a workflow ID."""
39
+ cmd = Command(
40
+ id=str(ULID()),
41
+ kind=CmdKind.run_describe,
42
+ timestamp=datetime.now(UTC),
43
+ msg=DescribeCmd(wf_id=wf_id),
44
+ )
45
+ # Use a routing-only RunInfo — publish_cmd only needs wf_id for subject routing.
46
+ routing_info = RunInfo(id="", wf_type="", wf_id=wf_id)
47
+ response_bytes = await self._connection.publisher.publish_cmd(routing_info, cmd)
48
+
49
+ response = msgspec.msgpack.decode(response_bytes, type=GrctlAPIResponse)
50
+ if not response.success:
51
+ error_msg = response.error.message if response.error else "unknown error"
52
+ error_code = response.error.code if response.error else 0
53
+ if error_code == ErrWorkflowRunNotFoundCode:
54
+ raise WorkflowNotFoundError(f"workflow '{wf_id}' not found: {error_msg}")
55
+ raise WorkflowError(f"describe failed (code={error_code}): {error_msg}")
56
+
57
+ return msgspec.msgpack.decode(response.payload, type=RunInfo)
58
+
59
+ @overload
60
+ async def run_workflow(
61
+ self,
62
+ type: str,
63
+ id: str,
64
+ input: Any | None = ...,
65
+ timeout: timedelta | None = ..., # noqa: ASYNC109
66
+ return_type: type[_T] = ...,
67
+ ) -> _T: ...
68
+
69
+ @overload
70
+ async def run_workflow(
71
+ self,
72
+ type: str,
73
+ id: str,
74
+ input: Any | None = ...,
75
+ timeout: timedelta | None = ..., # noqa: ASYNC109
76
+ return_type: None = ...,
77
+ ) -> Any: ...
78
+
79
+ async def run_workflow(
80
+ self,
81
+ type: str, # noqa: A002
82
+ id: str, # noqa: A002
83
+ input: Any | None = None, # noqa: A002
84
+ timeout: timedelta | None = None, # noqa: ASYNC109
85
+ return_type: type[_T] | None = None,
86
+ ) -> _T | Any:
87
+ """Run a workflow and wait for its result."""
88
+ wf_handle = await self.start_workflow(
89
+ type=type,
90
+ id=id,
91
+ input=input,
92
+ timeout=timeout,
93
+ )
94
+ wait_timeout = timeout.total_seconds() if timeout else None
95
+ try:
96
+ result = await asyncio.wait_for(wf_handle.future, timeout=wait_timeout)
97
+ if return_type is not None:
98
+ return self._codec.from_primitive(result, return_type)
99
+ return result
100
+ finally:
101
+ await wf_handle.future.stop()
102
+
103
+ async def get_workflow_handle(self, wfid: str) -> WorkflowHandle:
104
+ """Get a handle for an already-running workflow."""
105
+ run_info = await self.describe(wfid)
106
+
107
+ handle = WorkflowHandle(
108
+ run_info=run_info,
109
+ payload=None,
110
+ connection=self._connection,
111
+ codec=self._codec,
112
+ )
113
+ await handle.attach()
114
+ return handle
115
+
116
+ async def get_history(self, wf_id: str, run_id: str | None = None) -> list[HistoryEvent]:
117
+ """Return the ordered history events for a workflow run."""
118
+ resolved_run_id = run_id
119
+ if resolved_run_id is None:
120
+ resolved_run_id = (await self.describe(wf_id)).id
121
+
122
+ return await fetch_run_history(
123
+ js=self._connection.js,
124
+ manifest=self._connection.manifest,
125
+ wf_id=wf_id,
126
+ run_id=resolved_run_id,
127
+ )
128
+
129
+ async def start_workflow(
130
+ self,
131
+ type: str, # noqa: A002
132
+ id: str, # noqa: A002
133
+ input: Any | None = None, # noqa: A002
134
+ timeout: timedelta | None = None, # noqa: ASYNC109
135
+ ) -> WorkflowHandle:
136
+ """Start a workflow and return a handle to track and interact with it."""
137
+ workflow_run_id = str(ULID())
138
+
139
+ run_info = RunInfo(
140
+ id=workflow_run_id,
141
+ wf_type=type,
142
+ wf_id=id,
143
+ timeout=int(timeout.total_seconds()) if timeout else None,
144
+ created_at=datetime.now(UTC),
145
+ )
146
+
147
+ handle = WorkflowHandle(
148
+ run_info=run_info,
149
+ payload=input,
150
+ connection=self._connection,
151
+ codec=self._codec,
152
+ )
153
+
154
+ # Start the workflow future (subscribe to events and publish run command)
155
+ response_bytes = await handle.start()
156
+ response = msgspec.msgpack.decode(response_bytes, type=GrctlAPIResponse)
157
+ if not response.success:
158
+ await handle.future.stop()
159
+ error_msg = response.error.message if response.error else "unknown error"
160
+ error_code = response.error.code if response.error else 0
161
+ if error_code == ErrWorkflowAlreadyRunningCode:
162
+ raise WorkflowAlreadyRunningError(f"workflow '{id}' already has an active run: {error_msg}")
163
+ raise WorkflowError(f"start_workflow failed (code={error_code}): {error_msg}")
164
+
165
+ return handle
@@ -4,3 +4,7 @@ class WorkflowError(Exception):
4
4
 
5
5
  class WorkflowNotFoundError(WorkflowError):
6
6
  """Raised when a workflow ID does not correspond to any active run."""
7
+
8
+
9
+ class WorkflowAlreadyRunningError(WorkflowError):
10
+ """Raised when a workflow ID already has an active run."""
@@ -202,6 +202,7 @@ class ChildWorkflowStarted(msgspec.Struct):
202
202
  run_id: str
203
203
  wf_type: str
204
204
  wf_id: str
205
+ input: Any | None = None
205
206
 
206
207
 
207
208
  class ParentEventSent(msgspec.Struct):
@@ -209,6 +210,8 @@ class ParentEventSent(msgspec.Struct):
209
210
 
210
211
  event_name: str
211
212
  payload: Any
213
+ parent_wf_type: str
214
+ parent_wf_id: str
212
215
 
213
216
 
214
217
  RunEvents = RunCancelScheduled | RunCancelled | RunCompleted | RunFailed | RunScheduled | RunStarted | RunTimeout
@@ -0,0 +1,78 @@
1
+ import logging
2
+
3
+ from nats.js.api import AckPolicy, ConsumerConfig, DeliverPolicy
4
+ from nats.js.client import JetStreamContext
5
+ from nats.js.errors import FetchTimeoutError, ServiceUnavailableError
6
+
7
+ from grctl.models import HistoryEvent, history_decoder
8
+ from grctl.nats.manifest import NatsManifest
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ _FETCH_BATCH_SIZE = 256
13
+ _FETCH_TIMEOUT_SECONDS = 0.25
14
+
15
+
16
+ async def fetch_run_history(
17
+ js: JetStreamContext,
18
+ manifest: NatsManifest,
19
+ wf_id: str,
20
+ run_id: str,
21
+ ) -> list[HistoryEvent]:
22
+ history_subject = manifest.history_subject(wf_id=wf_id, run_id=run_id)
23
+ history_stream = manifest.history_stream_name()
24
+ subscription = await js.pull_subscribe(
25
+ subject=history_subject,
26
+ stream=history_stream,
27
+ config=ConsumerConfig(
28
+ deliver_policy=DeliverPolicy.ALL,
29
+ ack_policy=AckPolicy.NONE,
30
+ inactive_threshold=1.0,
31
+ ),
32
+ )
33
+
34
+ events: list[HistoryEvent] = []
35
+ try:
36
+ while True:
37
+ messages = await subscription.fetch(batch=_FETCH_BATCH_SIZE, timeout=_FETCH_TIMEOUT_SECONDS)
38
+ events.extend(history_decoder(msg.data) for msg in messages if msg.data)
39
+ except (TimeoutError, FetchTimeoutError, ServiceUnavailableError):
40
+ pass
41
+ finally:
42
+ await subscription.unsubscribe()
43
+ return events
44
+
45
+
46
+ async def fetch_step_history(
47
+ js: JetStreamContext,
48
+ manifest: NatsManifest,
49
+ wf_id: str,
50
+ run_id: str,
51
+ history_seq_id: int,
52
+ ) -> list[HistoryEvent]:
53
+ if history_seq_id <= 0:
54
+ return []
55
+
56
+ history_subject = manifest.history_subject(wf_id=wf_id, run_id=run_id)
57
+ history_stream = manifest.history_stream_name()
58
+ subscription = await js.pull_subscribe(
59
+ subject=history_subject,
60
+ stream=history_stream,
61
+ config=ConsumerConfig(
62
+ deliver_policy=DeliverPolicy.BY_START_SEQUENCE,
63
+ opt_start_seq=history_seq_id,
64
+ ack_policy=AckPolicy.NONE,
65
+ inactive_threshold=1.0,
66
+ ),
67
+ )
68
+
69
+ events: list[HistoryEvent] = []
70
+ try:
71
+ while True:
72
+ messages = await subscription.fetch(batch=_FETCH_BATCH_SIZE, timeout=_FETCH_TIMEOUT_SECONDS)
73
+ events.extend(history_decoder(msg.data) for msg in messages if msg.data)
74
+ except (TimeoutError, FetchTimeoutError):
75
+ pass
76
+ finally:
77
+ await subscription.unsubscribe()
78
+ return [event for event in events if event.operation_id]
@@ -37,7 +37,8 @@ class KVStore:
37
37
  return None
38
38
 
39
39
  except Exception as e:
40
- if "key not found" in str(e).lower():
40
+ msg = str(e).lower()
41
+ if "key not found" in msg or "no message found" in msg:
41
42
  return None
42
43
  raise
43
44
  else:
@@ -42,5 +42,5 @@ class Publisher:
42
42
  ) -> bytes:
43
43
  subject = self._manifest.api_subject(wf_id=run.wf_id)
44
44
  data = command_encoder(cmd)
45
- msg = await self._nc.request(subject, data)
45
+ msg = await self._nc.request(subject, data, timeout=5.0)
46
46
  return msg.data
@@ -1,6 +1,7 @@
1
1
  import asyncio
2
2
  import contextlib
3
3
  import json
4
+ from typing import Any
4
5
 
5
6
  import msgspec
6
7
  from nats.aio.msg import Msg
@@ -11,7 +12,6 @@ from grctl.logging_config import get_logger
11
12
  from grctl.models import directive_decoder
12
13
  from grctl.nats.manifest import NatsManifest
13
14
  from grctl.settings import get_settings
14
- from grctl.worker.run_manager import RunManager
15
15
 
16
16
  logger = get_logger(__name__)
17
17
 
@@ -24,7 +24,7 @@ class Subscriber:
24
24
  js: JetStreamContext,
25
25
  manifest: NatsManifest,
26
26
  wf_types: list[str],
27
- run_manager: RunManager,
27
+ run_manager: Any,
28
28
  ) -> None:
29
29
  self._js = js
30
30
  self._manifest = manifest
@@ -198,7 +198,12 @@ class Context:
198
198
  )
199
199
  await runtime.record(
200
200
  HistoryKind.parent_event_sent,
201
- ParentEventSent(event_name=event_name, payload=payload),
201
+ ParentEventSent(
202
+ event_name=event_name,
203
+ payload=payload,
204
+ parent_wf_type=self._parent_run.wf_type,
205
+ parent_wf_id=self._parent_run.wf_id,
206
+ ),
202
207
  operation_id,
203
208
  )
204
209
 
@@ -244,7 +249,7 @@ class Context:
244
249
  await handle.start()
245
250
  await runtime.record(
246
251
  HistoryKind.child_started,
247
- ChildWorkflowStarted(run_id=run_id, wf_type=workflow_type, wf_id=workflow_id),
252
+ ChildWorkflowStarted(run_id=run_id, wf_type=workflow_type, wf_id=workflow_id, input=workflow_input),
248
253
  operation_id,
249
254
  )
250
255
  return handle
@@ -12,13 +12,6 @@ class WorkflowRunnerNotFoundError(Exception):
12
12
  super().__init__(f"WorkflowRunner not found for WorkflowRun ID '{run_id}'.")
13
13
 
14
14
 
15
- class WorkflowAlreadyRunningError(Exception):
16
- """Exception raised when attempting to start a workflow that is already running."""
17
-
18
- def __init__(self, run_id: str) -> None:
19
- super().__init__(f"WorkflowRun ID '{run_id}' is already running.")
20
-
21
-
22
15
  class NextDirectiveMissingError(Exception):
23
16
  """Exception raised when a workflow handler does not return a NextDirective."""
24
17
 
@@ -122,7 +122,7 @@ class WorkflowRunner:
122
122
 
123
123
  spec = handler_config.spec
124
124
  handler = handler_config.handler
125
- if not spec.params:
125
+ if not spec.params or payload is None:
126
126
  directive = await handler(ctx)
127
127
 
128
128
  # Single param: if payload is already keyed by param name use the value,
@@ -365,7 +365,8 @@ async def _execute_task(
365
365
  return raw
366
366
  if isinstance(event, TaskFailed):
367
367
  raise _reconstruct_error(event.error)
368
- # TaskCancelled — task didn't finish; fall through to execute it live
368
+ if isinstance(event, TaskCancelled):
369
+ raise asyncio.CancelledError
369
370
 
370
371
  previous_attempts = sum(
371
372
  1
@@ -19,6 +19,7 @@ from grctl.workflow.workflow import Workflow
19
19
 
20
20
  logger = get_logger(__name__)
21
21
 
22
+
22
23
  # Constants
23
24
  DEFAULT_WORKFLOW_TIMEOUT_SECONDS: float = 30.0
24
25
  WORKER_HEARTBEAT_INTERVAL_SECONDS: int = 1
@@ -54,8 +55,10 @@ class Worker:
54
55
  self._connection = connection
55
56
  self._workflow_logger = workflow_logger
56
57
  self._stop_event = asyncio.Event()
58
+ self._startup_event = asyncio.Event()
57
59
  self._subscriber: Subscriber | None = None
58
60
  self._run_manager: RunManager | None = None
61
+ self._startup_error: Exception | None = None
59
62
 
60
63
  @cached_property
61
64
  def worker_name(self) -> str:
@@ -81,31 +84,48 @@ class Worker:
81
84
 
82
85
  Creates RunManager for workflow execution and subscribes to workflow subjects.
83
86
  """
87
+ self._startup_event.clear()
88
+ self._startup_error = None
89
+
84
90
  logger.info(
85
91
  f"Starting worker with {len(self._workflows)} registered workflows",
86
92
  )
87
93
 
88
- self._run_manager = RunManager(
89
- worker_name=self.worker_name,
90
- worker_id=self.worker_id,
91
- workflows=self._workflows,
92
- connection=self._connection,
93
- workflow_logger=self._workflow_logger,
94
- )
95
-
96
- wf_types = [wf.workflow_type for wf in self._workflows]
97
- self._subscriber = Subscriber(
98
- js=self._connection.js,
99
- manifest=self._connection.manifest,
100
- wf_types=wf_types,
101
- run_manager=self._run_manager,
102
- )
103
- await self._subscriber.start()
104
-
105
- logger.info(f"Worker {self.worker_name} ({self.worker_id}) started and ready to process messages")
106
-
107
- # Keep worker alive
108
- await self._process_messages()
94
+ try:
95
+ self._run_manager = RunManager(
96
+ worker_name=self.worker_name,
97
+ worker_id=self.worker_id,
98
+ workflows=self._workflows,
99
+ connection=self._connection,
100
+ workflow_logger=self._workflow_logger,
101
+ )
102
+
103
+ wf_types = [wf.workflow_type for wf in self._workflows]
104
+ self._subscriber = Subscriber(
105
+ js=self._connection.js,
106
+ manifest=self._connection.manifest,
107
+ wf_types=wf_types,
108
+ run_manager=self._run_manager,
109
+ )
110
+ await self._subscriber.start()
111
+ self._startup_event.set()
112
+
113
+ logger.info(f"Worker {self.worker_name} ({self.worker_id}) started and ready to process messages")
114
+
115
+ # Keep worker alive
116
+ await self._process_messages()
117
+ except Exception as exc:
118
+ self._startup_error = exc
119
+ self._startup_event.set()
120
+ raise
121
+
122
+ async def wait_until_ready(self, timeout_ms: float = 5.0) -> None:
123
+ """Wait until worker startup succeeds or fails."""
124
+ await asyncio.wait_for(self._startup_event.wait(), timeout=timeout_ms)
125
+ if self._startup_error is not None:
126
+ raise self._startup_error
127
+ if self._subscriber is None:
128
+ raise RuntimeError("Worker startup completed without creating a subscriber")
109
129
 
110
130
  async def _process_messages(self) -> None:
111
131
  """Keep worker alive to process commands."""
@@ -41,6 +41,8 @@ class WorkflowFuture(asyncio.Future[Any]):
41
41
  run_id=run_info.id,
42
42
  handler=self._handle_history_event,
43
43
  )
44
+ self._subscriber_stopped = False
45
+ self.add_done_callback(self._schedule_subscriber_stop)
44
46
  self._history_update_handlers: dict[HistoryKind, Callable[[HistoryEvent], None]] = {
45
47
  HistoryKind.run_scheduled: self._on_non_terminal_event,
46
48
  HistoryKind.run_started: self._on_non_terminal_event,
@@ -54,9 +56,18 @@ class WorkflowFuture(asyncio.Future[Any]):
54
56
  """Start listening for events and publish run command."""
55
57
  await self._subscriber.start()
56
58
 
59
+ def _schedule_subscriber_stop(self, _: asyncio.Future) -> None:
60
+ # done_callback must be sync, so we schedule the async stop as a task.
61
+ if self._subscriber_stopped:
62
+ return
63
+ self._subscriber_stopped = True
64
+ asyncio.ensure_future(self._subscriber.stop()) # noqa: RUF006
65
+
57
66
  async def stop(self) -> None:
58
67
  """Stop listening for events and cleanup."""
59
- await self._subscriber.stop()
68
+ if not self._subscriber_stopped:
69
+ self._subscriber_stopped = True
70
+ await self._subscriber.stop()
60
71
 
61
72
  if not self.done():
62
73
  self.cancel()
@@ -33,7 +33,7 @@ class WorkflowHandle:
33
33
  logger.debug("Attaching to existing workflow %s", self.run_info.wf_id)
34
34
  await self.future.start()
35
35
 
36
- async def start(self) -> None:
36
+ async def start(self) -> bytes:
37
37
  """Start the workflow future (subscribe to events and publish run command)."""
38
38
  input_value = self._codec.decode(self._codec.encode(self._payload)) if self._payload is not None else None
39
39
  cmd = Command(
@@ -48,7 +48,7 @@ class WorkflowHandle:
48
48
  logger.debug("Starting workflow history listener")
49
49
  await self.future.start()
50
50
  logger.debug("Publishing start command for workflow %s ", cmd)
51
- await self._connection.publisher.publish_cmd(self.run_info, cmd)
51
+ return await self._connection.publisher.publish_cmd(self.run_info, cmd)
52
52
 
53
53
  async def send(self, event_name: str, payload: Any | None = None) -> None:
54
54
  normalized = self._codec.decode(self._codec.encode(payload)) if payload is not None else None
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "grctl-sdk-python"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "The Python SDK for the Ground Control"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
7
7
  license = "Apache-2.0"
8
8
  license-files = ["LICENSE"]
9
9
  authors = [
10
- { name = "cemevren", email = "cemevren@gmail.com" },
10
+ { name = "Cem Evren Ates", email = "cemevre@gmail.com" },
11
11
  ]
12
12
  classifiers = [
13
13
  "Development Status :: 3 - Alpha",
@@ -33,11 +33,11 @@ dependencies = [
33
33
  ]
34
34
 
35
35
  [project.urls]
36
- Homepage = "https://github.com/cemevren/grctl"
37
- Documentation = "https://cemevren.github.io/grctl/"
38
- Repository = "https://github.com/cemevren/grctl"
39
- Issues = "https://github.com/cemevren/grctl/issues"
40
- Changelog = "https://github.com/cemevren/grctl/blob/main/sdk_python/CHANGELOG.md"
36
+ Homepage = "https://github.com/grctl/sdk-python"
37
+ Documentation = "https://grctl.github.io/grctl/"
38
+ Repository = "https://github.com/grctl/sdk-python"
39
+ Issues = "https://github.com/grctl/sdk-python/issues"
40
+ Changelog = "https://github.com/grctl/sdk-python/blob/main/CHANGELOG.md"
41
41
 
42
42
  [build-system]
43
43
  requires = ["uv_build>=0.11.6,<0.12"]
@@ -60,7 +60,7 @@ module-name = "grctl"
60
60
  module-root = ""
61
61
 
62
62
  [tool.pytest.ini_options]
63
- addopts = "--ignore=tests/examples --ignore=tests/e2e"
63
+ addopts = "--ignore=tests/examples --ignore=tests/e2e --ignore=tests/spec"
64
64
  testpaths = ["tests"]
65
65
  log_cli = true
66
66
  log_cli_level = "DEBUG"
@@ -107,6 +107,7 @@ lint.ignore = [
107
107
  "ANN204", # Allow missing type annotations for self and cls in tests
108
108
  "ARG", # Unused function args for fixtures
109
109
  "COM812",
110
+ "D104", # Missing docstring in public package
110
111
  "FBT", # Don't care about booleans as positional arguments in tests for @pytest.mark.parametrize
111
112
  "INP001", # Ignore that its not in a real python module. Doesn't matter for tests
112
113
  # The below are debateable
@@ -1,7 +0,0 @@
1
- """Ground Control Python SDK client package."""
2
-
3
- from grctl.client.client import Client
4
- from grctl.logging_config import get_logger, setup_logging
5
- from grctl.nats.connection import Connection
6
-
7
- __all__ = ["Client", "Connection", "get_logger", "setup_logging"]
@@ -1,111 +0,0 @@
1
- """Workflow Engine Client.
2
-
3
- Provides a simple interface for interacting with workflows.
4
- """
5
-
6
- import asyncio
7
- import logging
8
- from datetime import UTC, datetime, timedelta
9
- from typing import Any
10
-
11
- import msgspec
12
- from ulid import ULID
13
-
14
- from grctl.models import DescribeCmd, GrctlAPIResponse, RunInfo
15
- from grctl.models.command import CmdKind, Command
16
- from grctl.models.errors import WorkflowError, WorkflowNotFoundError
17
- from grctl.nats.connection import Connection
18
- from grctl.worker.codec import CodecRegistry
19
- from grctl.workflow.handle import WorkflowHandle
20
-
21
- logger = logging.getLogger(__name__)
22
-
23
- ErrWorkflowRunNotFoundCode = 4002
24
-
25
-
26
- class Client:
27
- """Client for interacting with the Workflow Engine."""
28
-
29
- def __init__(self, connection: Connection, codec: CodecRegistry | None = None) -> None:
30
- self._connection = connection
31
- self._codec = codec or CodecRegistry()
32
-
33
- async def run_workflow(
34
- self,
35
- workflow_type: str,
36
- workflow_id: str,
37
- workflow_input: Any | None = None,
38
- workflow_timeout: timedelta | None = None,
39
- ) -> Any:
40
- """Run a workflow and wait for its result."""
41
- wf_handle = await self.start_workflow(
42
- workflow_type=workflow_type,
43
- workflow_id=workflow_id,
44
- workflow_input=workflow_input,
45
- workflow_timeout=workflow_timeout,
46
- )
47
- timeout = workflow_timeout.total_seconds() if workflow_timeout else None
48
- try:
49
- return await asyncio.wait_for(wf_handle.future, timeout=timeout)
50
- finally:
51
- await wf_handle.future.stop()
52
-
53
- async def get_workflow_handle(self, workflow_id: str) -> WorkflowHandle:
54
- """Get a handle for an already-running workflow."""
55
- cmd = Command(
56
- id=str(ULID()),
57
- kind=CmdKind.run_describe,
58
- timestamp=datetime.now(UTC),
59
- msg=DescribeCmd(wf_id=workflow_id),
60
- )
61
- # Use a routing-only RunInfo — publish_cmd only needs wf_id for subject routing.
62
- routing_info = RunInfo(id="", wf_type="", wf_id=workflow_id)
63
- response_bytes = await self._connection.publisher.publish_cmd(routing_info, cmd)
64
-
65
- response = msgspec.msgpack.decode(response_bytes, type=GrctlAPIResponse)
66
- if not response.success:
67
- error_msg = response.error.message if response.error else "unknown error"
68
- error_code = response.error.code if response.error else 0
69
- if error_code == ErrWorkflowRunNotFoundCode:
70
- raise WorkflowNotFoundError(f"workflow '{workflow_id}' not found: {error_msg}")
71
- raise WorkflowError(f"describe failed (code={error_code}): {error_msg}")
72
-
73
- run_info = msgspec.msgpack.decode(response.payload, type=RunInfo)
74
-
75
- handle = WorkflowHandle(
76
- run_info=run_info,
77
- payload=None,
78
- connection=self._connection,
79
- codec=self._codec,
80
- )
81
- await handle.attach()
82
- return handle
83
-
84
- async def start_workflow(
85
- self,
86
- workflow_type: str,
87
- workflow_id: str,
88
- workflow_input: Any | None = None,
89
- workflow_timeout: timedelta | None = None,
90
- ) -> WorkflowHandle:
91
- """Start a workflow and return a handle to track and interact with it."""
92
- workflow_run_id = str(ULID())
93
-
94
- run_info = RunInfo(
95
- id=workflow_run_id,
96
- wf_type=workflow_type,
97
- wf_id=workflow_id,
98
- timeout=int(workflow_timeout.total_seconds()) if workflow_timeout else None,
99
- created_at=datetime.now(UTC),
100
- )
101
-
102
- handle = WorkflowHandle(
103
- run_info=run_info,
104
- payload=workflow_input,
105
- connection=self._connection,
106
- codec=self._codec,
107
- )
108
-
109
- # Start the workflow future (subscribe to events and publish run command)
110
- await handle.start()
111
- return handle
@@ -1,44 +0,0 @@
1
- from nats.js.api import AckPolicy, ConsumerConfig, DeliverPolicy
2
- from nats.js.client import JetStreamContext
3
- from nats.js.errors import FetchTimeoutError
4
-
5
- from grctl.models import HistoryEvent, history_decoder
6
- from grctl.nats.manifest import NatsManifest
7
-
8
- _FETCH_BATCH_SIZE = 256
9
- _FETCH_TIMEOUT_SECONDS = 0.25
10
-
11
-
12
- async def fetch_step_history(
13
- js: JetStreamContext,
14
- manifest: NatsManifest,
15
- wf_id: str,
16
- run_id: str,
17
- history_seq_id: int,
18
- ) -> list[HistoryEvent]:
19
- if history_seq_id <= 0:
20
- return []
21
-
22
- history_subject = manifest.history_subject(wf_id=wf_id, run_id=run_id)
23
- history_stream = manifest.history_stream_name()
24
- subscription = await js.pull_subscribe(
25
- subject=history_subject,
26
- stream=history_stream,
27
- config=ConsumerConfig(
28
- deliver_policy=DeliverPolicy.BY_START_SEQUENCE,
29
- opt_start_seq=history_seq_id,
30
- ack_policy=AckPolicy.NONE,
31
- inactive_threshold=1.0,
32
- ),
33
- )
34
-
35
- events: list[HistoryEvent] = []
36
- try:
37
- try:
38
- while True:
39
- messages = await subscription.fetch(batch=_FETCH_BATCH_SIZE, timeout=_FETCH_TIMEOUT_SECONDS)
40
- events.extend(history_decoder(msg.data) for msg in messages if msg.data)
41
- except (TimeoutError, FetchTimeoutError):
42
- return [event for event in events if event.operation_id]
43
- finally:
44
- await subscription.unsubscribe()