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.
- blaxel/__init__.py +3 -5
- blaxel/core/__init__.py +9 -1
- blaxel/core/client/api/{privateclusters/create_private_cluster.py → images/cleanup_images.py} +31 -25
- blaxel/core/client/models/__init__.py +2 -4
- blaxel/core/client/models/agent.py +1 -0
- blaxel/core/client/models/agent_spec.py +1 -24
- blaxel/core/client/models/billable_time_metric.py +1 -0
- blaxel/core/{sandbox/client/models/find_match.py → client/models/cleanup_images_response_200.py} +19 -19
- blaxel/core/client/models/configuration.py +1 -0
- blaxel/core/client/models/core_event.py +9 -0
- blaxel/core/client/models/core_spec.py +1 -24
- blaxel/core/client/models/core_spec_configurations.py +1 -0
- blaxel/core/client/models/create_job_execution_request.py +1 -0
- blaxel/core/client/models/create_job_execution_response.py +1 -0
- blaxel/core/client/models/custom_domain.py +1 -0
- blaxel/core/client/models/custom_domain_metadata.py +1 -0
- blaxel/core/client/models/custom_domain_spec.py +1 -0
- blaxel/core/client/models/delete_volume_template_version_response_200.py +1 -0
- blaxel/core/client/models/entrypoint.py +1 -0
- blaxel/core/client/models/form.py +1 -0
- blaxel/core/client/models/function.py +1 -0
- blaxel/core/client/models/function_spec.py +1 -24
- blaxel/core/client/models/image.py +1 -0
- blaxel/core/client/models/image_spec.py +1 -0
- blaxel/core/client/models/integration.py +1 -0
- blaxel/core/client/models/integration_connection.py +1 -0
- blaxel/core/client/models/integration_connection_spec.py +1 -0
- blaxel/core/client/models/integration_endpoint.py +1 -0
- blaxel/core/client/models/integration_endpoints.py +2 -0
- blaxel/core/client/models/job.py +1 -0
- blaxel/core/client/models/job_execution.py +1 -0
- blaxel/core/client/models/job_execution_spec.py +1 -0
- blaxel/core/client/models/job_execution_task.py +1 -0
- blaxel/core/client/models/job_metrics.py +1 -0
- blaxel/core/client/models/job_spec.py +1 -24
- blaxel/core/client/models/jobs_network_chart.py +1 -0
- blaxel/core/client/models/jobs_success_failed_chart.py +1 -0
- blaxel/core/client/models/latency_metric.py +1 -0
- blaxel/core/client/models/location_response.py +1 -0
- blaxel/core/client/models/mcp_definition.py +1 -0
- blaxel/core/client/models/metadata.py +1 -0
- blaxel/core/client/models/metrics.py +34 -0
- blaxel/core/client/models/model.py +1 -0
- blaxel/core/client/models/model_spec.py +1 -24
- blaxel/core/client/models/pending_invitation_accept.py +1 -0
- blaxel/core/client/models/pending_invitation_render.py +1 -0
- blaxel/core/client/models/policy.py +1 -0
- blaxel/core/client/models/policy_spec.py +1 -0
- blaxel/core/client/models/preview.py +1 -0
- blaxel/core/client/models/preview_spec.py +1 -0
- blaxel/core/client/models/preview_token.py +1 -0
- blaxel/core/client/models/public_ips.py +1 -0
- blaxel/core/client/models/request_duration_over_time_metrics.py +1 -0
- blaxel/core/client/models/request_total_by_origin_metric.py +1 -0
- blaxel/core/client/models/request_total_metric.py +1 -0
- blaxel/core/client/models/resource_metrics.py +1 -0
- blaxel/core/client/models/revision_configuration.py +9 -0
- blaxel/core/client/models/runtime.py +1 -0
- blaxel/core/client/models/sandbox.py +1 -0
- blaxel/core/client/models/sandbox_definition.py +1 -0
- blaxel/core/client/models/sandbox_lifecycle.py +1 -0
- blaxel/core/client/models/sandbox_spec.py +1 -24
- blaxel/core/client/models/serverless_config.py +1 -0
- blaxel/core/client/models/start_sandbox.py +1 -0
- blaxel/core/client/models/stop_sandbox.py +1 -0
- blaxel/core/client/models/store_agent.py +1 -0
- blaxel/core/client/models/store_configuration.py +1 -0
- blaxel/core/client/models/template.py +1 -0
- blaxel/core/client/models/time_to_first_token_over_time_metrics.py +1 -0
- blaxel/core/client/models/token_rate_metrics.py +1 -0
- blaxel/core/client/models/trigger.py +10 -0
- blaxel/core/client/models/trigger_configuration.py +28 -0
- blaxel/core/client/models/volume.py +1 -0
- blaxel/core/client/models/volume_template.py +1 -0
- blaxel/core/client/models/websocket_channel.py +9 -0
- blaxel/core/client/models/workspace.py +1 -0
- blaxel/core/client/response_interceptor.py +0 -1
- blaxel/core/common/__init__.py +11 -2
- blaxel/core/common/autoload.py +1 -85
- blaxel/core/common/settings.py +56 -16
- blaxel/core/common/webhook.py +187 -0
- blaxel/core/jobs/__init__.py +352 -3
- blaxel/core/sandbox/client/models/__init__.py +0 -16
- blaxel/core/sandbox/default/action.py +10 -27
- blaxel/core/sandbox/default/filesystem.py +47 -185
- blaxel/core/sandbox/default/interpreter.py +55 -62
- blaxel/core/sandbox/default/process.py +46 -66
- {blaxel-0.2.26rc119.dist-info → blaxel-0.2.27.dist-info}/METADATA +2 -3
- {blaxel-0.2.26rc119.dist-info → blaxel-0.2.27.dist-info}/RECORD +91 -114
- blaxel/core/client/api/privateclusters/__init__.py +0 -0
- blaxel/core/client/api/privateclusters/delete_private_cluster.py +0 -152
- blaxel/core/client/api/privateclusters/get_private_cluster.py +0 -155
- blaxel/core/client/api/privateclusters/get_private_cluster_health.py +0 -97
- blaxel/core/client/api/privateclusters/list_private_clusters.py +0 -136
- blaxel/core/client/api/privateclusters/update_private_cluster.py +0 -152
- blaxel/core/client/api/privateclusters/update_private_cluster_health.py +0 -97
- blaxel/core/client/models/model_private_cluster.py +0 -79
- blaxel/core/client/models/private_cluster.py +0 -183
- blaxel/core/sandbox/client/api/filesystem/delete_filesystem_tree_path.py +0 -188
- blaxel/core/sandbox/client/api/filesystem/get_filesystem_content_search_path.py +0 -265
- blaxel/core/sandbox/client/api/filesystem/get_filesystem_find_path.py +0 -248
- blaxel/core/sandbox/client/api/filesystem/get_filesystem_search_path.py +0 -237
- blaxel/core/sandbox/client/api/filesystem/get_filesystem_tree_path.py +0 -197
- blaxel/core/sandbox/client/api/filesystem/put_filesystem_tree_path.py +0 -223
- blaxel/core/sandbox/client/api/websocket/__init__.py +0 -0
- blaxel/core/sandbox/client/api/websocket/get_ws.py +0 -81
- blaxel/core/sandbox/client/models/content_search_match.py +0 -98
- blaxel/core/sandbox/client/models/content_search_response.py +0 -97
- blaxel/core/sandbox/client/models/find_response.py +0 -88
- blaxel/core/sandbox/client/models/fuzzy_search_match.py +0 -78
- blaxel/core/sandbox/client/models/fuzzy_search_response.py +0 -88
- blaxel/core/sandbox/client/models/tree_request.py +0 -76
- blaxel/core/sandbox/client/models/tree_request_files.py +0 -49
- {blaxel-0.2.26rc119.dist-info → blaxel-0.2.27.dist-info}/WHEEL +0 -0
- {blaxel-0.2.26rc119.dist-info → blaxel-0.2.27.dist-info}/licenses/LICENSE +0 -0
blaxel/core/common/settings.py
CHANGED
|
@@ -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
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
+
|
blaxel/core/jobs/__init__.py
CHANGED
|
@@ -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
|
|