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.
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/PKG-INFO +39 -4
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/README.md +38 -3
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_api.py +15 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_client.py +249 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_models.py +137 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_security.py +48 -3
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/__init__.py +2 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/__version__.py +1 -1
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/api/runs.py +7 -5
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/client.py +263 -18
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/models.py +145 -3
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots_python_sdk.egg-info/PKG-INFO +39 -4
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/LICENSE +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/pyproject.toml +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/setup.cfg +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_base_client.py +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_config.py +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_enums.py +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_exceptions.py +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_files_api.py +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_init_exports.py +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_integration.py +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_logger.py +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_regressions.py +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/tests/test_utils.py +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/api/__init__.py +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/api/base.py +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/api/files.py +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/config.py +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/enums.py +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/exceptions.py +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/py.typed +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/utils/__init__.py +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/utils/logger.py +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots/utils/validation.py +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots_python_sdk.egg-info/SOURCES.txt +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots_python_sdk.egg-info/dependency_links.txt +0 -0
- {wherobots_python_sdk-0.1.0 → wherobots_python_sdk-0.2.1}/wherobots_python_sdk.egg-info/requires.txt +0 -0
- {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
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Python SDK for Wherobots (currently covers the Jobs REST API)
|
|
5
5
|
Author-email: Wherobots <support@wherobots.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -130,7 +130,7 @@ The API key can also be set via the `WHEROBOTS_API_KEY` environment variable.
|
|
|
130
130
|
| Variable | Description | Default |
|
|
131
131
|
|------------------------------------|---------------------------------|--------------------------------------|
|
|
132
132
|
| `WHEROBOTS_API_KEY` | API key | *(required)* |
|
|
133
|
-
| `WHEROBOTS_REGION` |
|
|
133
|
+
| `WHEROBOTS_REGION` | Region override (else org default) | *(none — org default)* |
|
|
134
134
|
| `WHEROBOTS_API_BASE_URL` | API base URL | `https://api.cloud.wherobots.com` |
|
|
135
135
|
| `WHEROBOTS_VERSION` | Wherobots version | `latest` |
|
|
136
136
|
| `WHEROBOTS_REQUEST_TIMEOUT_SECONDS`| HTTP request timeout (seconds) | `30` |
|
|
@@ -176,8 +176,8 @@ The primary class for managing job runs.
|
|
|
176
176
|
WherobotsJob(
|
|
177
177
|
script: str, # Path or S3 URI to .py or .jar
|
|
178
178
|
name: str, # Job name (8-255 chars, [a-zA-Z0-9_\-.]+)
|
|
179
|
-
runtime: str | Runtime =
|
|
180
|
-
region: str | Region | None = None, #
|
|
179
|
+
runtime: str | Runtime | None = None, # Compute size (None -> org default)
|
|
180
|
+
region: str | Region | None = None, # Region; str passed as-is (None -> org default)
|
|
181
181
|
api_key: str | None = None, # API key (or env var)
|
|
182
182
|
version: str | None = None, # "latest" | "preview"
|
|
183
183
|
timeout_seconds: int = 3600, # Job timeout
|
|
@@ -202,6 +202,11 @@ WherobotsJob(
|
|
|
202
202
|
| `get_status()` | `RunView` | Get current job status and full details. |
|
|
203
203
|
| `get_logs(cursor=0, size=100)` | `LogsResponse` | Fetch a page of log entries. |
|
|
204
204
|
| `get_metrics()` | `RunMetricsResponse` | Fetch CPU/memory metrics for the run. |
|
|
205
|
+
| `get_cpu_utilization()` | `UtilizationStats` | Aggregated CPU utilization (`latest`, `max`, `avg`, `series`). |
|
|
206
|
+
| `get_mem_utilization()` | `UtilizationStats` | Aggregated memory utilization (`latest`, `max`, `avg`, `series`). |
|
|
207
|
+
| `get_cost()` | `float \| None` | Total run cost in USD, or `None` if not yet billed. |
|
|
208
|
+
| `get_consumed_spatial_units()` | `float \| None` | Spatial Units (SUs) consumed by the run. |
|
|
209
|
+
| `refresh()` | `RunView` | Re-fetch from the API and update `status`/`name`/`runtime`/`region`/`version` in place. |
|
|
205
210
|
| `iter_logs(cursor=0, size=100)` | `Iterator[dict]` | Iterate over all log entries, handling pagination automatically. |
|
|
206
211
|
| `poll_for_logs(follow=True, interval=2.0, log_handler=None, max_errors=10)` | `None` | Poll and print logs. If `follow=True`, continues until job completes. `max_errors` sets the max consecutive transient errors before giving up. |
|
|
207
212
|
| `cancel()` | `bool` | Request cancellation. Returns `True` on success. |
|
|
@@ -227,10 +232,40 @@ with WherobotsJob(script="s3://bucket/script.py", name="my-job") as job:
|
|
|
227
232
|
|
|
228
233
|
| Method | Returns | Description |
|
|
229
234
|
|--------|---------|-------------|
|
|
235
|
+
| `from_run_id(run_id, api_key=None, ...)` | `WherobotsJob` | Attach to an existing run for read-only log/metric access. No script required. `submit()` is disabled on the returned instance. |
|
|
230
236
|
| `list_runs(...)` | `RunListPage` | List runs with optional filters. No instance required. |
|
|
231
237
|
| `add_pypi_dependency(name, version)` | `dict` | Create a PyPI dependency dict for the `dependencies` parameter. |
|
|
232
238
|
| `add_file_dependency(file_path)` | `dict` | Create a file dependency dict (`.jar`, `.whl`, `.zip`, `.json`). |
|
|
233
239
|
|
|
240
|
+
#### Attaching to an Existing Run
|
|
241
|
+
|
|
242
|
+
If you already have a `run_id` (from the CLI, the Wherobots UI, or a prior SDK
|
|
243
|
+
session) you can attach without a script:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
from wherobots import WherobotsJob
|
|
247
|
+
|
|
248
|
+
job = WherobotsJob.from_run_id("run-abc-123")
|
|
249
|
+
print(job.status, job.name)
|
|
250
|
+
|
|
251
|
+
# Stream remaining logs
|
|
252
|
+
job.poll_for_logs(follow=False)
|
|
253
|
+
|
|
254
|
+
# Or paginate
|
|
255
|
+
for entry in job.iter_logs(size=200):
|
|
256
|
+
print(entry["raw"])
|
|
257
|
+
|
|
258
|
+
# Aggregated utilization for a completed run
|
|
259
|
+
cpu = job.get_cpu_utilization()
|
|
260
|
+
print(f"CPU peak {cpu.max}, avg {cpu.avg}, samples {len(cpu.series)}")
|
|
261
|
+
|
|
262
|
+
# Billing
|
|
263
|
+
print(f"cost ${job.get_cost():.2f}, SUs {job.get_consumed_spatial_units()}")
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Calling `job.submit()` on an attached instance raises
|
|
267
|
+
`WherobotsValidationError` — these instances are read-only.
|
|
268
|
+
|
|
234
269
|
#### Listing Runs
|
|
235
270
|
|
|
236
271
|
```python
|
|
@@ -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` |
|
|
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 =
|
|
147
|
-
region: str | Region | None = None, #
|
|
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
|
|
236
|
-
"""
|
|
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
|
]
|