wherobots-python-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.
- wherobots/__init__.py +87 -0
- wherobots/__version__.py +5 -0
- wherobots/api/__init__.py +7 -0
- wherobots/api/base.py +256 -0
- wherobots/api/files.py +386 -0
- wherobots/api/runs.py +255 -0
- wherobots/client.py +640 -0
- wherobots/config.py +165 -0
- wherobots/enums.py +140 -0
- wherobots/exceptions.py +47 -0
- wherobots/models.py +1080 -0
- wherobots/py.typed +0 -0
- wherobots/utils/__init__.py +6 -0
- wherobots/utils/logger.py +34 -0
- wherobots/utils/validation.py +31 -0
- wherobots_python_sdk-0.1.0.dist-info/METADATA +580 -0
- wherobots_python_sdk-0.1.0.dist-info/RECORD +20 -0
- wherobots_python_sdk-0.1.0.dist-info/WHEEL +5 -0
- wherobots_python_sdk-0.1.0.dist-info/licenses/LICENSE +191 -0
- wherobots_python_sdk-0.1.0.dist-info/top_level.txt +1 -0
wherobots/api/files.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Files API — upload scripts via presigned URLs.
|
|
2
|
+
|
|
3
|
+
Wraps the ``/organization``, ``/files/upload-url``, and
|
|
4
|
+
``/storage/{id}/file-upload-url/{path}`` endpoints to enable script
|
|
5
|
+
upload using only a Wherobots API key (no AWS credentials required).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
from wherobots.api.base import BaseClient
|
|
17
|
+
from wherobots.config import WherobotsConfig
|
|
18
|
+
from wherobots.exceptions import WherobotsAPIError, WherobotsValidationError
|
|
19
|
+
from wherobots.models import StorageIntegration
|
|
20
|
+
from wherobots.utils.logger import get_logger
|
|
21
|
+
|
|
22
|
+
logger = get_logger("api.files")
|
|
23
|
+
|
|
24
|
+
#: Maximum upload file size: 500 MB.
|
|
25
|
+
MAX_UPLOAD_FILE_SIZE_BYTES: int = 500 * 1024 * 1024
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FilesAPI:
|
|
29
|
+
"""Upload files to Wherobots managed storage via presigned URLs.
|
|
30
|
+
|
|
31
|
+
Uses the ``/files/upload-url`` endpoint to obtain a presigned S3
|
|
32
|
+
PUT URL, then uploads the file content directly. No AWS credentials
|
|
33
|
+
are needed — authentication is handled entirely through the
|
|
34
|
+
Wherobots API key.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
client: A configured ``BaseClient`` instance.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, client: BaseClient) -> None:
|
|
41
|
+
self._client = client
|
|
42
|
+
self._integrations_cache: list[StorageIntegration] | None = None
|
|
43
|
+
|
|
44
|
+
# ------------------------------------------------------------------ #
|
|
45
|
+
# Factory
|
|
46
|
+
# ------------------------------------------------------------------ #
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_config(cls, config: WherobotsConfig, **kwargs: Any) -> FilesAPI:
|
|
50
|
+
"""Build a ``FilesAPI`` from a ``WherobotsConfig``.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
config: Validated configuration object.
|
|
54
|
+
**kwargs: Additional keyword arguments forwarded to
|
|
55
|
+
``BaseClient.__init__`` (e.g. ``max_retries``).
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
A new ``FilesAPI`` instance backed by the configured client.
|
|
59
|
+
"""
|
|
60
|
+
client = BaseClient(config, **kwargs)
|
|
61
|
+
return cls(client)
|
|
62
|
+
|
|
63
|
+
# ------------------------------------------------------------------ #
|
|
64
|
+
# Endpoints
|
|
65
|
+
# ------------------------------------------------------------------ #
|
|
66
|
+
|
|
67
|
+
def get_organization(self) -> dict[str, Any]:
|
|
68
|
+
"""Fetch the current organization details (``GET /organization``).
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Organization dict including ``fileStore.bucketName`` and
|
|
72
|
+
``id`` (org identifier).
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
WherobotsAPIError: On HTTP or JSON-parsing failures.
|
|
76
|
+
"""
|
|
77
|
+
response = self._client.get("/organization")
|
|
78
|
+
try:
|
|
79
|
+
org: dict[str, Any] = response.json()
|
|
80
|
+
except (ValueError, requests.exceptions.JSONDecodeError) as exc:
|
|
81
|
+
raise WherobotsAPIError(
|
|
82
|
+
f"Invalid JSON in /organization response (status {response.status_code})",
|
|
83
|
+
status_code=response.status_code,
|
|
84
|
+
) from exc
|
|
85
|
+
return org
|
|
86
|
+
|
|
87
|
+
def get_upload_url(self, key: str) -> str:
|
|
88
|
+
"""Get a presigned S3 PUT URL for a given key.
|
|
89
|
+
|
|
90
|
+
Calls ``POST /files/upload-url?key=<key>`` and returns the
|
|
91
|
+
presigned URL from the response.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
key: The full destination key for the managed S3 bucket,
|
|
95
|
+
formatted as ``"{bucket}/{org_id}/{path}"``
|
|
96
|
+
(e.g. ``"wbts-bucket/org123/data/shared/scripts/my_script.py"``).
|
|
97
|
+
The ``upload_script`` method constructs this automatically.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
A presigned S3 PUT URL string.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
WherobotsAPIError: On HTTP or JSON-parsing failures.
|
|
104
|
+
"""
|
|
105
|
+
response = self._client.post(
|
|
106
|
+
"/files/upload-url",
|
|
107
|
+
params={"key": key},
|
|
108
|
+
)
|
|
109
|
+
try:
|
|
110
|
+
data = response.json()
|
|
111
|
+
except (ValueError, requests.exceptions.JSONDecodeError) as exc:
|
|
112
|
+
raise WherobotsAPIError(
|
|
113
|
+
f"Invalid JSON in /files/upload-url response (status {response.status_code})",
|
|
114
|
+
status_code=response.status_code,
|
|
115
|
+
) from exc
|
|
116
|
+
|
|
117
|
+
upload_url = data.get("uploadUrl")
|
|
118
|
+
if not isinstance(upload_url, str) or not upload_url:
|
|
119
|
+
raise WherobotsAPIError(
|
|
120
|
+
"No 'uploadUrl' in /files/upload-url response",
|
|
121
|
+
response=data,
|
|
122
|
+
)
|
|
123
|
+
return upload_url
|
|
124
|
+
|
|
125
|
+
def upload_script(
|
|
126
|
+
self,
|
|
127
|
+
content: str,
|
|
128
|
+
filename: str | None = None,
|
|
129
|
+
) -> str:
|
|
130
|
+
"""Upload a Python script to Wherobots managed storage.
|
|
131
|
+
|
|
132
|
+
This is a high-level convenience method that:
|
|
133
|
+
|
|
134
|
+
1. Calls ``GET /organization`` to discover the bucket name and
|
|
135
|
+
org ID.
|
|
136
|
+
2. Constructs the S3 key:
|
|
137
|
+
``{org_id}/data/shared/scripts/{filename}``.
|
|
138
|
+
3. Obtains a presigned PUT URL via ``POST /files/upload-url``.
|
|
139
|
+
4. Uploads the script content via a raw HTTP PUT.
|
|
140
|
+
5. Returns the ``s3://`` URI for the uploaded script.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
content: The Python script source code.
|
|
144
|
+
filename: Optional filename. If not provided, a UUID-based
|
|
145
|
+
name is generated (``sdk-upload-{uuid}.py``).
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
The ``s3://`` URI of the uploaded script, suitable for
|
|
149
|
+
passing to ``CreateRunPayload``.
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
WherobotsValidationError: If the filename is invalid.
|
|
153
|
+
WherobotsAPIError: On Wherobots API or presigned PUT failures.
|
|
154
|
+
"""
|
|
155
|
+
if filename is None:
|
|
156
|
+
filename = f"sdk-upload-{uuid.uuid4().hex[:12]}.py"
|
|
157
|
+
|
|
158
|
+
# Reject path-traversal characters in user-supplied filenames.
|
|
159
|
+
if "/" in filename or "\\" in filename or ".." in filename:
|
|
160
|
+
raise WherobotsValidationError(
|
|
161
|
+
f"Invalid filename '{filename}': must not contain '/', '\\', or '..'"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# 1. Discover bucket and org ID
|
|
165
|
+
org = self.get_organization()
|
|
166
|
+
bucket = org.get("fileStore", {}).get("bucketName")
|
|
167
|
+
org_id = org.get("id")
|
|
168
|
+
|
|
169
|
+
if not bucket or not org_id:
|
|
170
|
+
raise WherobotsAPIError(
|
|
171
|
+
"Could not determine bucket or org ID from /organization response",
|
|
172
|
+
response=org,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# 2. Construct the S3 key and the upload-url key.
|
|
176
|
+
# The upload-url endpoint expects: {bucket}/{org_id}/{path}
|
|
177
|
+
# The resulting S3 URI is: s3://{bucket}/{org_id}/{path}
|
|
178
|
+
relative_path = f"{org_id}/data/shared/scripts/{filename}"
|
|
179
|
+
upload_key = f"{bucket}/{relative_path}"
|
|
180
|
+
|
|
181
|
+
logger.info(
|
|
182
|
+
"Uploading script '%s' to s3://%s/%s",
|
|
183
|
+
filename,
|
|
184
|
+
bucket,
|
|
185
|
+
relative_path,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# 3. Get presigned upload URL
|
|
189
|
+
presigned_url = self.get_upload_url(upload_key)
|
|
190
|
+
|
|
191
|
+
# 4. PUT the content directly to S3 (no Wherobots auth headers)
|
|
192
|
+
put_response = requests.put(
|
|
193
|
+
presigned_url,
|
|
194
|
+
data=content.encode("utf-8"),
|
|
195
|
+
headers={},
|
|
196
|
+
timeout=60,
|
|
197
|
+
allow_redirects=False,
|
|
198
|
+
)
|
|
199
|
+
if not put_response.ok:
|
|
200
|
+
raise WherobotsAPIError(
|
|
201
|
+
f"Presigned PUT failed (HTTP {put_response.status_code}): "
|
|
202
|
+
f"{put_response.text[:500]}",
|
|
203
|
+
status_code=put_response.status_code,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# 5. Return the S3 URI
|
|
207
|
+
s3_uri = f"s3://{bucket}/{relative_path}"
|
|
208
|
+
logger.info("Upload complete: %s", s3_uri)
|
|
209
|
+
return s3_uri
|
|
210
|
+
|
|
211
|
+
def upload_file(
|
|
212
|
+
self,
|
|
213
|
+
local_path: str,
|
|
214
|
+
filename: str | None = None,
|
|
215
|
+
max_file_size_bytes: int = MAX_UPLOAD_FILE_SIZE_BYTES,
|
|
216
|
+
) -> str:
|
|
217
|
+
"""Upload a local file to Wherobots managed storage via presigned URL.
|
|
218
|
+
|
|
219
|
+
Unlike ``upload_script()`` which accepts a string of source code,
|
|
220
|
+
this method reads a file from disk in binary mode, making it
|
|
221
|
+
suitable for Python scripts, JARs, and other binary artifacts.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
local_path: Filesystem path to the file to upload.
|
|
225
|
+
filename: Optional destination filename. Defaults to the
|
|
226
|
+
basename of *local_path*.
|
|
227
|
+
max_file_size_bytes: Maximum allowed file size in bytes.
|
|
228
|
+
Defaults to ``MAX_UPLOAD_FILE_SIZE_BYTES`` (500 MB).
|
|
229
|
+
Set to ``0`` to disable the check.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
The ``s3://`` URI of the uploaded file.
|
|
233
|
+
|
|
234
|
+
Raises:
|
|
235
|
+
WherobotsValidationError: If the file exceeds the size limit,
|
|
236
|
+
does not exist, or the filename is invalid.
|
|
237
|
+
WherobotsAPIError: On Wherobots API failures or presigned
|
|
238
|
+
PUT failures.
|
|
239
|
+
"""
|
|
240
|
+
path = Path(local_path)
|
|
241
|
+
if not path.is_file():
|
|
242
|
+
raise WherobotsValidationError(f"File not found: {local_path}")
|
|
243
|
+
|
|
244
|
+
if filename is None:
|
|
245
|
+
filename = path.name
|
|
246
|
+
|
|
247
|
+
# Reject path-traversal characters in user-supplied filenames.
|
|
248
|
+
if "/" in filename or "\\" in filename or ".." in filename:
|
|
249
|
+
raise WherobotsValidationError(
|
|
250
|
+
f"Invalid filename '{filename}': must not contain '/', '\\', or '..'"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Size validation
|
|
254
|
+
if max_file_size_bytes > 0:
|
|
255
|
+
try:
|
|
256
|
+
file_size = path.stat().st_size
|
|
257
|
+
except OSError as exc:
|
|
258
|
+
raise WherobotsValidationError(f"Cannot read file '{local_path}': {exc}") from exc
|
|
259
|
+
if file_size > max_file_size_bytes:
|
|
260
|
+
size_mb = file_size / (1024 * 1024)
|
|
261
|
+
limit_mb = max_file_size_bytes / (1024 * 1024)
|
|
262
|
+
raise WherobotsValidationError(
|
|
263
|
+
f"File '{path.name}' is {size_mb:.1f} MB, exceeding the "
|
|
264
|
+
f"{limit_mb:.0f} MB upload limit. Use an S3 URI instead."
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# 1. Discover bucket and org ID
|
|
268
|
+
org = self.get_organization()
|
|
269
|
+
bucket = org.get("fileStore", {}).get("bucketName")
|
|
270
|
+
org_id = org.get("id")
|
|
271
|
+
|
|
272
|
+
if not bucket or not org_id:
|
|
273
|
+
raise WherobotsAPIError(
|
|
274
|
+
"Could not determine bucket or org ID from /organization response",
|
|
275
|
+
response=org,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# 2. Construct the S3 key
|
|
279
|
+
relative_path = f"{org_id}/data/shared/scripts/{filename}"
|
|
280
|
+
upload_key = f"{bucket}/{relative_path}"
|
|
281
|
+
|
|
282
|
+
logger.info(
|
|
283
|
+
"Uploading file '%s' (%s) to s3://%s/%s",
|
|
284
|
+
path.name,
|
|
285
|
+
filename,
|
|
286
|
+
bucket,
|
|
287
|
+
relative_path,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# 3. Get presigned upload URL
|
|
291
|
+
presigned_url = self.get_upload_url(upload_key)
|
|
292
|
+
|
|
293
|
+
# 4. Stream the file to S3 without loading it fully into memory.
|
|
294
|
+
with path.open("rb") as fh:
|
|
295
|
+
put_response = requests.put(
|
|
296
|
+
presigned_url,
|
|
297
|
+
data=fh,
|
|
298
|
+
headers={},
|
|
299
|
+
timeout=120,
|
|
300
|
+
allow_redirects=False,
|
|
301
|
+
)
|
|
302
|
+
if not put_response.ok:
|
|
303
|
+
raise WherobotsAPIError(
|
|
304
|
+
f"Presigned PUT failed (HTTP {put_response.status_code}): "
|
|
305
|
+
f"{put_response.text[:500]}",
|
|
306
|
+
status_code=put_response.status_code,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# 5. Return the S3 URI
|
|
310
|
+
s3_uri = f"s3://{bucket}/{relative_path}"
|
|
311
|
+
logger.info("Upload complete: %s", s3_uri)
|
|
312
|
+
return s3_uri
|
|
313
|
+
|
|
314
|
+
# ------------------------------------------------------------------ #
|
|
315
|
+
# Storage Integration Endpoints
|
|
316
|
+
# ------------------------------------------------------------------ #
|
|
317
|
+
|
|
318
|
+
def list_integrations(self) -> list[StorageIntegration]:
|
|
319
|
+
"""List all configured storage integrations (``GET /storage``).
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
A list of ``StorageIntegration`` objects.
|
|
323
|
+
|
|
324
|
+
Raises:
|
|
325
|
+
WherobotsAPIError: On HTTP or JSON-parsing failures.
|
|
326
|
+
"""
|
|
327
|
+
response = self._client.get("/storage")
|
|
328
|
+
try:
|
|
329
|
+
data = response.json()
|
|
330
|
+
except (ValueError, requests.exceptions.JSONDecodeError) as exc:
|
|
331
|
+
raise WherobotsAPIError(
|
|
332
|
+
f"Invalid JSON in /storage response (status {response.status_code})",
|
|
333
|
+
status_code=response.status_code,
|
|
334
|
+
) from exc
|
|
335
|
+
return [StorageIntegration.from_dict(item) for item in data]
|
|
336
|
+
|
|
337
|
+
def resolve_integration(
|
|
338
|
+
self,
|
|
339
|
+
name_or_id: str,
|
|
340
|
+
*,
|
|
341
|
+
refresh: bool = False,
|
|
342
|
+
) -> StorageIntegration:
|
|
343
|
+
"""Find a storage integration by name (case-insensitive) or ID.
|
|
344
|
+
|
|
345
|
+
Results from ``list_integrations`` are cached on the instance to
|
|
346
|
+
avoid repeated ``GET /storage`` calls. Pass ``refresh=True`` to
|
|
347
|
+
force a fresh fetch if the set of integrations has changed.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
name_or_id: The integration name or unique ID.
|
|
351
|
+
refresh: Force a fresh fetch of integrations.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
The matching ``StorageIntegration``.
|
|
355
|
+
|
|
356
|
+
Raises:
|
|
357
|
+
WherobotsValidationError: If no matching integration is found.
|
|
358
|
+
WherobotsAPIError: On HTTP failures.
|
|
359
|
+
"""
|
|
360
|
+
if refresh or self._integrations_cache is None:
|
|
361
|
+
self._integrations_cache = self.list_integrations()
|
|
362
|
+
integrations = self._integrations_cache
|
|
363
|
+
query = name_or_id.lower()
|
|
364
|
+
for integration in integrations:
|
|
365
|
+
if integration.id == name_or_id or integration.name.lower() == query:
|
|
366
|
+
return integration
|
|
367
|
+
available = [i.name for i in integrations]
|
|
368
|
+
raise WherobotsValidationError(
|
|
369
|
+
f"Storage integration '{name_or_id}' not found. Available integrations: {available}"
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
# ------------------------------------------------------------------ #
|
|
373
|
+
# Lifecycle
|
|
374
|
+
# ------------------------------------------------------------------ #
|
|
375
|
+
|
|
376
|
+
def close(self) -> None:
|
|
377
|
+
"""Close the underlying HTTP client and release resources."""
|
|
378
|
+
self._client.close()
|
|
379
|
+
|
|
380
|
+
def __enter__(self) -> FilesAPI:
|
|
381
|
+
"""Enter the runtime context (context-manager protocol)."""
|
|
382
|
+
return self
|
|
383
|
+
|
|
384
|
+
def __exit__(self, *exc: Any) -> None:
|
|
385
|
+
"""Exit the runtime context, closing the client."""
|
|
386
|
+
self.close()
|
wherobots/api/runs.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Runs API — typed wrappers around ``/runs`` endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import quote as _url_quote
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from wherobots.api.base import BaseClient
|
|
12
|
+
from wherobots.config import WherobotsConfig
|
|
13
|
+
from wherobots.enums import JobStatus
|
|
14
|
+
from wherobots.exceptions import WherobotsAPIError
|
|
15
|
+
from wherobots.models import (
|
|
16
|
+
CreateRunPayload,
|
|
17
|
+
LogsResponse,
|
|
18
|
+
RunListPage,
|
|
19
|
+
RunMetricsResponse,
|
|
20
|
+
RunView,
|
|
21
|
+
)
|
|
22
|
+
from wherobots.utils.logger import get_logger
|
|
23
|
+
|
|
24
|
+
logger = get_logger("api.runs")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RunsAPI:
|
|
28
|
+
"""High-level, typed API surface for Wherobots job runs.
|
|
29
|
+
|
|
30
|
+
Wraps the low-level ``BaseClient`` and provides typed request/response
|
|
31
|
+
methods for every ``/runs`` endpoint.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
client: A configured ``BaseClient`` instance.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, client: BaseClient) -> None:
|
|
38
|
+
self._client = client
|
|
39
|
+
|
|
40
|
+
# ------------------------------------------------------------------ #
|
|
41
|
+
# Helpers
|
|
42
|
+
# ------------------------------------------------------------------ #
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def _safe_run_id(run_id: str) -> str:
|
|
46
|
+
"""URL-encode a run ID for safe path interpolation.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
run_id: Raw run identifier.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The percent-encoded run ID string.
|
|
53
|
+
"""
|
|
54
|
+
return _url_quote(run_id, safe="")
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def _parse_json(response: requests.Response) -> dict[str, Any]:
|
|
58
|
+
"""Safely parse JSON from an HTTP response.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
response: The ``requests.Response`` to parse.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Parsed JSON body as a dictionary.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
WherobotsAPIError: If the body is not valid JSON.
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
parsed: dict[str, Any] = response.json()
|
|
71
|
+
except (ValueError, requests.exceptions.JSONDecodeError) as exc:
|
|
72
|
+
raise WherobotsAPIError(
|
|
73
|
+
f"Invalid JSON in API response (status {response.status_code})",
|
|
74
|
+
status_code=response.status_code,
|
|
75
|
+
) from exc
|
|
76
|
+
return parsed
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------------ #
|
|
79
|
+
# Factory
|
|
80
|
+
# ------------------------------------------------------------------ #
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_config(cls, config: WherobotsConfig, **kwargs: Any) -> RunsAPI:
|
|
84
|
+
"""Build a ``RunsAPI`` from a ``WherobotsConfig``.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
config: Validated configuration object.
|
|
88
|
+
**kwargs: Additional keyword arguments forwarded to
|
|
89
|
+
``BaseClient.__init__`` (e.g. ``max_retries``).
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
A new ``RunsAPI`` instance backed by the configured client.
|
|
93
|
+
"""
|
|
94
|
+
client = BaseClient(config, **kwargs)
|
|
95
|
+
return cls(client)
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------------------------ #
|
|
98
|
+
# Endpoints
|
|
99
|
+
# ------------------------------------------------------------------ #
|
|
100
|
+
|
|
101
|
+
def create(
|
|
102
|
+
self,
|
|
103
|
+
payload: CreateRunPayload,
|
|
104
|
+
region: str = "aws-us-west-2",
|
|
105
|
+
) -> RunView:
|
|
106
|
+
"""Submit a new job run (``POST /runs``).
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
payload: The run configuration including script/JAR
|
|
110
|
+
details, runtime, and resource settings.
|
|
111
|
+
region: Wherobots deployment region (default
|
|
112
|
+
``"aws-us-west-2"``).
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
A ``RunView`` representing the created run.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
WherobotsAPIError: On HTTP or JSON-parsing failures.
|
|
119
|
+
"""
|
|
120
|
+
params = {"region": region}
|
|
121
|
+
body = payload.to_dict()
|
|
122
|
+
logger.info("Creating run '%s' in %s", payload.name, region)
|
|
123
|
+
response = self._client.post("/runs", params=params, json_body=body)
|
|
124
|
+
return RunView.from_dict(self._parse_json(response))
|
|
125
|
+
|
|
126
|
+
def get(self, run_id: str) -> RunView:
|
|
127
|
+
"""Fetch a single run (``GET /runs/{run_id}``).
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
run_id: Unique identifier of the run to retrieve.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
A ``RunView`` with the current run state.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
WherobotsAPIError: On HTTP or JSON-parsing failures.
|
|
137
|
+
"""
|
|
138
|
+
safe_id = self._safe_run_id(run_id)
|
|
139
|
+
response = self._client.get(f"/runs/{safe_id}")
|
|
140
|
+
return RunView.from_dict(self._parse_json(response))
|
|
141
|
+
|
|
142
|
+
def cancel(self, run_id: str) -> None:
|
|
143
|
+
"""Cancel a run (``POST /runs/{run_id}/cancel``).
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
run_id: Unique identifier of the run to cancel.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
WherobotsAPIError: On HTTP failures.
|
|
150
|
+
"""
|
|
151
|
+
safe_id = self._safe_run_id(run_id)
|
|
152
|
+
self._client.post(f"/runs/{safe_id}/cancel")
|
|
153
|
+
logger.info("Cancel requested for run %s", run_id)
|
|
154
|
+
|
|
155
|
+
def get_logs(
|
|
156
|
+
self,
|
|
157
|
+
run_id: str,
|
|
158
|
+
cursor: int | str = 0,
|
|
159
|
+
size: int = 100,
|
|
160
|
+
) -> LogsResponse:
|
|
161
|
+
"""Fetch logs (``GET /runs/{run_id}/logs``).
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
run_id: Unique identifier of the run.
|
|
165
|
+
cursor: Pagination cursor — accepts either an integer byte
|
|
166
|
+
offset (legacy) or a string cursor returned by the API
|
|
167
|
+
via ``LogsResponse.next_page``. Defaults to ``0``.
|
|
168
|
+
size: Maximum number of log lines to return. Defaults to
|
|
169
|
+
``100``.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
A ``LogsResponse`` containing log items and pagination info.
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
WherobotsAPIError: On HTTP or JSON-parsing failures.
|
|
176
|
+
"""
|
|
177
|
+
safe_id = self._safe_run_id(run_id)
|
|
178
|
+
params: dict[str, Any] = {"cursor": cursor, "size": size}
|
|
179
|
+
response = self._client.get(f"/runs/{safe_id}/logs", params=params)
|
|
180
|
+
return LogsResponse.from_dict(self._parse_json(response))
|
|
181
|
+
|
|
182
|
+
def get_metrics(self, run_id: str) -> RunMetricsResponse:
|
|
183
|
+
"""Fetch metrics (``GET /runs/{run_id}/metrics``).
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
run_id: Unique identifier of the run.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
A ``RunMetricsResponse`` with instant and series metrics.
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
WherobotsAPIError: On HTTP or JSON-parsing failures.
|
|
193
|
+
"""
|
|
194
|
+
safe_id = self._safe_run_id(run_id)
|
|
195
|
+
response = self._client.get(f"/runs/{safe_id}/metrics")
|
|
196
|
+
return RunMetricsResponse.from_dict(self._parse_json(response))
|
|
197
|
+
|
|
198
|
+
def list(
|
|
199
|
+
self,
|
|
200
|
+
region: str | None = None,
|
|
201
|
+
name_pattern: str | None = None,
|
|
202
|
+
created_after: str | None = None,
|
|
203
|
+
status: builtins.list[str | JobStatus] | None = None,
|
|
204
|
+
cursor: str | None = None,
|
|
205
|
+
size: int = 50,
|
|
206
|
+
) -> RunListPage:
|
|
207
|
+
"""List runs with optional filters (``GET /runs``).
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
region: Filter by deployment region.
|
|
211
|
+
name_pattern: Filter by job name (substring match).
|
|
212
|
+
created_after: ISO-8601 timestamp; only return runs created
|
|
213
|
+
after this time.
|
|
214
|
+
status: Filter by one or more job statuses (strings or
|
|
215
|
+
``JobStatus`` enum values).
|
|
216
|
+
cursor: Opaque pagination cursor from a previous response.
|
|
217
|
+
size: Page size (max ``100``). Defaults to ``50``.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
A ``RunListPage`` containing the matched runs, total count,
|
|
221
|
+
and an optional cursor for the next page.
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
WherobotsAPIError: On HTTP or JSON-parsing failures.
|
|
225
|
+
"""
|
|
226
|
+
params: dict[str, Any] = {"size": min(size, 100)}
|
|
227
|
+
if region:
|
|
228
|
+
params["region"] = region
|
|
229
|
+
if name_pattern:
|
|
230
|
+
params["name"] = name_pattern
|
|
231
|
+
if created_after:
|
|
232
|
+
params["created_after"] = created_after
|
|
233
|
+
if status:
|
|
234
|
+
params["status"] = [s.value if isinstance(s, JobStatus) else s for s in status]
|
|
235
|
+
if cursor:
|
|
236
|
+
params["cursor"] = cursor
|
|
237
|
+
|
|
238
|
+
response = self._client.get("/runs", params=params)
|
|
239
|
+
return RunListPage.from_dict(self._parse_json(response))
|
|
240
|
+
|
|
241
|
+
# ------------------------------------------------------------------ #
|
|
242
|
+
# Lifecycle
|
|
243
|
+
# ------------------------------------------------------------------ #
|
|
244
|
+
|
|
245
|
+
def close(self) -> None:
|
|
246
|
+
"""Close the underlying HTTP client and release resources."""
|
|
247
|
+
self._client.close()
|
|
248
|
+
|
|
249
|
+
def __enter__(self) -> RunsAPI:
|
|
250
|
+
"""Enter the runtime context (context-manager protocol)."""
|
|
251
|
+
return self
|
|
252
|
+
|
|
253
|
+
def __exit__(self, *exc: Any) -> None:
|
|
254
|
+
"""Exit the runtime context, closing the client."""
|
|
255
|
+
self.close()
|