blaxel 0.2.26rc119__py3-none-any.whl → 0.2.27__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.
Files changed (115) hide show
  1. blaxel/__init__.py +3 -5
  2. blaxel/core/__init__.py +9 -1
  3. blaxel/core/client/api/{privateclusters/create_private_cluster.py → images/cleanup_images.py} +31 -25
  4. blaxel/core/client/models/__init__.py +2 -4
  5. blaxel/core/client/models/agent.py +1 -0
  6. blaxel/core/client/models/agent_spec.py +1 -24
  7. blaxel/core/client/models/billable_time_metric.py +1 -0
  8. blaxel/core/{sandbox/client/models/find_match.py → client/models/cleanup_images_response_200.py} +19 -19
  9. blaxel/core/client/models/configuration.py +1 -0
  10. blaxel/core/client/models/core_event.py +9 -0
  11. blaxel/core/client/models/core_spec.py +1 -24
  12. blaxel/core/client/models/core_spec_configurations.py +1 -0
  13. blaxel/core/client/models/create_job_execution_request.py +1 -0
  14. blaxel/core/client/models/create_job_execution_response.py +1 -0
  15. blaxel/core/client/models/custom_domain.py +1 -0
  16. blaxel/core/client/models/custom_domain_metadata.py +1 -0
  17. blaxel/core/client/models/custom_domain_spec.py +1 -0
  18. blaxel/core/client/models/delete_volume_template_version_response_200.py +1 -0
  19. blaxel/core/client/models/entrypoint.py +1 -0
  20. blaxel/core/client/models/form.py +1 -0
  21. blaxel/core/client/models/function.py +1 -0
  22. blaxel/core/client/models/function_spec.py +1 -24
  23. blaxel/core/client/models/image.py +1 -0
  24. blaxel/core/client/models/image_spec.py +1 -0
  25. blaxel/core/client/models/integration.py +1 -0
  26. blaxel/core/client/models/integration_connection.py +1 -0
  27. blaxel/core/client/models/integration_connection_spec.py +1 -0
  28. blaxel/core/client/models/integration_endpoint.py +1 -0
  29. blaxel/core/client/models/integration_endpoints.py +2 -0
  30. blaxel/core/client/models/job.py +1 -0
  31. blaxel/core/client/models/job_execution.py +1 -0
  32. blaxel/core/client/models/job_execution_spec.py +1 -0
  33. blaxel/core/client/models/job_execution_task.py +1 -0
  34. blaxel/core/client/models/job_metrics.py +1 -0
  35. blaxel/core/client/models/job_spec.py +1 -24
  36. blaxel/core/client/models/jobs_network_chart.py +1 -0
  37. blaxel/core/client/models/jobs_success_failed_chart.py +1 -0
  38. blaxel/core/client/models/latency_metric.py +1 -0
  39. blaxel/core/client/models/location_response.py +1 -0
  40. blaxel/core/client/models/mcp_definition.py +1 -0
  41. blaxel/core/client/models/metadata.py +1 -0
  42. blaxel/core/client/models/metrics.py +34 -0
  43. blaxel/core/client/models/model.py +1 -0
  44. blaxel/core/client/models/model_spec.py +1 -24
  45. blaxel/core/client/models/pending_invitation_accept.py +1 -0
  46. blaxel/core/client/models/pending_invitation_render.py +1 -0
  47. blaxel/core/client/models/policy.py +1 -0
  48. blaxel/core/client/models/policy_spec.py +1 -0
  49. blaxel/core/client/models/preview.py +1 -0
  50. blaxel/core/client/models/preview_spec.py +1 -0
  51. blaxel/core/client/models/preview_token.py +1 -0
  52. blaxel/core/client/models/public_ips.py +1 -0
  53. blaxel/core/client/models/request_duration_over_time_metrics.py +1 -0
  54. blaxel/core/client/models/request_total_by_origin_metric.py +1 -0
  55. blaxel/core/client/models/request_total_metric.py +1 -0
  56. blaxel/core/client/models/resource_metrics.py +1 -0
  57. blaxel/core/client/models/revision_configuration.py +9 -0
  58. blaxel/core/client/models/runtime.py +1 -0
  59. blaxel/core/client/models/sandbox.py +1 -0
  60. blaxel/core/client/models/sandbox_definition.py +1 -0
  61. blaxel/core/client/models/sandbox_lifecycle.py +1 -0
  62. blaxel/core/client/models/sandbox_spec.py +1 -24
  63. blaxel/core/client/models/serverless_config.py +1 -0
  64. blaxel/core/client/models/start_sandbox.py +1 -0
  65. blaxel/core/client/models/stop_sandbox.py +1 -0
  66. blaxel/core/client/models/store_agent.py +1 -0
  67. blaxel/core/client/models/store_configuration.py +1 -0
  68. blaxel/core/client/models/template.py +1 -0
  69. blaxel/core/client/models/time_to_first_token_over_time_metrics.py +1 -0
  70. blaxel/core/client/models/token_rate_metrics.py +1 -0
  71. blaxel/core/client/models/trigger.py +10 -0
  72. blaxel/core/client/models/trigger_configuration.py +28 -0
  73. blaxel/core/client/models/volume.py +1 -0
  74. blaxel/core/client/models/volume_template.py +1 -0
  75. blaxel/core/client/models/websocket_channel.py +9 -0
  76. blaxel/core/client/models/workspace.py +1 -0
  77. blaxel/core/client/response_interceptor.py +0 -1
  78. blaxel/core/common/__init__.py +11 -2
  79. blaxel/core/common/autoload.py +1 -85
  80. blaxel/core/common/settings.py +56 -16
  81. blaxel/core/common/webhook.py +187 -0
  82. blaxel/core/jobs/__init__.py +352 -3
  83. blaxel/core/sandbox/client/models/__init__.py +0 -16
  84. blaxel/core/sandbox/default/action.py +10 -27
  85. blaxel/core/sandbox/default/filesystem.py +47 -185
  86. blaxel/core/sandbox/default/interpreter.py +55 -62
  87. blaxel/core/sandbox/default/process.py +46 -66
  88. {blaxel-0.2.26rc119.dist-info → blaxel-0.2.27.dist-info}/METADATA +2 -3
  89. {blaxel-0.2.26rc119.dist-info → blaxel-0.2.27.dist-info}/RECORD +91 -114
  90. blaxel/core/client/api/privateclusters/__init__.py +0 -0
  91. blaxel/core/client/api/privateclusters/delete_private_cluster.py +0 -152
  92. blaxel/core/client/api/privateclusters/get_private_cluster.py +0 -155
  93. blaxel/core/client/api/privateclusters/get_private_cluster_health.py +0 -97
  94. blaxel/core/client/api/privateclusters/list_private_clusters.py +0 -136
  95. blaxel/core/client/api/privateclusters/update_private_cluster.py +0 -152
  96. blaxel/core/client/api/privateclusters/update_private_cluster_health.py +0 -97
  97. blaxel/core/client/models/model_private_cluster.py +0 -79
  98. blaxel/core/client/models/private_cluster.py +0 -183
  99. blaxel/core/sandbox/client/api/filesystem/delete_filesystem_tree_path.py +0 -188
  100. blaxel/core/sandbox/client/api/filesystem/get_filesystem_content_search_path.py +0 -265
  101. blaxel/core/sandbox/client/api/filesystem/get_filesystem_find_path.py +0 -248
  102. blaxel/core/sandbox/client/api/filesystem/get_filesystem_search_path.py +0 -237
  103. blaxel/core/sandbox/client/api/filesystem/get_filesystem_tree_path.py +0 -197
  104. blaxel/core/sandbox/client/api/filesystem/put_filesystem_tree_path.py +0 -223
  105. blaxel/core/sandbox/client/api/websocket/__init__.py +0 -0
  106. blaxel/core/sandbox/client/api/websocket/get_ws.py +0 -81
  107. blaxel/core/sandbox/client/models/content_search_match.py +0 -98
  108. blaxel/core/sandbox/client/models/content_search_response.py +0 -97
  109. blaxel/core/sandbox/client/models/find_response.py +0 -88
  110. blaxel/core/sandbox/client/models/fuzzy_search_match.py +0 -78
  111. blaxel/core/sandbox/client/models/fuzzy_search_response.py +0 -88
  112. blaxel/core/sandbox/client/models/tree_request.py +0 -76
  113. blaxel/core/sandbox/client/models/tree_request_files.py +0 -49
  114. {blaxel-0.2.26rc119.dist-info → blaxel-0.2.27.dist-info}/WHEEL +0 -0
  115. {blaxel-0.2.26rc119.dist-info → blaxel-0.2.27.dist-info}/licenses/LICENSE +0 -0
