wherobots-python-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
wherobots/api/files.py ADDED
@@ -0,0 +1,386 @@
1
+ """Files API — upload scripts via presigned URLs.
2
+
3
+ Wraps the ``/organization``, ``/files/upload-url``, and
4
+ ``/storage/{id}/file-upload-url/{path}`` endpoints to enable script
5
+ upload using only a Wherobots API key (no AWS credentials required).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import requests
15
+
16
+ from wherobots.api.base import BaseClient
17
+ from wherobots.config import WherobotsConfig
18
+ from wherobots.exceptions import WherobotsAPIError, WherobotsValidationError
19
+ from wherobots.models import StorageIntegration
20
+ from wherobots.utils.logger import get_logger
21
+
22
+ logger = get_logger("api.files")
23
+
24
+ #: Maximum upload file size: 500 MB.
25
+ MAX_UPLOAD_FILE_SIZE_BYTES: int = 500 * 1024 * 1024
26
+
27
+
28
+ class FilesAPI:
29
+ """Upload files to Wherobots managed storage via presigned URLs.
30
+
31
+ Uses the ``/files/upload-url`` endpoint to obtain a presigned S3
32
+ PUT URL, then uploads the file content directly. No AWS credentials
33
+ are needed — authentication is handled entirely through the
34
+ Wherobots API key.
35
+
36
+ Args:
37
+ client: A configured ``BaseClient`` instance.
38
+ """
39
+
40
+ def __init__(self, client: BaseClient) -> None:
41
+ self._client = client
42
+ self._integrations_cache: list[StorageIntegration] | None = None
43
+
44
+ # ------------------------------------------------------------------ #
45
+ # Factory
46
+ # ------------------------------------------------------------------ #
47
+
48
+ @classmethod
49
+ def from_config(cls, config: WherobotsConfig, **kwargs: Any) -> FilesAPI:
50
+ """Build a ``FilesAPI`` from a ``WherobotsConfig``.
51
+
52
+ Args:
53
+ config: Validated configuration object.
54
+ **kwargs: Additional keyword arguments forwarded to
55
+ ``BaseClient.__init__`` (e.g. ``max_retries``).
56
+
57
+ Returns:
58
+ A new ``FilesAPI`` instance backed by the configured client.
59
+ """
60
+ client = BaseClient(config, **kwargs)
61
+ return cls(client)
62
+
63
+ # ------------------------------------------------------------------ #
64
+ # Endpoints
65
+ # ------------------------------------------------------------------ #
66
+
67
+ def get_organization(self) -> dict[str, Any]:
68
+ """Fetch the current organization details (``GET /organization``).
69
+
70
+ Returns:
71
+ Organization dict including ``fileStore.bucketName`` and
72
+ ``id`` (org identifier).
73
+
74
+ Raises:
75
+ WherobotsAPIError: On HTTP or JSON-parsing failures.
76
+ """
77
+ response = self._client.get("/organization")
78
+ try:
79
+ org: dict[str, Any] = response.json()
80
+ except (ValueError, requests.exceptions.JSONDecodeError) as exc:
81
+ raise WherobotsAPIError(
82
+ f"Invalid JSON in /organization response (status {response.status_code})",
83
+ status_code=response.status_code,
84
+ ) from exc
85
+ return org
86
+
87
+ def get_upload_url(self, key: str) -> str:
88
+ """Get a presigned S3 PUT URL for a given key.
89
+
90
+ Calls ``POST /files/upload-url?key=<key>`` and returns the
91
+ presigned URL from the response.
92
+
93
+ Args:
94
+ key: The full destination key for the managed S3 bucket,
95
+ formatted as ``"{bucket}/{org_id}/{path}"``
96
+ (e.g. ``"wbts-bucket/org123/data/shared/scripts/my_script.py"``).
97
+ The ``upload_script`` method constructs this automatically.
98
+
99
+ Returns:
100
+ A presigned S3 PUT URL string.
101
+
102
+ Raises:
103
+ WherobotsAPIError: On HTTP or JSON-parsing failures.
104
+ """
105
+ response = self._client.post(
106
+ "/files/upload-url",
107
+ params={"key": key},
108
+ )
109
+ try:
110
+ data = response.json()
111
+ except (ValueError, requests.exceptions.JSONDecodeError) as exc:
112
+ raise WherobotsAPIError(
113
+ f"Invalid JSON in /files/upload-url response (status {response.status_code})",
114
+ status_code=response.status_code,
115
+ ) from exc
116
+
117
+ upload_url = data.get("uploadUrl")
118
+ if not isinstance(upload_url, str) or not upload_url:
119
+ raise WherobotsAPIError(
120
+ "No 'uploadUrl' in /files/upload-url response",
121
+ response=data,
122
+ )
123
+ return upload_url
124
+
125
+ def upload_script(
126
+ self,
127
+ content: str,
128
+ filename: str | None = None,
129
+ ) -> str:
130
+ """Upload a Python script to Wherobots managed storage.
131
+
132
+ This is a high-level convenience method that:
133
+
134
+ 1. Calls ``GET /organization`` to discover the bucket name and
135
+ org ID.
136
+ 2. Constructs the S3 key:
137
+ ``{org_id}/data/shared/scripts/{filename}``.
138
+ 3. Obtains a presigned PUT URL via ``POST /files/upload-url``.
139
+ 4. Uploads the script content via a raw HTTP PUT.
140
+ 5. Returns the ``s3://`` URI for the uploaded script.
141
+
142
+ Args:
143
+ content: The Python script source code.
144
+ filename: Optional filename. If not provided, a UUID-based
145
+ name is generated (``sdk-upload-{uuid}.py``).
146
+
147
+ Returns:
148
+ The ``s3://`` URI of the uploaded script, suitable for
149
+ passing to ``CreateRunPayload``.
150
+
151
+ Raises:
152
+ WherobotsValidationError: If the filename is invalid.
153
+ WherobotsAPIError: On Wherobots API or presigned PUT failures.
154
+ """
155
+ if filename is None:
156
+ filename = f"sdk-upload-{uuid.uuid4().hex[:12]}.py"
157
+
158
+ # Reject path-traversal characters in user-supplied filenames.
159
+ if "/" in filename or "\\" in filename or ".." in filename:
160
+ raise WherobotsValidationError(
161
+ f"Invalid filename '{filename}': must not contain '/', '\\', or '..'"
162
+ )
163
+
164
+ # 1. Discover bucket and org ID
165
+ org = self.get_organization()
166
+ bucket = org.get("fileStore", {}).get("bucketName")
167
+ org_id = org.get("id")
168
+
169
+ if not bucket or not org_id:
170
+ raise WherobotsAPIError(
171
+ "Could not determine bucket or org ID from /organization response",
172
+ response=org,
173
+ )
174
+
175
+ # 2. Construct the S3 key and the upload-url key.
176
+ # The upload-url endpoint expects: {bucket}/{org_id}/{path}
177
+ # The resulting S3 URI is: s3://{bucket}/{org_id}/{path}
178
+ relative_path = f"{org_id}/data/shared/scripts/{filename}"
179
+ upload_key = f"{bucket}/{relative_path}"
180
+
181
+ logger.info(
182
+ "Uploading script '%s' to s3://%s/%s",
183
+ filename,
184
+ bucket,
185
+ relative_path,
186
+ )
187
+
188
+ # 3. Get presigned upload URL
189
+ presigned_url = self.get_upload_url(upload_key)
190
+
191
+ # 4. PUT the content directly to S3 (no Wherobots auth headers)
192
+ put_response = requests.put(
193
+ presigned_url,
194
+ data=content.encode("utf-8"),
195
+ headers={},
196
+ timeout=60,
197
+ allow_redirects=False,
198
+ )
199
+ if not put_response.ok:
200
+ raise WherobotsAPIError(
201
+ f"Presigned PUT failed (HTTP {put_response.status_code}): "
202
+ f"{put_response.text[:500]}",
203
+ status_code=put_response.status_code,
204
+ )
205
+
206
+ # 5. Return the S3 URI
207
+ s3_uri = f"s3://{bucket}/{relative_path}"
208
+ logger.info("Upload complete: %s", s3_uri)
209
+ return s3_uri
210
+
211
+ def upload_file(
212
+ self,
213
+ local_path: str,
214
+ filename: str | None = None,
215
+ max_file_size_bytes: int = MAX_UPLOAD_FILE_SIZE_BYTES,
216
+ ) -> str:
217
+ """Upload a local file to Wherobots managed storage via presigned URL.
218
+
219
+ Unlike ``upload_script()`` which accepts a string of source code,
220
+ this method reads a file from disk in binary mode, making it
221
+ suitable for Python scripts, JARs, and other binary artifacts.
222
+
223
+ Args:
224
+ local_path: Filesystem path to the file to upload.
225
+ filename: Optional destination filename. Defaults to the
226
+ basename of *local_path*.
227
+ max_file_size_bytes: Maximum allowed file size in bytes.
228
+ Defaults to ``MAX_UPLOAD_FILE_SIZE_BYTES`` (500 MB).
229
+ Set to ``0`` to disable the check.
230
+
231
+ Returns:
232
+ The ``s3://`` URI of the uploaded file.
233
+
234
+ Raises:
235
+ WherobotsValidationError: If the file exceeds the size limit,
236
+ does not exist, or the filename is invalid.
237
+ WherobotsAPIError: On Wherobots API failures or presigned
238
+ PUT failures.
239
+ """
240
+ path = Path(local_path)
241
+ if not path.is_file():
242
+ raise WherobotsValidationError(f"File not found: {local_path}")
243
+
244
+ if filename is None:
245
+ filename = path.name
246
+
247
+ # Reject path-traversal characters in user-supplied filenames.
248
+ if "/" in filename or "\\" in filename or ".." in filename:
249
+ raise WherobotsValidationError(
250
+ f"Invalid filename '{filename}': must not contain '/', '\\', or '..'"
251
+ )
252
+
253
+ # Size validation
254
+ if max_file_size_bytes > 0:
255
+ try:
256
+ file_size = path.stat().st_size
257
+ except OSError as exc:
258
+ raise WherobotsValidationError(f"Cannot read file '{local_path}': {exc}") from exc
259
+ if file_size > max_file_size_bytes:
260
+ size_mb = file_size / (1024 * 1024)
261
+ limit_mb = max_file_size_bytes / (1024 * 1024)
262
+ raise WherobotsValidationError(
263
+ f"File '{path.name}' is {size_mb:.1f} MB, exceeding the "
264
+ f"{limit_mb:.0f} MB upload limit. Use an S3 URI instead."
265
+ )
266
+
267
+ # 1. Discover bucket and org ID
268
+ org = self.get_organization()
269
+ bucket = org.get("fileStore", {}).get("bucketName")
270
+ org_id = org.get("id")
271
+
272
+ if not bucket or not org_id:
273
+ raise WherobotsAPIError(
274
+ "Could not determine bucket or org ID from /organization response",
275
+ response=org,
276
+ )
277
+
278
+ # 2. Construct the S3 key
279
+ relative_path = f"{org_id}/data/shared/scripts/{filename}"
280
+ upload_key = f"{bucket}/{relative_path}"
281
+
282
+ logger.info(
283
+ "Uploading file '%s' (%s) to s3://%s/%s",
284
+ path.name,
285
+ filename,
286
+ bucket,
287
+ relative_path,
288
+ )
289
+
290
+ # 3. Get presigned upload URL
291
+ presigned_url = self.get_upload_url(upload_key)
292
+
293
+ # 4. Stream the file to S3 without loading it fully into memory.
294
+ with path.open("rb") as fh:
295
+ put_response = requests.put(
296
+ presigned_url,
297
+ data=fh,
298
+ headers={},
299
+ timeout=120,
300
+ allow_redirects=False,
301
+ )
302
+ if not put_response.ok:
303
+ raise WherobotsAPIError(
304
+ f"Presigned PUT failed (HTTP {put_response.status_code}): "
305
+ f"{put_response.text[:500]}",
306
+ status_code=put_response.status_code,
307
+ )
308
+
309
+ # 5. Return the S3 URI
310
+ s3_uri = f"s3://{bucket}/{relative_path}"
311
+ logger.info("Upload complete: %s", s3_uri)
312
+ return s3_uri
313
+
314
+ # ------------------------------------------------------------------ #
315
+ # Storage Integration Endpoints
316
+ # ------------------------------------------------------------------ #
317
+
318
+ def list_integrations(self) -> list[StorageIntegration]:
319
+ """List all configured storage integrations (``GET /storage``).
320
+
321
+ Returns:
322
+ A list of ``StorageIntegration`` objects.
323
+
324
+ Raises:
325
+ WherobotsAPIError: On HTTP or JSON-parsing failures.
326
+ """
327
+ response = self._client.get("/storage")
328
+ try:
329
+ data = response.json()
330
+ except (ValueError, requests.exceptions.JSONDecodeError) as exc:
331
+ raise WherobotsAPIError(
332
+ f"Invalid JSON in /storage response (status {response.status_code})",
333
+ status_code=response.status_code,
334
+ ) from exc
335
+ return [StorageIntegration.from_dict(item) for item in data]
336
+
337
+ def resolve_integration(
338
+ self,
339
+ name_or_id: str,
340
+ *,
341
+ refresh: bool = False,
342
+ ) -> StorageIntegration:
343
+ """Find a storage integration by name (case-insensitive) or ID.
344
+
345
+ Results from ``list_integrations`` are cached on the instance to
346
+ avoid repeated ``GET /storage`` calls. Pass ``refresh=True`` to
347
+ force a fresh fetch if the set of integrations has changed.
348
+
349
+ Args:
350
+ name_or_id: The integration name or unique ID.
351
+ refresh: Force a fresh fetch of integrations.
352
+
353
+ Returns:
354
+ The matching ``StorageIntegration``.
355
+
356
+ Raises:
357
+ WherobotsValidationError: If no matching integration is found.
358
+ WherobotsAPIError: On HTTP failures.
359
+ """
360
+ if refresh or self._integrations_cache is None:
361
+ self._integrations_cache = self.list_integrations()
362
+ integrations = self._integrations_cache
363
+ query = name_or_id.lower()
364
+ for integration in integrations:
365
+ if integration.id == name_or_id or integration.name.lower() == query:
366
+ return integration
367
+ available = [i.name for i in integrations]
368
+ raise WherobotsValidationError(
369
+ f"Storage integration '{name_or_id}' not found. Available integrations: {available}"
370
+ )
371
+
372
+ # ------------------------------------------------------------------ #
373
+ # Lifecycle
374
+ # ------------------------------------------------------------------ #
375
+
376
+ def close(self) -> None:
377
+ """Close the underlying HTTP client and release resources."""
378
+ self._client.close()
379
+
380
+ def __enter__(self) -> FilesAPI:
381
+ """Enter the runtime context (context-manager protocol)."""
382
+ return self
383
+
384
+ def __exit__(self, *exc: Any) -> None:
385
+ """Exit the runtime context, closing the client."""
386
+ self.close()
wherobots/api/runs.py ADDED
@@ -0,0 +1,255 @@
1
+ """Runs API — typed wrappers around ``/runs`` endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import builtins
6
+ from typing import Any
7
+ from urllib.parse import quote as _url_quote
8
+
9
+ import requests
10
+
11
+ from wherobots.api.base import BaseClient
12
+ from wherobots.config import WherobotsConfig
13
+ from wherobots.enums import JobStatus
14
+ from wherobots.exceptions import WherobotsAPIError
15
+ from wherobots.models import (
16
+ CreateRunPayload,
17
+ LogsResponse,
18
+ RunListPage,
19
+ RunMetricsResponse,
20
+ RunView,
21
+ )
22
+ from wherobots.utils.logger import get_logger
23
+
24
+ logger = get_logger("api.runs")
25
+
26
+
27
+ class RunsAPI:
28
+ """High-level, typed API surface for Wherobots job runs.
29
+
30
+ Wraps the low-level ``BaseClient`` and provides typed request/response
31
+ methods for every ``/runs`` endpoint.
32
+
33
+ Args:
34
+ client: A configured ``BaseClient`` instance.
35
+ """
36
+
37
+ def __init__(self, client: BaseClient) -> None:
38
+ self._client = client
39
+
40
+ # ------------------------------------------------------------------ #
41
+ # Helpers
42
+ # ------------------------------------------------------------------ #
43
+
44
+ @staticmethod
45
+ def _safe_run_id(run_id: str) -> str:
46
+ """URL-encode a run ID for safe path interpolation.
47
+
48
+ Args:
49
+ run_id: Raw run identifier.
50
+
51
+ Returns:
52
+ The percent-encoded run ID string.
53
+ """
54
+ return _url_quote(run_id, safe="")
55
+
56
+ @staticmethod
57
+ def _parse_json(response: requests.Response) -> dict[str, Any]:
58
+ """Safely parse JSON from an HTTP response.
59
+
60
+ Args:
61
+ response: The ``requests.Response`` to parse.
62
+
63
+ Returns:
64
+ Parsed JSON body as a dictionary.
65
+
66
+ Raises:
67
+ WherobotsAPIError: If the body is not valid JSON.
68
+ """
69
+ try:
70
+ parsed: dict[str, Any] = response.json()
71
+ except (ValueError, requests.exceptions.JSONDecodeError) as exc:
72
+ raise WherobotsAPIError(
73
+ f"Invalid JSON in API response (status {response.status_code})",
74
+ status_code=response.status_code,
75
+ ) from exc
76
+ return parsed
77
+
78
+ # ------------------------------------------------------------------ #
79
+ # Factory
80
+ # ------------------------------------------------------------------ #
81
+
82
+ @classmethod
83
+ def from_config(cls, config: WherobotsConfig, **kwargs: Any) -> RunsAPI:
84
+ """Build a ``RunsAPI`` from a ``WherobotsConfig``.
85
+
86
+ Args:
87
+ config: Validated configuration object.
88
+ **kwargs: Additional keyword arguments forwarded to
89
+ ``BaseClient.__init__`` (e.g. ``max_retries``).
90
+
91
+ Returns:
92
+ A new ``RunsAPI`` instance backed by the configured client.
93
+ """
94
+ client = BaseClient(config, **kwargs)
95
+ return cls(client)
96
+
97
+ # ------------------------------------------------------------------ #
98
+ # Endpoints
99
+ # ------------------------------------------------------------------ #
100
+
101
+ def create(
102
+ self,
103
+ payload: CreateRunPayload,
104
+ region: str = "aws-us-west-2",
105
+ ) -> RunView:
106
+ """Submit a new job run (``POST /runs``).
107
+
108
+ Args:
109
+ payload: The run configuration including script/JAR
110
+ details, runtime, and resource settings.
111
+ region: Wherobots deployment region (default
112
+ ``"aws-us-west-2"``).
113
+
114
+ Returns:
115
+ A ``RunView`` representing the created run.
116
+
117
+ Raises:
118
+ WherobotsAPIError: On HTTP or JSON-parsing failures.
119
+ """
120
+ params = {"region": region}
121
+ body = payload.to_dict()
122
+ logger.info("Creating run '%s' in %s", payload.name, region)
123
+ response = self._client.post("/runs", params=params, json_body=body)
124
+ return RunView.from_dict(self._parse_json(response))
125
+
126
+ def get(self, run_id: str) -> RunView:
127
+ """Fetch a single run (``GET /runs/{run_id}``).
128
+
129
+ Args:
130
+ run_id: Unique identifier of the run to retrieve.
131
+
132
+ Returns:
133
+ A ``RunView`` with the current run state.
134
+
135
+ Raises:
136
+ WherobotsAPIError: On HTTP or JSON-parsing failures.
137
+ """
138
+ safe_id = self._safe_run_id(run_id)
139
+ response = self._client.get(f"/runs/{safe_id}")
140
+ return RunView.from_dict(self._parse_json(response))
141
+
142
+ def cancel(self, run_id: str) -> None:
143
+ """Cancel a run (``POST /runs/{run_id}/cancel``).
144
+
145
+ Args:
146
+ run_id: Unique identifier of the run to cancel.
147
+
148
+ Raises:
149
+ WherobotsAPIError: On HTTP failures.
150
+ """
151
+ safe_id = self._safe_run_id(run_id)
152
+ self._client.post(f"/runs/{safe_id}/cancel")
153
+ logger.info("Cancel requested for run %s", run_id)
154
+
155
+ def get_logs(
156
+ self,
157
+ run_id: str,
158
+ cursor: int | str = 0,
159
+ size: int = 100,
160
+ ) -> LogsResponse:
161
+ """Fetch logs (``GET /runs/{run_id}/logs``).
162
+
163
+ Args:
164
+ run_id: Unique identifier of the run.
165
+ cursor: Pagination cursor — accepts either an integer byte
166
+ offset (legacy) or a string cursor returned by the API
167
+ via ``LogsResponse.next_page``. Defaults to ``0``.
168
+ size: Maximum number of log lines to return. Defaults to
169
+ ``100``.
170
+
171
+ Returns:
172
+ A ``LogsResponse`` containing log items and pagination info.
173
+
174
+ Raises:
175
+ WherobotsAPIError: On HTTP or JSON-parsing failures.
176
+ """
177
+ safe_id = self._safe_run_id(run_id)
178
+ params: dict[str, Any] = {"cursor": cursor, "size": size}
179
+ response = self._client.get(f"/runs/{safe_id}/logs", params=params)
180
+ return LogsResponse.from_dict(self._parse_json(response))
181
+
182
+ def get_metrics(self, run_id: str) -> RunMetricsResponse:
183
+ """Fetch metrics (``GET /runs/{run_id}/metrics``).
184
+
185
+ Args:
186
+ run_id: Unique identifier of the run.
187
+
188
+ Returns:
189
+ A ``RunMetricsResponse`` with instant and series metrics.
190
+
191
+ Raises:
192
+ WherobotsAPIError: On HTTP or JSON-parsing failures.
193
+ """
194
+ safe_id = self._safe_run_id(run_id)
195
+ response = self._client.get(f"/runs/{safe_id}/metrics")
196
+ return RunMetricsResponse.from_dict(self._parse_json(response))
197
+
198
+ def list(
199
+ self,
200
+ region: str | None = None,
201
+ name_pattern: str | None = None,
202
+ created_after: str | None = None,
203
+ status: builtins.list[str | JobStatus] | None = None,
204
+ cursor: str | None = None,
205
+ size: int = 50,
206
+ ) -> RunListPage:
207
+ """List runs with optional filters (``GET /runs``).
208
+
209
+ Args:
210
+ region: Filter by deployment region.
211
+ name_pattern: Filter by job name (substring match).
212
+ created_after: ISO-8601 timestamp; only return runs created
213
+ after this time.
214
+ status: Filter by one or more job statuses (strings or
215
+ ``JobStatus`` enum values).
216
+ cursor: Opaque pagination cursor from a previous response.
217
+ size: Page size (max ``100``). Defaults to ``50``.
218
+
219
+ Returns:
220
+ A ``RunListPage`` containing the matched runs, total count,
221
+ and an optional cursor for the next page.
222
+
223
+ Raises:
224
+ WherobotsAPIError: On HTTP or JSON-parsing failures.
225
+ """
226
+ params: dict[str, Any] = {"size": min(size, 100)}
227
+ if region:
228
+ params["region"] = region
229
+ if name_pattern:
230
+ params["name"] = name_pattern
231
+ if created_after:
232
+ params["created_after"] = created_after
233
+ if status:
234
+ params["status"] = [s.value if isinstance(s, JobStatus) else s for s in status]
235
+ if cursor:
236
+ params["cursor"] = cursor
237
+
238
+ response = self._client.get("/runs", params=params)
239
+ return RunListPage.from_dict(self._parse_json(response))
240
+
241
+ # ------------------------------------------------------------------ #
242
+ # Lifecycle
243
+ # ------------------------------------------------------------------ #
244
+
245
+ def close(self) -> None:
246
+ """Close the underlying HTTP client and release resources."""
247
+ self._client.close()
248
+
249
+ def __enter__(self) -> RunsAPI:
250
+ """Enter the runtime context (context-manager protocol)."""
251
+ return self
252
+
253
+ def __exit__(self, *exc: Any) -> None:
254
+ """Exit the runtime context, closing the client."""
255
+ self.close()