grctl-sdk-python 0.1.2__tar.gz → 0.1.3__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 (45) hide show
  1. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/PKG-INFO +13 -13
  2. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/README.md +5 -5
  3. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/client/client.py +53 -35
  4. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/models/history.py +3 -0
  5. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/nats/history_fetch.py +30 -0
  6. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/nats/subscriber.py +2 -2
  7. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/worker/context.py +7 -2
  8. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/worker/worker.py +41 -21
  9. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/workflow/future.py +12 -1
  10. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/pyproject.toml +9 -8
  11. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/LICENSE +0 -0
  12. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/__init__.py +0 -0
  13. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/client/__init__.py +0 -0
  14. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/logging_config.py +0 -0
  15. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/models/__init__.py +0 -0
  16. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/models/api.py +0 -0
  17. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/models/command.py +0 -0
  18. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/models/common.py +0 -0
  19. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/models/directive.py +0 -0
  20. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/models/errors.py +0 -0
  21. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/models/run_info.py +0 -0
  22. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/models/run_info_helper.py +0 -0
  23. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/models/worker.py +0 -0
  24. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/nats/__init__.py +0 -0
  25. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/nats/connection.py +0 -0
  26. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/nats/history_sub.py +0 -0
  27. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/nats/kv_store.py +0 -0
  28. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/nats/manifest.py +0 -0
  29. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/nats/nats_client.py +0 -0
  30. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/nats/nats_manifest.yaml +0 -0
  31. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/nats/publisher.py +0 -0
  32. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/py.typed +0 -0
  33. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/settings.py +0 -0
  34. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/worker/__init__.py +0 -0
  35. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/worker/codec.py +0 -0
  36. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/worker/errors.py +0 -0
  37. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/worker/logger.py +0 -0
  38. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/worker/run_manager.py +0 -0
  39. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/worker/runner.py +0 -0
  40. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/worker/runtime.py +0 -0
  41. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/worker/store.py +0 -0
  42. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/worker/task.py +0 -0
  43. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/workflow/__init__.py +0 -0
  44. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/grctl/workflow/handle.py +0 -0
  45. {grctl_sdk_python-0.1.2 → grctl_sdk_python-0.1.3}/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.3
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.
@@ -11,10 +11,11 @@ from typing import Any
11
11
  import msgspec
12
12
  from ulid import ULID
13
13
 
14
- from grctl.models import DescribeCmd, GrctlAPIResponse, RunInfo
14
+ from grctl.models import DescribeCmd, GrctlAPIResponse, HistoryEvent, RunInfo
15
15
  from grctl.models.command import CmdKind, Command
16
16
  from grctl.models.errors import WorkflowError, WorkflowNotFoundError
17
17
  from grctl.nats.connection import Connection
18
+ from grctl.nats.history_fetch import fetch_run_history
18
19
  from grctl.worker.codec import CodecRegistry
19
20
  from grctl.workflow.handle import WorkflowHandle
20
21
 
@@ -30,36 +31,16 @@ class Client:
30
31
  self._connection = connection
31
32
  self._codec = codec or CodecRegistry()
