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 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
@@ -1,5 +1,5 @@
1
1
  """Version information."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.1"
4
4
  __author__ = "Wherobots"
5
5
  __email__ = "support@wherobots.com"
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 = "aws-us-west-2",
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 (default
112
- ``"aws-us-west-2"``).
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 = {"region": region}
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 = "tiny",
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: Compute runtime size (default: "tiny")
86
- region: AWS region (default: from config or env)
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.script = script
123
- self.name = validate_name(name)
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.region = region_value or self._config.region or "aws-us-west-2"
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
- break
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 or not follow:
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 or not follow:
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.0
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` | Default AWS region | `aws-us-west-2` |
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 = "tiny", # Compute size
180
- region: str | Region | None = None, # AWS region
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=UGvPiw86dJwrZiA-7u-Nx4thyZj7QmdOio343-vscHg,1754
2
- wherobots/__version__.py,sha256=gdQx5awJ980s8gFK_3u0b4RogasTty0-FJ6cROVHjZo,111
3
- wherobots/client.py,sha256=go2-usZMWm74zUXupiy5k-M3Faprn_WJaH1wK0Sr0po,23467
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=RKmxMpHTLzhOdb3FcvxWLgrxjpSCBpuc9l-BacbqLl4,35673
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=25Hz0evE0gfu7YCQma77AcxAbrk89iKgzdoE0XFJr7U,8442
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.0.dist-info/licenses/LICENSE,sha256=U1EH466XmvxbqaWOY0cax49w0R87_6w6yJLyV14v4bc,10767
17
- wherobots_python_sdk-0.1.0.dist-info/METADATA,sha256=EH_LfjKliEImWBAi3a2t2a5HvbvokyzpIsqV0p-jqWI,21512
18
- wherobots_python_sdk-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
19
- wherobots_python_sdk-0.1.0.dist-info/top_level.txt,sha256=oL-n-9GIG0rmUeukuytUqdN6yfROIsVh8bNrgZsOdxw,10
20
- wherobots_python_sdk-0.1.0.dist-info/RECORD,,
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,,