gl-runner-sdk 0.1.0__py3-none-any.whl
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.
- gl_runner_sdk/__init__.py +26 -0
- gl_runner_sdk/client/__init__.py +12 -0
- gl_runner_sdk/client/clients.py +82 -0
- gl_runner_sdk/runnables/__init__.py +12 -0
- gl_runner_sdk/runnables/runnables.py +629 -0
- gl_runner_sdk/runs/__init__.py +12 -0
- gl_runner_sdk/runs/runs.py +304 -0
- gl_runner_sdk/types/__init__.py +18 -0
- gl_runner_sdk/types/runnables.py +29 -0
- gl_runner_sdk/types/runs.py +67 -0
- gl_runner_sdk-0.1.0.dist-info/METADATA +10 -0
- gl_runner_sdk-0.1.0.dist-info/RECORD +13 -0
- gl_runner_sdk-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""GL Runner SDK - Lightweight Python SDK for GL Runner API.
|
|
2
|
+
|
|
3
|
+
Authors:
|
|
4
|
+
Christopher Sebastian (christophersebastian0205@gmail.com)
|
|
5
|
+
|
|
6
|
+
References:
|
|
7
|
+
- specs/GL1_core_service/lite_sdk/spec.md
|
|
8
|
+
- specs/GL1_core_service/lite_sdk/design.md
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .client import Client
|
|
14
|
+
from .runnables import Runnable, RunStream
|
|
15
|
+
from .runs import Runs
|
|
16
|
+
from .types import RunnableRecord, RunDetailResponse, RunResponse
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Client",
|
|
20
|
+
"Runnable",
|
|
21
|
+
"RunStream",
|
|
22
|
+
"Runs",
|
|
23
|
+
"RunnableRecord",
|
|
24
|
+
"RunDetailResponse",
|
|
25
|
+
"RunResponse",
|
|
26
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Client construction for the GL Runner SDK.
|
|
2
|
+
|
|
3
|
+
Authors:
|
|
4
|
+
Christopher Sebastian (christophersebastian0205@gmail.com)
|
|
5
|
+
|
|
6
|
+
References:
|
|
7
|
+
specs/GL1_core_service/lite_sdk/spec.md
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from ..runnables.runnables import RunnableCollection
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Client:
|
|
20
|
+
"""Transport-only GL Runner client."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, base_url: str, api_key: str):
|
|
23
|
+
"""Initialize client.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
base_url: Base URL of the GL Runner API.
|
|
27
|
+
api_key: API key for authentication.
|
|
28
|
+
"""
|
|
29
|
+
self.base_url = base_url.rstrip("/")
|
|
30
|
+
self.api_key = api_key
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def runnables(self) -> "RunnableCollection":
|
|
34
|
+
"""Return server-scoped runnable collection operations."""
|
|
35
|
+
from ..runnables.runnables import RunnableCollection
|
|
36
|
+
|
|
37
|
+
return RunnableCollection(self)
|
|
38
|
+
|
|
39
|
+
def auth_headers(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
content_type: str | None = None,
|
|
43
|
+
accept: str | None = None,
|
|
44
|
+
) -> dict[str, str]:
|
|
45
|
+
"""Build standard auth headers for SDK API calls."""
|
|
46
|
+
headers = {"Authorization": f"Bearer {self.api_key}"}
|
|
47
|
+
if content_type:
|
|
48
|
+
headers["Content-Type"] = content_type
|
|
49
|
+
if accept:
|
|
50
|
+
headers["Accept"] = accept
|
|
51
|
+
return headers
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def from_api_key(base_url: str, api_key: str) -> "Client":
|
|
55
|
+
"""Create client from API key.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
base_url: Base URL of the GL Runner API.
|
|
59
|
+
api_key: API key for authentication.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Configured Client instance.
|
|
63
|
+
"""
|
|
64
|
+
return Client(base_url=base_url, api_key=api_key)
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_env(cls) -> "Client":
|
|
68
|
+
"""Create client from environment variables.
|
|
69
|
+
|
|
70
|
+
Uses GL_RUNNER_BASE_URL and GL_RUNNER_API_KEY.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Configured Client instance.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
ValueError: If required environment variables are missing.
|
|
77
|
+
"""
|
|
78
|
+
base_url = os.environ.get("GL_RUNNER_BASE_URL", "http://localhost:4200")
|
|
79
|
+
api_key = os.environ.get("GL_RUNNER_API_KEY")
|
|
80
|
+
if not api_key:
|
|
81
|
+
raise ValueError("GL_RUNNER_API_KEY environment variable is required.")
|
|
82
|
+
return cls(base_url=base_url, api_key=api_key)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Runnable operation exports for the GL Runner SDK.
|
|
2
|
+
|
|
3
|
+
Authors:
|
|
4
|
+
Christopher Sebastian (christophersebastian0205@gmail.com)
|
|
5
|
+
|
|
6
|
+
References:
|
|
7
|
+
specs/GL1_core_service/lite_sdk/spec.md
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .runnables import Runnable, RunStream
|
|
11
|
+
|
|
12
|
+
__all__ = ["Runnable", "RunStream"]
|
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
"""Runnable management and execution resource.
|
|
2
|
+
|
|
3
|
+
Authors:
|
|
4
|
+
Christopher Sebastian (christophersebastian0205@gmail.com)
|
|
5
|
+
|
|
6
|
+
References:
|
|
7
|
+
specs/GL1_core_service/lite_sdk/spec.md
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import fnmatch
|
|
13
|
+
import io
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
import zipfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Iterator
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from ..client import Client
|
|
22
|
+
from ..runs import Runs
|
|
23
|
+
from ..types import RunDetailResponse, RunResponse, RunnableRecord
|
|
24
|
+
|
|
25
|
+
_DEFAULT_EXCLUDED_FILES = {
|
|
26
|
+
".DS_Store",
|
|
27
|
+
"Thumbs.db",
|
|
28
|
+
}
|
|
29
|
+
_DEFAULT_ALLOWED_FILES = {
|
|
30
|
+
".env.example",
|
|
31
|
+
}
|
|
32
|
+
_DEFAULT_EXCLUDED_DIRS = {
|
|
33
|
+
".git",
|
|
34
|
+
".hg",
|
|
35
|
+
".mypy_cache",
|
|
36
|
+
".pytest_cache",
|
|
37
|
+
".ruff_cache",
|
|
38
|
+
".svn",
|
|
39
|
+
".tox",
|
|
40
|
+
".venv",
|
|
41
|
+
"__pycache__",
|
|
42
|
+
"build",
|
|
43
|
+
"dist",
|
|
44
|
+
"node_modules",
|
|
45
|
+
"venv",
|
|
46
|
+
}
|
|
47
|
+
_DEFAULT_EXCLUDED_PATTERNS = (
|
|
48
|
+
".env",
|
|
49
|
+
".env.*",
|
|
50
|
+
"*.pyc",
|
|
51
|
+
"*.pyo",
|
|
52
|
+
"*~",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RunnableCollection:
|
|
57
|
+
"""Server-scoped runnable collection operations."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, client: Client) -> None:
|
|
60
|
+
self._client = client
|
|
61
|
+
|
|
62
|
+
def get(self, runnable_id: str) -> RunnableRecord:
|
|
63
|
+
"""Get runnable by ID."""
|
|
64
|
+
url = f"{self._client.base_url}/v1/runnables/{runnable_id}"
|
|
65
|
+
headers = self._client.auth_headers()
|
|
66
|
+
|
|
67
|
+
resp = httpx.get(url, headers=headers, timeout=30)
|
|
68
|
+
if resp.is_error:
|
|
69
|
+
raise RuntimeError(f"Get failed ({resp.status_code}): {resp.text}")
|
|
70
|
+
|
|
71
|
+
return _runnable_record_from_json(resp.json())
|
|
72
|
+
|
|
73
|
+
def list(self, limit: int = 100, offset: int = 0) -> list[RunnableRecord]:
|
|
74
|
+
"""List runnables."""
|
|
75
|
+
url = f"{self._client.base_url}/v1/runnables"
|
|
76
|
+
headers = self._client.auth_headers()
|
|
77
|
+
params = {"limit": limit, "offset": offset}
|
|
78
|
+
|
|
79
|
+
resp = httpx.get(url, headers=headers, params=params, timeout=30)
|
|
80
|
+
if resp.is_error:
|
|
81
|
+
raise RuntimeError(f"List failed ({resp.status_code}): {resp.text}")
|
|
82
|
+
|
|
83
|
+
return [_runnable_record_from_json(item) for item in resp.json()]
|
|
84
|
+
|
|
85
|
+
def delete(self, runnable_id: str) -> None:
|
|
86
|
+
"""Delete a runnable by ID."""
|
|
87
|
+
url = f"{self._client.base_url}/v1/runnables/{runnable_id}"
|
|
88
|
+
headers = self._client.auth_headers()
|
|
89
|
+
|
|
90
|
+
resp = httpx.delete(url, headers=headers, timeout=30)
|
|
91
|
+
if resp.is_error:
|
|
92
|
+
raise RuntimeError(f"Delete failed ({resp.status_code}): {resp.text}")
|
|
93
|
+
|
|
94
|
+
def get_by_key(self, key: str) -> RunnableRecord | None:
|
|
95
|
+
"""Get a runnable by key."""
|
|
96
|
+
page_size = 100
|
|
97
|
+
offset = 0
|
|
98
|
+
while True:
|
|
99
|
+
runnables = self.list(limit=page_size, offset=offset)
|
|
100
|
+
if not runnables:
|
|
101
|
+
break
|
|
102
|
+
for runnable in runnables:
|
|
103
|
+
if runnable.key == key:
|
|
104
|
+
return runnable
|
|
105
|
+
if len(runnables) < page_size:
|
|
106
|
+
break
|
|
107
|
+
offset += page_size
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def get_by_id_or_key(self, runnable_id_or_key: str) -> RunnableRecord | None:
|
|
111
|
+
"""Get a runnable by ID or key. Key lookup always attempted first."""
|
|
112
|
+
runnable = self.get_by_key(runnable_id_or_key)
|
|
113
|
+
if runnable is not None:
|
|
114
|
+
return runnable
|
|
115
|
+
try:
|
|
116
|
+
return self.get(runnable_id_or_key)
|
|
117
|
+
except RuntimeError as exc:
|
|
118
|
+
if not _is_not_found_error(exc):
|
|
119
|
+
raise
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class RunStream:
|
|
124
|
+
"""Iterable wrapper that yields SSE events and provides the final result.
|
|
125
|
+
|
|
126
|
+
Returned by ``Runnable.run(stream=True)``. Iterate to consume streaming
|
|
127
|
+
events in real time, then access ``.result`` for the completed run output.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
runnable: Runnable,
|
|
133
|
+
run_id: str,
|
|
134
|
+
deadline: float,
|
|
135
|
+
poll_interval: float,
|
|
136
|
+
) -> None:
|
|
137
|
+
self._runnable = runnable
|
|
138
|
+
self._run_id = run_id
|
|
139
|
+
self._deadline = deadline
|
|
140
|
+
self._poll_interval = poll_interval
|
|
141
|
+
self._result: dict[str, Any] | None = None
|
|
142
|
+
|
|
143
|
+
def __iter__(self) -> Iterator[dict[str, Any]]:
|
|
144
|
+
for event in self._runnable.stream(self._run_id, deadline=self._deadline):
|
|
145
|
+
yield event
|
|
146
|
+
remaining = max(self._deadline - time.time(), 0.0)
|
|
147
|
+
completed = self._runnable.wait(
|
|
148
|
+
self._run_id, timeout=remaining, poll_interval=self._poll_interval
|
|
149
|
+
)
|
|
150
|
+
self._result = completed.result
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def result(self) -> dict[str, Any] | None:
|
|
154
|
+
if self._result is None:
|
|
155
|
+
raise RuntimeError("Stream not yet consumed. Iterate over RunStream before accessing .result.")
|
|
156
|
+
return self._result
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class Runnable:
|
|
160
|
+
"""Runnable management and execution facade."""
|
|
161
|
+
|
|
162
|
+
def __init__(
|
|
163
|
+
self,
|
|
164
|
+
client: Client | None = None,
|
|
165
|
+
*,
|
|
166
|
+
base_url: str | None = None,
|
|
167
|
+
api_key: str | None = None,
|
|
168
|
+
key: str | None = None,
|
|
169
|
+
kind: str = "code",
|
|
170
|
+
bundle_path: str | Path | None = None,
|
|
171
|
+
version: str | None = None,
|
|
172
|
+
entrypoint: str | None = None,
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Initialize Runnables.
|
|
175
|
+
|
|
176
|
+
Client is handled internally when `base_url` and `api_key` are
|
|
177
|
+
provided, or when `GL_RUNNER_BASE_URL` / `GL_RUNNER_API_KEY` env
|
|
178
|
+
vars are set. Pass an explicit `client` to share a single transport.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
client: Shared Client instance (optional; created from base_url/api_key otherwise).
|
|
182
|
+
base_url: Base URL of the GL Runner API.
|
|
183
|
+
api_key: API key for authentication.
|
|
184
|
+
key: Optional runnable key for public usage.
|
|
185
|
+
kind: Runnable kind.
|
|
186
|
+
bundle_path: Optional local bundle path.
|
|
187
|
+
version: Optional deployment version.
|
|
188
|
+
entrypoint: Optional entrypoint override.
|
|
189
|
+
"""
|
|
190
|
+
if client is None:
|
|
191
|
+
base_url = base_url or os.environ.get("GL_RUNNER_BASE_URL", "http://localhost:4200")
|
|
192
|
+
api_key = api_key or os.environ.get("GL_RUNNER_API_KEY")
|
|
193
|
+
if not api_key:
|
|
194
|
+
raise ValueError("GL_RUNNER_API_KEY environment variable is required.")
|
|
195
|
+
client = Client(base_url=base_url, api_key=api_key)
|
|
196
|
+
self._client = client
|
|
197
|
+
self._runs = Runs(client)
|
|
198
|
+
self.key = key
|
|
199
|
+
self.kind = kind
|
|
200
|
+
self.bundle_path = Path(bundle_path) if bundle_path is not None else None
|
|
201
|
+
self.version = version
|
|
202
|
+
self.entrypoint = entrypoint
|
|
203
|
+
self._deployment: RunnableRecord | None = None
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def deployment(self) -> RunnableRecord | None:
|
|
207
|
+
"""Return the tracked server deployment."""
|
|
208
|
+
return self._deployment
|
|
209
|
+
|
|
210
|
+
def _create_bundle_zip(
|
|
211
|
+
self,
|
|
212
|
+
bundle_path: Path,
|
|
213
|
+
include_sensitive_files: list[str] | tuple[str, ...] | None = None,
|
|
214
|
+
) -> io.BytesIO:
|
|
215
|
+
"""Create a ZIP archive from a bundle directory.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
bundle_path: Path to the bundle directory.
|
|
219
|
+
include_sensitive_files: Exact bundle-relative files to include even
|
|
220
|
+
when they match the default sensitive-file denylist.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
BytesIO buffer containing the ZIP archive.
|
|
224
|
+
"""
|
|
225
|
+
allowed_sensitive_files = _normalize_sensitive_includes(include_sensitive_files)
|
|
226
|
+
zip_buffer = io.BytesIO()
|
|
227
|
+
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
228
|
+
for item in bundle_path.rglob("*"):
|
|
229
|
+
relative_path = item.relative_to(bundle_path)
|
|
230
|
+
if item.is_symlink():
|
|
231
|
+
continue
|
|
232
|
+
if item.is_file() and not _is_excluded_bundle_path(relative_path, allowed_sensitive_files):
|
|
233
|
+
zf.write(item, arcname=relative_path)
|
|
234
|
+
zip_buffer.seek(0)
|
|
235
|
+
return zip_buffer
|
|
236
|
+
|
|
237
|
+
def _prepare_zip_bytes(
|
|
238
|
+
self,
|
|
239
|
+
bundle_path: str | Path | None,
|
|
240
|
+
bundle_zip: bytes | io.BytesIO | None,
|
|
241
|
+
include_sensitive_files: list[str] | tuple[str, ...] | None,
|
|
242
|
+
) -> bytes:
|
|
243
|
+
"""Resolve bundle bytes from either a directory path or provided ZIP bytes."""
|
|
244
|
+
if bundle_path is not None:
|
|
245
|
+
bundle_path = Path(bundle_path)
|
|
246
|
+
if not bundle_path.exists() or not bundle_path.is_dir():
|
|
247
|
+
raise ValueError(f"Bundle path must be a directory: {bundle_path}")
|
|
248
|
+
zip_buffer = self._create_bundle_zip(bundle_path, include_sensitive_files=include_sensitive_files)
|
|
249
|
+
return zip_buffer.getvalue()
|
|
250
|
+
if bundle_zip is not None:
|
|
251
|
+
if include_sensitive_files:
|
|
252
|
+
raise ValueError("include_sensitive_files only applies when bundle_path is provided.")
|
|
253
|
+
if isinstance(bundle_zip, io.BytesIO):
|
|
254
|
+
return bundle_zip.getvalue()
|
|
255
|
+
return bundle_zip
|
|
256
|
+
raise ValueError("Either bundle_path or bundle_zip must be provided.")
|
|
257
|
+
|
|
258
|
+
def set_bundle_path(self, bundle_path: str | Path) -> "Runnable":
|
|
259
|
+
"""Set the runnable bundle path on this facade."""
|
|
260
|
+
self.bundle_path = Path(bundle_path)
|
|
261
|
+
return self
|
|
262
|
+
|
|
263
|
+
def deploy(
|
|
264
|
+
self,
|
|
265
|
+
key: str | None = None,
|
|
266
|
+
kind: str | None = None,
|
|
267
|
+
bundle_zip: bytes | io.BytesIO | None = None,
|
|
268
|
+
include_sensitive_files: list[str] | tuple[str, ...] | None = None,
|
|
269
|
+
version: str | None = None,
|
|
270
|
+
entrypoint: str | None = None,
|
|
271
|
+
wait_for_active: bool = True,
|
|
272
|
+
activation_timeout: float = 120.0,
|
|
273
|
+
activation_poll_interval: float = 5.0,
|
|
274
|
+
) -> RunnableRecord:
|
|
275
|
+
"""Deploy or update a runnable.
|
|
276
|
+
|
|
277
|
+
When ``self._deployment`` is already tracked (e.g. after
|
|
278
|
+
``load_from_id()`` or a previous ``deploy()`` call) this posts to the
|
|
279
|
+
update endpoint. Otherwise it performs a first-time deploy.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
key: Runnable key.
|
|
283
|
+
kind: Runnable kind (default: code).
|
|
284
|
+
bundle_zip: Pre-built ZIP archive.
|
|
285
|
+
include_sensitive_files: Explicit bundle-relative sensitive files to include.
|
|
286
|
+
version: Optional version string.
|
|
287
|
+
entrypoint: Optional entrypoint module path.
|
|
288
|
+
wait_for_active: If True (default), polls until status is no longer
|
|
289
|
+
``deploying``.
|
|
290
|
+
activation_timeout: Max seconds to wait for activation.
|
|
291
|
+
activation_poll_interval: Seconds between activation status checks.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
Deployed Runnable.
|
|
295
|
+
|
|
296
|
+
Raises:
|
|
297
|
+
ValueError: If neither self.bundle_path nor bundle_zip is provided.
|
|
298
|
+
RuntimeError: If deployment fails or runnable becomes inactive.
|
|
299
|
+
TimeoutError: If activation times out.
|
|
300
|
+
"""
|
|
301
|
+
if key is not None:
|
|
302
|
+
self.key = key
|
|
303
|
+
if kind is not None:
|
|
304
|
+
self.kind = kind
|
|
305
|
+
if version is not None:
|
|
306
|
+
self.version = version
|
|
307
|
+
if entrypoint is not None:
|
|
308
|
+
self.entrypoint = entrypoint
|
|
309
|
+
|
|
310
|
+
if not self.key:
|
|
311
|
+
raise ValueError("key is required to deploy a runnable.")
|
|
312
|
+
if self.kind in (None, ""):
|
|
313
|
+
self.kind = "code"
|
|
314
|
+
|
|
315
|
+
# NOTE: ordering matters — this block must execute before the auto-discovery
|
|
316
|
+
# block below; _track_deployment() updates self.version and self._deployment,
|
|
317
|
+
# which would invalidate the guard against replaying the tracked version.
|
|
318
|
+
request_version = version
|
|
319
|
+
if request_version is None:
|
|
320
|
+
request_version = self.version
|
|
321
|
+
if (self._deployment is not None and request_version == self._deployment.version):
|
|
322
|
+
request_version = None
|
|
323
|
+
|
|
324
|
+
zip_bytes = self._prepare_zip_bytes(self.bundle_path, bundle_zip, include_sensitive_files)
|
|
325
|
+
|
|
326
|
+
if self._deployment is None and self.key:
|
|
327
|
+
existing = self._client.runnables.get_by_key(self.key)
|
|
328
|
+
if existing is not None:
|
|
329
|
+
self._track_deployment(existing)
|
|
330
|
+
|
|
331
|
+
if self._deployment is not None:
|
|
332
|
+
url = f"{self._client.base_url}/v1/runnables/{self._deployment.id}:deploy"
|
|
333
|
+
else:
|
|
334
|
+
url = f"{self._client.base_url}/v1/runnables:deploy"
|
|
335
|
+
|
|
336
|
+
headers = self._client.auth_headers()
|
|
337
|
+
files = {"file": ("bundle.zip", zip_bytes, "application/zip")}
|
|
338
|
+
data: dict[str, Any] = {"key": self.key, "kind": self.kind}
|
|
339
|
+
if request_version:
|
|
340
|
+
data["version"] = request_version
|
|
341
|
+
if self.entrypoint:
|
|
342
|
+
data["entrypoint"] = self.entrypoint
|
|
343
|
+
|
|
344
|
+
resp = httpx.post(url, headers=headers, data=data, files=files, timeout=60)
|
|
345
|
+
if resp.is_error:
|
|
346
|
+
raise RuntimeError(f"Deploy failed ({resp.status_code}): {resp.text}")
|
|
347
|
+
|
|
348
|
+
runnable = _runnable_record_from_json(resp.json())
|
|
349
|
+
self._track_deployment(runnable)
|
|
350
|
+
|
|
351
|
+
if wait_for_active:
|
|
352
|
+
_wait_for_deployment_active(
|
|
353
|
+
self._client,
|
|
354
|
+
self._deployment.id,
|
|
355
|
+
activation_timeout,
|
|
356
|
+
activation_poll_interval,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
return runnable
|
|
360
|
+
|
|
361
|
+
def trigger(
|
|
362
|
+
self,
|
|
363
|
+
payload: dict[str, Any] | None = None,
|
|
364
|
+
context: dict[str, Any] | None = None,
|
|
365
|
+
) -> RunResponse:
|
|
366
|
+
"""Trigger a runnable without waiting.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
payload: Optional input payload.
|
|
370
|
+
context: Optional context data.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
RunResponse with run details.
|
|
374
|
+
|
|
375
|
+
Raises:
|
|
376
|
+
RuntimeError: If trigger fails.
|
|
377
|
+
"""
|
|
378
|
+
runnable_id = self._tracked_deployment_id()
|
|
379
|
+
url = f"{self._client.base_url}/v1/runnables/{runnable_id}:run"
|
|
380
|
+
headers = self._client.auth_headers(content_type="application/json")
|
|
381
|
+
data: dict[str, Any] = {}
|
|
382
|
+
if payload is not None:
|
|
383
|
+
data["payload"] = payload
|
|
384
|
+
if context is not None:
|
|
385
|
+
data["context"] = context
|
|
386
|
+
|
|
387
|
+
resp = httpx.post(url, headers=headers, json=data, timeout=30)
|
|
388
|
+
if resp.is_error:
|
|
389
|
+
raise RuntimeError(f"Trigger failed ({resp.status_code}): {resp.text}")
|
|
390
|
+
|
|
391
|
+
result = resp.json()
|
|
392
|
+
return RunResponse(
|
|
393
|
+
id=result["id"],
|
|
394
|
+
runnable_id=result["runnable_id"],
|
|
395
|
+
status=result["status"],
|
|
396
|
+
payload=result.get("requested_payload"),
|
|
397
|
+
context=result.get("context_metadata"),
|
|
398
|
+
result=None,
|
|
399
|
+
error=result.get("error_code"),
|
|
400
|
+
created_at=result.get("created_at"),
|
|
401
|
+
completed_at=result.get("completed_at"),
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
def run(
|
|
405
|
+
self,
|
|
406
|
+
payload: dict[str, Any] | None = None,
|
|
407
|
+
context: dict[str, Any] | None = None,
|
|
408
|
+
timeout: float = 120.0,
|
|
409
|
+
poll_interval: float = 2.0,
|
|
410
|
+
stream: bool = False,
|
|
411
|
+
) -> dict[str, Any] | None | RunStream:
|
|
412
|
+
"""Trigger a runnable, wait for completion, and return its result.
|
|
413
|
+
|
|
414
|
+
When ``stream=True`` returns a ``RunStream`` iterable that yields
|
|
415
|
+
raw OpenAI-compatible events and provides the final result via
|
|
416
|
+
``.result`` after iteration.
|
|
417
|
+
"""
|
|
418
|
+
run = self.trigger(payload=payload, context=context)
|
|
419
|
+
if stream:
|
|
420
|
+
return RunStream(self, run.id, time.time() + timeout, poll_interval)
|
|
421
|
+
completed = self.wait(run.id, timeout=timeout, poll_interval=poll_interval)
|
|
422
|
+
return completed.result
|
|
423
|
+
|
|
424
|
+
def wait(
|
|
425
|
+
self,
|
|
426
|
+
run_id: str,
|
|
427
|
+
timeout: float = 120.0,
|
|
428
|
+
poll_interval: float = 2.0,
|
|
429
|
+
) -> RunDetailResponse:
|
|
430
|
+
"""Wait for a run to reach a terminal status."""
|
|
431
|
+
return self._runs.wait(run_id, timeout=timeout, poll_interval=poll_interval)
|
|
432
|
+
|
|
433
|
+
def get_run(self, run_id: str) -> RunDetailResponse:
|
|
434
|
+
"""Get a run by ID."""
|
|
435
|
+
return self._runs.get(run_id)
|
|
436
|
+
|
|
437
|
+
def stream(self, run_id: str, deadline: float | None = None) -> Iterator[dict[str, Any]]:
|
|
438
|
+
"""Stream run events as raw OpenAI-compatible payloads."""
|
|
439
|
+
return self._runs.stream(run_id, deadline=deadline)
|
|
440
|
+
|
|
441
|
+
def events(
|
|
442
|
+
self,
|
|
443
|
+
run_id: str,
|
|
444
|
+
event_type: str | None = None,
|
|
445
|
+
limit: int = 100,
|
|
446
|
+
) -> list[dict[str, Any]]:
|
|
447
|
+
"""Get historical events for a run."""
|
|
448
|
+
return self._runs.events(run_id, event_type=event_type, limit=limit)
|
|
449
|
+
|
|
450
|
+
@classmethod
|
|
451
|
+
def from_id(
|
|
452
|
+
cls,
|
|
453
|
+
runnable_id: str,
|
|
454
|
+
*,
|
|
455
|
+
client: Client | None = None,
|
|
456
|
+
base_url: str | None = None,
|
|
457
|
+
api_key: str | None = None,
|
|
458
|
+
) -> "Runnable":
|
|
459
|
+
"""Construct a Runnable facade and load by ID."""
|
|
460
|
+
runnable = cls(client=client, base_url=base_url, api_key=api_key)
|
|
461
|
+
return runnable.load_from_id(runnable_id)
|
|
462
|
+
|
|
463
|
+
def load_from_id(self, runnable_id: str) -> "Runnable":
|
|
464
|
+
"""Load a server deployment into this runnable facade.
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
runnable_id: ID of the runnable to track.
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
This Runnable facade, with deployment/key/kind/version/entrypoint
|
|
471
|
+
updated from the server record.
|
|
472
|
+
|
|
473
|
+
Raises:
|
|
474
|
+
RuntimeError: If loading the runnable fails.
|
|
475
|
+
"""
|
|
476
|
+
self._track_deployment(self._client.runnables.get(runnable_id))
|
|
477
|
+
return self
|
|
478
|
+
|
|
479
|
+
@classmethod
|
|
480
|
+
def from_key(
|
|
481
|
+
cls,
|
|
482
|
+
key: str,
|
|
483
|
+
*,
|
|
484
|
+
client: Client | None = None,
|
|
485
|
+
base_url: str | None = None,
|
|
486
|
+
api_key: str | None = None,
|
|
487
|
+
) -> "Runnable":
|
|
488
|
+
"""Construct a Runnable facade and load by key."""
|
|
489
|
+
runnable = cls(client=client, base_url=base_url, api_key=api_key)
|
|
490
|
+
return runnable.load_from_key(key)
|
|
491
|
+
|
|
492
|
+
def load_from_key(self, key: str) -> "Runnable":
|
|
493
|
+
"""Load a server deployment by key.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
key: Stable user-facing key of the runnable to track.
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
This Runnable facade, with deployment/key/kind/version/entrypoint
|
|
500
|
+
updated from the server record.
|
|
501
|
+
|
|
502
|
+
Raises:
|
|
503
|
+
RuntimeError: If no runnable is found with the given key.
|
|
504
|
+
"""
|
|
505
|
+
deployment = self._client.runnables.get_by_key(key)
|
|
506
|
+
if deployment is None:
|
|
507
|
+
raise RuntimeError(f"Runnable not found: {key}")
|
|
508
|
+
self._track_deployment(deployment)
|
|
509
|
+
return self
|
|
510
|
+
|
|
511
|
+
def sync(self) -> RunnableRecord:
|
|
512
|
+
"""Refresh the tracked deployment record from the server in-place.
|
|
513
|
+
|
|
514
|
+
Updates both ``_deployment`` and the facade attributes
|
|
515
|
+
(``key``, ``kind``, ``version``, ``entrypoint``) to match the
|
|
516
|
+
server state.
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
The updated RunnableRecord fetched from the server.
|
|
520
|
+
"""
|
|
521
|
+
self._track_deployment(self._client.runnables.get(self._tracked_deployment_id()))
|
|
522
|
+
return self._deployment
|
|
523
|
+
|
|
524
|
+
def delete(self) -> None:
|
|
525
|
+
"""Delete the tracked runnable deployment."""
|
|
526
|
+
runnable_id = self._tracked_deployment_id()
|
|
527
|
+
self._client.runnables.delete(runnable_id)
|
|
528
|
+
self._deployment = None
|
|
529
|
+
|
|
530
|
+
def _tracked_deployment_id(self) -> str:
|
|
531
|
+
"""Return the tracked deployment ID or raise a clear caller error."""
|
|
532
|
+
if self._deployment is None:
|
|
533
|
+
raise ValueError("runnable_id is required before a deployment is tracked. Call deploy() first.")
|
|
534
|
+
return self._deployment.id
|
|
535
|
+
|
|
536
|
+
def _track_deployment(self, deployment: RunnableRecord) -> None:
|
|
537
|
+
"""Replace this facade's tracked deployment from a server record."""
|
|
538
|
+
self._deployment = deployment
|
|
539
|
+
self.key = deployment.key
|
|
540
|
+
self.kind = deployment.kind
|
|
541
|
+
self.version = deployment.version
|
|
542
|
+
self.entrypoint = deployment.entrypoint
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _is_not_found_error(exc: RuntimeError) -> bool:
|
|
546
|
+
return "404" in str(exc)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _runnable_record_from_json(data: dict[str, Any]) -> RunnableRecord:
|
|
550
|
+
return RunnableRecord(
|
|
551
|
+
id=data["id"],
|
|
552
|
+
key=data["key"],
|
|
553
|
+
entrypoint=data["entrypoint"],
|
|
554
|
+
kind=data.get("kind", "code"),
|
|
555
|
+
version=data.get("version"),
|
|
556
|
+
status=data.get("status"),
|
|
557
|
+
metadata=data.get("metadata", {}),
|
|
558
|
+
config=data.get("config", {}),
|
|
559
|
+
created_at=data.get("created_at"),
|
|
560
|
+
updated_at=data.get("updated_at"),
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _wait_for_deployment_active(
|
|
565
|
+
client: Client,
|
|
566
|
+
runnable_id: str,
|
|
567
|
+
timeout: float = 120.0,
|
|
568
|
+
poll_interval: float = 5.0,
|
|
569
|
+
) -> None:
|
|
570
|
+
"""Wait until a deployed runnable is no longer ``deploying``.
|
|
571
|
+
|
|
572
|
+
Args:
|
|
573
|
+
client: SDK Client for API calls.
|
|
574
|
+
runnable_id: ID of the deployed runnable.
|
|
575
|
+
timeout: Max seconds to wait.
|
|
576
|
+
poll_interval: Seconds between status checks.
|
|
577
|
+
|
|
578
|
+
Raises:
|
|
579
|
+
RuntimeError: If the runnable becomes ``inactive``.
|
|
580
|
+
TimeoutError: If the runnable stays ``deploying`` past the timeout.
|
|
581
|
+
"""
|
|
582
|
+
deadline = time.time() + timeout
|
|
583
|
+
while True:
|
|
584
|
+
deployed = client.runnables.get(runnable_id)
|
|
585
|
+
if deployed.status == "inactive":
|
|
586
|
+
raise RuntimeError(f"Deploy failed: runnable {runnable_id} became inactive.")
|
|
587
|
+
if deployed.status == "active" and deployed.metadata.get("prefect_deployment_id"):
|
|
588
|
+
return
|
|
589
|
+
if (deployed.status not in ("deploying", "active") and deployed.status is not None):
|
|
590
|
+
return
|
|
591
|
+
if deployed.status is None:
|
|
592
|
+
import warnings
|
|
593
|
+
|
|
594
|
+
warnings.warn(
|
|
595
|
+
f"Runnable {runnable_id} returned status=None while polling for activation.",
|
|
596
|
+
RuntimeWarning,
|
|
597
|
+
stacklevel=2,
|
|
598
|
+
)
|
|
599
|
+
if time.time() > deadline:
|
|
600
|
+
raise TimeoutError(
|
|
601
|
+
f"Timed out waiting for runnable {runnable_id} activation "
|
|
602
|
+
f"(last status: {deployed.status!r})."
|
|
603
|
+
)
|
|
604
|
+
time.sleep(poll_interval)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _normalize_sensitive_includes(include_sensitive_files: list[str] | tuple[str, ...] | None) -> set[Path]:
|
|
608
|
+
allowed: set[Path] = set()
|
|
609
|
+
for value in include_sensitive_files or ():
|
|
610
|
+
path = Path(value)
|
|
611
|
+
if path.is_absolute() or ".." in path.parts:
|
|
612
|
+
raise ValueError(f"Sensitive include must be a bundle-root filename, got: {value}")
|
|
613
|
+
if len(path.parts) != 1:
|
|
614
|
+
raise ValueError(f"Sensitive include must be a bundle-root filename (no subdirectories): {value}")
|
|
615
|
+
allowed.add(path)
|
|
616
|
+
return allowed
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _is_excluded_bundle_path(relative_path: Path, allowed_sensitive_files: set[Path]) -> bool:
|
|
620
|
+
if relative_path in allowed_sensitive_files:
|
|
621
|
+
return False
|
|
622
|
+
if any(part in _DEFAULT_EXCLUDED_DIRS for part in relative_path.parts[:-1]):
|
|
623
|
+
return True
|
|
624
|
+
name = relative_path.name
|
|
625
|
+
if name in _DEFAULT_ALLOWED_FILES:
|
|
626
|
+
return False
|
|
627
|
+
if name in _DEFAULT_EXCLUDED_FILES:
|
|
628
|
+
return True
|
|
629
|
+
return any(fnmatch.fnmatch(name, pattern) for pattern in _DEFAULT_EXCLUDED_PATTERNS)
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Run history and streaming resource.
|
|
2
|
+
|
|
3
|
+
Authors:
|
|
4
|
+
Christopher Sebastian (christophersebastian0205@gmail.com)
|
|
5
|
+
|
|
6
|
+
References:
|
|
7
|
+
specs/GL1_core_service/lite_sdk/spec.md
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any, Iterator
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from ..client import Client
|
|
20
|
+
from ..types import RunDetailResponse
|
|
21
|
+
|
|
22
|
+
_TERMINAL_STATUSES = {"success", "failed", "cancelled"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Runs:
|
|
26
|
+
"""Run status, polling, streaming, and history."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
client: Client | None = None,
|
|
31
|
+
*,
|
|
32
|
+
base_url: str | None = None,
|
|
33
|
+
api_key: str | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Initialize Runs.
|
|
36
|
+
|
|
37
|
+
Client is handled internally when `base_url` and `api_key` are
|
|
38
|
+
provided, or when `GL_RUNNER_BASE_URL` / `GL_RUNNER_API_KEY` env
|
|
39
|
+
vars are set. Pass an explicit `client` to share a single transport.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
client: Shared Client instance (optional; created from base_url/api_key otherwise).
|
|
43
|
+
base_url: Base URL of the GL Runner API.
|
|
44
|
+
api_key: API key for authentication.
|
|
45
|
+
"""
|
|
46
|
+
if client is None:
|
|
47
|
+
base_url = base_url or os.environ.get("GL_RUNNER_BASE_URL", "http://localhost:4200")
|
|
48
|
+
api_key = api_key or os.environ.get("GL_RUNNER_API_KEY")
|
|
49
|
+
if not api_key:
|
|
50
|
+
raise ValueError("GL_RUNNER_API_KEY environment variable is required.")
|
|
51
|
+
client = Client(base_url=base_url, api_key=api_key)
|
|
52
|
+
self._client = client
|
|
53
|
+
|
|
54
|
+
def get(self, run_id: str) -> RunDetailResponse:
|
|
55
|
+
"""Get a run by ID.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
run_id: ID of the run to retrieve.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
RunDetailResponse with run details.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
RuntimeError: If the get fails.
|
|
65
|
+
"""
|
|
66
|
+
url = f"{self._client.base_url}/v1/runs/{run_id}"
|
|
67
|
+
headers = self._client.auth_headers()
|
|
68
|
+
|
|
69
|
+
resp = httpx.get(url, headers=headers, timeout=30)
|
|
70
|
+
if resp.is_error:
|
|
71
|
+
raise RuntimeError(f"Get run failed ({resp.status_code}): {resp.text}")
|
|
72
|
+
|
|
73
|
+
data = resp.json()
|
|
74
|
+
return RunDetailResponse(
|
|
75
|
+
id=data["run_id"],
|
|
76
|
+
runnable_id=data["runnable_id"],
|
|
77
|
+
status=data["status"],
|
|
78
|
+
payload=data.get("inputs"),
|
|
79
|
+
context=data.get("context"),
|
|
80
|
+
result=data.get("outputs"),
|
|
81
|
+
error=data.get("error"),
|
|
82
|
+
created_at=data.get("created_at"),
|
|
83
|
+
completed_at=data.get("completed_at"),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def list(
|
|
87
|
+
self,
|
|
88
|
+
runnable_key: str | None = None,
|
|
89
|
+
limit: int = 100,
|
|
90
|
+
cursor: str | None = None,
|
|
91
|
+
) -> list[RunDetailResponse]:
|
|
92
|
+
"""List runs, optionally filtered by runnable.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
runnable_key: Optional runnable key to filter by.
|
|
96
|
+
limit: Maximum number of results to return.
|
|
97
|
+
cursor: Pagination cursor from previous response.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
List of RunDetailResponse objects.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
RuntimeError: If the list fails.
|
|
104
|
+
"""
|
|
105
|
+
url = f"{self._client.base_url}/v1/runs"
|
|
106
|
+
headers = self._client.auth_headers()
|
|
107
|
+
params: dict[str, Any] = {"limit": limit}
|
|
108
|
+
if cursor:
|
|
109
|
+
params["cursor"] = cursor
|
|
110
|
+
if runnable_key:
|
|
111
|
+
params["runnable_key"] = runnable_key
|
|
112
|
+
|
|
113
|
+
resp = httpx.get(url, headers=headers, params=params, timeout=30)
|
|
114
|
+
if resp.is_error:
|
|
115
|
+
raise RuntimeError(f"List runs failed ({resp.status_code}): {resp.text}")
|
|
116
|
+
|
|
117
|
+
data = resp.json()
|
|
118
|
+
runs = []
|
|
119
|
+
for item in data.get("runs", []):
|
|
120
|
+
runs.append(
|
|
121
|
+
RunDetailResponse(
|
|
122
|
+
id=item.get("run_id"),
|
|
123
|
+
runnable_id=item.get("runnable_id"),
|
|
124
|
+
status=item.get("status"),
|
|
125
|
+
payload=item.get("inputs"),
|
|
126
|
+
context=item.get("context"),
|
|
127
|
+
result=item.get("outputs"),
|
|
128
|
+
error=item.get("error"),
|
|
129
|
+
created_at=item.get("created_at"),
|
|
130
|
+
completed_at=item.get("completed_at"),
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
return runs
|
|
134
|
+
|
|
135
|
+
def cancel(self, run_id: str) -> RunDetailResponse:
|
|
136
|
+
"""Cancel a running run.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
run_id: ID of the run to cancel.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
RunDetailResponse with updated run details.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
NotImplementedError: Cancel endpoint not yet implemented in server.
|
|
146
|
+
"""
|
|
147
|
+
raise NotImplementedError("Cancel endpoint not yet implemented in server")
|
|
148
|
+
|
|
149
|
+
def wait(
|
|
150
|
+
self,
|
|
151
|
+
run_id: str,
|
|
152
|
+
timeout: float = 120.0,
|
|
153
|
+
poll_interval: float = 2.0,
|
|
154
|
+
) -> RunDetailResponse:
|
|
155
|
+
"""Poll a run until it reaches a terminal server status.
|
|
156
|
+
|
|
157
|
+
This matches the canonical example flow in ``scripts/run_runnable.py``:
|
|
158
|
+
trigger the run, then poll ``GET /v1/runs/{run_id}`` until the status is
|
|
159
|
+
``success``, ``failed``, or ``cancelled``.
|
|
160
|
+
"""
|
|
161
|
+
deadline = time.time() + timeout
|
|
162
|
+
while True:
|
|
163
|
+
run = self.get(run_id)
|
|
164
|
+
if run.status in _TERMINAL_STATUSES:
|
|
165
|
+
return run
|
|
166
|
+
if time.time() > deadline:
|
|
167
|
+
raise TimeoutError("Timed out waiting for run completion")
|
|
168
|
+
time.sleep(poll_interval)
|
|
169
|
+
|
|
170
|
+
def stream(self, run_id: str, deadline: float | None = None) -> Iterator[dict[str, Any]]:
|
|
171
|
+
"""Stream events from a run in real-time via SSE.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
run_id: ID of the run to stream events from.
|
|
175
|
+
deadline: Optional wall-clock time (from time.time()) after which streaming stops.
|
|
176
|
+
|
|
177
|
+
Yields:
|
|
178
|
+
Raw OpenAI Responses API payload dicts as received from the server.
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
RuntimeError: If the stream request fails.
|
|
182
|
+
TimeoutError: If the deadline is exceeded.
|
|
183
|
+
"""
|
|
184
|
+
url = f"{self._client.base_url}/v1/runs/{run_id}/stream"
|
|
185
|
+
headers = self._client.auth_headers(accept="text/event-stream")
|
|
186
|
+
|
|
187
|
+
with httpx.stream("GET", url, headers=headers, timeout=self._stream_timeout(deadline)) as resp:
|
|
188
|
+
if resp.is_error:
|
|
189
|
+
raise RuntimeError(f"Stream failed ({resp.status_code}): {resp.text}")
|
|
190
|
+
|
|
191
|
+
yield from self._consume_sse_stream(resp, deadline)
|
|
192
|
+
|
|
193
|
+
def _stream_timeout(self, deadline: float | None) -> httpx.Timeout:
|
|
194
|
+
"""Build an httpx timeout suitable for long-lived SSE streams.
|
|
195
|
+
|
|
196
|
+
When a wall-clock deadline is provided the read timeout is set to None
|
|
197
|
+
so the stream relies on the deadline checks inside
|
|
198
|
+
``_consume_sse_stream`` rather than fixed per-read timeouts.
|
|
199
|
+
"""
|
|
200
|
+
if deadline is None:
|
|
201
|
+
return httpx.Timeout(connect=30.0, read=None, write=30.0, pool=30.0)
|
|
202
|
+
remaining = max(deadline - time.time(), 1.0)
|
|
203
|
+
return httpx.Timeout(connect=min(remaining, 30.0), read=remaining, write=30.0, pool=30.0)
|
|
204
|
+
|
|
205
|
+
def _consume_sse_stream(
|
|
206
|
+
self,
|
|
207
|
+
resp: httpx.Response,
|
|
208
|
+
deadline: float | None,
|
|
209
|
+
) -> Iterator[dict[str, Any]]:
|
|
210
|
+
"""Consume an SSE stream with deadline-aware chunk reading."""
|
|
211
|
+
current_event_type: str = ""
|
|
212
|
+
remainder: bytes = b""
|
|
213
|
+
|
|
214
|
+
if deadline is not None and time.time() >= deadline:
|
|
215
|
+
raise TimeoutError("Stream deadline exceeded")
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
chunks = iter(resp.iter_bytes())
|
|
219
|
+
while True:
|
|
220
|
+
if deadline is not None and time.time() >= deadline:
|
|
221
|
+
raise TimeoutError("Stream deadline exceeded")
|
|
222
|
+
try:
|
|
223
|
+
chunk = next(chunks)
|
|
224
|
+
except StopIteration:
|
|
225
|
+
break
|
|
226
|
+
except httpx.ReadTimeout as exc:
|
|
227
|
+
if deadline is not None:
|
|
228
|
+
if time.time() >= deadline:
|
|
229
|
+
raise TimeoutError("Stream deadline exceeded") from exc
|
|
230
|
+
continue
|
|
231
|
+
raise
|
|
232
|
+
if not chunk:
|
|
233
|
+
continue
|
|
234
|
+
full = remainder + chunk
|
|
235
|
+
if b"\n" in full:
|
|
236
|
+
last_nl = full.rfind(b"\n")
|
|
237
|
+
remainder = full[last_nl + 1 :]
|
|
238
|
+
ctx: dict[str, str] = {"event_type": current_event_type}
|
|
239
|
+
yield from self._parse_sse_lines(full[: last_nl + 1], ctx, deadline)
|
|
240
|
+
current_event_type = ctx["event_type"]
|
|
241
|
+
else:
|
|
242
|
+
remainder = full
|
|
243
|
+
except httpx.ReadTimeout as exc:
|
|
244
|
+
if deadline is not None:
|
|
245
|
+
raise TimeoutError("Stream deadline exceeded") from exc
|
|
246
|
+
raise
|
|
247
|
+
|
|
248
|
+
def _parse_sse_lines(
|
|
249
|
+
self,
|
|
250
|
+
data: bytes,
|
|
251
|
+
ctx: dict[str, str],
|
|
252
|
+
deadline: float | None,
|
|
253
|
+
) -> Iterator[dict[str, Any]]:
|
|
254
|
+
"""Parse raw SSE lines and yield raw OpenAI payload dicts."""
|
|
255
|
+
while b"\n" in data:
|
|
256
|
+
if deadline is not None and time.time() >= deadline:
|
|
257
|
+
raise TimeoutError("Stream deadline exceeded")
|
|
258
|
+
line_bytes, data = data.split(b"\n", 1)
|
|
259
|
+
line = line_bytes.decode("utf-8", errors="replace").strip()
|
|
260
|
+
if not line:
|
|
261
|
+
continue
|
|
262
|
+
if line.startswith("event: "):
|
|
263
|
+
ctx["event_type"] = line[7:].strip()
|
|
264
|
+
continue
|
|
265
|
+
if line.startswith("data: "):
|
|
266
|
+
data_str = line[6:]
|
|
267
|
+
try:
|
|
268
|
+
parsed = json.loads(data_str)
|
|
269
|
+
if isinstance(parsed, dict) and parsed.get("type"):
|
|
270
|
+
yield parsed
|
|
271
|
+
except json.JSONDecodeError:
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
def events(
|
|
275
|
+
self,
|
|
276
|
+
run_id: str,
|
|
277
|
+
event_type: str | None = None,
|
|
278
|
+
limit: int = 100,
|
|
279
|
+
) -> list[dict[str, Any]]:
|
|
280
|
+
"""Get historical events for a run from the database.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
run_id: ID of the run to get events for.
|
|
284
|
+
event_type: Optional event type to filter by.
|
|
285
|
+
limit: Maximum number of events to return.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
List of raw event dicts as returned by the server.
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
RuntimeError: If the request fails.
|
|
292
|
+
"""
|
|
293
|
+
url = f"{self._client.base_url}/v1/runs/{run_id}/events"
|
|
294
|
+
headers = self._client.auth_headers()
|
|
295
|
+
params: dict[str, Any] = {"limit": limit}
|
|
296
|
+
if event_type is not None:
|
|
297
|
+
params["event_type"] = event_type
|
|
298
|
+
|
|
299
|
+
resp = httpx.get(url, headers=headers, params=params, timeout=30)
|
|
300
|
+
if resp.is_error:
|
|
301
|
+
raise RuntimeError(f"Get events failed ({resp.status_code}): {resp.text}")
|
|
302
|
+
|
|
303
|
+
data = resp.json()
|
|
304
|
+
return data.get("events", [])
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Public type exports for the GL Runner SDK.
|
|
2
|
+
|
|
3
|
+
Authors:
|
|
4
|
+
Christopher Sebastian (christophersebastian0205@gmail.com)
|
|
5
|
+
|
|
6
|
+
References:
|
|
7
|
+
specs/GL1_core_service/lite_sdk/spec.md
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .runnables import RunnableRecord
|
|
11
|
+
from .runs import RunDetailResponse, RunRequest, RunResponse
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"RunnableRecord",
|
|
15
|
+
"RunDetailResponse",
|
|
16
|
+
"RunRequest",
|
|
17
|
+
"RunResponse",
|
|
18
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Runnable entity types.
|
|
2
|
+
|
|
3
|
+
Authors:
|
|
4
|
+
Christopher Sebastian (christophersebastian0205@gmail.com)
|
|
5
|
+
|
|
6
|
+
References:
|
|
7
|
+
specs/GL1_core_service/lite_sdk/spec.md
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class RunnableRecord:
|
|
18
|
+
"""Runnable entity record returned by server APIs."""
|
|
19
|
+
|
|
20
|
+
id: str
|
|
21
|
+
key: str
|
|
22
|
+
entrypoint: str
|
|
23
|
+
kind: str = "code"
|
|
24
|
+
version: str | None = None
|
|
25
|
+
status: str | None = None
|
|
26
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
27
|
+
config: dict[str, Any] = field(default_factory=dict)
|
|
28
|
+
created_at: str | None = None
|
|
29
|
+
updated_at: str | None = None
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Run request and response types.
|
|
2
|
+
|
|
3
|
+
Authors:
|
|
4
|
+
Christopher Sebastian (christophersebastian0205@gmail.com)
|
|
5
|
+
|
|
6
|
+
References:
|
|
7
|
+
specs/GL1_core_service/lite_sdk/spec.md
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class RunRequest:
|
|
18
|
+
"""Request type for triggering a run."""
|
|
19
|
+
|
|
20
|
+
payload: dict[str, Any] | None = None
|
|
21
|
+
context: dict[str, Any] | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class RunResponse:
|
|
26
|
+
"""Response from triggering a run (POST /v1/runnables/{id}:run).
|
|
27
|
+
|
|
28
|
+
Maps the server trigger RunResponse fields:
|
|
29
|
+
requested_payload -> RunResponse.payload
|
|
30
|
+
context_metadata -> RunResponse.context
|
|
31
|
+
error_code -> RunResponse.error
|
|
32
|
+
result is always None (run just started)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
id: str
|
|
36
|
+
runnable_id: str
|
|
37
|
+
status: str
|
|
38
|
+
payload: dict[str, Any] | None = None
|
|
39
|
+
context: dict[str, Any] | None = None
|
|
40
|
+
result: dict[str, Any] | None = None
|
|
41
|
+
error: str | None = None
|
|
42
|
+
created_at: str | None = None
|
|
43
|
+
completed_at: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class RunDetailResponse:
|
|
48
|
+
"""Response from querying a run (GET /v1/runs/{run_id} or /v1/runs).
|
|
49
|
+
|
|
50
|
+
Maps the server RunDetailResponse / RunListItem fields:
|
|
51
|
+
inputs -> RunDetailResponse.payload
|
|
52
|
+
outputs -> RunDetailResponse.result
|
|
53
|
+
error -> RunDetailResponse.error
|
|
54
|
+
|
|
55
|
+
Note: context is not yet returned by the detail endpoint; the field
|
|
56
|
+
is kept forward-looking for a planned server-side addition.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
id: str
|
|
60
|
+
runnable_id: str
|
|
61
|
+
status: str
|
|
62
|
+
payload: dict[str, Any] | None = None
|
|
63
|
+
context: dict[str, Any] | None = None
|
|
64
|
+
result: dict[str, Any] | None = None
|
|
65
|
+
error: str | None = None
|
|
66
|
+
created_at: str | None = None
|
|
67
|
+
completed_at: str | None = None
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gl-runner-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: GL Runner Python SDK
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: httpx>=0.27
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: coverage>=7.11.0; extra == 'dev'
|
|
9
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
10
|
+
Requires-Dist: ruff>=0.14.0; extra == 'dev'
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
gl_runner_sdk/__init__.py,sha256=v4g1An-A_UADdb1w128urMEYQD3swZ3H2oyu5d4ccIU,581
|
|
2
|
+
gl_runner_sdk/client/__init__.py,sha256=WvR2fz_4ZCLj7KPPUzAhuAthlqs5RTBvHKjbkdISNsY,226
|
|
3
|
+
gl_runner_sdk/client/clients.py,sha256=OXTI-kwj_9SMvpgv5e7MNGzSb1dMgb6ObJK5TsUoGOI,2330
|
|
4
|
+
gl_runner_sdk/runnables/__init__.py,sha256=psqWsKlhN_ZxoqV7cD7XfdDZ7yb7z8pRBD3u07GQRn4,268
|
|
5
|
+
gl_runner_sdk/runnables/runnables.py,sha256=SxvehojJslacfb4_cWrPVEkVcmaftmBT1KO4MUhV3zM,22477
|
|
6
|
+
gl_runner_sdk/runs/__init__.py,sha256=DEd3s7pW2XOxrygleb0aGD8q8yOjPYE1_wzRVy2E_rI,226
|
|
7
|
+
gl_runner_sdk/runs/runs.py,sha256=KcyiO6cW7L-yLDIIfH2MVwAQBt6Fc_rIfcqluqdnN9I,10757
|
|
8
|
+
gl_runner_sdk/types/__init__.py,sha256=YBdDk7UNw23nJkXFJ1Z-g4B-9tJ2gfIRPmDVkyeRWJA,379
|
|
9
|
+
gl_runner_sdk/types/runnables.py,sha256=RIY141y2EKUUfVTffsANnlrrXiPQc1OXN7blqUU2BSQ,668
|
|
10
|
+
gl_runner_sdk/types/runs.py,sha256=0wv_cO5VzSmStZpQCZluBsbtzj1iIpmopjS9nd6Zx4U,1776
|
|
11
|
+
gl_runner_sdk-0.1.0.dist-info/METADATA,sha256=2nLsJSq-rwUNKObcJBjCeE4fDiUU5YzSMKFYzk2Qz3E,295
|
|
12
|
+
gl_runner_sdk-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
13
|
+
gl_runner_sdk-0.1.0.dist-info/RECORD,,
|