32
33
 
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."""
34
+ async def describe(self, wf_id: str) -> RunInfo:
35
+ """Describe the latest run for a workflow ID."""
55
36
  cmd = Command(
56
37
  id=str(ULID()),
57
38
  kind=CmdKind.run_describe,
58
39
  timestamp=datetime.now(UTC),
59
- msg=DescribeCmd(wf_id=workflow_id),
40
+ msg=DescribeCmd(wf_id=wf_id),
60
41
  )
61
42
  # 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)
43
+ routing_info = RunInfo(id="", wf_type="", wf_id=wf_id)
63
44
  response_bytes = await self._connection.publisher.publish_cmd(routing_info, cmd)
64
45
 
65
46
  response = msgspec.msgpack.decode(response_bytes, type=GrctlAPIResponse)
@@ -67,10 +48,34 @@ class Client:
67
48
  error_msg = response.error.message if response.error else "unknown error"
68
49
  error_code = response.error.code if response.error else 0
69
50
  if error_code == ErrWorkflowRunNotFoundCode:
70
- raise WorkflowNotFoundError(f"workflow '{workflow_id}' not found: {error_msg}")
51
+ raise WorkflowNotFoundError(f"workflow '{wf_id}' not found: {error_msg}")
71
52
  raise WorkflowError(f"describe failed (code={error_code}): {error_msg}")
72
53
 
73
- run_info = msgspec.msgpack.decode(response.payload, type=RunInfo)
54
+ return msgspec.msgpack.decode(response.payload, type=RunInfo)
55
+
56
+ async def run_workflow(
57
+ self,
58
+ type: str, # noqa: A002
59
+ id: str, # noqa: A002
60
+ input: Any | None = None, # noqa: A002
61
+ timeout: timedelta | None = None, # noqa: ASYNC109
62
+ ) -> Any:
63
+ """Run a workflow and wait for its result."""
64
+ wf_handle = await self.start_workflow(
65
+ type=type,
66
+ id=id,
67
+ input=input,
68
+ timeout=timeout,
69
+ )
70
+ wait_timeout = timeout.total_seconds() if timeout else None
71
+ try:
72
+ return await asyncio.wait_for(wf_handle.future, timeout=wait_timeout)
73
+ finally:
74
+ await wf_handle.future.stop()
75
+
76
+ async def get_workflow_handle(self, wfid: str) -> WorkflowHandle:
77
+ """Get a handle for an already-running workflow."""
78
+ run_info = await self.describe(wfid)
74
79
 
75
80
  handle = WorkflowHandle(
76
81
  run_info=run_info,
@@ -81,27 +86,40 @@ class Client:
81
86
  await handle.attach()
82
87
  return handle
83
88
 
89
+ async def get_history(self, wf_id: str, run_id: str | None = None) -> list[HistoryEvent]:
90
+ """Return the ordered history events for a workflow run."""
91
+ resolved_run_id = run_id
92
+ if resolved_run_id is None:
93
+ resolved_run_id = (await self.describe(wf_id)).id
94
+
95
+ return await fetch_run_history(
96
+ js=self._connection.js,
97
+ manifest=self._connection.manifest,
98
+ wf_id=wf_id,
99
+ run_id=resolved_run_id,
100
+ )
101
+
84
102
  async def start_workflow(
85
103
  self,
86
- workflow_type: str,
87
- workflow_id: str,
88
- workflow_input: Any | None = None,
89
- workflow_timeout: timedelta | None = None,
104
+ type: str, # noqa: A002
105
+ id: str, # noqa: A002
106
+ input: Any | None = None, # noqa: A002
107
+ timeout: timedelta | None = None, # noqa: ASYNC109
90
108
  ) -> WorkflowHandle:
91
109
  """Start a workflow and return a handle to track and interact with it."""
92
110
  workflow_run_id = str(ULID())
93
111
 
94
112
  run_info = RunInfo(
95
113
  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,
114
+ wf_type=type,
115
+ wf_id=id,
116
+ timeout=int(timeout.total_seconds()) if timeout else None,
99
117
  created_at=datetime.now(UTC),
100
118
  )
101
119
 
102
120
  handle = WorkflowHandle(
103
121
  run_info=run_info,
104
- payload=workflow_input,
122
+ payload=input,
105
123
  connection=self._connection,
106
124
  codec=self._codec,
107
125
  )
@@ -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
@@ -9,6 +9,36 @@ _FETCH_BATCH_SIZE = 256
9
9
  _FETCH_TIMEOUT_SECONDS = 0.25
10
10
 
11
11
 
12
+ async def fetch_run_history(
13
+ js: JetStreamContext,
14
+ manifest: NatsManifest,
15
+ wf_id: str,
16
+ run_id: str,
17
+ ) -> list[HistoryEvent]:
18
+ history_subject = manifest.history_subject(wf_id=wf_id, run_id=run_id)
19
+ history_stream = manifest.history_stream_name()
20
+ subscription = await js.pull_subscribe(
21
+ subject=history_subject,
22
+ stream=history_stream,
23
+ config=ConsumerConfig(
24
+ deliver_policy=DeliverPolicy.ALL,
25
+ ack_policy=AckPolicy.NONE,
26
+ inactive_threshold=1.0,
27
+ ),
28
+ )
29
+
30
+ events: list[HistoryEvent] = []
31
+ try:
32
+ try:
33
+ while True:
34
+ messages = await subscription.fetch(batch=_FETCH_BATCH_SIZE, timeout=_FETCH_TIMEOUT_SECONDS)
35
+ events.extend(history_decoder(msg.data) for msg in messages if msg.data)
36
+ except (TimeoutError, FetchTimeoutError):
37
+ return events
38
+ finally:
39
+ await subscription.unsubscribe()
40
+
41
+
12
42
  async def fetch_step_history(
13
43
  js: JetStreamContext,
14
44
  manifest: NatsManifest,
@@ -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
@@ -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()
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "grctl-sdk-python"
3
- version = "0.1.2"
3
+ version = "0.1.3"
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