@@ -1,11 +1,33 @@
1
1
  import os
2
2
  import platform
3
+ from pathlib import Path
3
4
  from typing import Dict
4
5
 
6
+ import tomli
7
+
5
8
  from ..authentication import BlaxelAuth, auth
6
9
  from .logger import init_logger
7
10
 
8
11
 
12
+ def _get_package_version() -> str:
13
+ """Get the package version from pyproject.toml."""
14
+ try:
15
+ # Navigate up from this file to the project root
16
+ current_file = Path(__file__)
17
+ project_root = current_file.parent.parent.parent.parent.parent
18
+ pyproject_path = project_root / "pyproject.toml"
19
+
20
+ if pyproject_path.exists():
21
+ with open(pyproject_path, "rb") as f:
22
+ pyproject_data = tomli.load(f)
23
+ return pyproject_data.get("project", {}).get("version", "unknown")
24
+ else:
25
+ return "unknown"
26
+ except Exception as e:
27
+ print(f"Warning: Failed to read package version: {e}")
28
+ return "unknown"
29
+
30
+
9
31
  def _get_os_arch() -> str:
10
32
  """Get OS and architecture information."""
11
33
  try:
@@ -34,6 +56,33 @@ def _get_os_arch() -> str:
34
56
  return "unknown/unknown"
