wherobots-python-sdk 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {wherobots_python_sdk-0.2.0/wherobots_python_sdk.egg-info → wherobots_python_sdk-0.2.1}/PKG-INFO +36 -1
  2. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/README.md +35 -0
  3. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/tests/test_client.py +218 -0
  4. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/tests/test_models.py +127 -0
  5. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/tests/test_security.py +48 -3
  6. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/wherobots/__init__.py +2 -0
  7. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/wherobots/__version__.py +1 -1
  8. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/wherobots/client.py +246 -14
  9. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/wherobots/models.py +140 -0
  10. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1/wherobots_python_sdk.egg-info}/PKG-INFO +36 -1
  11. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/LICENSE +0 -0
  12. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/pyproject.toml +0 -0
  13. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/setup.cfg +0 -0
  14. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/tests/test_api.py +0 -0
  15. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/tests/test_base_client.py +0 -0
  16. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/tests/test_config.py +0 -0
  17. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/tests/test_enums.py +0 -0
  18. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/tests/test_exceptions.py +0 -0
  19. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/tests/test_files_api.py +0 -0
  20. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/tests/test_init_exports.py +0 -0
  21. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/tests/test_integration.py +0 -0
  22. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/tests/test_logger.py +0 -0
  23. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/tests/test_regressions.py +0 -0
  24. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/tests/test_utils.py +0 -0
  25. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/wherobots/api/__init__.py +0 -0
  26. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/wherobots/api/base.py +0 -0
  27. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/wherobots/api/files.py +0 -0
  28. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/wherobots/api/runs.py +0 -0
  29. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/wherobots/config.py +0 -0
  30. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/wherobots/enums.py +0 -0
  31. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/wherobots/exceptions.py +0 -0
  32. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/wherobots/py.typed +0 -0
  33. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/wherobots/utils/__init__.py +0 -0
  34. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/wherobots/utils/logger.py +0 -0
  35. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/wherobots/utils/validation.py +0 -0
  36. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/wherobots_python_sdk.egg-info/SOURCES.txt +0 -0
  37. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/wherobots_python_sdk.egg-info/dependency_links.txt +0 -0
  38. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/wherobots_python_sdk.egg-info/requires.txt +0 -0
  39. {wherobots_python_sdk-0.2.0 → wherobots_python_sdk-0.2.1}/wherobots_python_sdk.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wherobots-python-sdk
