wherobots-python-sdk 0.4.0__py3-none-any.whl → 0.5.1__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 CHANGED
@@ -1,6 +1,7 @@
1
- """Wherobots Python SDK (Jobs API surface)."""
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
@@ -1,5 +1,5 @@
1
1
  """Version information."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.5.1"
4
4
  __author__ = "Wherobots"
5
5
  __email__ = "support@wherobots.com"
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
- response = exc.response
167
- status_code = response.status_code if response is not None else None
168
- error_payload = None
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
- raise WherobotsAPIError(
186
- message,
187
- status_code=status_code,
188
- response=error_payload,
189
- request_id=request_id,
190
- ) from exc
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.4.0
3
+ Version: 0.5.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
@@ -21,8 +21,8 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
21
  Requires-Python: >=3.10
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
- Requires-Dist: requests>=2.33.0
25
- Requires-Dist: boto3>=1.34.0
24
+ Requires-Dist: requests>=2.34.2
25
+ Requires-Dist: boto3>=1.43.108
26
26
  Dynamic: license-file
27
27
 
28
28
  # Wherobots Python SDK
@@ -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` | S3 upload or access operation failed. Deprecated — presigned uploads raise `WherobotsAPIError` instead. |
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=tYjwx74Jxz6yibZCdQ8vyn6eFhmW4idWIHRwEYUjxKE,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.1.dist-info/licenses/LICENSE,sha256=U1EH466XmvxbqaWOY0cax49w0R87_6w6yJLyV14v4bc,10767
20
+ wherobots_python_sdk-0.5.1.dist-info/METADATA,sha256=gGx72Y7tgYhJ7Ka4vDmAqv7Gb3CUWhJWX3QTBKf-LGA,30684
21
+ wherobots_python_sdk-0.5.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
22
+ wherobots_python_sdk-0.5.1.dist-info/top_level.txt,sha256=oL-n-9GIG0rmUeukuytUqdN6yfROIsVh8bNrgZsOdxw,10
23
+ wherobots_python_sdk-0.5.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (84.0.0)
2
+ Generator: setuptools (84.2.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -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,,