35
57
 
36
58
 
59
+ def _get_commit_hash() -> str:
60
+ """Get commit hash from pyproject.toml."""
61
+ try:
62
+ # Try to read from pyproject.toml (build-time injection)
63
+ current_file = Path(__file__)
64
+ project_root = current_file.parent.parent.parent.parent.parent
65
+ pyproject_path = project_root / "pyproject.toml"
66
+
67
+ if pyproject_path.exists():
68
+ with open(pyproject_path, "rb") as f:
69
+ pyproject_data = tomli.load(f)
70
+ # Check multiple possible locations for commit
71
+ commit = None
72
+ if "tool" in pyproject_data and "blaxel" in pyproject_data["tool"]:
73
+ commit = pyproject_data["tool"]["blaxel"].get("commit")
74
+ if not commit and "project" in pyproject_data:
75
+ commit = pyproject_data["project"].get("commit")
76
+ if not commit and "build" in pyproject_data:
77
+ commit = pyproject_data["build"].get("commit")
78
+
79
+ if commit:
80
+ return commit[:7] if len(commit) > 7 else commit
81
+ except Exception:
82
+ pass
83
+
84
+ return "unknown"
85
+
37
86
  class Settings:
38
87
  auth: BlaxelAuth
39
88
 
@@ -41,6 +90,7 @@ class Settings:
41
90
  init_logger(self.log_level)
42
91
  self.auth = auth(self.env, self.base_url)
43
92
  self._headers = None
93
+ self._version = None
44
94
 
45
95
  @property
46
96
  def env(self) -> str:
@@ -67,30 +117,20 @@ class Settings:
67
117
  return "https://run.blaxel.dev"
68
118
 
69
119
 
70
- @property
71
- def sentry_dsn(self) -> str:
72
- """Get the Sentry DSN (injected at build time)."""
73
- import blaxel
74
- return blaxel.__sentry_dsn__
75
-
76
120
  @property
77
121
  def version(self) -> str:
