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.
@@ -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,12 @@
1
+ """Client 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 .clients import Client
11
+
12
+ __all__ = ["Client"]
@@ -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,12 @@
1
+ """Run 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 .runs import Runs
11
+
12
+ __all__ = ["Runs"]
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any