wherobots-python-sdk 0.1.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.1.0 → wherobots_python_sdk-0.2.1}/PKG-INFO +39 -4
  2. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/README.md +38 -3
  3. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_api.py +15 -0
  4. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_client.py +249 -0
  5. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_models.py +137 -0
  6. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_security.py +48 -3
  7. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/__init__.py +2 -0
  8. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/__version__.py +1 -1
  9. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/api/runs.py +7 -5
  10. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/client.py +263 -18
  11. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/models.py +145 -3
  12. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots_python_sdk.egg-info/PKG-INFO +39 -4
  13. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/LICENSE +0 -0
  14. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/pyproject.toml +0 -0
  15. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/setup.cfg +0 -0
  16. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_base_client.py +0 -0
  17. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_config.py +0 -0
  18. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_enums.py +0 -0
  19. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_exceptions.py +0 -0
  20. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_files_api.py +0 -0
  21. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_init_exports.py +0 -0
  22. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_integration.py +0 -0
  23. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_logger.py +0 -0
  24. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_regressions.py +0 -0
  25. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_utils.py +0 -0
  26. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/api/__init__.py +0 -0
  27. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/api/base.py +0 -0
  28. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/api/files.py +0 -0
  29. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/config.py +0 -0
  30. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/enums.py +0 -0
  31. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/exceptions.py +0 -0
  32. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/py.typed +0 -0
  33. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/utils/__init__.py +0 -0
  34. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/utils/logger.py +0 -0
  35. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/utils/validation.py +0 -0
  36. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots_python_sdk.egg-info/SOURCES.txt +0 -0
  37. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots_python_sdk.egg-info/dependency_links.txt +0 -0
  38. {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots_python_sdk.egg-info/requires.txt +0 -0
  39. {wherobots_python_sdk-0.1.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.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
@@ -97,7 +97,7 @@ The API key can also be set via the `WHEROBOTS_API_KEY` environment variable.
97
97
  | Variable | Description | Default |
98
98
  |------------------------------------|---------------------------------|--------------------------------------|
99
99
  | `WHEROBOTS_API_KEY` | API key | *(required)* |
100
- | `WHEROBOTS_REGION` | Default AWS region | `aws-us-west-2` |
100
+ | `WHEROBOTS_REGION` | Region override (else org default) | *(none — org default)* |
101
101
  | `WHEROBOTS_API_BASE_URL` | API base URL | `https://api.cloud.wherobots.com` |
102
102
  | `WHEROBOTS_VERSION` | Wherobots version | `latest` |
103
103
  | `WHEROBOTS_REQUEST_TIMEOUT_SECONDS`| HTTP request timeout (seconds) | `30` |
@@ -143,8 +143,8 @@ The primary class for managing job runs.
143
143
  WherobotsJob(
144
144
  script: str, # Path or S3 URI to .py or .jar
145
145
  name: str, # Job name (8-255 chars, [a-zA-Z0-9_\-.]+)
146
- runtime: str | Runtime = "tiny", # Compute size
147
- region: str | Region | None = None, # AWS region
146
+ runtime: str | Runtime | None = None, # Compute size (None -> org default)
147
+ region: str | Region | None = None, # Region; str passed as-is (None -> org default)
148
148
  api_key: str | None = None, # API key (or env var)
149
149
  version: str | None = None, # "latest" | "preview"
150
150
  timeout_seconds: int = 3600, # Job timeout
@@ -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
@@ -145,6 +145,21 @@ class TestCreate:
145
145
  assert "/runs" in call_args[0][1]
146
146
  assert call_args[1]["params"]["region"] == "aws-us-east-1"
147
147
 
148
+ def test_omits_region_when_not_provided(self, api, mock_session):
149
+ mock_session.request.return_value = _make_response(
150
+ {"id": "r2", "name": "t", "status": "PENDING"}
151
+ )
152
+ payload = CreateRunPayload(
153
+ name="default-region-job",
154
+ run_python=RunPythonPayload(uri="s3://b/s.py"),
155
+ )
156
+ api.create(payload) # no region -> API applies org default
157
+
158
+ call_kwargs = mock_session.request.call_args[1]
159
+ assert "region" not in call_kwargs["params"]
160
+ # runtime omitted from the body too -> org default applies
161
+ assert "runtime" not in call_kwargs["json"]
162
+
148
163
  def test_sends_payload_as_json(self, api, mock_session):
149
164
  mock_session.request.return_value = _make_response(
150
165
  {"id": "r1", "name": "test", "status": "PENDING"}
@@ -69,6 +69,37 @@ def test_build_payload_jar_job(mock_env):
69
69
  assert payload.run_python is None
70
70
 
71
71
 
72
+ def test_no_region_or_runtime_uses_no_default(monkeypatch, mock_api_key):
73
+ """When neither arg, config, nor env supplies region/runtime, they stay
74
+ unset so the API applies the organization's configured defaults."""
75
+ monkeypatch.setenv("WHEROBOTS_API_KEY", mock_api_key)
76
+ monkeypatch.delenv("WHEROBOTS_REGION", raising=False)
77
+
78
+ job = WherobotsJob(script="s3://test-bucket/test-script.py", name="defaults-job")
79
+
80
+ assert job.region is None
81
+ assert job.runtime is None
82
+ payload = job._build_payload()
83
+ assert payload.runtime is None
84
+ assert "runtime" not in payload.to_dict()
85
+
86
+
87
+ def test_string_region_runtime_passthrough(monkeypatch, mock_api_key):
88
+ """Raw region/runtime strings (e.g. BYOC regions) are kept as-is."""
89
+ monkeypatch.setenv("WHEROBOTS_API_KEY", mock_api_key)
90
+ monkeypatch.delenv("WHEROBOTS_REGION", raising=False)
91
+
92
+ job = WherobotsJob(
93
+ script="s3://test-bucket/test-script.py",
94
+ name="byoc-job",
95
+ runtime="x-large",
96
+ region="byoc-acme-us-east-1",
97
+ )
98
+
99
+ assert job.region == "byoc-acme-us-east-1"
100
+ assert job.runtime == "x-large"
101
+
102
+
72
103
  def test_submit_sets_run_id(mock_env, sample_job_config):
73
104
  job = WherobotsJob(**sample_job_config)
74
105
 
@@ -416,3 +447,221 @@ class TestS3DeprecationWarnings:
416
447
  )
417
448
  deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
418
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
  # ---------------------------------------------------------------------------
@@ -196,6 +198,16 @@ class TestCreateRunPayload:
196
198
  out = payload.to_dict()
197
199
  assert "environment" not in out
198
200
 
201
+ def test_runtime_omitted_when_none(self):
202
+ payload = CreateRunPayload(name="no-runtime")
203
+ out = payload.to_dict()
204
+ assert "runtime" not in out
205
+
206
+ def test_runtime_included_when_set(self):
207
+ payload = CreateRunPayload(name="with-runtime", runtime="large")
208
+ out = payload.to_dict()
209
+ assert out["runtime"] == "large"
210
+
199
211
 
200
212
  # ---------------------------------------------------------------------------
201
213
  # RunView
@@ -776,3 +788,128 @@ class TestRunListPage:
776
788
  assert run.run_python.uri == "s3://b/s.py"
777
789
  assert run.triggered_by is not None
778
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.1.0"
3
+ __version__ = "0.2.1"
4
4
  __author__ = "Wherobots"
5
5
  __email__ = "support@wherobots.com"