78
- """Get the package version (injected at build time)."""
79
- import blaxel
80
- return blaxel.__version__ or "unknown"
81
-
82
- @property
83
- def commit(self) -> str:
84
- """Get the commit hash (injected at build time)."""
85
- import blaxel
86
- return blaxel.__commit__ or "unknown"
122
+ """Get the package version."""
123
+ if self._version is None:
124
+ self._version = _get_package_version()
125
+ return self._version
87
126
 
88
127
  @property
89
128
  def headers(self) -> Dict[str, str]:
90
129
  """Get the headers for API requests."""
91
130
  headers = self.auth.get_headers()
92
131
  os_arch = _get_os_arch()
93
- headers["User-Agent"] = f"blaxel/sdk/python/{self.version} ({os_arch}) blaxel/{self.commit}"
132
+ commit_hash = _get_commit_hash()
133
+ headers["User-Agent"] = f"blaxel/sdk/python/{self.version} ({os_arch}) blaxel/{commit_hash}"
94
134
  return headers
95
135
 
96
136
 
@@ -0,0 +1,187 @@
1
+ """Webhook signature verification for async-sidecar callbacks."""
2
+
3
+ import hashlib
4
+ import hmac
5
+ import time
6
+ from typing import Optional, Protocol
7
+
8
+
9
+ class RequestLike(Protocol):
10
+ """Protocol for request-like objects with body and headers."""
11
+
12
+ @property
13
+ def body(self) -> bytes:
14
+ """Raw request body as bytes."""
15
+ ...
16
+
17
+ @property
18
+ def headers(self) -> dict[str, str]:
19
+ """Request headers as dictionary."""
20
+ ...
21
+
22
+
23
+ class AsyncSidecarCallback:
24
+ """Callback payload from async-sidecar."""
25
+
26
+ def __init__(
27
+ self,
28
+ status_code: int,
29
+ response_body: str,
30
+ response_length: int,
31
+ timestamp: int,
32
+ ):
33
+ self.status_code = status_code
34
+ self.response_body = response_body
35
+ self.response_length = response_length
36
+ self.timestamp = timestamp
37
+
38
+
39
+ def verify_webhook_signature(
40
+ body: bytes | str,
41
+ signature: str,
42
+ secret: str,
43
+ timestamp: Optional[str] = None,
44
+ max_age: int = 300,
45
+ ) -> bool:
46
+ """
47
+ Verify the HMAC-SHA256 signature of a webhook callback from async-sidecar.
48
+
49
+ Args:
50
+ body: The raw request body (bytes or string)
51
+ signature: The X-Blaxel-Signature header value (format: "sha256=<hex_digest>")
52
+ secret: The secret key used to sign the webhook (same as CALLBACK_SECRET in async-sidecar)
53
+ timestamp: Optional X-Blaxel-Timestamp header value for replay attack prevention
54
+ max_age: Maximum age of the webhook in seconds (default: 300 = 5 minutes)
55
+
56
+ Returns:
57
+ True if the signature is valid, False otherwise
58
+
59
+ Example:
60
+ ```python
61
+ from blaxel.core import verify_webhook_signature
62
+ from flask import Flask, request
63
+
64
+ app = Flask(__name__)
65
+
66
+ @app.route('/webhook', methods=['POST'])
67
+ def webhook():
68
+ is_valid = verify_webhook_signature(
69
+ body=request.get_data(),
70
+ signature=request.headers.get('X-Blaxel-Signature', ''),
71
+ secret='your-callback-secret'
72
+ )
73
+
74
+ if not is_valid:
75
+ return {'error': 'Invalid signature'}, 401
76
+
77
+ data = request.json
78
+ # Process callback...
79
+ return {'received': True}
80
+ ```
81
+ """
82
+ if not body or not signature or not secret:
83
+ return False
84
+
85
+ try:
86
+ # Verify timestamp if provided (prevents replay attacks)
87
+ if timestamp:
88
+ request_time = int(timestamp)
89
+ current_time = int(time.time())
90
+ age = abs(current_time - request_time)
91
+
92
+ if age > max_age:
93
+ return False
94
+
95
+ # Convert body to bytes if string
96
+ body_bytes = body.encode("utf-8") if isinstance(body, str) else body
97
+
98
+ # Extract hex signature from "sha256=<hex>" format
99
+ expected_signature = signature.replace("sha256=", "")
100
+
101
+ # Compute HMAC-SHA256 signature
102
+ computed_signature = hmac.new(
103
+ secret.encode("utf-8"), body_bytes, hashlib.sha256
104
+ ).hexdigest()
105
+
106
+ # Timing-safe comparison to prevent timing attacks
107
+ return hmac.compare_digest(expected_signature, computed_signature)
108
+
109
+ except (ValueError, TypeError):
110
+ # Invalid signature format or other error
111
+ return False
112
+
113
+
114
+ def verify_webhook_from_request(
115
+ request: RequestLike,
116
+ secret: str,
117
+ max_age: int = 300,
118
+ ) -> bool:
119
+ """
120
+ Helper to verify webhook from a request object (Flask, FastAPI, etc.).
121
+
122
+ Args:
123
+ request: Request object with `body` and `headers` attributes
124
+ secret: The callback secret
125
+ max_age: Optional maximum age in seconds (default: 300)
126
+
127
+ Returns:
128
+ True if the signature is valid, False otherwise
129
+
130
+ Example with Flask:
131
+ ```python
132
+ from blaxel.core import verify_webhook_from_request
133
+ from flask import Flask, request
134
+
135
+ app = Flask(__name__)
136
+
137
+ @app.route('/webhook', methods=['POST'])
138
+ def webhook():
139
+ if not verify_webhook_from_request(request, 'your-callback-secret'):
140
+ return {'error': 'Invalid signature'}, 401
141
+
142
+ data = request.json
143
+ print(f"Received callback: {data}")
144
+ return {'received': True}
145
+ ```
146
+
147
+ Example with FastAPI:
148
+ ```python
149
+ from blaxel.core import verify_webhook_signature
150
+ from fastapi import FastAPI, Request, HTTPException
151
+
152
+ app = FastAPI()
153
+
154
+ @app.post('/webhook')
155
+ async def webhook(request: Request):
156
+ body = await request.body()
157
+ signature = request.headers.get('x-blaxel-signature', '')
158
+
159
+ if not verify_webhook_signature(body, signature, 'your-callback-secret'):
160
+ raise HTTPException(status_code=401, detail='Invalid signature')
161
+
162
+ data = await request.json()
163
+ return {'received': True}
164
+ ```
165
+ """
166
+ signature = request.headers.get("x-blaxel-signature", "")
167
+ timestamp = request.headers.get("x-blaxel-timestamp")
168
+
169
+ if not signature:
170
+ return False
171
+
172
+ return verify_webhook_signature(
173
+ body=request.body,
174
+ signature=signature,
175
+ secret=secret,
176
+ timestamp=timestamp,
177
+ max_age=max_age,
178
+ )
179
+
180
+
181
+ __all__ = [
182
+ "verify_webhook_signature",
183
+ "verify_webhook_from_request",
184
+ "AsyncSidecarCallback",
185
+ "RequestLike",
186
+ ]
187
+
@@ -2,12 +2,21 @@ import argparse
2
2
  import asyncio
