wherobots-python-sdk 0.4.0__py3-none-any.whl → 0.5.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/__init__.py +16 -1
- wherobots/__version__.py +1 -1
- wherobots/api/__init__.py +2 -1
- wherobots/api/base.py +81 -24
- wherobots/api/drive.py +481 -0
- wherobots/enums.py +10 -0
- wherobots/exceptions.py +34 -0
- wherobots/models.py +130 -0
- wherobots/sdk.py +91 -0
- {wherobots_python_sdk-0.4.0.dist-info → wherobots_python_sdk-0.5.0.dist-info}/METADATA +49 -2
- wherobots_python_sdk-0.5.0.dist-info/RECORD +23 -0
- wherobots_python_sdk-0.4.0.dist-info/RECORD +0 -21
- {wherobots_python_sdk-0.4.0.dist-info → wherobots_python_sdk-0.5.0.dist-info}/WHEEL +0 -0
- {wherobots_python_sdk-0.4.0.dist-info → wherobots_python_sdk-0.5.0.dist-info}/licenses/LICENSE +0 -0
- {wherobots_python_sdk-0.4.0.dist-info → wherobots_python_sdk-0.5.0.dist-info}/top_level.txt +0 -0
wherobots/__init__.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
"""Wherobots Python SDK (Jobs API
|
|
1
|
+
"""Wherobots Python SDK (Jobs API and Files surfaces)."""
|
|
2
2
|
|
|
3
3
|
from wherobots.__version__ import __version__
|
|
4
|
+
from wherobots.api.drive import Files, FilesDrive
|
|
4
5
|
from wherobots.api.files import FilesAPI
|
|
5
6
|
from wherobots.client import WherobotsJob
|
|
6
7
|
from wherobots.config import WherobotsConfig
|
|
@@ -9,6 +10,7 @@ from wherobots.enums import (
|
|
|
9
10
|
AppType,
|
|
10
11
|
DependencyFileType,
|
|
11
12
|
DependencyType,
|
|
13
|
+
Drive,
|
|
12
14
|
JobStatus,
|
|
13
15
|
Region,
|
|
14
16
|
Runtime,
|
|
@@ -17,7 +19,10 @@ from wherobots.enums import (
|
|
|
17
19
|
from wherobots.exceptions import (
|
|
18
20
|
WherobotsAPIError,
|
|
19
21
|
WherobotsConfigError,
|
|
22
|
+
WherobotsFileNotFoundError,
|
|
23
|
+
WherobotsFilesNotEnabledError,
|
|
20
24
|
WherobotsJobError,
|
|
25
|
+
WherobotsNotAuthenticatedError,
|
|
21
26
|
WherobotsS3Error,
|
|
22
27
|
WherobotsTimeoutError,
|
|
23
28
|
WherobotsValidationError,
|
|
@@ -25,6 +30,7 @@ from wherobots.exceptions import (
|
|
|
25
30
|
from wherobots.models import (
|
|
26
31
|
CreateRunPayload,
|
|
27
32
|
FileDependency,
|
|
33
|
+
FileEntry,
|
|
28
34
|
KubeAppEvent,
|
|
29
35
|
LogItem,
|
|
30
36
|
LogsResponse,
|
|
@@ -43,6 +49,7 @@ from wherobots.models import (
|
|
|
43
49
|
TemporaryAwsCredentials,
|
|
44
50
|
UtilizationStats,
|
|
45
51
|
)
|
|
52
|
+
from wherobots.sdk import Wherobots
|
|
46
53
|
|
|
47
54
|
# Convenience alias
|
|
48
55
|
Job = WherobotsJob
|
|
@@ -50,6 +57,9 @@ Job = WherobotsJob
|
|
|
50
57
|
__all__ = [
|
|
51
58
|
"__version__",
|
|
52
59
|
# Client
|
|
60
|
+
"Wherobots",
|
|
61
|
+
"Files",
|
|
62
|
+
"FilesDrive",
|
|
53
63
|
"WherobotsJob",
|
|
54
64
|
"Job",
|
|
55
65
|
"FilesAPI",
|
|
@@ -57,6 +67,7 @@ __all__ = [
|
|
|
57
67
|
# Enums
|
|
58
68
|
"Runtime",
|
|
59
69
|
"Region",
|
|
70
|
+
"Drive",
|
|
60
71
|
"JobStatus",
|
|
61
72
|
"AppStatus",
|
|
62
73
|
"AppType",
|
|
@@ -70,6 +81,9 @@ __all__ = [
|
|
|
70
81
|
"WherobotsS3Error",
|
|
71
82
|
"WherobotsConfigError",
|
|
72
83
|
"WherobotsTimeoutError",
|
|
84
|
+
"WherobotsNotAuthenticatedError",
|
|
85
|
+
"WherobotsFilesNotEnabledError",
|
|
86
|
+
"WherobotsFileNotFoundError",
|
|
73
87
|
# Models
|
|
74
88
|
"CreateRunPayload",
|
|
75
89
|
"RunView",
|
|
@@ -90,4 +104,5 @@ __all__ = [
|
|
|
90
104
|
"StorageIntegration",
|
|
91
105
|
"TemporaryAwsCredentials",
|
|
92
106
|
"UtilizationStats",
|
|
107
|
+
"FileEntry",
|
|
93
108
|
]
|
wherobots/__version__.py
CHANGED
wherobots/api/__init__.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""Wherobots API layer — public exports."""
|
|
2
2
|
|
|
3
3
|
from wherobots.api.base import BaseClient
|
|
4
|
+
from wherobots.api.drive import Files, FilesDrive
|
|
4
5
|
from wherobots.api.files import FilesAPI
|
|
5
6
|
from wherobots.api.runs import RunsAPI
|
|
6
7
|
|
|
7
|
-
__all__ = ["BaseClient", "FilesAPI", "RunsAPI"]
|
|
8
|
+
__all__ = ["BaseClient", "Files", "FilesAPI", "FilesDrive", "RunsAPI"]
|
wherobots/api/base.py
CHANGED
|
@@ -32,6 +32,7 @@ logger = get_logger("api")
|
|
|
32
32
|
_DEFAULT_RETRIES = 3
|
|
33
33
|
_DEFAULT_BACKOFF_FACTOR = 0.5
|
|
34
34
|
_RETRIABLE_STATUS_CODES = (429, 502, 503)
|
|
35
|
+
_REDIRECT_STATUS_CODES = frozenset({301, 302, 303, 307, 308})
|
|
35
36
|
|
|
36
37
|
|
|
37
38
|
class BaseClient:
|
|
@@ -110,6 +111,11 @@ class BaseClient:
|
|
|
110
111
|
}
|
|
111
112
|
)
|
|
112
113
|
|
|
114
|
+
@property
|
|
115
|
+
def timeout(self) -> int:
|
|
116
|
+
"""Per-request timeout in seconds, from ``WherobotsConfig``."""
|
|
117
|
+
return self._timeout
|
|
118
|
+
|
|
113
119
|
# --------------------------------------------------------------------- #
|
|
114
120
|
# Auth
|
|
115
121
|
# --------------------------------------------------------------------- #
|
|
@@ -163,33 +169,84 @@ class BaseClient:
|
|
|
163
169
|
response.raise_for_status()
|
|
164
170
|
return response # noqa: TRY300
|
|
165
171
|
except requests.HTTPError as exc:
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
detail = None
|
|
170
|
-
request_id = None
|
|
171
|
-
if response is not None:
|
|
172
|
-
request_id = response.headers.get("X-Request-Id")
|
|
173
|
-
try:
|
|
174
|
-
error_payload = response.json()
|
|
175
|
-
detail = error_payload.get("message", error_payload)
|
|
176
|
-
except ValueError:
|
|
177
|
-
detail = response.text
|
|
178
|
-
|
|
179
|
-
message = "API request failed"
|
|
180
|
-
if status_code:
|
|
181
|
-
message = f"{message} (status {status_code})"
|
|
182
|
-
if detail:
|
|
183
|
-
message = f"{message}: {detail}"
|
|
172
|
+
raise self._api_error(exc) from exc
|
|
173
|
+
except requests.RequestException as exc:
|
|
174
|
+
raise WherobotsAPIError(f"Request failed: {exc}") from exc
|
|
184
175
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _api_error(exc: requests.HTTPError) -> WherobotsAPIError:
|
|
178
|
+
"""Map an HTTP error response to ``WherobotsAPIError``.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
exc: The ``HTTPError`` raised by ``raise_for_status``.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
A ``WherobotsAPIError`` carrying the status, parsed body and request id.
|
|
185
|
+
"""
|
|
186
|
+
response = exc.response
|
|
187
|
+
status_code = response.status_code if response is not None else None
|
|
188
|
+
error_payload = None
|
|
189
|
+
detail = None
|
|
190
|
+
request_id = None
|
|
191
|
+
if response is not None:
|
|
192
|
+
request_id = response.headers.get("X-Request-Id")
|
|
193
|
+
try:
|
|
194
|
+
error_payload = response.json()
|
|
195
|
+
detail = error_payload.get("message", error_payload)
|
|
196
|
+
except ValueError:
|
|
197
|
+
detail = response.text
|
|
198
|
+
|
|
199
|
+
message = "API request failed"
|
|
200
|
+
if status_code:
|
|
201
|
+
message = f"{message} (status {status_code})"
|
|
202
|
+
if detail:
|
|
203
|
+
message = f"{message}: {detail}"
|
|
204
|
+
|
|
205
|
+
return WherobotsAPIError(
|
|
206
|
+
message,
|
|
207
|
+
status_code=status_code,
|
|
208
|
+
response=error_payload,
|
|
209
|
+
request_id=request_id,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def get_redirect_target(self, path: str, params: dict[str, str] | None = None) -> str:
|
|
213
|
+
"""Send a GET that must answer with a redirect, and return its target.
|
|
214
|
+
|
|
215
|
+
The redirect is not followed, so the API key never travels to the
|
|
216
|
+
target. Errors are mapped exactly as in ``request``.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
path: API path appended to the base URL.
|
|
220
|
+
params: Optional query-string parameters.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
The ``Location`` the API redirected to.
|
|
224
|
+
|
|
225
|
+
Raises:
|
|
226
|
+
WherobotsAPIError: On HTTP or connection failures, or when the
|
|
227
|
+
API answers without a redirect.
|
|
228
|
+
"""
|
|
229
|
+
url = f"{self._base_url}{path}"
|
|
230
|
+
try:
|
|
231
|
+
response = self._session.request(
|
|
232
|
+
"GET",
|
|
233
|
+
url,
|
|
234
|
+
params=params,
|
|
235
|
+
timeout=self._timeout,
|
|
236
|
+
allow_redirects=False,
|
|
237
|
+
)
|
|
238
|
+
response.raise_for_status()
|
|
239
|
+
except requests.HTTPError as exc:
|
|
240
|
+
raise self._api_error(exc) from exc
|
|
191
241
|
except requests.RequestException as exc:
|
|
192
242
|
raise WherobotsAPIError(f"Request failed: {exc}") from exc
|
|
243
|
+
location = response.headers.get("Location")
|
|
244
|
+
if response.status_code not in _REDIRECT_STATUS_CODES or not isinstance(location, str):
|
|
245
|
+
raise WherobotsAPIError(
|
|
246
|
+
f"Expected a redirect from GET {path}, got status {response.status_code}",
|
|
247
|
+
status_code=response.status_code,
|
|
248
|
+
)
|
|
249
|
+
return location
|
|
193
250
|
|
|
194
251
|
# Convenience wrappers
|
|
195
252
|
def get(self, path: str, params: dict[str, Any] | None = None) -> requests.Response:
|
wherobots/api/drive.py
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""Files drives — the caller's file areas, over presigned URLs (ADR-0002).
|
|
2
|
+
|
|
3
|
+
``Files`` hands out one ``FilesDrive`` per (drive, region). Every drive
|
|
4
|
+
operation is written once on ``FilesDrive``; the only drive-specific code is
|
|
5
|
+
``_storage_id``, which maps a drive and region to the API's storage id.
|
|
6
|
+
|
|
7
|
+
Bytes never pass through the Wherobots API and never carry the API key:
|
|
8
|
+
uploads PUT to a presigned URL, and downloads read the 307 target without
|
|
9
|
+
following it, then GET that URL with a plain request. No STS credentials are
|
|
10
|
+
ever requested.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
import os
|
|
17
|
+
import secrets
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
from urllib.parse import quote
|
|
21
|
+
|
|
22
|
+
import requests
|
|
23
|
+
|
|
24
|
+
from wherobots.enums import Drive, Region
|
|
25
|
+
from wherobots.exceptions import (
|
|
26
|
+
WherobotsAPIError,
|
|
27
|
+
WherobotsConfigError,
|
|
28
|
+
WherobotsFileNotFoundError,
|
|
29
|
+
WherobotsFilesNotEnabledError,
|
|
30
|
+
WherobotsNotAuthenticatedError,
|
|
31
|
+
WherobotsS3Error,
|
|
32
|
+
WherobotsValidationError,
|
|
33
|
+
)
|
|
34
|
+
from wherobots.models import DirectoryPage, FileEntry, FileUploadTarget
|
|
35
|
+
from wherobots.utils.logger import get_logger
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from wherobots.api.base import BaseClient
|
|
39
|
+
|
|
40
|
+
logger = get_logger("api.drive")
|
|
41
|
+
|
|
42
|
+
# The server accepts 1..1000; 0 (its default) disables cursor paging.
|
|
43
|
+
_LIST_PAGE_LIMIT = 1000
|
|
44
|
+
# The root probe after a 404 only needs to know the drive answers, as in the CLI and TS SDK.
|
|
45
|
+
_PROBE_LIMIT = 1
|
|
46
|
+
_MIB = 1024 * 1024
|
|
47
|
+
# S3 refuses a single PUT above 5 GiB, and uploads here are one PUT each: a ceiling we cannot pass.
|
|
48
|
+
_S3_SINGLE_PUT_MAX_BYTES = 5 * 1024**3
|
|
49
|
+
# Product decision, not a protocol limit: one per-file cap across the CLI, Studio web UI and SDK.
|
|
50
|
+
_FILES_UPLOAD_CAP_BYTES = 500 * _MIB
|
|
51
|
+
_DOWNLOAD_CHUNK_BYTES = 1024 * 1024
|
|
52
|
+
_PARTIAL_MODE = 0o666 # before umask, as open() uses; a download keeps the user's usual mode
|
|
53
|
+
_HTTP_UNAUTHORIZED = 401
|
|
54
|
+
_HTTP_NOT_FOUND = 404
|
|
55
|
+
_HTTP_OK = 200
|
|
56
|
+
_HTTP_MULTIPLE_CHOICES = 300
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _storage_id(drive: Drive, region: str) -> str:
|
|
60
|
+
"""Resolve a drive in a region to the API's storage id.
|
|
61
|
+
|
|
62
|
+
The one place a new drive type changes, besides ``Drive`` itself.
|
|
63
|
+
"""
|
|
64
|
+
if drive is Drive.MY_FILES:
|
|
65
|
+
return f"user_files::{region}"
|
|
66
|
+
raise WherobotsValidationError(f"Unsupported drive: {drive!r}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _region_name(region: Region | str | None) -> str | None:
|
|
70
|
+
"""Return the region's public name, or ``None`` when none is given."""
|
|
71
|
+
if isinstance(region, Region):
|
|
72
|
+
return region.value
|
|
73
|
+
if region is None or not region.strip():
|
|
74
|
+
return None
|
|
75
|
+
return region.strip()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _segments(path: str, *, directory: bool) -> list[str]:
|
|
79
|
+
"""Split a drive path into its segments, refusing unsafe ones.
|
|
80
|
+
|
|
81
|
+
Leading ``/`` are dropped. A directory path may end in one ``/``. Empty
|
|
82
|
+
levels, ``.`` and ``..`` are refused before any request is sent.
|
|
83
|
+
"""
|
|
84
|
+
relative = path.lstrip("/")
|
|
85
|
+
if directory:
|
|
86
|
+
relative = relative.removesuffix("/")
|
|
87
|
+
if not relative:
|
|
88
|
+
return []
|
|
89
|
+
segments = relative.split("/")
|
|
90
|
+
if "" in segments:
|
|
91
|
+
raise WherobotsValidationError(f"Path must not contain an empty level: {path!r}")
|
|
92
|
+
if any(segment in {".", ".."} for segment in segments):
|
|
93
|
+
raise WherobotsValidationError(f"Path must not contain '.' or '..' segments: {path!r}")
|
|
94
|
+
return segments
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _encode(segments: list[str]) -> str:
|
|
98
|
+
"""Percent-encode each segment on its own and join with ``/`` (never ``%2F``)."""
|
|
99
|
+
return "/".join(quote(segment, safe="") for segment in segments)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _json_object(response: requests.Response) -> dict[str, object]:
|
|
103
|
+
"""Parse a JSON object body, or raise ``WherobotsAPIError``."""
|
|
104
|
+
try:
|
|
105
|
+
data = response.json()
|
|
106
|
+
except ValueError as exc:
|
|
107
|
+
raise WherobotsAPIError(
|
|
108
|
+
"Files API returned a non-JSON body", status_code=response.status_code
|
|
109
|
+
) from exc
|
|
110
|
+
if not isinstance(data, dict):
|
|
111
|
+
raise WherobotsAPIError(
|
|
112
|
+
"Files API returned an unexpected body", status_code=response.status_code
|
|
113
|
+
)
|
|
114
|
+
return data
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class FilesDrive:
|
|
118
|
+
"""One Files drive in one region: list, create folders, transfer, rename, delete.
|
|
119
|
+
|
|
120
|
+
Obtain one from ``Files.drive`` or ``Files.my_files``; do not build it directly.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
client: The shared API connection.
|
|
124
|
+
drive: Which drive this is.
|
|
125
|
+
region: The region's public name (e.g. ``"aws-us-west-2"``).
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(self, client: BaseClient, drive: Drive, region: str) -> None:
|
|
129
|
+
"""Bind the drive to a connection and region; see the class docstring."""
|
|
130
|
+
self._client = client
|
|
131
|
+
self._drive = drive
|
|
132
|
+
self._region = region
|
|
133
|
+
self._storage_id = _storage_id(drive, region)
|
|
134
|
+
self._timeout = client.timeout
|
|
135
|
+
|
|
136
|
+
def __repr__(self) -> str:
|
|
137
|
+
"""Return a short description naming the drive and region."""
|
|
138
|
+
return f"FilesDrive(drive={self._drive.value!r}, region={self._region!r})"
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def drive(self) -> Drive:
|
|
142
|
+
"""The drive this object addresses."""
|
|
143
|
+
return self._drive
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def region(self) -> str:
|
|
147
|
+
"""The region this drive lives in."""
|
|
148
|
+
return self._region
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def storage_id(self) -> str:
|
|
152
|
+
"""The API storage id this drive resolves to."""
|
|
153
|
+
return self._storage_id
|
|
154
|
+
|
|
155
|
+
# ------------------------------------------------------------------ #
|
|
156
|
+
# Operations
|
|
157
|
+
# ------------------------------------------------------------------ #
|
|
158
|
+
|
|
159
|
+
def list_directory(self, path: str = "") -> list[FileEntry]:
|
|
160
|
+
"""List a folder, following the cursor through every page.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
path: Folder path inside the drive; ``""`` (the default) is the root.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Every file and folder directly inside ``path``.
|
|
167
|
+
"""
|
|
168
|
+
route_segments = _segments(path, directory=True)
|
|
169
|
+
route = self._route("directories", route_segments)
|
|
170
|
+
entries: list[FileEntry] = []
|
|
171
|
+
cursor: str | None = None
|
|
172
|
+
seen: set[str] = set()
|
|
173
|
+
while True:
|
|
174
|
+
params: dict[str, str | int] = {"limit": _LIST_PAGE_LIMIT}
|
|
175
|
+
if cursor is not None:
|
|
176
|
+
params["cursor"] = cursor
|
|
177
|
+
response = self._call("GET", route, params=params, root_listing=not route_segments)
|
|
178
|
+
page = DirectoryPage.from_dict(_json_object(response))
|
|
179
|
+
entries.extend(page.entries)
|
|
180
|
+
# Any cursor seen before means the server is cycling; stop rather than loop forever.
|
|
181
|
+
if page.next_cursor is None or page.next_cursor in seen:
|
|
182
|
+
return entries
|
|
183
|
+
seen.add(page.next_cursor)
|
|
184
|
+
cursor = page.next_cursor
|
|
185
|
+
|
|
186
|
+
def make_directory(self, path: str) -> None:
|
|
187
|
+
"""Create a folder and every missing folder above it, one level at a time.
|
|
188
|
+
|
|
189
|
+
Each level is created explicitly so it is a real folder that stays
|
|
190
|
+
writable from a notebook. If a level fails, the ones above it stay.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
path: Folder path inside the drive, e.g. ``"a/b/c"``.
|
|
194
|
+
"""
|
|
195
|
+
segments = self._required(path, directory=True)
|
|
196
|
+
for depth in range(1, len(segments) + 1):
|
|
197
|
+
self._call("PUT", self._route("directories", segments[:depth]))
|
|
198
|
+
|
|
199
|
+
def upload_file(self, local_path: str | os.PathLike[str], path: str) -> str:
|
|
200
|
+
"""Upload one local file to ``path`` in the drive.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
local_path: The file to send.
|
|
204
|
+
path: Destination file path inside the drive, e.g. ``"data/a.csv"``.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
The storage location the file was written to.
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
WherobotsValidationError: If the local file is missing or over 500 MiB,
|
|
211
|
+
or ``path`` is not a valid file path.
|
|
212
|
+
WherobotsS3Error: If the transfer to storage fails, storage answers
|
|
213
|
+
other than 2xx (redirects are not followed), or the file cannot be read.
|
|
214
|
+
"""
|
|
215
|
+
source = Path(local_path)
|
|
216
|
+
if not source.is_file():
|
|
217
|
+
raise WherobotsValidationError(f"Not a file: {source}")
|
|
218
|
+
size = source.stat().st_size
|
|
219
|
+
limit = min(_FILES_UPLOAD_CAP_BYTES, _S3_SINGLE_PUT_MAX_BYTES)
|
|
220
|
+
if size > limit:
|
|
221
|
+
# Round up to 0.1 MiB so a file just over the limit never reads as equal to it.
|
|
222
|
+
size_mib = math.ceil(size * 10 / _MIB) / 10
|
|
223
|
+
raise WherobotsValidationError(
|
|
224
|
+
f"{source} is {size_mib:.1f} MiB; Files uploads are limited to "
|
|
225
|
+
f"{limit // _MIB} MiB per file"
|
|
226
|
+
)
|
|
227
|
+
segments = self._required(path, directory=False)
|
|
228
|
+
response = self._call("POST", self._route("file-upload-url", segments))
|
|
229
|
+
try:
|
|
230
|
+
target = FileUploadTarget.from_dict(_json_object(response))
|
|
231
|
+
except ValueError as exc:
|
|
232
|
+
raise WherobotsAPIError(str(exc), status_code=response.status_code) from exc
|
|
233
|
+
if not target.upload_url.startswith("https://"):
|
|
234
|
+
raise WherobotsS3Error("Refusing an upload URL that is not https://")
|
|
235
|
+
logger.debug("Uploading %s to %s", source, target.destination)
|
|
236
|
+
try:
|
|
237
|
+
with source.open("rb") as body:
|
|
238
|
+
upload = requests.put(
|
|
239
|
+
target.upload_url, data=body, timeout=self._timeout, allow_redirects=False
|
|
240
|
+
)
|
|
241
|
+
# RequestException subclasses OSError, so this one clause also covers local read failures.
|
|
242
|
+
except OSError as exc:
|
|
243
|
+
raise WherobotsS3Error(f"Upload of {source} failed: {type(exc).__name__}") from exc
|
|
244
|
+
if not _HTTP_OK <= upload.status_code < _HTTP_MULTIPLE_CHOICES:
|
|
245
|
+
raise WherobotsS3Error(
|
|
246
|
+
f"Upload of {source} failed: storage answered {upload.status_code}"
|
|
247
|
+
)
|
|
248
|
+
return target.destination
|
|
249
|
+
|
|
250
|
+
def download_file(self, path: str, local_path: str | os.PathLike[str]) -> Path:
|
|
251
|
+
"""Download one file from the drive.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
path: File path inside the drive.
|
|
255
|
+
local_path: Where to write it. An existing folder receives the file
|
|
256
|
+
under its drive name.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
The local file written.
|
|
260
|
+
|
|
261
|
+
Raises:
|
|
262
|
+
WherobotsValidationError: If the folder ``local_path`` names does not exist.
|
|
263
|
+
WherobotsFileNotFoundError: If the drive has no file at ``path``.
|
|
264
|
+
WherobotsS3Error: If the transfer from storage or the local write fails.
|
|
265
|
+
"""
|
|
266
|
+
segments = self._required(path, directory=False)
|
|
267
|
+
destination = Path(local_path)
|
|
268
|
+
if destination.is_dir():
|
|
269
|
+
destination /= segments[-1]
|
|
270
|
+
if not destination.parent.is_dir():
|
|
271
|
+
raise WherobotsValidationError(f"Not a folder: {destination.parent}")
|
|
272
|
+
route = self._route("files", segments)
|
|
273
|
+
try:
|
|
274
|
+
url = self._client.get_redirect_target(route)
|
|
275
|
+
except WherobotsAPIError as err:
|
|
276
|
+
raise self._translate(err) from err
|
|
277
|
+
if not url.startswith("https://"):
|
|
278
|
+
raise WherobotsS3Error("Refusing a download URL that is not https://")
|
|
279
|
+
self._fetch(url, path, destination)
|
|
280
|
+
return destination
|
|
281
|
+
|
|
282
|
+
def rename_file(self, path: str, new_name: str) -> None:
|
|
283
|
+
"""Rename a file in place.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
path: Current file path inside the drive.
|
|
287
|
+
new_name: The new name — one path segment, not a path.
|
|
288
|
+
"""
|
|
289
|
+
if not new_name or "/" in new_name or new_name in {".", ".."}:
|
|
290
|
+
raise WherobotsValidationError(
|
|
291
|
+
f"New name must be a single path segment without '/': {new_name!r}"
|
|
292
|
+
)
|
|
293
|
+
segments = self._required(path, directory=False)
|
|
294
|
+
self._call("POST", self._route("file-rename", segments), params={"new_name": new_name})
|
|
295
|
+
|
|
296
|
+
def delete_file(self, path: str) -> None:
|
|
297
|
+
"""Delete exactly one file.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
path: File path inside the drive.
|
|
301
|
+
"""
|
|
302
|
+
self._call("DELETE", self._route("files", self._required(path, directory=False)))
|
|
303
|
+
|
|
304
|
+
def delete_directory(self, path: str) -> None:
|
|
305
|
+
"""Delete a folder and everything in it.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
path: Folder path inside the drive (not the root).
|
|
309
|
+
"""
|
|
310
|
+
self._call("DELETE", self._route("directories", self._required(path, directory=True)))
|
|
311
|
+
|
|
312
|
+
# ------------------------------------------------------------------ #
|
|
313
|
+
# Helpers
|
|
314
|
+
# ------------------------------------------------------------------ #
|
|
315
|
+
|
|
316
|
+
@staticmethod
|
|
317
|
+
def _required(path: str, *, directory: bool) -> list[str]:
|
|
318
|
+
"""Segments of a path that must name something below the root."""
|
|
319
|
+
segments = _segments(path, directory=directory)
|
|
320
|
+
if not segments:
|
|
321
|
+
raise WherobotsValidationError("Path must name a file or folder inside the drive")
|
|
322
|
+
return segments
|
|
323
|
+
|
|
324
|
+
def _route(self, kind: str, segments: list[str]) -> str:
|
|
325
|
+
"""Build ``/storage/{storage_id}/{kind}/{path}`` with every part encoded."""
|
|
326
|
+
storage = quote(self._storage_id, safe="")
|
|
327
|
+
return f"/storage/{storage}/{kind}/{_encode(segments)}"
|
|
328
|
+
|
|
329
|
+
def _call(
|
|
330
|
+
self,
|
|
331
|
+
method: str,
|
|
332
|
+
route: str,
|
|
333
|
+
params: dict[str, str | int] | None = None,
|
|
334
|
+
*,
|
|
335
|
+
root_listing: bool = False,
|
|
336
|
+
) -> requests.Response:
|
|
337
|
+
"""Send one API request, translating Files-specific failures."""
|
|
338
|
+
try:
|
|
339
|
+
return self._client.request(method, route, params=params)
|
|
340
|
+
except WherobotsAPIError as err:
|
|
341
|
+
raise self._translate(err, root_listing=root_listing) from err
|
|
342
|
+
|
|
343
|
+
def _translate(
|
|
344
|
+
self, err: WherobotsAPIError, *, root_listing: bool = False
|
|
345
|
+
) -> WherobotsAPIError:
|
|
346
|
+
"""Map 401 and the two kinds of 404 onto their own errors.
|
|
347
|
+
|
|
348
|
+
A 404 is "not enabled" when the drive root is missing too: known already
|
|
349
|
+
for a root listing, otherwise asked with one ``_root_missing`` probe.
|
|
350
|
+
"""
|
|
351
|
+
if err.status_code == _HTTP_UNAUTHORIZED:
|
|
352
|
+
return WherobotsNotAuthenticatedError(
|
|
353
|
+
"The Wherobots API key was not accepted (HTTP 401)",
|
|
354
|
+
status_code=err.status_code,
|
|
355
|
+
response=err.response,
|
|
356
|
+
request_id=err.request_id,
|
|
357
|
+
)
|
|
358
|
+
if err.status_code == _HTTP_NOT_FOUND:
|
|
359
|
+
if root_listing or self._root_missing():
|
|
360
|
+
return WherobotsFilesNotEnabledError(self._drive.value, self._region, err)
|
|
361
|
+
return WherobotsFileNotFoundError(
|
|
362
|
+
f"No such file or folder in {self._drive.value!r}: {err}",
|
|
363
|
+
status_code=err.status_code,
|
|
364
|
+
response=err.response,
|
|
365
|
+
request_id=err.request_id,
|
|
366
|
+
)
|
|
367
|
+
return err
|
|
368
|
+
|
|
369
|
+
def _root_missing(self) -> bool:
|
|
370
|
+
"""List the drive root once; only a 404 counts as missing.
|
|
371
|
+
|
|
372
|
+
Calls the client directly, never ``_call``, so the probe's own 404 cannot probe again.
|
|
373
|
+
"""
|
|
374
|
+
route = self._route("directories", [])
|
|
375
|
+
try:
|
|
376
|
+
self._client.request("GET", route, params={"limit": _PROBE_LIMIT})
|
|
377
|
+
except WherobotsAPIError as err:
|
|
378
|
+
return err.status_code == _HTTP_NOT_FOUND
|
|
379
|
+
return False
|
|
380
|
+
|
|
381
|
+
def _check_download(self, response: requests.Response, path: str) -> None:
|
|
382
|
+
"""Raise unless storage answered the presigned GET with success."""
|
|
383
|
+
if response.status_code == _HTTP_NOT_FOUND:
|
|
384
|
+
raise WherobotsFileNotFoundError(
|
|
385
|
+
f"No such file in {self._drive.value!r}: {path}",
|
|
386
|
+
status_code=response.status_code,
|
|
387
|
+
)
|
|
388
|
+
if not _HTTP_OK <= response.status_code < _HTTP_MULTIPLE_CHOICES:
|
|
389
|
+
raise WherobotsS3Error(
|
|
390
|
+
f"Download of {path} failed: storage answered {response.status_code}"
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
def _fetch(self, url: str, path: str, destination: Path) -> None:
|
|
394
|
+
"""Stream a presigned URL to ``destination`` via a fresh temporary ``.part`` file.
|
|
395
|
+
|
|
396
|
+
A plain request: no session, so no Wherobots headers reach storage.
|
|
397
|
+
Only the temporary file this call created is ever removed.
|
|
398
|
+
"""
|
|
399
|
+
partial: Path | None = None
|
|
400
|
+
try:
|
|
401
|
+
with requests.get(
|
|
402
|
+
url, stream=True, timeout=self._timeout, allow_redirects=False
|
|
403
|
+
) as response:
|
|
404
|
+
self._check_download(response, path)
|
|
405
|
+
partial, fd = _create_partial(destination)
|
|
406
|
+
with os.fdopen(fd, "wb") as out:
|
|
407
|
+
for chunk in response.iter_content(chunk_size=_DOWNLOAD_CHUNK_BYTES):
|
|
408
|
+
out.write(chunk)
|
|
409
|
+
partial.replace(destination)
|
|
410
|
+
# RequestException subclasses OSError, so this also covers local write failures.
|
|
411
|
+
except OSError as exc:
|
|
412
|
+
_discard(partial)
|
|
413
|
+
raise WherobotsS3Error(f"Download of {path} failed: {type(exc).__name__}") from exc
|
|
414
|
+
except BaseException:
|
|
415
|
+
_discard(partial)
|
|
416
|
+
raise
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _create_partial(destination: Path) -> tuple[Path, int]:
|
|
420
|
+
"""Create a fresh ``.part`` file beside ``destination``; return its path and open fd.
|
|
421
|
+
|
|
422
|
+
Mode 0o666 is filtered by the umask, so the renamed file gets the user's usual permissions.
|
|
423
|
+
"""
|
|
424
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
|
|
425
|
+
while True:
|
|
426
|
+
candidate = destination.parent / f".{destination.name}.{secrets.token_hex(8)}.part"
|
|
427
|
+
try:
|
|
428
|
+
fd = os.open(candidate, flags, _PARTIAL_MODE)
|
|
429
|
+
except FileExistsError:
|
|
430
|
+
continue
|
|
431
|
+
return candidate, fd
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _discard(partial: Path | None) -> None:
|
|
435
|
+
"""Remove a temporary file this download created, if it created one."""
|
|
436
|
+
if partial is not None:
|
|
437
|
+
partial.unlink(missing_ok=True)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class Files:
|
|
441
|
+
"""The Files surface: hands out drives, defaulting to the configured region.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
client: The shared API connection.
|
|
445
|
+
region: Default region for drives; ``None`` requires one per ``drive`` call.
|
|
446
|
+
"""
|
|
447
|
+
|
|
448
|
+
def __init__(self, client: BaseClient, region: Region | str | None = None) -> None:
|
|
449
|
+
"""Bind to a connection and default region; see the class docstring."""
|
|
450
|
+
self._client = client
|
|
451
|
+
self._region = _region_name(region)
|
|
452
|
+
self._drives: dict[tuple[Drive, str], FilesDrive] = {}
|
|
453
|
+
|
|
454
|
+
def drive(self, drive: Drive, region: Region | str | None = None) -> FilesDrive:
|
|
455
|
+
"""Return the drive object for ``drive`` in ``region``.
|
|
456
|
+
|
|
457
|
+
Args:
|
|
458
|
+
drive: Which drive, e.g. ``Drive.MY_FILES``.
|
|
459
|
+
region: Region override; defaults to the configured region.
|
|
460
|
+
|
|
461
|
+
Returns:
|
|
462
|
+
The same ``FilesDrive`` for the same drive and region on every call.
|
|
463
|
+
|
|
464
|
+
Raises:
|
|
465
|
+
WherobotsConfigError: If no region is passed or configured.
|
|
466
|
+
"""
|
|
467
|
+
resolved = _region_name(region) or self._region
|
|
468
|
+
if resolved is None:
|
|
469
|
+
raise WherobotsConfigError(
|
|
470
|
+
"Files needs a region: set WHEROBOTS_REGION or pass region= "
|
|
471
|
+
"(e.g. region='aws-us-west-2')."
|
|
472
|
+
)
|
|
473
|
+
key = (drive, resolved)
|
|
474
|
+
if key not in self._drives:
|
|
475
|
+
self._drives[key] = FilesDrive(self._client, drive, resolved)
|
|
476
|
+
return self._drives[key]
|
|
477
|
+
|
|
478
|
+
@property
|
|
479
|
+
def my_files(self) -> FilesDrive:
|
|
480
|
+
"""Your personal file area in the default region (``Drive.MY_FILES``)."""
|
|
481
|
+
return self.drive(Drive.MY_FILES)
|
wherobots/enums.py
CHANGED
|
@@ -138,3 +138,13 @@ class AppStatus(Enum):
|
|
|
138
138
|
DESTROYING = "DESTROYING"
|
|
139
139
|
DESTROY_FAILED = "DESTROY_FAILED"
|
|
140
140
|
DESTROYED = "DESTROYED"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class Drive(Enum):
|
|
144
|
+
"""Files drives a caller can address through ``Wherobots().files``.
|
|
145
|
+
|
|
146
|
+
``MY_FILES`` is the caller's personal file area in one region. A shared
|
|
147
|
+
drive would be a new member here plus one branch in the drive resolver.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
MY_FILES = "my_files"
|
wherobots/exceptions.py
CHANGED
|
@@ -45,3 +45,37 @@ class WherobotsConfigError(WherobotsJobError):
|
|
|
45
45
|
|
|
46
46
|
class WherobotsTimeoutError(WherobotsJobError):
|
|
47
47
|
"""Raised when a job exceeds the specified timeout."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class WherobotsNotAuthenticatedError(WherobotsAPIError):
|
|
51
|
+
"""Raised when the Wherobots API does not accept the API key (HTTP 401)."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class WherobotsFileNotFoundError(WherobotsAPIError):
|
|
55
|
+
"""Raised when a Files drive has no file or folder at the requested path."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class WherobotsFilesNotEnabledError(WherobotsAPIError):
|
|
59
|
+
"""Raised when Files is not enabled for a drive in a region.
|
|
60
|
+
|
|
61
|
+
Attributes:
|
|
62
|
+
drive: The drive that was addressed (e.g. ``"my_files"``).
|
|
63
|
+
region: The region the drive was addressed in (e.g. ``"aws-us-west-2"``).
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, drive: str, region: str, cause: WherobotsAPIError) -> None:
|
|
67
|
+
"""Build the error from an API 404 on a drive whose root also answers 404.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
drive: The drive that was addressed.
|
|
71
|
+
region: The region the drive was addressed in.
|
|
72
|
+
cause: The underlying 404 from the API.
|
|
73
|
+
"""
|
|
74
|
+
self.drive = drive
|
|
75
|
+
self.region = region
|
|
76
|
+
super().__init__(
|
|
77
|
+
f"Files is not enabled for drive {drive!r} in region {region!r}",
|
|
78
|
+
status_code=cause.status_code,
|
|
79
|
+
response=cause.response,
|
|
80
|
+
request_id=cause.request_id,
|
|
81
|
+
)
|
wherobots/models.py
CHANGED
|
@@ -1351,3 +1351,133 @@ class TemporaryAwsCredentials:
|
|
|
1351
1351
|
expiration=expiration,
|
|
1352
1352
|
region=data["region"],
|
|
1353
1353
|
)
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
# ---------------------------------------------------------------------------
|
|
1357
|
+
# Files drive models
|
|
1358
|
+
# ---------------------------------------------------------------------------
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
def _optional_str(data: dict[str, object], key: str) -> str | None:
|
|
1362
|
+
"""Return ``data[key]`` when it is a non-empty string, else ``None``."""
|
|
1363
|
+
value = data.get(key)
|
|
1364
|
+
return value if isinstance(value, str) and value else None
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
def _parse_timestamp(value: object) -> datetime | None:
|
|
1368
|
+
"""Parse an ISO-8601 timestamp (trailing ``Z`` accepted); ``None`` if absent or bad."""
|
|
1369
|
+
if not isinstance(value, str) or not value:
|
|
1370
|
+
return None
|
|
1371
|
+
try:
|
|
1372
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
1373
|
+
except ValueError:
|
|
1374
|
+
return None
|
|
1375
|
+
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=timezone.utc)
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
@dataclass(frozen=True)
|
|
1379
|
+
class FileEntry:
|
|
1380
|
+
"""One file or folder in a Files drive listing.
|
|
1381
|
+
|
|
1382
|
+
Attributes:
|
|
1383
|
+
name: The entry's own name, without a trailing ``/``.
|
|
1384
|
+
path: The entry's path as the API reports it.
|
|
1385
|
+
is_dir: ``True`` for a folder.
|
|
1386
|
+
size: Size in bytes (``0`` for folders).
|
|
1387
|
+
last_modified: Last modification time, when the API reports one.
|
|
1388
|
+
"""
|
|
1389
|
+
|
|
1390
|
+
name: str
|
|
1391
|
+
path: str
|
|
1392
|
+
is_dir: bool
|
|
1393
|
+
size: int = 0
|
|
1394
|
+
last_modified: datetime | None = None
|
|
1395
|
+
|
|
1396
|
+
@classmethod
|
|
1397
|
+
def from_dict(cls, data: dict[str, object]) -> FileEntry:
|
|
1398
|
+
"""Deserialize one ``FileEntry`` from the API's listing JSON.
|
|
1399
|
+
|
|
1400
|
+
Args:
|
|
1401
|
+
data: One item of ``DirectoryListingResult.items``.
|
|
1402
|
+
|
|
1403
|
+
Returns:
|
|
1404
|
+
A new ``FileEntry``.
|
|
1405
|
+
"""
|
|
1406
|
+
entry_type = data.get("type")
|
|
1407
|
+
is_dir = isinstance(entry_type, str) and entry_type.upper() == "FOLDER"
|
|
1408
|
+
size = data.get("size")
|
|
1409
|
+
name = _optional_str(data, "name") or ""
|
|
1410
|
+
return cls(
|
|
1411
|
+
name=name.rstrip("/") if is_dir else name,
|
|
1412
|
+
path=_optional_str(data, "path") or "",
|
|
1413
|
+
is_dir=is_dir,
|
|
1414
|
+
size=size if isinstance(size, int) and not isinstance(size, bool) else 0,
|
|
1415
|
+
last_modified=_parse_timestamp(data.get("lastModified")),
|
|
1416
|
+
)
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
@dataclass(frozen=True)
|
|
1420
|
+
class DirectoryPage:
|
|
1421
|
+
"""One page of a Files drive listing.
|
|
1422
|
+
|
|
1423
|
+
Attributes:
|
|
1424
|
+
entries: The files and folders on this page.
|
|
1425
|
+
next_cursor: Cursor for the next page, or ``None`` on the last page.
|
|
1426
|
+
"""
|
|
1427
|
+
|
|
1428
|
+
entries: list[FileEntry] = field(default_factory=list)
|
|
1429
|
+
next_cursor: str | None = None
|
|
1430
|
+
|
|
1431
|
+
@classmethod
|
|
1432
|
+
def from_dict(cls, data: dict[str, object]) -> DirectoryPage:
|
|
1433
|
+
"""Deserialize a ``DirectoryListingResult`` page.
|
|
1434
|
+
|
|
1435
|
+
Args:
|
|
1436
|
+
data: The listing response body (``items`` + snake_case ``next_page``).
|
|
1437
|
+
|
|
1438
|
+
Returns:
|
|
1439
|
+
A new ``DirectoryPage``.
|
|
1440
|
+
"""
|
|
1441
|
+
items = data.get("items")
|
|
1442
|
+
entries = (
|
|
1443
|
+
[FileEntry.from_dict(item) for item in items if isinstance(item, dict)]
|
|
1444
|
+
if isinstance(items, list)
|
|
1445
|
+
else []
|
|
1446
|
+
)
|
|
1447
|
+
return cls(entries=entries, next_cursor=_optional_str(data, "next_page"))
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
@dataclass(frozen=True, repr=False)
|
|
1451
|
+
class FileUploadTarget:
|
|
1452
|
+
"""Where a Files upload lands, and the presigned URL to send it to.
|
|
1453
|
+
|
|
1454
|
+
Attributes:
|
|
1455
|
+
destination: The storage location the file will be written to.
|
|
1456
|
+
upload_url: Presigned PUT URL. It is a bearer credential, so ``repr`` hides it.
|
|
1457
|
+
"""
|
|
1458
|
+
|
|
1459
|
+
destination: str
|
|
1460
|
+
upload_url: str
|
|
1461
|
+
|
|
1462
|
+
def __repr__(self) -> str:
|
|
1463
|
+
"""Return a representation that never shows the presigned URL."""
|
|
1464
|
+
return f"FileUploadTarget(destination={self.destination!r}, upload_url='***')"
|
|
1465
|
+
|
|
1466
|
+
@classmethod
|
|
1467
|
+
def from_dict(cls, data: dict[str, object]) -> FileUploadTarget:
|
|
1468
|
+
"""Deserialize the ``createFileUploadUrl`` response.
|
|
1469
|
+
|
|
1470
|
+
Args:
|
|
1471
|
+
data: Response body with ``destination`` and ``uploadUrl``.
|
|
1472
|
+
|
|
1473
|
+
Returns:
|
|
1474
|
+
A new ``FileUploadTarget``.
|
|
1475
|
+
|
|
1476
|
+
Raises:
|
|
1477
|
+
ValueError: If either field is missing or empty.
|
|
1478
|
+
"""
|
|
1479
|
+
destination = _optional_str(data, "destination")
|
|
1480
|
+
upload_url = _optional_str(data, "uploadUrl")
|
|
1481
|
+
if destination is None or upload_url is None:
|
|
1482
|
+
raise ValueError("Upload URL response is missing 'destination' or 'uploadUrl'")
|
|
1483
|
+
return cls(destination=destination, upload_url=upload_url)
|
wherobots/sdk.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""``Wherobots`` — the SDK's top-level client (ADR-0002).
|
|
2
|
+
|
|
3
|
+
One object, one API connection, one config::
|
|
4
|
+
|
|
5
|
+
import wherobots
|
|
6
|
+
|
|
7
|
+
with wherobots.Wherobots() as wb:
|
|
8
|
+
wb.files.my_files.list_directory()
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import dataclasses
|
|
14
|
+
from typing import TYPE_CHECKING, final
|
|
15
|
+
|
|
16
|
+
from wherobots.api.base import BaseClient
|
|
17
|
+
from wherobots.api.drive import Files
|
|
18
|
+
from wherobots.config import WherobotsConfig
|
|
19
|
+
from wherobots.enums import Region
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from types import TracebackType
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@final
|
|
26
|
+
class Wherobots:
|
|
27
|
+
"""Top-level Wherobots client.
|
|
28
|
+
|
|
29
|
+
Built from ``WherobotsConfig`` (or the environment), it owns one API
|
|
30
|
+
connection shared by every surface it exposes. Close it, or use it as a
|
|
31
|
+
context manager, to release that connection.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
config: Configuration to use; ``None`` reads it from the environment.
|
|
35
|
+
api_key: Overrides the configured API key.
|
|
36
|
+
region: Overrides the configured region (a ``Region`` or its name).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
config: WherobotsConfig | None = None,
|
|
42
|
+
*,
|
|
43
|
+
api_key: str | None = None,
|
|
44
|
+
region: Region | str | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Build the client; see the class docstring."""
|
|
47
|
+
region_name = region.value if isinstance(region, Region) else region
|
|
48
|
+
if config is None:
|
|
49
|
+
config = WherobotsConfig.from_env(api_key=api_key, region=region_name)
|
|
50
|
+
elif api_key or region_name:
|
|
51
|
+
config = dataclasses.replace(
|
|
52
|
+
config,
|
|
53
|
+
api_key=api_key or config.api_key,
|
|
54
|
+
region=region_name or config.region,
|
|
55
|
+
)
|
|
56
|
+
self._config = config
|
|
57
|
+
self._client = BaseClient(config)
|
|
58
|
+
self._files: Files | None = None
|
|
59
|
+
|
|
60
|
+
def __repr__(self) -> str:
|
|
61
|
+
"""Return a representation whose config masks the API key."""
|
|
62
|
+
return f"Wherobots(config={self._config!r})"
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def config(self) -> WherobotsConfig:
|
|
66
|
+
"""The configuration this client was built from."""
|
|
67
|
+
return self._config
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def files(self) -> Files:
|
|
71
|
+
"""The Files surface (``wb.files.my_files``, ``wb.files.drive(...)``)."""
|
|
72
|
+
if self._files is None:
|
|
73
|
+
self._files = Files(self._client, self._config.region)
|
|
74
|
+
return self._files
|
|
75
|
+
|
|
76
|
+
def close(self) -> None:
|
|
77
|
+
"""Close the API connection."""
|
|
78
|
+
self._client.close()
|
|
79
|
+
|
|
80
|
+
def __enter__(self) -> Wherobots:
|
|
81
|
+
"""Enter the runtime context (context-manager protocol)."""
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def __exit__(
|
|
85
|
+
self,
|
|
86
|
+
exc_type: type[BaseException] | None,
|
|
87
|
+
exc: BaseException | None,
|
|
88
|
+
tb: TracebackType | None,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Exit the runtime context, closing the API connection."""
|
|
91
|
+
self.close()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wherobots-python-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
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
|
|
@@ -45,6 +45,7 @@ Additional Wherobots surfaces will be added over time.
|
|
|
45
45
|
- List runs with status, name, and date filters
|
|
46
46
|
- Typed dataclass models for all API responses (no pydantic dependency)
|
|
47
47
|
- Environment-based configuration
|
|
48
|
+
- Files: list, create folders, upload, download, rename and delete in your Wherobots file area
|
|
48
49
|
|
|
49
50
|
## Installation
|
|
50
51
|
|
|
@@ -418,6 +419,49 @@ job = WherobotsJob(
|
|
|
418
419
|
)
|
|
419
420
|
```
|
|
420
421
|
|
|
422
|
+
## Files
|
|
423
|
+
|
|
424
|
+
`Files` is your Wherobots file area ("My Files" in Studio) from Python. Start from a
|
|
425
|
+
`Wherobots` client; it reads `WHEROBOTS_API_KEY` and `WHEROBOTS_REGION`, or takes `api_key=` /
|
|
426
|
+
`region=` overrides, and closes its connection as a context manager.
|
|
427
|
+
|
|
428
|
+
```python
|
|
429
|
+
import wherobots
|
|
430
|
+
from wherobots import Drive, Region
|
|
431
|
+
|
|
432
|
+
with wherobots.Wherobots() as wb:
|
|
433
|
+
drive = wb.files.my_files # same object as wb.files.drive(Drive.MY_FILES)
|
|
434
|
+
eu = wb.files.drive(Drive.MY_FILES, region=Region.EU_WEST_1) # another region
|
|
435
|
+
|
|
436
|
+
drive.make_directory("projects/2026/q3") # creates projects, then projects/2026, then .../q3
|
|
437
|
+
drive.upload_file("report.csv", "projects/2026/q3/report.csv")
|
|
438
|
+
for entry in drive.list_directory("projects/2026/q3"): # "" lists the root
|
|
439
|
+
print(entry.name, entry.is_dir, entry.size, entry.last_modified)
|
|
440
|
+
drive.download_file("projects/2026/q3/report.csv", "local-copy.csv")
|
|
441
|
+
drive.rename_file("projects/2026/q3/report.csv", "final.csv")
|
|
442
|
+
drive.delete_file("projects/2026/q3/final.csv")
|
|
443
|
+
drive.delete_directory("projects")
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
- Each path segment is encoded on its own; empty levels, `.` and `..` are refused before any
|
|
447
|
+
request.
|
|
448
|
+
- Uploads and downloads go straight to storage through short-lived presigned URLs. The API key
|
|
449
|
+
is never sent to storage, and no storage credentials are requested.
|
|
450
|
+
- Uploads are capped at 500 MiB per file, matching the CLI and the Studio web UI; a larger file
|
|
451
|
+
is refused before any request is sent.
|
|
452
|
+
- Listing follows the server's pages until the end and returns every entry.
|
|
453
|
+
- A region is required: with none passed or configured, `wb.files.my_files` raises
|
|
454
|
+
`WherobotsConfigError`.
|
|
455
|
+
|
|
456
|
+
| Failure | Raised as |
|
|
457
|
+
|---|---|
|
|
458
|
+
| Files is not enabled for this drive in this region | `WherobotsFilesNotEnabledError` (`.drive`, `.region`) |
|
|
459
|
+
| The API key was not accepted | `WherobotsNotAuthenticatedError` |
|
|
460
|
+
| No such file or folder | `WherobotsFileNotFoundError` |
|
|
461
|
+
| Transfer to or from storage failed | `WherobotsS3Error` |
|
|
462
|
+
|
|
463
|
+
The three `Wherobots*Error` classes above subclass `WherobotsAPIError`.
|
|
464
|
+
|
|
421
465
|
## Enums
|
|
422
466
|
|
|
423
467
|
### JobStatus
|
|
@@ -585,9 +629,12 @@ All exceptions inherit from `WherobotsJobError`.
|
|
|
585
629
|
| `WherobotsJobError` | Base exception for all SDK errors. |
|
|
586
630
|
| `WherobotsAPIError` | API returned an error response. Has `status_code`, `response`, `request_id` attributes. |
|
|
587
631
|
| `WherobotsValidationError` | Client-side validation failed (e.g., invalid name). |
|
|
588
|
-
| `WherobotsS3Error` |
|
|
632
|
+
| `WherobotsS3Error` | A transfer to or from storage failed, including Files presigned uploads and downloads. |
|
|
589
633
|
| `WherobotsConfigError` | Configuration is missing or invalid. |
|
|
590
634
|
| `WherobotsTimeoutError` | Job exceeded the specified timeout. |
|
|
635
|
+
| `WherobotsNotAuthenticatedError` | Files: the API key was not accepted (HTTP 401). Subclass of `WherobotsAPIError`. |
|
|
636
|
+
| `WherobotsFilesNotEnabledError` | Files is not enabled for a drive in a region. Has `drive`, `region`. Subclass of `WherobotsAPIError`. |
|
|
637
|
+
| `WherobotsFileNotFoundError` | Files: no such file or folder. Subclass of `WherobotsAPIError`. |
|
|
591
638
|
|
|
592
639
|
```python
|
|
593
640
|
from wherobots import WherobotsAPIError, WherobotsJobError
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
wherobots/__init__.py,sha256=851lY2cWlkalilGZ2pvjCBOJIRdixrXRkHOuIkgRMTw,2330
|
|
2
|
+
wherobots/__version__.py,sha256=Jb0N0VuH9FWKfWZpo_RdVUCfOylgV7rwlsHL5uwLslo,111
|
|
3
|
+
wherobots/client.py,sha256=mFShTtxXfCg9GLVMykM7jC1SvuDCZ_sY9JnHIOlOwxQ,37797
|
|
4
|
+
wherobots/config.py,sha256=d1QePrl7V0ooSlv_Ut0GJ5o0ShMBDH1k9yqgQ1cnxmE,7376
|
|
5
|
+
wherobots/enums.py,sha256=0Wdw50bwWrZbslL6eb6pV8ebzWNPV6ouBjVX6PizbsY,4435
|
|
6
|
+
wherobots/exceptions.py,sha256=JnqMghCANt9Ci6IPmYssbysovqvxxfbB1QMV8xJ3uGY,2565
|
|
7
|
+
wherobots/models.py,sha256=iUeSxARQFlKGz8xtkweordFMsPwMavPGsLmSrEwIqbE,50926
|
|
8
|
+
wherobots/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
wherobots/sdk.py,sha256=Cvp2a1I_Wvvk5Bp_qsawGfOqQ3DiMcS6JKhyrHtM6ds,2804
|
|
10
|
+
wherobots/api/__init__.py,sha256=KbpcEzKbQ0Z35_hwHsm63Zmn7hfqSApDcRb8u5Y-Af0,291
|
|
11
|
+
wherobots/api/base.py,sha256=EeMq2ESZ50oTf5Wpv50cqC6ljPlXXScg3SU8xfWMero,11471
|
|
12
|
+
wherobots/api/drive.py,sha256=qdhOgL8d34noxE0SwEbqpI3Ql6u53pgLU0cePRwHEmM,19281
|
|
13
|
+
wherobots/api/files.py,sha256=_v89I8tJ_ANnxaTZd4AxtxrkZfZc0BDbL2jpmPBwvWw,23294
|
|
14
|
+
wherobots/api/runs.py,sha256=y0yF_h74S4ScebgCxHjT07kBlj8aIHlfYN6iBaBktgs,8654
|
|
15
|
+
wherobots/utils/__init__.py,sha256=cTM2QNJDauQWyGBTJPE5yH4ULc4Ucg_4KFzemjM3HaA,193
|
|
16
|
+
wherobots/utils/client_header.py,sha256=-zczKIl8QfFBs1uTLbzp6cGLEzit1qGgdWbV9MLYEcM,8083
|
|
17
|
+
wherobots/utils/logger.py,sha256=eclhD9Vf2LsSm4engRzT3jE2v0wdHJH01OfeI3ml4HM,1103
|
|
18
|
+
wherobots/utils/validation.py,sha256=mjWGGhNji1crdn1AhLZoTIfknSQm38X0MmgI4ow14T8,907
|
|
19
|
+
wherobots_python_sdk-0.5.0.dist-info/licenses/LICENSE,sha256=U1EH466XmvxbqaWOY0cax49w0R87_6w6yJLyV14v4bc,10767
|
|
20
|
+
wherobots_python_sdk-0.5.0.dist-info/METADATA,sha256=CWI2ckQPnC8dvVleWq2yVHClzodf7rImM1ELiay7KTk,30682
|
|
21
|
+
wherobots_python_sdk-0.5.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
22
|
+
wherobots_python_sdk-0.5.0.dist-info/top_level.txt,sha256=oL-n-9GIG0rmUeukuytUqdN6yfROIsVh8bNrgZsOdxw,10
|
|
23
|
+
wherobots_python_sdk-0.5.0.dist-info/RECORD,,
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
wherobots/__init__.py,sha256=TENVmuGg882ujMs7zj4bSKYHF3SOPXOfiqwLQEPggXE,1917
|
|
2
|
-
wherobots/__version__.py,sha256=k6ZfWdXQCuI__hhJ5rdjMmRn4JkT3w2Isev99qNJH5M,111
|
|
3
|
-
wherobots/client.py,sha256=mFShTtxXfCg9GLVMykM7jC1SvuDCZ_sY9JnHIOlOwxQ,37797
|
|
4
|
-
wherobots/config.py,sha256=d1QePrl7V0ooSlv_Ut0GJ5o0ShMBDH1k9yqgQ1cnxmE,7376
|
|
5
|
-
wherobots/enums.py,sha256=YsxXN3ZcY99aoEsUtjYs4otHDevsSPizAgCp-vShds8,4154
|
|
6
|
-
wherobots/exceptions.py,sha256=pwof-CAvDwXnQpSz6YiTptjNHvtWgBe1PproBbaz0lw,1348
|
|
7
|
-
wherobots/models.py,sha256=aZmEO1Kg0LPZf-F52hW13qmMArwQ3PhEODNgVlAXam8,46573
|
|
8
|
-
wherobots/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
-
wherobots/api/__init__.py,sha256=jUb587jazP_FA-sH_6yCYfvGxsNiUOyIYAPb3CHOYtQ,218
|
|
10
|
-
wherobots/api/base.py,sha256=-VZ7LkrjlpMdtFfG3Y6IM3dEdg0sYkjZviza7mboInM,9438
|
|
11
|
-
wherobots/api/files.py,sha256=_v89I8tJ_ANnxaTZd4AxtxrkZfZc0BDbL2jpmPBwvWw,23294
|
|
12
|
-
wherobots/api/runs.py,sha256=y0yF_h74S4ScebgCxHjT07kBlj8aIHlfYN6iBaBktgs,8654
|
|
13
|
-
wherobots/utils/__init__.py,sha256=cTM2QNJDauQWyGBTJPE5yH4ULc4Ucg_4KFzemjM3HaA,193
|
|
14
|
-
wherobots/utils/client_header.py,sha256=-zczKIl8QfFBs1uTLbzp6cGLEzit1qGgdWbV9MLYEcM,8083
|
|
15
|
-
wherobots/utils/logger.py,sha256=eclhD9Vf2LsSm4engRzT3jE2v0wdHJH01OfeI3ml4HM,1103
|
|
16
|
-
wherobots/utils/validation.py,sha256=mjWGGhNji1crdn1AhLZoTIfknSQm38X0MmgI4ow14T8,907
|
|
17
|
-
wherobots_python_sdk-0.4.0.dist-info/licenses/LICENSE,sha256=U1EH466XmvxbqaWOY0cax49w0R87_6w6yJLyV14v4bc,10767
|
|
18
|
-
wherobots_python_sdk-0.4.0.dist-info/METADATA,sha256=u3yXE1-I_-iD9tyXok-z4c06ebgxLqv8_OJchNDxj5E,28176
|
|
19
|
-
wherobots_python_sdk-0.4.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
20
|
-
wherobots_python_sdk-0.4.0.dist-info/top_level.txt,sha256=oL-n-9GIG0rmUeukuytUqdN6yfROIsVh8bNrgZsOdxw,10
|
|
21
|
-
wherobots_python_sdk-0.4.0.dist-info/RECORD,,
|
|
File without changes
|
{wherobots_python_sdk-0.4.0.dist-info → wherobots_python_sdk-0.5.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|