wherobots-python-sdk 0.1.0__py3-none-any.whl → 0.2.1__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 +2 -0
- wherobots/__version__.py +1 -1
- wherobots/api/runs.py +7 -5
- wherobots/client.py +263 -18
- wherobots/models.py +145 -3
- {wherobots_python_sdk-0.1.0.dist-info → wherobots_python_sdk-0.2.1.dist-info}/METADATA +39 -4
- {wherobots_python_sdk-0.1.0.dist-info → wherobots_python_sdk-0.2.1.dist-info}/RECORD +10 -10
- {wherobots_python_sdk-0.1.0.dist-info → wherobots_python_sdk-0.2.1.dist-info}/WHEEL +0 -0
- {wherobots_python_sdk-0.1.0.dist-info → wherobots_python_sdk-0.2.1.dist-info}/licenses/LICENSE +0 -0
- {wherobots_python_sdk-0.1.0.dist-info → wherobots_python_sdk-0.2.1.dist-info}/top_level.txt +0 -0
wherobots/__init__.py
CHANGED
|
@@ -39,6 +39,7 @@ from wherobots.models import (
|
|
|
39
39
|
RunPythonPayload,
|
|
40
40
|
RunView,
|
|
41
41
|
StorageIntegration,
|
|
42
|
+
UtilizationStats,
|
|
42
43
|
)
|
|
43
44
|
|
|
44
45
|
# Convenience alias
|
|
@@ -84,4 +85,5 @@ __all__ = [
|
|
|
84
85
|
"PyPiDependency",
|
|
85
86
|
"FileDependency",
|
|
86
87
|
"StorageIntegration",
|
|
88
|
+
"UtilizationStats",
|
|
87
89
|
]
|
wherobots/__version__.py
CHANGED
wherobots/api/runs.py
CHANGED
|
@@ -101,15 +101,15 @@ class RunsAPI:
|
|
|
101
101
|
def create(
|
|
102
102
|
self,
|
|
103
103
|
payload: CreateRunPayload,
|
|
104
|
-
region: str =
|
|
104
|
+
region: str | None = None,
|
|
105
105
|
) -> RunView:
|
|
106
106
|
"""Submit a new job run (``POST /runs``).
|
|
107
107
|
|
|
108
108
|
Args:
|
|
109
109
|
payload: The run configuration including script/JAR
|
|
110
110
|
details, runtime, and resource settings.
|
|
111
|
-
region: Wherobots deployment region
|
|
112
|
-
|
|
111
|
+
region: Wherobots deployment region. When omitted, the API
|
|
112
|
+
applies the organization's configured default region.
|
|
113
113
|
|
|
114
114
|
Returns:
|
|
115
115
|
A ``RunView`` representing the created run.
|
|
@@ -117,9 +117,11 @@ class RunsAPI:
|
|
|
117
117
|
Raises:
|
|
118
118
|
WherobotsAPIError: On HTTP or JSON-parsing failures.
|
|
119
119
|
"""
|
|
120
|
-
params = {
|
|
120
|
+
params = {}
|
|
121
|
+
if region:
|
|
122
|
+
params["region"] = region
|
|
121
123
|
body = payload.to_dict()
|
|
122
|
-
logger.info("Creating run '%s' in %s", payload.name, region)
|
|
124
|
+
logger.info("Creating run '%s' in %s", payload.name, region or "org default")
|
|
123
125
|
response = self._client.post("/runs", params=params, json_body=body)
|
|
124
126
|
return RunView.from_dict(self._parse_json(response))
|
|
125
127
|
|
wherobots/client.py
CHANGED
|
@@ -29,6 +29,8 @@ from wherobots.models import (
|
|
|
29
29
|
RunMetricsResponse,
|
|
30
30
|
RunPythonPayload,
|
|
31
31
|
RunView,
|
|
32
|
+
UtilizationStats,
|
|
33
|
+
extract_instant_value,
|
|
32
34
|
)
|
|
33
35
|
from wherobots.utils.logger import get_logger
|
|
34
36
|
from wherobots.utils.validation import validate_name
|
|
@@ -52,13 +54,60 @@ class WherobotsJob:
|
|
|
52
54
|
|
|
53
55
|
Manages the lifecycle of Wherobots job runs including submission,
|
|
54
56
|
monitoring, log streaming, and cancellation.
|
|
57
|
+
|
|
58
|
+
Use :meth:`from_run_id` to attach to an existing run for read-only
|
|
59
|
+
log/metrics access without binding a script.
|
|
55
60
|
"""
|
|
56
61
|
|
|
62
|
+
# Candidate metric key names. The metrics endpoint is server-defined
|
|
63
|
+
# and untyped (see ``RunMetricsResponse``); the UPPERCASE names are
|
|
64
|
+
# the current production keys, with lowercase fallbacks kept for
|
|
65
|
+
# forward-compat. Confirmed against run gt3oirei5widjk (2026-06-11).
|
|
66
|
+
_CPU_METRIC_KEYS: tuple[str, ...] = ("CPU_UTILIZATION_PERCENT", "cpu_usage", "cpu")
|
|
67
|
+
_MEM_METRIC_KEYS: tuple[str, ...] = (
|
|
68
|
+
"MEMORY_UTILIZATION_PERCENT",
|
|
69
|
+
"memory_usage",
|
|
70
|
+
"mem_usage",
|
|
71
|
+
)
|
|
72
|
+
_COST_METRIC_KEYS: tuple[str, ...] = ("COST_USD", "cost_usd")
|
|
73
|
+
_SU_METRIC_KEYS: tuple[str, ...] = ("CONSUMED_SPATIAL_UNITS", "consumed_spatial_units")
|
|
74
|
+
|
|
75
|
+
# Instance attribute type declarations. ``_init_defaults`` (called
|
|
76
|
+
# by both ``__init__`` and ``from_run_id``) assigns initial values
|
|
77
|
+
# for every name listed here. Adding a new instance attribute means
|
|
78
|
+
# one declaration here + one assignment in ``_init_defaults`` —
|
|
79
|
+
# both constructor paths then pick it up automatically.
|
|
80
|
+
script: str | None
|
|
81
|
+
name: str | None
|
|
82
|
+
runtime: str | None
|
|
83
|
+
region: str | None
|
|
84
|
+
version: str | None
|
|
85
|
+
timeout_seconds: int
|
|
86
|
+
args: list[str]
|
|
87
|
+
spark_configs: dict[str, str]
|
|
88
|
+
dependencies: list[dict[str, Any]]
|
|
89
|
+
spark_driver_disk_gb: int | None
|
|
90
|
+
spark_executor_disk_gb: int | None
|
|
91
|
+
s3_bucket: str | None
|
|
92
|
+
s3_prefix: str | None
|
|
93
|
+
jar_main_class: str | None
|
|
94
|
+
auto_upload: bool
|
|
95
|
+
is_jar: bool
|
|
96
|
+
run_id: str | None
|
|
97
|
+
# Status may be a string when the server returns a value the
|
|
98
|
+
# SDK's JobStatus enum doesn't recognize yet (forward-compat).
|
|
99
|
+
status: JobStatus | str | None
|
|
100
|
+
_last_log_cursor: int | str
|
|
101
|
+
_script_uri: str | None
|
|
102
|
+
_attached: bool
|
|
103
|
+
_config: WherobotsConfig
|
|
104
|
+
_api: RunsAPI
|
|
105
|
+
|
|
57
106
|
def __init__(
|
|
58
107
|
self,
|
|
59
108
|
script: str,
|
|
60
109
|
name: str,
|
|
61
|
-
runtime: str | Runtime =
|
|
110
|
+
runtime: str | Runtime | None = None,
|
|
62
111
|
region: str | Region | None = None,
|
|
63
112
|
api_key: str | None = None,
|
|
64
113
|
version: str | None = None,
|
|
@@ -82,8 +131,19 @@ class WherobotsJob:
|
|
|
82
131
|
script: Path to Python script (.py) or JAR file (.jar).
|
|
83
132
|
Can be local path or S3 URI (s3://bucket/key)
|
|
84
133
|
name: Job name (8-255 chars, alphanumeric, _, -, .)
|
|
85
|
-
runtime:
|
|
86
|
-
|
|
134
|
+
runtime: The compute runtime to use. Accepts a ``Runtime`` enum
|
|
135
|
+
value or a raw string; strings are passed to the API as-is.
|
|
136
|
+
Override the default runtime set for your organization — only
|
|
137
|
+
set this if you need a specific runtime instead of the one your
|
|
138
|
+
administrator has configured. When omitted, your organization's
|
|
139
|
+
default runtime is used.
|
|
140
|
+
region: The compute region to run in. Accepts a ``Region`` enum
|
|
141
|
+
value or a raw string (e.g. a BYOC region such as
|
|
142
|
+
``byoc-acme-us-east-1``); strings are passed to the API as-is.
|
|
143
|
+
Override the default region set for your organization — only set
|
|
144
|
+
this if you intend to use a specific region instead of the one
|
|
145
|
+
your administrator has configured. When omitted, your
|
|
146
|
+
organization's default region is used.
|
|
87
147
|
api_key: Wherobots API key (or set WHEROBOTS_API_KEY env var)
|
|
88
148
|
version: Wherobots version ("latest" or "preview")
|
|
89
149
|
timeout_seconds: Job timeout in seconds (default: 3600)
|
|
@@ -119,9 +179,8 @@ class WherobotsJob:
|
|
|
119
179
|
f"spark_executor_disk_gb must be non-negative, got {spark_executor_disk_gb}"
|
|
120
180
|
)
|
|
121
181
|
|
|
122
|
-
self.
|
|
123
|
-
|
|
124
|
-
self.runtime = runtime.value if isinstance(runtime, Runtime) else runtime
|
|
182
|
+
self._init_defaults()
|
|
183
|
+
|
|
125
184
|
region_value = region.value if isinstance(region, Region) else region
|
|
126
185
|
|
|
127
186
|
# Deprecation warnings for s3_bucket / s3_prefix are emitted by
|
|
@@ -138,7 +197,12 @@ class WherobotsJob:
|
|
|
138
197
|
request_timeout_seconds=request_timeout_seconds,
|
|
139
198
|
)
|
|
140
199
|
|
|
141
|
-
self.
|
|
200
|
+
self.script = script
|
|
201
|
+
self.name = validate_name(name)
|
|
202
|
+
self.runtime = runtime.value if isinstance(runtime, Runtime) else runtime
|
|
203
|
+
# No hardcoded fallback: when neither the argument nor the config
|
|
204
|
+
# supplies a region, leave it unset so the API applies the org default.
|
|
205
|
+
self.region = region_value or self._config.region
|
|
142
206
|
self.version = version or self._config.version
|
|
143
207
|
self.timeout_seconds = timeout_seconds
|
|
144
208
|
self.args = args or []
|
|
@@ -151,20 +215,44 @@ class WherobotsJob:
|
|
|
151
215
|
self.jar_main_class = jar_main_class
|
|
152
216
|
self.auto_upload = auto_upload
|
|
153
217
|
|
|
154
|
-
self.run_id: str | None = None
|
|
155
|
-
# Status may be a string when the server returns a value the
|
|
156
|
-
# SDK's JobStatus enum doesn't recognize yet (forward-compat).
|
|
157
|
-
self.status: JobStatus | str | None = None
|
|
158
|
-
self._last_log_cursor: int | str = 0
|
|
159
|
-
self._script_uri: str | None = None
|
|
160
|
-
|
|
161
218
|
self.is_jar = script.lower().endswith(".jar")
|
|
162
219
|
if self.is_jar and not jar_main_class:
|
|
163
220
|
raise WherobotsValidationError("jar_main_class is required for JAR files")
|
|
164
221
|
|
|
165
|
-
# Build the API layer
|
|
166
222
|
self._api = RunsAPI.from_config(self._config)
|
|
167
223
|
|
|
224
|
+
def _init_defaults(self) -> None:
|
|
225
|
+
"""Initialize instance attributes shared by every construction path.
|
|
226
|
+
|
|
227
|
+
Both ``__init__`` and :meth:`from_run_id` MUST call this first,
|
|
228
|
+
then override the subset of fields they own. Any new instance
|
|
229
|
+
attribute that lives on every ``WherobotsJob`` belongs here
|
|
230
|
+
(and in the class-level type declarations above) — adding it
|
|
231
|
+
in only one constructor would leave the other in a
|
|
232
|
+
partially-constructed state.
|
|
233
|
+
"""
|
|
234
|
+
self.script = None
|
|
235
|
+
self.name = None
|
|
236
|
+
self.runtime = None
|
|
237
|
+
self.region = None
|
|
238
|
+
self.version = None
|
|
239
|
+
self.timeout_seconds = 0
|
|
240
|
+
self.args = []
|
|
241
|
+
self.spark_configs = {}
|
|
242
|
+
self.dependencies = []
|
|
243
|
+
self.spark_driver_disk_gb = None
|
|
244
|
+
self.spark_executor_disk_gb = None
|
|
245
|
+
self.s3_bucket = None
|
|
246
|
+
self.s3_prefix = None
|
|
247
|
+
self.jar_main_class = None
|
|
248
|
+
self.auto_upload = False
|
|
249
|
+
self.is_jar = False
|
|
250
|
+
self.run_id = None
|
|
251
|
+
self.status = None
|
|
252
|
+
self._last_log_cursor = 0
|
|
253
|
+
self._script_uri = None
|
|
254
|
+
self._attached = False
|
|
255
|
+
|
|
168
256
|
# ------------------------------------------------------------------ #
|
|
169
257
|
# Upload helpers
|
|
170
258
|
# ------------------------------------------------------------------ #
|
|
@@ -187,6 +275,10 @@ class WherobotsJob:
|
|
|
187
275
|
if self._script_uri:
|
|
188
276
|
return self._script_uri
|
|
189
277
|
|
|
278
|
+
# Only reachable from submit(), which raises in attached mode
|
|
279
|
+
# before getting here. The asserts narrow ``str | None`` -> ``str``.
|
|
280
|
+
assert self.script is not None, "script must be set in submit-mode"
|
|
281
|
+
|
|
190
282
|
if self._is_s3_uri(self.script):
|
|
191
283
|
self._script_uri = self.script
|
|
192
284
|
elif self.auto_upload:
|
|
@@ -227,6 +319,10 @@ class WherobotsJob:
|
|
|
227
319
|
# ------------------------------------------------------------------ #
|
|
228
320
|
|
|
229
321
|
def _build_payload(self) -> CreateRunPayload:
|
|
322
|
+
# Only reachable from submit(); attached instances raise earlier.
|
|
323
|
+
# Runtime is optional — the API applies the org default when unset.
|
|
324
|
+
assert self.name is not None, "name must be set in submit-mode"
|
|
325
|
+
|
|
230
326
|
script_uri = self._prepare_script_uri()
|
|
231
327
|
|
|
232
328
|
run_python: RunPythonPayload | None = None
|
|
@@ -265,6 +361,99 @@ class WherobotsJob:
|
|
|
265
361
|
environment=environment,
|
|
266
362
|
)
|
|
267
363
|
|
|
364
|
+
# ------------------------------------------------------------------ #
|
|
365
|
+
# Attach
|
|
366
|
+
# ------------------------------------------------------------------ #
|
|
367
|
+
|
|
368
|
+
@classmethod
|
|
369
|
+
def from_run_id(
|
|
370
|
+
cls,
|
|
371
|
+
run_id: str,
|
|
372
|
+
*,
|
|
373
|
+
api_key: str | None = None,
|
|
374
|
+
config: WherobotsConfig | None = None,
|
|
375
|
+
base_url: str | None = None,
|
|
376
|
+
region: str | None = None,
|
|
377
|
+
request_timeout_seconds: int | None = None,
|
|
378
|
+
) -> WherobotsJob:
|
|
379
|
+
"""Attach to an existing run for read-only log/metric access.
|
|
380
|
+
|
|
381
|
+
Unlike the regular constructor this does not require a script
|
|
382
|
+
or name — only a ``run_id``. The returned instance is read-only:
|
|
383
|
+
:meth:`submit` will raise. All other read methods (``get_logs``,
|
|
384
|
+
``iter_logs``, ``poll_for_logs``, ``get_metrics``,
|
|
385
|
+
``get_cpu_utilization``, ``get_mem_utilization``, ``get_status``,
|
|
386
|
+
``cancel``, ``wait_for_completion``) work normally.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
run_id: Run identifier from a prior submission, the CLI, or
|
|
390
|
+
the Wherobots UI.
|
|
391
|
+
api_key: Wherobots API key (or set ``WHEROBOTS_API_KEY``).
|
|
392
|
+
config: Pre-built ``WherobotsConfig`` to use instead of the
|
|
393
|
+
environment.
|
|
394
|
+
base_url: Override the API base URL.
|
|
395
|
+
region: AWS region override.
|
|
396
|
+
request_timeout_seconds: HTTP request timeout in seconds.
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
A ``WherobotsJob`` bound to *run_id* with descriptive
|
|
400
|
+
fields (name, runtime, status, ...) hydrated from the API.
|
|
401
|
+
|
|
402
|
+
Raises:
|
|
403
|
+
WherobotsValidationError: If *run_id* is empty.
|
|
404
|
+
WherobotsAPIError: If the initial refresh fails.
|
|
405
|
+
"""
|
|
406
|
+
if not run_id:
|
|
407
|
+
raise WherobotsValidationError("run_id must not be None or empty")
|
|
408
|
+
|
|
409
|
+
self = cls.__new__(cls)
|
|
410
|
+
self._init_defaults()
|
|
411
|
+
|
|
412
|
+
self._config = config or WherobotsConfig.from_env(
|
|
413
|
+
api_key=api_key,
|
|
414
|
+
region=region,
|
|
415
|
+
base_url=base_url,
|
|
416
|
+
request_timeout_seconds=request_timeout_seconds,
|
|
417
|
+
)
|
|
418
|
+
self._api = RunsAPI.from_config(self._config)
|
|
419
|
+
|
|
420
|
+
# Attached-mode overrides on top of _init_defaults().
|
|
421
|
+
self.run_id = run_id
|
|
422
|
+
self._attached = True
|
|
423
|
+
self.region = region or self._config.region
|
|
424
|
+
self.version = self._config.version
|
|
425
|
+
self.s3_bucket = self._config.s3_bucket
|
|
426
|
+
self.s3_prefix = self._config.s3_prefix
|
|
427
|
+
|
|
428
|
+
self.refresh()
|
|
429
|
+
return self
|
|
430
|
+
|
|
431
|
+
def refresh(self) -> RunView:
|
|
432
|
+
"""Re-fetch the run from the API and update local fields.
|
|
433
|
+
|
|
434
|
+
Updates ``status``, ``name``, ``runtime``, ``region``, and
|
|
435
|
+
``version`` in place. Works in both attached and submitted modes.
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
The freshly fetched ``RunView``.
|
|
439
|
+
|
|
440
|
+
Raises:
|
|
441
|
+
WherobotsJobError: If ``run_id`` is not set.
|
|
442
|
+
"""
|
|
443
|
+
if not self.run_id:
|
|
444
|
+
raise WherobotsJobError("No run_id bound. Call submit() or use from_run_id().")
|
|
445
|
+
run_view = self._api.get(self.run_id)
|
|
446
|
+
self.status = run_view.status
|
|
447
|
+
if run_view.name:
|
|
448
|
+
self.name = run_view.name
|
|
449
|
+
if run_view.runtime:
|
|
450
|
+
self.runtime = run_view.runtime
|
|
451
|
+
if run_view.region:
|
|
452
|
+
self.region = run_view.region
|
|
453
|
+
if run_view.version:
|
|
454
|
+
self.version = run_view.version
|
|
455
|
+
return run_view
|
|
456
|
+
|
|
268
457
|
# ------------------------------------------------------------------ #
|
|
269
458
|
# Lifecycle
|
|
270
459
|
# ------------------------------------------------------------------ #
|
|
@@ -293,6 +482,10 @@ class WherobotsJob:
|
|
|
293
482
|
Returns:
|
|
294
483
|
Run ID
|
|
295
484
|
"""
|
|
485
|
+
if self._attached:
|
|
486
|
+
raise WherobotsValidationError(
|
|
487
|
+
"Cannot submit a job attached via from_run_id(); this instance is read-only."
|
|
488
|
+
)
|
|
296
489
|
if self.run_id:
|
|
297
490
|
logger.warning("Job already submitted with run_id: %s", self.run_id)
|
|
298
491
|
return self.run_id
|
|
@@ -355,6 +548,52 @@ class WherobotsJob:
|
|
|
355
548
|
|
|
356
549
|
return self._api.get_metrics(self.run_id)
|
|
357
550
|
|
|
551
|
+
def get_cpu_utilization(self) -> UtilizationStats:
|
|
552
|
+
"""Get aggregated CPU utilization for the run.
|
|
553
|
+
|
|
554
|
+
Returns:
|
|
555
|
+
``UtilizationStats`` with ``latest``/``max``/``avg``/``series``.
|
|
556
|
+
All fields are ``None``/empty when the CPU metric is absent
|
|
557
|
+
from the server response.
|
|
558
|
+
"""
|
|
559
|
+
metrics = self.get_metrics()
|
|
560
|
+
return UtilizationStats.from_metric(
|
|
561
|
+
metrics.instant_metrics, metrics.series_metrics, self._CPU_METRIC_KEYS
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
def get_mem_utilization(self) -> UtilizationStats:
|
|
565
|
+
"""Get aggregated memory utilization for the run.
|
|
566
|
+
|
|
567
|
+
Returns:
|
|
568
|
+
``UtilizationStats`` with ``latest``/``max``/``avg``/``series``.
|
|
569
|
+
All fields are ``None``/empty when the memory metric is
|
|
570
|
+
absent from the server response.
|
|
571
|
+
"""
|
|
572
|
+
metrics = self.get_metrics()
|
|
573
|
+
return UtilizationStats.from_metric(
|
|
574
|
+
metrics.instant_metrics, metrics.series_metrics, self._MEM_METRIC_KEYS
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
def get_cost(self) -> float | None:
|
|
578
|
+
"""Get the run's total cost in USD.
|
|
579
|
+
|
|
580
|
+
Returns:
|
|
581
|
+
Cost in USD (e.g. ``16.90``), or ``None`` if the server did
|
|
582
|
+
not surface a cost for this run (typical for runs that are
|
|
583
|
+
still running or have not yet been billed).
|
|
584
|
+
"""
|
|
585
|
+
metrics = self.get_metrics()
|
|
586
|
+
return extract_instant_value(metrics.instant_metrics, self._COST_METRIC_KEYS)
|
|
587
|
+
|
|
588
|
+
def get_consumed_spatial_units(self) -> float | None:
|
|
589
|
+
"""Get the spatial units (SUs) consumed by the run.
|
|
590
|
+
|
|
591
|
+
Returns:
|
|
592
|
+
SUs consumed (e.g. ``11.27``), or ``None`` if not reported.
|
|
593
|
+
"""
|
|
594
|
+
metrics = self.get_metrics()
|
|
595
|
+
return extract_instant_value(metrics.instant_metrics, self._SU_METRIC_KEYS)
|
|
596
|
+
|
|
358
597
|
def iter_logs(
|
|
359
598
|
self,
|
|
360
599
|
cursor: int | str = 0,
|
|
@@ -429,6 +668,7 @@ class WherobotsJob:
|
|
|
429
668
|
)
|
|
430
669
|
|
|
431
670
|
try:
|
|
671
|
+
prev_cursor = self._last_log_cursor
|
|
432
672
|
logs = self.get_logs(cursor=self._last_log_cursor)
|
|
433
673
|
|
|
434
674
|
for item in logs.items:
|
|
@@ -440,7 +680,12 @@ class WherobotsJob:
|
|
|
440
680
|
consecutive_errors = 0 # Reset on success
|
|
441
681
|
|
|
442
682
|
if not follow:
|
|
443
|
-
|
|
683
|
+
# Drain remaining pages, then exit. Mirror iter_logs:
|
|
684
|
+
# stop when next_page is missing or the cursor doesn't
|
|
685
|
+
# advance (server-side loop guard).
|
|
686
|
+
if logs.next_page is None or logs.next_page == prev_cursor:
|
|
687
|
+
break
|
|
688
|
+
continue # fetch the next page immediately, no sleep
|
|
444
689
|
|
|
445
690
|
run_view = self.get_status()
|
|
446
691
|
if is_terminal_status(run_view.status):
|
|
@@ -461,13 +706,13 @@ class WherobotsJob:
|
|
|
461
706
|
raise
|
|
462
707
|
consecutive_errors += 1
|
|
463
708
|
logger.error("Error polling logs (%d/%d): %s", consecutive_errors, max_errors, exc)
|
|
464
|
-
if consecutive_errors >= max_errors
|
|
709
|
+
if consecutive_errors >= max_errors:
|
|
465
710
|
raise
|
|
466
711
|
time.sleep(interval)
|
|
467
712
|
except Exception as exc:
|
|
468
713
|
consecutive_errors += 1
|
|
469
714
|
logger.error("Error polling logs (%d/%d): %s", consecutive_errors, max_errors, exc)
|
|
470
|
-
if consecutive_errors >= max_errors
|
|
715
|
+
if consecutive_errors >= max_errors:
|
|
471
716
|
raise
|
|
472
717
|
time.sleep(interval)
|
|
473
718
|
|
wherobots/models.py
CHANGED
|
@@ -244,8 +244,8 @@ class RunJarPayload:
|
|
|
244
244
|
class CreateRunPayload:
|
|
245
245
|
"""Full payload for ``POST /runs``."""
|
|
246
246
|
|
|
247
|
-
runtime: str
|
|
248
247
|
name: str
|
|
248
|
+
runtime: str | None = None
|
|
249
249
|
version: str = "latest"
|
|
250
250
|
timeout_seconds: int = 3600
|
|
251
251
|
run_python: RunPythonPayload | None = None
|
|
@@ -259,11 +259,13 @@ class CreateRunPayload:
|
|
|
259
259
|
Dict suitable for ``POST /runs`` request body.
|
|
260
260
|
"""
|
|
261
261
|
d: dict[str, Any] = {
|
|
262
|
-
"runtime": self.runtime,
|
|
263
262
|
"name": self.name,
|
|
264
263
|
"version": self.version,
|
|
265
264
|
"timeoutSeconds": self.timeout_seconds,
|
|
266
265
|
}
|
|
266
|
+
# Omit runtime when unset so the API applies the org default.
|
|
267
|
+
if self.runtime is not None:
|
|
268
|
+
d["runtime"] = self.runtime
|
|
267
269
|
if self.run_python is not None:
|
|
268
270
|
d["runPython"] = self.run_python.to_dict()
|
|
269
271
|
if self.run_jar is not None:
|
|
@@ -296,7 +298,7 @@ class CreateRunPayload:
|
|
|
296
298
|
if "environment" in data and data["environment"] is not None:
|
|
297
299
|
environment = RunEnvironment.from_dict(data["environment"])
|
|
298
300
|
return cls(
|
|
299
|
-
runtime=data.get("runtime"
|
|
301
|
+
runtime=data.get("runtime"),
|
|
300
302
|
name=data.get("name", ""),
|
|
301
303
|
version=data.get("version", "latest"),
|
|
302
304
|
timeout_seconds=data.get("timeoutSeconds", 3600),
|
|
@@ -942,6 +944,146 @@ class RunMetricsResponse:
|
|
|
942
944
|
return d
|
|
943
945
|
|
|
944
946
|
|
|
947
|
+
@dataclass
|
|
948
|
+
class UtilizationStats:
|
|
949
|
+
"""Aggregated utilization for a single metric (e.g. CPU or memory).
|
|
950
|
+
|
|
951
|
+
``series`` is the raw time-series of ``(timestamp, value)`` points
|
|
952
|
+
as returned by the server, normalized to floats. ``latest`` /
|
|
953
|
+
``max`` / ``avg`` are derived from that series for convenience.
|
|
954
|
+
All fields are ``None`` / empty when the metric was not present in
|
|
955
|
+
the response.
|
|
956
|
+
"""
|
|
957
|
+
|
|
958
|
+
latest: float | None = None
|
|
959
|
+
max: float | None = None
|
|
960
|
+
avg: float | None = None
|
|
961
|
+
series: list[tuple[float, float]] = field(default_factory=list)
|
|
962
|
+
|
|
963
|
+
@classmethod
|
|
964
|
+
def from_metric(
|
|
965
|
+
cls,
|
|
966
|
+
instant: dict[str, Any],
|
|
967
|
+
series: dict[str, Any],
|
|
968
|
+
keys: tuple[str, ...],
|
|
969
|
+
) -> UtilizationStats:
|
|
970
|
+
"""Build stats from the server's untyped metric dicts.
|
|
971
|
+
|
|
972
|
+
Args:
|
|
973
|
+
instant: ``RunMetricsResponse.instant_metrics``.
|
|
974
|
+
series: ``RunMetricsResponse.series_metrics``.
|
|
975
|
+
keys: Candidate metric names to look up, in priority order.
|
|
976
|
+
The first key found in *series* (or *instant* as a
|
|
977
|
+
fallback) is used. Multiple candidates are supported
|
|
978
|
+
because metric names are server-defined and untyped.
|
|
979
|
+
|
|
980
|
+
Returns:
|
|
981
|
+
A populated ``UtilizationStats``, or an empty instance if
|
|
982
|
+
none of the candidate keys are present.
|
|
983
|
+
|
|
984
|
+
Note:
|
|
985
|
+
Tie-break is "first key with ≥1 parseable point wins" — once
|
|
986
|
+
we begin parsing a given key we do not fall through to
|
|
987
|
+
subsequent candidates, even if some points within that key
|
|
988
|
+
are malformed. In practice the primary (UPPERCASE) production
|
|
989
|
+
key matches and the lowercase aliases only get tried when the
|
|
990
|
+
primary key is entirely absent from the response.
|
|
991
|
+
"""
|
|
992
|
+
points = cls._extract_series(series, keys)
|
|
993
|
+
if points:
|
|
994
|
+
values = [v for _, v in points]
|
|
995
|
+
return cls(
|
|
996
|
+
latest=values[-1],
|
|
997
|
+
max=max(values),
|
|
998
|
+
avg=sum(values) / len(values),
|
|
999
|
+
series=points,
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
instant_value = extract_instant_value(instant, keys)
|
|
1003
|
+
if instant_value is not None:
|
|
1004
|
+
return cls(latest=instant_value, max=instant_value, avg=instant_value)
|
|
1005
|
+
|
|
1006
|
+
return cls()
|
|
1007
|
+
|
|
1008
|
+
@staticmethod
|
|
1009
|
+
def _extract_series(
|
|
1010
|
+
series: dict[str, Any],
|
|
1011
|
+
keys: tuple[str, ...],
|
|
1012
|
+
) -> list[tuple[float, float]]:
|
|
1013
|
+
for key in keys:
|
|
1014
|
+
raw = series.get(key)
|
|
1015
|
+
if raw is None:
|
|
1016
|
+
continue
|
|
1017
|
+
data = _unwrap_metric_envelope(raw)
|
|
1018
|
+
points: list[tuple[float, float]] = []
|
|
1019
|
+
for item in data if isinstance(data, list) else []:
|
|
1020
|
+
point = UtilizationStats._coerce_point(item)
|
|
1021
|
+
if point is not None:
|
|
1022
|
+
points.append(point)
|
|
1023
|
+
if points:
|
|
1024
|
+
return points
|
|
1025
|
+
return []
|
|
1026
|
+
|
|
1027
|
+
@staticmethod
|
|
1028
|
+
def _coerce_point(item: Any) -> tuple[float, float] | None:
|
|
1029
|
+
"""Coerce a single series entry to ``(timestamp, value)``.
|
|
1030
|
+
|
|
1031
|
+
Accepts ``[ts, value]`` / ``(ts, value)`` tuples and dicts
|
|
1032
|
+
with ``timestamp``/``value`` (or ``t``/``v``) keys. Returns
|
|
1033
|
+
``None`` for shapes that can't be coerced — silently skipped.
|
|
1034
|
+
"""
|
|
1035
|
+
if isinstance(item, (list, tuple)) and len(item) == 2:
|
|
1036
|
+
ts, value = item
|
|
1037
|
+
elif isinstance(item, dict):
|
|
1038
|
+
ts = item.get("timestamp", item.get("t"))
|
|
1039
|
+
value = item.get("value", item.get("v"))
|
|
1040
|
+
else:
|
|
1041
|
+
return None
|
|
1042
|
+
try:
|
|
1043
|
+
return float(ts), float(value)
|
|
1044
|
+
except (TypeError, ValueError):
|
|
1045
|
+
return None
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def _unwrap_metric_envelope(value: Any) -> Any:
|
|
1049
|
+
"""Strip the server's ``{display_name, metric: {data, format}}`` wrapper.
|
|
1050
|
+
|
|
1051
|
+
The Wherobots metrics endpoint wraps each metric as
|
|
1052
|
+
``{"display_name": ..., "metric": {"data": ..., "format": ...}}``.
|
|
1053
|
+
Returns ``data`` if present, otherwise the value untouched (so
|
|
1054
|
+
callers tolerate raw lists/scalars too).
|
|
1055
|
+
"""
|
|
1056
|
+
if isinstance(value, dict) and "metric" in value:
|
|
1057
|
+
metric = value.get("metric")
|
|
1058
|
+
if isinstance(metric, dict) and "data" in metric:
|
|
1059
|
+
return metric["data"]
|
|
1060
|
+
return value
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def extract_instant_value(
|
|
1064
|
+
instant: dict[str, Any],
|
|
1065
|
+
keys: tuple[str, ...],
|
|
1066
|
+
) -> float | None:
|
|
1067
|
+
"""Pull a scalar from ``instant_metrics`` by candidate key name.
|
|
1068
|
+
|
|
1069
|
+
Handles both the server's wrapped shape
|
|
1070
|
+
(``{metric: {data: {value, timestamp}}}``) and a bare numeric
|
|
1071
|
+
value. Returns ``None`` if no key matched or coercion failed.
|
|
1072
|
+
"""
|
|
1073
|
+
for key in keys:
|
|
1074
|
+
raw = instant.get(key)
|
|
1075
|
+
if raw is None:
|
|
1076
|
+
continue
|
|
1077
|
+
data = _unwrap_metric_envelope(raw)
|
|
1078
|
+
if isinstance(data, dict) and "value" in data:
|
|
1079
|
+
data = data["value"]
|
|
1080
|
+
try:
|
|
1081
|
+
return float(data)
|
|
1082
|
+
except (TypeError, ValueError):
|
|
1083
|
+
continue
|
|
1084
|
+
return None
|
|
1085
|
+
|
|
1086
|
+
|
|
945
1087
|
# ---------------------------------------------------------------------------
|
|
946
1088
|
# Pagination
|
|
947
1089
|
# ---------------------------------------------------------------------------
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wherobots-python-sdk
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Python SDK for Wherobots (currently covers the Jobs REST API)
|
|
5
5
|
Author-email: Wherobots <support@wherobots.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -130,7 +130,7 @@ The API key can also be set via the `WHEROBOTS_API_KEY` environment variable.
|
|
|
130
130
|
| Variable | Description | Default |
|
|
131
131
|
|------------------------------------|---------------------------------|--------------------------------------|
|
|
132
132
|
| `WHEROBOTS_API_KEY` | API key | *(required)* |
|
|
133
|
-
| `WHEROBOTS_REGION` |
|
|
133
|
+
| `WHEROBOTS_REGION` | Region override (else org default) | *(none — org default)* |
|
|
134
134
|
| `WHEROBOTS_API_BASE_URL` | API base URL | `https://api.cloud.wherobots.com` |
|
|
135
135
|
| `WHEROBOTS_VERSION` | Wherobots version | `latest` |
|
|
136
136
|
| `WHEROBOTS_REQUEST_TIMEOUT_SECONDS`| HTTP request timeout (seconds) | `30` |
|
|
@@ -176,8 +176,8 @@ The primary class for managing job runs.
|
|
|
176
176
|
WherobotsJob(
|
|
177
177
|
script: str, # Path or S3 URI to .py or .jar
|
|
178
178
|
name: str, # Job name (8-255 chars, [a-zA-Z0-9_\-.]+)
|
|
179
|
-
runtime: str | Runtime =
|
|
180
|
-
region: str | Region | None = None, #
|
|
179
|
+
runtime: str | Runtime | None = None, # Compute size (None -> org default)
|
|
180
|
+
region: str | Region | None = None, # Region; str passed as-is (None -> org default)
|
|
181
181
|
api_key: str | None = None, # API key (or env var)
|
|
182
182
|
version: str | None = None, # "latest" | "preview"
|
|
183
183
|
timeout_seconds: int = 3600, # Job timeout
|
|
@@ -202,6 +202,11 @@ WherobotsJob(
|
|
|
202
202
|
| `get_status()` | `RunView` | Get current job status and full details. |
|
|
203
203
|
| `get_logs(cursor=0, size=100)` | `LogsResponse` | Fetch a page of log entries. |
|
|
204
204
|
| `get_metrics()` | `RunMetricsResponse` | Fetch CPU/memory metrics for the run. |
|
|
205
|
+
| `get_cpu_utilization()` | `UtilizationStats` | Aggregated CPU utilization (`latest`, `max`, `avg`, `series`). |
|
|
206
|
+
| `get_mem_utilization()` | `UtilizationStats` | Aggregated memory utilization (`latest`, `max`, `avg`, `series`). |
|
|
207
|
+
| `get_cost()` | `float \| None` | Total run cost in USD, or `None` if not yet billed. |
|
|
208
|
+
| `get_consumed_spatial_units()` | `float \| None` | Spatial Units (SUs) consumed by the run. |
|
|
209
|
+
| `refresh()` | `RunView` | Re-fetch from the API and update `status`/`name`/`runtime`/`region`/`version` in place. |
|
|
205
210
|
| `iter_logs(cursor=0, size=100)` | `Iterator[dict]` | Iterate over all log entries, handling pagination automatically. |
|
|
206
211
|
| `poll_for_logs(follow=True, interval=2.0, log_handler=None, max_errors=10)` | `None` | Poll and print logs. If `follow=True`, continues until job completes. `max_errors` sets the max consecutive transient errors before giving up. |
|
|
207
212
|
| `cancel()` | `bool` | Request cancellation. Returns `True` on success. |
|
|
@@ -227,10 +232,40 @@ with WherobotsJob(script="s3://bucket/script.py", name="my-job") as job:
|
|
|
227
232
|
|
|
228
233
|
| Method | Returns | Description |
|
|
229
234
|
|--------|---------|-------------|
|
|
235
|
+
| `from_run_id(run_id, api_key=None, ...)` | `WherobotsJob` | Attach to an existing run for read-only log/metric access. No script required. `submit()` is disabled on the returned instance. |
|
|
230
236
|
| `list_runs(...)` | `RunListPage` | List runs with optional filters. No instance required. |
|
|
231
237
|
| `add_pypi_dependency(name, version)` | `dict` | Create a PyPI dependency dict for the `dependencies` parameter. |
|
|
232
238
|
| `add_file_dependency(file_path)` | `dict` | Create a file dependency dict (`.jar`, `.whl`, `.zip`, `.json`). |
|
|
233
239
|
|
|
240
|
+
#### Attaching to an Existing Run
|
|
241
|
+
|
|
242
|
+
If you already have a `run_id` (from the CLI, the Wherobots UI, or a prior SDK
|
|
243
|
+
session) you can attach without a script:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
from wherobots import WherobotsJob
|
|
247
|
+
|
|
248
|
+
job = WherobotsJob.from_run_id("run-abc-123")
|
|
249
|
+
print(job.status, job.name)
|
|
250
|
+
|
|
251
|
+
# Stream remaining logs
|
|
252
|
+
job.poll_for_logs(follow=False)
|
|
253
|
+
|
|
254
|
+
# Or paginate
|
|
255
|
+
for entry in job.iter_logs(size=200):
|
|
256
|
+
print(entry["raw"])
|
|
257
|
+
|
|
258
|
+
# Aggregated utilization for a completed run
|
|
259
|
+
cpu = job.get_cpu_utilization()
|
|
260
|
+
print(f"CPU peak {cpu.max}, avg {cpu.avg}, samples {len(cpu.series)}")
|
|
261
|
+
|
|
262
|
+
# Billing
|
|
263
|
+
print(f"cost ${job.get_cost():.2f}, SUs {job.get_consumed_spatial_units()}")
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Calling `job.submit()` on an attached instance raises
|
|
267
|
+
`WherobotsValidationError` — these instances are read-only.
|
|
268
|
+
|
|
234
269
|
#### Listing Runs
|
|
235
270
|
|
|
236
271
|
```python
|
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
wherobots/__init__.py,sha256=
|
|
2
|
-
wherobots/__version__.py,sha256=
|
|
3
|
-
wherobots/client.py,sha256=
|
|
1
|
+
wherobots/__init__.py,sha256=vUSHeAhv2Vk26evXDuOdTjlJAG6sTM0DV1DVvI1XsPc,1800
|
|
2
|
+
wherobots/__version__.py,sha256=kDkfSolu4vWo-Y2a1tCbG94akdaRELoEhg9L_Ko-mwQ,111
|
|
3
|
+
wherobots/client.py,sha256=NJFw8Okb7i416iuVh-buqDk6XKm1eP4scTD5bNdoQaE,33406
|
|
4
4
|
wherobots/config.py,sha256=Ib20Y5mGsE4aP_g99ZKEyZvV8q-aOXq7RVpYP8i0lpo,6679
|
|
5
5
|
wherobots/enums.py,sha256=YsxXN3ZcY99aoEsUtjYs4otHDevsSPizAgCp-vShds8,4154
|
|
6
6
|
wherobots/exceptions.py,sha256=ERoTU7Edf-dFCE4-ScyuEcC-2O2d5JRa8issQWc-PNA,1296
|
|
7
|
-
wherobots/models.py,sha256=
|
|
7
|
+
wherobots/models.py,sha256=gs8X3husFp5k_EBxYp-_7WFTMI99tuwL_bw53Ffg5bM,40786
|
|
8
8
|
wherobots/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
9
|
wherobots/api/__init__.py,sha256=jUb587jazP_FA-sH_6yCYfvGxsNiUOyIYAPb3CHOYtQ,218
|
|
10
10
|
wherobots/api/base.py,sha256=IiT3k17G19bT0rM2zYZs9Tpob8pgTrSLAWdKlLUVM1o,8769
|
|
11
11
|
wherobots/api/files.py,sha256=c2yGQ-yQKzvp1O-47JrFp1H6Uj7FDCBRiyJ0ym8g-TE,14040
|
|
12
|
-
wherobots/api/runs.py,sha256=
|
|
12
|
+
wherobots/api/runs.py,sha256=ef5ouPGUsuaIIpBW3sHjkOXxfxTuOA6ieFjkMIuNAyc,8542
|
|
13
13
|
wherobots/utils/__init__.py,sha256=cTM2QNJDauQWyGBTJPE5yH4ULc4Ucg_4KFzemjM3HaA,193
|
|
14
14
|
wherobots/utils/logger.py,sha256=eclhD9Vf2LsSm4engRzT3jE2v0wdHJH01OfeI3ml4HM,1103
|
|
15
15
|
wherobots/utils/validation.py,sha256=R0iG4OqzER4prdP8-TOQV_pQFJ3emEQyWYYuc3aCefU,890
|
|
16
|
-
wherobots_python_sdk-0.1.
|
|
17
|
-
wherobots_python_sdk-0.1.
|
|
18
|
-
wherobots_python_sdk-0.1.
|
|
19
|
-
wherobots_python_sdk-0.1.
|
|
20
|
-
wherobots_python_sdk-0.1.
|
|
16
|
+
wherobots_python_sdk-0.2.1.dist-info/licenses/LICENSE,sha256=U1EH466XmvxbqaWOY0cax49w0R87_6w6yJLyV14v4bc,10767
|
|
17
|
+
wherobots_python_sdk-0.2.1.dist-info/METADATA,sha256=YHfG_yTnllcs-I6gfo-LVd8-QmIm2JtkcwOrRGiVS54,23068
|
|
18
|
+
wherobots_python_sdk-0.2.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
19
|
+
wherobots_python_sdk-0.2.1.dist-info/top_level.txt,sha256=oL-n-9GIG0rmUeukuytUqdN6yfROIsVh8bNrgZsOdxw,10
|
|
20
|
+
wherobots_python_sdk-0.2.1.dist-info/RECORD,,
|
|
File without changes
|
{wherobots_python_sdk-0.1.0.dist-info → wherobots_python_sdk-0.2.1.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|