3
3
  import os
4
4
  import sys
5
+ import time
5
6
  from logging import getLogger
6
- from typing import Any, Awaitable, Callable, Dict
7
+ from typing import Any, Awaitable, Callable, Dict, List
7
8
 
8
9
  import requests
9
10
 
10
11
  from ..client import client
12
+ from ..client.api.jobs import (
13
+ create_job_execution,
14
+ delete_job_execution,
15
+ get_job_execution,
16
+ list_job_executions,
17
+ )
18
+ from ..client.models.create_job_execution_request import CreateJobExecutionRequest
19
+ from ..client.models.job_execution import JobExecution
11
20
  from ..common.internal import get_forced_url, get_global_unique_hash
12
21
  from ..common.settings import settings
13
22
 
@@ -96,7 +105,7 @@ class BlJob:
96
105
 
97
106
  def call(self, url, input_data, headers: dict = {}, params: dict = {}):
98
107
  body = {"tasks": input_data}
99
-
108
+
100
109
  # Merge settings headers with provided headers
101
110
  merged_headers = {**settings.headers, "Content-Type": "application/json", **headers}
102
111
 
@@ -110,7 +119,7 @@ class BlJob:
110
119
  async def acall(self, url, input_data, headers: dict = {}, params: dict = {}):
111
120
  logger.debug(f"Job Calling: {self.name}")