3
- Version: 0.2.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
@@ -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
@@ -169,6 +169,11 @@ WherobotsJob(
169
169
  | `get_status()` | `RunView` | Get current job status and full details. |
170
170
  | `get_logs(cursor=0, size=100)` | `LogsResponse` | Fetch a page of log entries. |
171
171
  | `get_metrics()` | `RunMetricsResponse` | Fetch CPU/memory metrics for the run. |
172
+ | `get_cpu_utilization()` | `UtilizationStats` | Aggregated CPU utilization (`latest`, `max`, `avg`, `series`). |
173
+ | `get_mem_utilization()` | `UtilizationStats` | Aggregated memory utilization (`latest`, `max`, `avg`, `series`). |
174
+ | `get_cost()` | `float \| None` | Total run cost in USD, or `None` if not yet billed. |
175
+ | `get_consumed_spatial_units()` | `float \| None` | Spatial Units (SUs) consumed by the run. |
176
+ | `refresh()` | `RunView` | Re-fetch from the API and update `status`/`name`/`runtime`/`region`/`version` in place. |
172
177
  | `iter_logs(cursor=0, size=100)` | `Iterator[dict]` | Iterate over all log entries, handling pagination automatically. |
173
178
  | `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. |
174
179
  | `cancel()` | `bool` | Request cancellation. Returns `True` on success. |
@@ -194,10 +199,40 @@ with WherobotsJob(script="s3://bucket/script.py", name="my-job") as job:
194
199
 
195
200
  | Method | Returns | Description |
196
201
  |--------|---------|-------------|
202
+ | `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. |
197
203
  | `list_runs(...)` | `RunListPage` | List runs with optional filters. No instance required. |
198
204
  | `add_pypi_dependency(name, version)` | `dict` | Create a PyPI dependency dict for the `dependencies` parameter. |
199
205
  | `add_file_dependency(file_path)` | `dict` | Create a file dependency dict (`.jar`, `.whl`, `.zip`, `.json`). |
200
206
 
207
+ #### Attaching to an Existing Run
208
+
209
+ If you already have a `run_id` (from the CLI, the Wherobots UI, or a prior SDK
210
+ session) you can attach without a script:
211
+
212
+ ```python
213
+ from wherobots import WherobotsJob
214
+
215
+ job = WherobotsJob.from_run_id("run-abc-123")
216
+ print(job.status, job.name)
217
+
218
+ # Stream remaining logs
219
+ job.poll_for_logs(follow=False)
220
+
221
+ # Or paginate
222
+ for entry in job.iter_logs(size=200):
223
+ print(entry["raw"])
224
+
225
+ # Aggregated utilization for a completed run
226
+ cpu = job.get_cpu_utilization()
227
+ print(f"CPU peak {cpu.max}, avg {cpu.avg}, samples {len(cpu.series)}")
228
+
229
+ # Billing
230
+ print(f"cost ${job.get_cost():.2f}, SUs {job.get_consumed_spatial_units()}")
231
+ ```
232
+
233
+ Calling `job.submit()` on an attached instance raises
234
+ `WherobotsValidationError` — these instances are read-only.
235
+
201
236
  #### Listing Runs
202
237
 
203
238
  ```python
@@ -447,3 +447,221 @@ class TestS3DeprecationWarnings:
447
447
  )
448
448
  deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
449
449
  assert any("s3_prefix" in str(w.message) for w in deprecations)
450
+
451
+
452
+ # ── from_run_id (attach to existing run) ────────────────────────────────
453
+
454
+
455
+ class TestFromRunId:
456
+ """Read-only attach constructor — no script required."""
457
+
458
+ def test_from_run_id_hydrates_from_api(self, mock_env, mock_run_view):
459
+ with patch("wherobots.client.RunsAPI") as runs_cls:
460
+ api = _make_mock_api(run_view=mock_run_view)
461
+ runs_cls.from_config.return_value = api
462
+
463
+ job = WherobotsJob.from_run_id("run-123")
464
+
465
+ assert job.run_id == "run-123"
466
+ assert job._attached is True
467
+ assert job.name == "test-job-name"
468
+ assert job.runtime == "tiny"
469
+ assert job.status == JobStatus.PENDING
470
+ api.get.assert_called_once_with("run-123")
471
+
472
+ def test_from_run_id_empty_raises(self, mock_env):
473
+ with pytest.raises(WherobotsValidationError):
474
+ WherobotsJob.from_run_id("")
475
+
476
+ def test_attached_submit_raises(self, mock_env, mock_run_view):
477
+ with patch("wherobots.client.RunsAPI") as runs_cls:
478
+ runs_cls.from_config.return_value = _make_mock_api(run_view=mock_run_view)
479
+ job = WherobotsJob.from_run_id("run-123")
480
+
481
+ with pytest.raises(WherobotsValidationError, match="read-only"):
482
+ job.submit()
483
+
484
+ def test_attached_get_logs(self, mock_env, mock_run_view, mock_logs_response):
485
+ with patch("wherobots.client.RunsAPI") as runs_cls:
486
+ api = _make_mock_api(run_view=mock_run_view, logs_response=mock_logs_response)
487
+ runs_cls.from_config.return_value = api
488
+
489
+ job = WherobotsJob.from_run_id("run-123")
490
+ logs = job.get_logs(size=10)
491
+
492
+ assert len(logs.items) == 2
493
+ api.get_logs.assert_called_once_with("run-123", cursor=0, size=10)
494
+
495
+ def test_refresh_updates_fields(self, mock_env):
496
+ with patch("wherobots.client.RunsAPI") as runs_cls:
497
+ first = RunView.from_dict({"id": "r", "name": "n1", "status": "PENDING"})
498
+ second = RunView.from_dict(
499
+ {"id": "r", "name": "n2", "status": "COMPLETED", "runtime": "small"}
500
+ )
501
+ api = MagicMock()
502
+ api.get.side_effect = [first, second]
503
+ runs_cls.from_config.return_value = api
504
+
505
+ job = WherobotsJob.from_run_id("r")
506
+ assert job.status == JobStatus.PENDING
507
+
508
+ run_view = job.refresh()
509
+ assert run_view.status == JobStatus.COMPLETED
510
+ assert job.status == JobStatus.COMPLETED
511
+ assert job.name == "n2"
512
+ assert job.runtime == "small"
513
+
514
+
515
+ # ── Utilization accessors ──────────────────────────────────────────────
516
+
517
+
518
+ class TestUtilizationAccessors:
519
+ def test_cpu_utilization_from_series(self, mock_env, sample_job_config):
520
+ job = WherobotsJob(**sample_job_config)
521
+ job.run_id = "run-1"
522
+ metrics = RunMetricsResponse.from_dict(
523
+ {"series_metrics": {"cpu_usage": [[1.0, 10.0], [2.0, 30.0], [3.0, 20.0]]}}
524
+ )
525
+ job._api = _make_mock_api(metrics_response=metrics)
526
+
527
+ stats = job.get_cpu_utilization()
528
+ assert stats.latest == 20.0
529
+ assert stats.max == 30.0
530
+ assert stats.avg == pytest.approx(20.0)
531
+ assert stats.series == [(1.0, 10.0), (2.0, 30.0), (3.0, 20.0)]
532
+
533
+ def test_mem_utilization_falls_back_to_instant(self, mock_env, sample_job_config):
534
+ job = WherobotsJob(**sample_job_config)
535
+ job.run_id = "run-1"
536
+ metrics = RunMetricsResponse.from_dict(
537
+ {"series_metrics": {}, "instant_metrics": {"memory_usage": 0.42}}
538
+ )
539
+ job._api = _make_mock_api(metrics_response=metrics)
540
+
541
+ stats = job.get_mem_utilization()
542
+ assert stats.latest == 0.42
543
+ assert stats.max == 0.42
544
+ assert stats.avg == 0.42
545
+ assert stats.series == []
546
+
547
+ def test_utilization_absent_returns_empty(self, mock_env, sample_job_config):
548
+ job = WherobotsJob(**sample_job_config)
549
+ job.run_id = "run-1"
550
+ metrics = RunMetricsResponse.from_dict({"series_metrics": {}, "instant_metrics": {}})
551
+ job._api = _make_mock_api(metrics_response=metrics)
552
+
553
+ stats = job.get_cpu_utilization()
554
+ assert stats.latest is None
555
+ assert stats.max is None
556
+ assert stats.avg is None
557
+ assert stats.series == []
558
+
559
+ def test_real_api_shape_cpu_and_cost(self, mock_env, sample_job_config):
560
+ """End-to-end shape match against the actual API envelope."""
561
+ job = WherobotsJob(**sample_job_config)
562
+ job.run_id = "run-1"
563
+ metrics = RunMetricsResponse.from_dict(
564
+ {
565
+ "series_metrics": {
566
+ "CPU_UTILIZATION_PERCENT": {
567
+ "display_name": "CPU Utilization",
568
+ "metric": {
569
+ "data": [
570
+ {"value": 5.0, "timestamp": 1000},
571
+ {"value": 50.0, "timestamp": 1015},
572
+ ],
573
+ "format": "PERCENT",
574
+ },
575
+ },
576
+ "MEMORY_UTILIZATION_PERCENT": {
577
+ "display_name": "Memory Utilization",
578
+ "metric": {
579
+ "data": [{"value": 25.0, "timestamp": 1000}],
580
+ "format": "PERCENT",
581
+ },
582
+ },
583
+ },
584
+ "instant_metrics": {
585
+ "COST_USD": {
586
+ "display_name": "Cost",
587
+ "metric": {
588
+ "data": {"value": 16.904, "timestamp": 1000},
589
+ "format": "CURRENCY",
590
+ },
591
+ },
592
+ "CONSUMED_SPATIAL_UNITS": {
593
+ "display_name": "Spatial Units Consumed",
594
+ "metric": {
595
+ "data": {"value": 11.27, "timestamp": 1000},
596
+ "format": "NUMBER",
597
+ },
598
+ },
599
+ },
600
+ }
601
+ )
602
+ job._api = _make_mock_api(metrics_response=metrics)
603
+
604
+ cpu = job.get_cpu_utilization()
605
+ assert cpu.latest == 50.0
606
+ assert cpu.max == 50.0
607
+ assert len(cpu.series) == 2
608
+
609
+ mem = job.get_mem_utilization()
610
+ assert mem.latest == 25.0
611
+
612
+ assert job.get_cost() == 16.904
613
+ assert job.get_consumed_spatial_units() == 11.27
614
+
615
+ def test_cost_missing_returns_none(self, mock_env, sample_job_config):
616
+ job = WherobotsJob(**sample_job_config)
617
+ job.run_id = "run-1"
618
+ metrics = RunMetricsResponse.from_dict({"series_metrics": {}, "instant_metrics": {}})
619
+ job._api = _make_mock_api(metrics_response=metrics)
620
+
621
+ assert job.get_cost() is None
622
+ assert job.get_consumed_spatial_units() is None
623
+
624
+
625
+ class TestPollForLogsDrain:
626
+ """``poll_for_logs(follow=False)`` must drain ALL pages, not just one."""
627
+
628
+ def test_follow_false_drains_all_pages(self, mock_env, sample_job_config):
629
+ job = WherobotsJob(**sample_job_config)
630
+ job.run_id = "run-drain"
631
+
632
+ pages = [
633
+ LogsResponse(
634
+ items=[LogItem(raw=f"line-{i}") for i in range(3)],
635
+ next_page="cursor-1",
636
+ ),
637
+ LogsResponse(
638
+ items=[LogItem(raw=f"line-{i}") for i in range(3, 6)],
639
+ next_page="cursor-2",
640
+ ),
641
+ LogsResponse(
642
+ items=[LogItem(raw=f"line-{i}") for i in range(6, 8)],
643
+ next_page=None,
644
+ ),
645
+ ]
646
+ job._api = MagicMock()
647
+ job._api.get_logs.side_effect = pages
648
+
649
+ captured: list[dict] = []
650
+ job.poll_for_logs(follow=False, log_handler=captured.append)
651
+
652
+ assert [c["raw"] for c in captured] == [f"line-{i}" for i in range(8)]
653
+ assert job._api.get_logs.call_count == 3
654
+
655
+ def test_follow_false_stuck_cursor_breaks(self, mock_env, sample_job_config):
656
+ """Server returning the same cursor stops the drain (no infinite loop)."""
657
+ job = WherobotsJob(**sample_job_config)
658
+ job.run_id = "run-stuck"
659
+
660
+ stuck = LogsResponse(items=[LogItem(raw="x")], next_page=0)
661
+ job._api = MagicMock()
662
+ job._api.get_logs.return_value = stuck
663
+
664
+ captured: list[dict] = []
665
+ job.poll_for_logs(follow=False, log_handler=captured.append)
666
+
667
+ assert job._api.get_logs.call_count == 1
@@ -18,6 +18,8 @@ from wherobots.models import (
18
18
  RunMetricsResponse,
19
19
  RunPythonPayload,
20
20
  RunView,
21
+ UtilizationStats,
22
+ extract_instant_value,
21
23
  )
22
24
 
23
25
  # ---------------------------------------------------------------------------
@@ -786,3 +788,128 @@ class TestRunListPage:
786
788
  assert run.run_python.uri == "s3://b/s.py"
787
789
  assert run.triggered_by is not None
788
790
  assert run.triggered_by.email == "user@example.com"
791
+
792
+
793
+ # ---------------------------------------------------------------------------
794
+ # UtilizationStats.from_metric
795
+ # ---------------------------------------------------------------------------
796
+
797
+
798
+ class TestUtilizationStats:
799
+ def test_empty(self):
800
+ stats = UtilizationStats.from_metric({}, {}, ("cpu_usage",))
801
+ assert stats.latest is None
802
+ assert stats.max is None
803
+ assert stats.avg is None
804
+ assert stats.series == []
805
+
806
+ def test_series_list_of_pairs(self):
807
+ stats = UtilizationStats.from_metric(
808
+ {}, {"cpu_usage": [[0, 1.0], [1, 5.0], [2, 3.0]]}, ("cpu_usage",)
809
+ )
810
+ assert stats.series == [(0.0, 1.0), (1.0, 5.0), (2.0, 3.0)]
811
+ assert stats.latest == 3.0
812
+ assert stats.max == 5.0
813
+ assert stats.avg == 3.0
814
+
815
+ def test_series_list_of_dicts(self):
816
+ stats = UtilizationStats.from_metric(
817
+ {},
818
+ {"cpu_usage": [{"timestamp": 0, "value": 2.0}, {"t": 1, "v": 4.0}]},
819
+ ("cpu_usage",),
820
+ )
821
+ assert stats.series == [(0.0, 2.0), (1.0, 4.0)]
822
+ assert stats.max == 4.0
823
+
824
+ def test_instant_fallback_when_series_empty(self):
825
+ stats = UtilizationStats.from_metric({"memory_usage": 0.75}, {}, ("memory_usage",))
826
+ assert stats.latest == 0.75
827
+ assert stats.max == 0.75
828
+ assert stats.avg == 0.75
829
+ assert stats.series == []
830
+
831
+ def test_first_matching_key_wins(self):
832
+ stats = UtilizationStats.from_metric(
833
+ {},
834
+ {"cpu_utilization": [[0, 100.0]], "cpu": [[0, 50.0]]},
835
+ ("cpu_usage", "cpu_utilization", "cpu"),
836
+ )
837
+ assert stats.latest == 100.0
838
+
839
+ def test_unrecognized_shape_skipped(self):
840
+ stats = UtilizationStats.from_metric(
841
+ {},
842
+ {"cpu_usage": [[0, 1.0], "garbage", {"no": "fields"}, [1, 2.0]]},
843
+ ("cpu_usage",),
844
+ )
845
+ assert stats.series == [(0.0, 1.0), (1.0, 2.0)]
846
+
847
+ def test_non_numeric_instant_ignored(self):
848
+ stats = UtilizationStats.from_metric(
849
+ {"memory_usage": "not-a-number"}, {}, ("memory_usage",)
850
+ )
851
+ assert stats.latest is None
852
+ assert stats.series == []
853
+
854
+ def test_missing_keys(self):
855
+ stats = UtilizationStats.from_metric(
856
+ {"other": 1.0}, {"different": [[0, 1.0]]}, ("cpu_usage",)
857
+ )
858
+ assert stats.latest is None
859
+ assert stats.series == []
860
+
861
+ def test_real_api_envelope_series(self):
862
+ """Real API wraps values as {display_name, metric: {data, format}}."""
863
+ series = {
864
+ "CPU_UTILIZATION_PERCENT": {
865
+ "display_name": "CPU Utilization",
866
+ "metric": {
867
+ "data": [
868
+ {"value": 10.0, "timestamp": 1000},
869
+ {"value": 30.0, "timestamp": 1015},
870
+ {"value": 20.0, "timestamp": 1030},
871
+ ],
872
+ "format": "PERCENT",
873
+ },
874
+ }
875
+ }
876
+ stats = UtilizationStats.from_metric({}, series, ("CPU_UTILIZATION_PERCENT",))
877
+ assert stats.series == [(1000.0, 10.0), (1015.0, 30.0), (1030.0, 20.0)]
878
+ assert stats.latest == 20.0
879
+ assert stats.max == 30.0
880
+
881
+ def test_real_api_envelope_instant(self):
882
+ """Real API instant_metrics wrap a single point under metric.data."""
883
+ instant = {
884
+ "COST_USD": {
885
+ "display_name": "Cost",
886
+ "metric": {"data": {"value": 16.904, "timestamp": 1000}, "format": "CURRENCY"},
887
+ }
888
+ }
889
+ stats = UtilizationStats.from_metric(instant, {}, ("COST_USD",))
890
+ assert stats.latest == 16.904
891
+ assert stats.max == 16.904
892
+
893
+
894
+ class TestExtractInstantValue:
895
+ def test_real_envelope(self):
896
+ instant = {
897
+ "COST_USD": {
898
+ "display_name": "Cost",
899
+ "metric": {"data": {"value": 16.904, "timestamp": 1000}, "format": "CURRENCY"},
900
+ }
901
+ }
902
+ assert extract_instant_value(instant, ("COST_USD",)) == 16.904
903
+
904
+ def test_bare_number(self):
905
+ assert extract_instant_value({"x": 42}, ("x",)) == 42.0
906
+
907
+ def test_missing_key(self):
908
+ assert extract_instant_value({"other": 1.0}, ("x",)) is None
909
+
910
+ def test_non_numeric_skipped(self):
911
+ assert extract_instant_value({"x": "nope"}, ("x", "y")) is None
912
+
913
+ def test_first_match_wins(self):
914
+ instant = {"a": 1.0, "b": 2.0}
915
+ assert extract_instant_value(instant, ("a", "b")) == 1.0
@@ -232,17 +232,62 @@ class TestPollForLogsErrorHandling:
232
232
  # Should not raise — error count resets after successful request
233
233
  job.poll_for_logs(follow=True, interval=0.01, max_errors=2)
234
234
 
235
- def test_no_follow_does_not_retry(self, mock_env, sample_job_config):
236
- """In oneshot mode (follow=False), errors should be raised immediately."""
235
+ def test_no_follow_retries_transient_then_raises(self, mock_env, sample_job_config):
236
+ """``follow=False`` drains multiple pages and must respect ``max_errors``
237
+ — transient 5xx are retried, only exhausting the budget propagates."""
237
238
  job = WherobotsJob(**sample_job_config)
238
239
  job.run_id = "run-nofollow"
239
240
  job._api = MagicMock()
240
241
  job._api.get_logs.side_effect = WherobotsAPIError("Server error", status_code=500)
241
242
 
242
- with pytest.raises(WherobotsAPIError):
243
+ with pytest.raises(WherobotsAPIError, match="Server error"):
244
+ job.poll_for_logs(follow=False, interval=0.01, max_errors=3)
245
+ assert job._api.get_logs.call_count == 3
246
+
247
+ def test_no_follow_non_transient_raises_immediately(self, mock_env, sample_job_config):
248
+ """Non-transient 4xx (except 429) must propagate on the first hit even
249
+ in drain mode — we don't retry permission/validation errors."""
250
+ job = WherobotsJob(**sample_job_config)
251
+ job.run_id = "run-nofollow-4xx"
252
+ job._api = MagicMock()
253
+ job._api.get_logs.side_effect = WherobotsAPIError("Forbidden", status_code=403)
254
+
255
+ with pytest.raises(WherobotsAPIError, match="Forbidden"):
243
256
  job.poll_for_logs(follow=False, interval=0.01, max_errors=5)
244
257
  assert job._api.get_logs.call_count == 1
245
258
 
259
+ def test_no_follow_recovers_mid_drain(self, mock_env, sample_job_config):
260
+ """A transient error mid-drain should not abort the whole drain, and
261
+ the retry must reuse the un-advanced cursor (not skip the failed page)."""
262
+ job = WherobotsJob(**sample_job_config)
263
+ job.run_id = "run-nofollow-recover"
264
+
265
+ from wherobots.models import LogItem
266
+
267
+ pages = [
268
+ LogsResponse(items=[LogItem(raw="a")], next_page="c1"),
269
+ WherobotsAPIError("Transient", status_code=500), # mid-drain blip
270
+ LogsResponse(items=[LogItem(raw="b")], next_page=None),
271
+ ]
272
+ job._api = MagicMock()
273
+ job._api.get_logs.side_effect = pages
274
+
275
+ captured: list[dict] = []
276
+ job.poll_for_logs(follow=False, interval=0.01, max_errors=3, log_handler=captured.append)
277
+ assert [c["raw"] for c in captured] == ["a", "b"]
278
+
279
+ # Pin retry semantics: the mock's side_effect list returns by call
280
+ # index, so without these assertions a buggy implementation that
281
+ # advanced the cursor past the failed page would still pass.
282
+ calls = job._api.get_logs.call_args_list
283
+ assert len(calls) == 3
284
+ assert calls[0].kwargs["cursor"] == 0
285
+ # The failed page (call 2) and its retry (call 3) MUST use the same
286
+ # cursor — the implementation must not advance past a page that errored.
287
+ assert calls[1].kwargs["cursor"] == "c1"
288
+ assert calls[2].kwargs["cursor"] == "c1"
289
+ assert calls[1] == calls[2]
290
+
246
291
 
247
292
  # =========================================================================== #
248
293
  # RunsAPI._parse_json()
@@ -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
  ]
@@ -1,5 +1,5 @@
1
1
  """Version information."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.1"
4
4
  __author__ = "Wherobots"
5
5
  __email__ = "support@wherobots.com"
@@ -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,8 +54,55 @@ 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,
@@ -130,9 +179,8 @@ class WherobotsJob:
130
179
  f"spark_executor_disk_gb must be non-negative, got {spark_executor_disk_gb}"
131
180
  )
132
181
 
133
- self.script = script
134
- self.name = validate_name(name)
135
- self.runtime = runtime.value if isinstance(runtime, Runtime) else runtime
182
+ self._init_defaults()
183
+
136
184
  region_value = region.value if isinstance(region, Region) else region
137
185
 
138
186
  # Deprecation warnings for s3_bucket / s3_prefix are emitted by
@@ -149,6 +197,9 @@ class WherobotsJob:
149
197
  request_timeout_seconds=request_timeout_seconds,
150
198
  )
151
199
 
200
+ self.script = script
201
+ self.name = validate_name(name)
202
+ self.runtime = runtime.value if isinstance(runtime, Runtime) else runtime
152
203
  # No hardcoded fallback: when neither the argument nor the config
153
204
  # supplies a region, leave it unset so the API applies the org default.
154
205
  self.region = region_value or self._config.region
@@ -164,20 +215,44 @@ class WherobotsJob:
164
215
  self.jar_main_class = jar_main_class
165
216
  self.auto_upload = auto_upload
166
217
 
167
- self.run_id: str | None = None
168
- # Status may be a string when the server returns a value the
169
- # SDK's JobStatus enum doesn't recognize yet (forward-compat).
170
- self.status: JobStatus | str | None = None
171
- self._last_log_cursor: int | str = 0
172
- self._script_uri: str | None = None
173
-
174
218
  self.is_jar = script.lower().endswith(".jar")
175
219
  if self.is_jar and not jar_main_class:
176
220
  raise WherobotsValidationError("jar_main_class is required for JAR files")
177
221
 
178
- # Build the API layer
179
222
  self._api = RunsAPI.from_config(self._config)
180
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
+
181
256
  # ------------------------------------------------------------------ #
182
257
  # Upload helpers
183
258
  # ------------------------------------------------------------------ #
@@ -200,6 +275,10 @@ class WherobotsJob:
200
275
  if self._script_uri:
201
276
  return self._script_uri
202
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
+
203
282
  if self._is_s3_uri(self.script):
204
283
  self._script_uri = self.script
205
284
  elif self.auto_upload:
@@ -240,6 +319,10 @@ class WherobotsJob:
240
319
  # ------------------------------------------------------------------ #
241
320
 
242
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
+
243
326
  script_uri = self._prepare_script_uri()
244
327
 
245
328
  run_python: RunPythonPayload | None = None
@@ -278,6 +361,99 @@ class WherobotsJob:
278
361
  environment=environment,
279
362
  )
280
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
+
281
457
  # ------------------------------------------------------------------ #
282
458
  # Lifecycle
283
459
  # ------------------------------------------------------------------ #
@@ -306,6 +482,10 @@ class WherobotsJob:
306
482
  Returns:
307
483
  Run ID
308
484
  """
485
+ if self._attached:
486
+ raise WherobotsValidationError(
487
+ "Cannot submit a job attached via from_run_id(); this instance is read-only."
488
+ )
309
489
  if self.run_id:
310
490
  logger.warning("Job already submitted with run_id: %s", self.run_id)
311
491
  return self.run_id
@@ -368,6 +548,52 @@ class WherobotsJob:
368
548
 
369
549
  return self._api.get_metrics(self.run_id)
370
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
+
371
597
  def iter_logs(
372
598
  self,
373
599
  cursor: int | str = 0,
@@ -442,6 +668,7 @@ class WherobotsJob:
442
668
  )
443
669
 
444
670
  try:
671
+ prev_cursor = self._last_log_cursor
445
672
  logs = self.get_logs(cursor=self._last_log_cursor)
446
673
 
447
674
  for item in logs.items:
@@ -453,7 +680,12 @@ class WherobotsJob:
453
680
  consecutive_errors = 0 # Reset on success
454
681
 
455
682
  if not follow:
456
- 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
457
689
 
458
690
  run_view = self.get_status()
459
691
  if is_terminal_status(run_view.status):
@@ -474,13 +706,13 @@ class WherobotsJob:
474
706
  raise
475
707
  consecutive_errors += 1
476
708
  logger.error("Error polling logs (%d/%d): %s", consecutive_errors, max_errors, exc)
477
- if consecutive_errors >= max_errors or not follow:
709
+ if consecutive_errors >= max_errors:
478
710
  raise
479
711
  time.sleep(interval)
480
712
  except Exception as exc:
481
713
  consecutive_errors += 1
482
714
  logger.error("Error polling logs (%d/%d): %s", consecutive_errors, max_errors, exc)
483
- if consecutive_errors >= max_errors or not follow:
715
+ if consecutive_errors >= max_errors:
484
716
  raise
485
717
  time.sleep(interval)
486
718
 
@@ -944,6 +944,146 @@ class RunMetricsResponse:
944
944
  return d
945
945
 
946
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
+
947
1087
  # ---------------------------------------------------------------------------
948
1088
  # Pagination
949
1089
  # ---------------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wherobots-python-sdk
3
- Version: 0.2.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
@@ -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