112
121
  body = {"tasks": input_data}
113
-
122
+
114
123
  # Merge settings headers with provided headers
115
124
  merged_headers = {**settings.headers, "Content-Type": "application/json", **headers}
116
125
 
@@ -151,6 +160,346 @@ class BlJob:
151
160
  )
152
161
  return response.text
153
162
 
163
+ def create_execution(self, request: CreateJobExecutionRequest) -> str:
164
+ """
165
+ Create a new execution for this job and return the execution ID.
166
+
167
+ Args:
168
+ request: The job execution request containing tasks and optional execution ID
169
+
170
+ Returns:
171
+ str: The execution ID
172
+
173
+ Raises:
174
+ Exception: If no execution ID is returned or the request fails
175
+ """
176
+ logger.debug(f"Creating execution for job: {self.name}")
177
+
178
+ response = create_job_execution.sync_detailed(
179
+ job_id=self.name,
180
+ client=client,
181
+ body=request,
182
+ )
183
+
184
+ if response.status_code != 200:
185
+ raise Exception(f"Failed to create job execution: {response.status_code}")
186
+
187
+ # The API returns executionId at the root level
188
+ if response.parsed and hasattr(response.parsed, "to_dict"):
189
+ response_dict = response.parsed.to_dict()
190
+ else:
191
+ # Parse the raw response to get executionId
192
+ import json
193
+
194
+ response_dict = json.loads(response.content)
195
+
196
+ # Check for both camelCase (API) and snake_case (parsed) formats
197
+ execution_id = response_dict.get("execution_id") or response_dict.get("executionId")
198
+ if not execution_id:
199
+ raise Exception("No execution ID returned from create job execution")
200
+
201
+ logger.debug(f"Created execution: {execution_id}")
202
+ return execution_id
203
+
204
+ async def acreate_execution(self, request: CreateJobExecutionRequest) -> str:
205
+ """
206
+ Create a new execution for this job and return the execution ID (async).
207
+
208
+ Args:
209
+ request: The job execution request containing tasks and optional execution ID
210
+
211
+ Returns:
212
+ str: The execution ID
213
+
214
+ Raises:
215
+ Exception: If no execution ID is returned or the request fails
216
+ """
217
+ logger.debug(f"Creating execution for job: {self.name}")
218
+
219
+ response = await create_job_execution.asyncio_detailed(
220
+ job_id=self.name,
221
+ client=client,
222
+ body=request,
223
+ )
224
+
225
+ if response.status_code != 200:
226
+ raise Exception(f"Failed to create job execution: {response.status_code}")
227
+
228
+ # The API returns executionId at the root level
229
+ if response.parsed and hasattr(response.parsed, "to_dict"):
230
+ response_dict = response.parsed.to_dict()
231
+ else:
232
+ # Parse the raw response to get executionId
233
+ import json
234
+
235
+ response_dict = json.loads(response.content)
236
+
237
+ # Check for both camelCase (API) and snake_case (parsed) formats
238
+ execution_id = response_dict.get("execution_id") or response_dict.get("executionId")
239
+ if not execution_id:
240
+ raise Exception("No execution ID returned from create job execution")
241
+
242
+ logger.debug(f"Created execution: {execution_id}")
243
+ return execution_id
244
+
245
+ def get_execution(self, execution_id: str) -> JobExecution:
246
+ """
247
+ Get a specific execution by ID.
248
+
249
+ Args:
250
+ execution_id: The execution ID
251
+
252
+ Returns:
253
+ JobExecution: The execution object
254
+
255
+ Raises:
256
+ Exception: If the execution is not found or the request fails
257
+ """
258
+ logger.debug(f"Getting execution {execution_id} for job: {self.name}")
259
+
260
+ response = get_job_execution.sync_detailed(
261
+ job_id=self.name,
262
+ execution_id=execution_id,
263
+ client=client,
264
+ )
265
+
266
+ if response.status_code == 404:
267
+ raise Exception(f"Execution '{execution_id}' not found for job '{self.name}'")
268
+
269
+ if response.status_code != 200:
270
+ raise Exception(f"Failed to get job execution: {response.status_code}")
271
+
272
+ if not response.parsed:
273
+ raise Exception("No execution data returned")
274
+
275
+ return response.parsed
276
+
277
+ async def aget_execution(self, execution_id: str) -> JobExecution:
278
+ """
279
+ Get a specific execution by ID (async).
280
+
281
+ Args:
282
+ execution_id: The execution ID
283
+
284
+ Returns:
285
+ JobExecution: The execution object
286
+
287
+ Raises:
288
+ Exception: If the execution is not found or the request fails
289
+ """
290
+ logger.debug(f"Getting execution {execution_id} for job: {self.name}")
291
+
292
+ response = await get_job_execution.asyncio_detailed(
293
+ job_id=self.name,
294
+ execution_id=execution_id,
295
+ client=client,
296
+ )
297
+
298
+ if response.status_code == 404:
299
+ raise Exception(f"Execution '{execution_id}' not found for job '{self.name}'")
300
+
301
+ if response.status_code != 200:
302
+ raise Exception(f"Failed to get job execution: {response.status_code}")
303
+
304
+ if not response.parsed:
305
+ raise Exception("No execution data returned")
306
+
307
+ return response.parsed
308
+
309
+ def list_executions(self, limit: int = 20, offset: int = 0) -> List[JobExecution]:
310
+ """
311
+ List all executions for this job.
312
+
313
+ Args:
314
+ limit: Maximum number of executions to return
315
+ offset: Offset for pagination
316
+
317
+ Returns:
318
+ List[JobExecution]: List of execution objects
319
+ """
320
+ logger.debug(f"Listing executions for job: {self.name}")
321
+
322
+ response = list_job_executions.sync_detailed(
323
+ job_id=self.name,
324
+ client=client,
325
+ limit=limit,
326
+ offset=offset,
327
+ )
328
+
329
+ if response.status_code != 200:
330
+ raise Exception(f"Failed to list job executions: {response.status_code}")
331
+
332
+ return response.parsed or []
333
+
334
+ async def alist_executions(self, limit: int = 20, offset: int = 0) -> List[JobExecution]:
335
+ """
336
+ List all executions for this job (async).
337
+
338
+ Args:
339
+ limit: Maximum number of executions to return
340
+ offset: Offset for pagination
341
+
342
+ Returns:
343
+ List[JobExecution]: List of execution objects
344
+ """
345
+ logger.debug(f"Listing executions for job: {self.name}")
346
+
347
+ response = await list_job_executions.asyncio_detailed(
348
+ job_id=self.name,
349
+ client=client,
350
+ limit=limit,
351
+ offset=offset,
352
+ )
353
+
354
+ if response.status_code != 200:
355
+ raise Exception(f"Failed to list job executions: {response.status_code}")
356
+
357
+ return response.parsed or []
358
+
359
+ def get_execution_status(self, execution_id: str) -> str:
360
+ """
361
+ Get the status of a specific execution.
362
+
363
+ Args:
364
+ execution_id: The execution ID
365
+
366
+ Returns:
367
+ str: The execution status
368
+ """
369
+ execution = self.get_execution(execution_id)
370
+ return execution.status if execution.status else "UNKNOWN"
371
+
372
+ async def aget_execution_status(self, execution_id: str) -> str:
373
+ """
374
+ Get the status of a specific execution (async).
375
+
376
+ Args:
377
+ execution_id: The execution ID
378
+
379
+ Returns:
380
+ str: The execution status
381
+ """
382
+ execution = await self.aget_execution(execution_id)
383
+ return execution.status if execution.status else "UNKNOWN"
384
+
385
+ def cancel_execution(self, execution_id: str) -> None:
386
+ """
387
+ Cancel a specific execution.
388
+
389
+ Args:
390
+ execution_id: The execution ID
391
+
392
+ Raises:
393
+ Exception: If the cancellation fails
394
+ """
395
+ logger.debug(f"Cancelling execution {execution_id} for job: {self.name}")
396
+
397
+ response = delete_job_execution.sync_detailed(
398
+ job_id=self.name,
399
+ execution_id=execution_id,
400
+ client=client,
401
+ )
402
+
403
+ if response.status_code != 200:
404
+ raise Exception(f"Failed to cancel job execution: {response.status_code}")
405
+
406
+ async def acancel_execution(self, execution_id: str) -> None:
407
+ """
408
+ Cancel a specific execution (async).
409
+
410
+ Args:
411
+ execution_id: The execution ID
412
+
413
+ Raises:
414
+ Exception: If the cancellation fails
415
+ """
416
+ logger.debug(f"Cancelling execution {execution_id} for job: {self.name}")
417
+
418
+ response = await delete_job_execution.asyncio_detailed(
419
+ job_id=self.name,
420
+ execution_id=execution_id,
421
+ client=client,
422
+ )
423
+
424
+ if response.status_code != 200:
425
+ raise Exception(f"Failed to cancel job execution: {response.status_code}")
426
+
427
+ def wait_for_execution(
428
+ self,
429
+ execution_id: str,
430
+ max_wait: int = 360,
431
+ interval: int = 3,
432
+ ) -> JobExecution:
433
+ """
434
+ Wait for an execution to complete.
435
+
436
+ Args:
437
+ execution_id: The execution ID to wait for
438
+ max_wait: Maximum time to wait in seconds (default: 360 = 6 minutes)
439
+ interval: Polling interval in seconds (default: 3 seconds)
440
+
441
+ Returns:
442
+ JobExecution: The completed execution
443
+
444
+ Raises:
445
+ Exception: If the execution doesn't complete within max_wait seconds
446
+ """
447
+ logger.debug(f"Waiting for execution {execution_id} to complete (max {max_wait}s)")
448
+
449
+ start_time = time.time()
450
+
451
+ while time.time() - start_time < max_wait:
452
+ execution = self.get_execution(execution_id)
453
+ status = execution.status
454
+
455
+ # Terminal states (Kubernetes-style: succeeded, failed, cancelled)
456
+ if status in ["succeeded", "failed", "cancelled"]:
457
+ logger.debug(f"Execution {execution_id} finished with status: {status}")
458
+ return execution
459
+
460
+ # Wait before polling again
461
+ time.sleep(interval)
462
+
463
+ raise Exception(f"Execution {execution_id} did not complete within {max_wait}s")
464
+
465
+ async def await_for_execution(
466
+ self,
467
+ execution_id: str,
468
+ max_wait: int = 360,
469
+ interval: int = 3,
470
+ ) -> JobExecution:
471
+ """
472
+ Wait for an execution to complete (async).
473
+
474
+ Args:
475
+ execution_id: The execution ID to wait for
476
+ max_wait: Maximum time to wait in seconds (default: 360 = 6 minutes)
477
+ interval: Polling interval in seconds (default: 3 seconds)
478
+
479
+ Returns:
480
+ JobExecution: The completed execution
481
+
482
+ Raises:
483
+ Exception: If the execution doesn't complete within max_wait seconds
484
+ """
485
+ logger.debug(f"Waiting for execution {execution_id} to complete (max {max_wait}s)")
486
+
487
+ start_time = time.time()
488
+
489
+ while time.time() - start_time < max_wait:
490
+ execution = await self.aget_execution(execution_id)
491
+ status = execution.status
492
+
493
+ # Terminal states (Kubernetes-style: succeeded, failed, cancelled)
494
+ if status in ["succeeded", "failed", "cancelled"]:
495
+ logger.debug(f"Execution {execution_id} finished with status: {status}")
496
+ return execution
497
+
498
+ # Wait before polling again
499
+ await asyncio.sleep(interval)
500
+
501
+ raise Exception(f"Execution {execution_id} did not complete within {max_wait}s")
502
+
154
503
  def __str__(self):
155
504
  return f"Job {self.name}"
156
505