wherobots-python-sdk 0.5.0__py3-none-any.whl → 0.6.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 CHANGED
@@ -3,6 +3,7 @@
3
3
  from wherobots.__version__ import __version__
4
4
  from wherobots.api.drive import Files, FilesDrive
5
5
  from wherobots.api.files import FilesAPI
6
+ from wherobots.api.tasks import TaskRuns, Tasks
6
7
  from wherobots.client import WherobotsJob
7
8
  from wherobots.config import WherobotsConfig
8
9
  from wherobots.enums import (
@@ -14,6 +15,7 @@ from wherobots.enums import (
14
15
  JobStatus,
15
16
  Region,
16
17
  Runtime,
18
+ TaskRunState,
17
19
  is_terminal_status,
18
20
  )
19
21
  from wherobots.exceptions import (
@@ -34,6 +36,7 @@ from wherobots.models import (
34
36
  KubeAppEvent,
35
37
  LogItem,
36
38
  LogsResponse,
39
+ OffsetPage,
37
40
  OrganizationCustomer,
38
41
  PyPiDependency,
39
42
  RunAppMeta,
@@ -46,6 +49,14 @@ from wherobots.models import (
46
49
  RunPythonPayload,
47
50
  RunView,
48
51
  StorageIntegration,
52
+ Task,
53
+ TaskExample,
54
+ TaskRun,
55
+ TaskRunCost,
56
+ TaskRunLogEntry,
57
+ TaskRunLogs,
58
+ TaskRunSummary,
59
+ TaskSummary,
49
60
  TemporaryAwsCredentials,
50
61
  UtilizationStats,
51
62
  )
@@ -63,6 +74,8 @@ __all__ = [
63
74
  "WherobotsJob",
64
75
  "Job",
65
76
  "FilesAPI",
77
+ "Tasks",
78
+ "TaskRuns",
66
79
  "WherobotsConfig",
67
80
  # Enums
68
81
  "Runtime",
@@ -73,6 +86,7 @@ __all__ = [
73
86
  "AppType",
74
87
  "DependencyType",
75
88
  "DependencyFileType",
89
+ "TaskRunState",
76
90
  "is_terminal_status",
77
91
  # Exceptions
78
92
  "WherobotsJobError",
@@ -105,4 +119,13 @@ __all__ = [
105
119
  "TemporaryAwsCredentials",
106
120
  "UtilizationStats",
107
121
  "FileEntry",
122
+ "OffsetPage",
123
+ "TaskSummary",
124
+ "Task",
125
+ "TaskExample",
126
+ "TaskRunSummary",
127
+ "TaskRun",
128
+ "TaskRunLogs",
129
+ "TaskRunLogEntry",
130
+ "TaskRunCost",
108
131
  ]
wherobots/__version__.py CHANGED
@@ -1,5 +1,5 @@
1
1
  """Version information."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.6.0"
4
4
  __author__ = "Wherobots"
5
5
  __email__ = "support@wherobots.com"
wherobots/api/__init__.py CHANGED
@@ -4,5 +4,6 @@ from wherobots.api.base import BaseClient
4
4
  from wherobots.api.drive import Files, FilesDrive
5
5
  from wherobots.api.files import FilesAPI
6
6
  from wherobots.api.runs import RunsAPI
7
+ from wherobots.api.tasks import TaskRuns, Tasks
7
8
 
8
- __all__ = ["BaseClient", "Files", "FilesAPI", "FilesDrive", "RunsAPI"]
9
+ __all__ = ["BaseClient", "Files", "FilesAPI", "FilesDrive", "RunsAPI", "TaskRuns", "Tasks"]
wherobots/api/base.py CHANGED
@@ -35,6 +35,21 @@ _RETRIABLE_STATUS_CODES = (429, 502, 503)
35
35
  _REDIRECT_STATUS_CODES = frozenset({301, 302, 303, 307, 308})
36
36
 
37
37
 
38
+ def json_object(response: requests.Response) -> dict[str, object]:
39
+ """Parse a JSON object body, or raise ``WherobotsAPIError``."""
40
+ try:
41
+ data = response.json()
42
+ except ValueError as exc:
43
+ raise WherobotsAPIError(
44
+ "Wherobots API returned a non-JSON body", status_code=response.status_code
45
+ ) from exc
46
+ if not isinstance(data, dict):
47
+ raise WherobotsAPIError(
48
+ "Wherobots API returned an unexpected body", status_code=response.status_code
49
+ )
50
+ return data
51
+
52
+
38
53
  class BaseClient:
39
54
  """Thin HTTP wrapper around ``requests.Session``.
40
55
 
wherobots/api/drive.py CHANGED
@@ -21,6 +21,7 @@ from urllib.parse import quote
21
21
 
22
22
  import requests
23
23
 
24
+ from wherobots.api.base import json_object
24
25
  from wherobots.enums import Drive, Region
25
26
  from wherobots.exceptions import (
26
27
  WherobotsAPIError,
@@ -99,21 +100,6 @@ def _encode(segments: list[str]) -> str:
99
100
  return "/".join(quote(segment, safe="") for segment in segments)
100
101
 
101
102
 
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
103
  class FilesDrive:
118
104
  """One Files drive in one region: list, create folders, transfer, rename, delete.
119
105
 
@@ -175,7 +161,7 @@ class FilesDrive:
175
161
  if cursor is not None:
176
162
  params["cursor"] = cursor
177
163
  response = self._call("GET", route, params=params, root_listing=not route_segments)
178
- page = DirectoryPage.from_dict(_json_object(response))
164
+ page = DirectoryPage.from_dict(json_object(response))
179
165
  entries.extend(page.entries)
180
166
  # Any cursor seen before means the server is cycling; stop rather than loop forever.
181
167
  if page.next_cursor is None or page.next_cursor in seen:
@@ -227,7 +213,7 @@ class FilesDrive:
227
213
  segments = self._required(path, directory=False)
228
214
  response = self._call("POST", self._route("file-upload-url", segments))
229
215
  try:
230
- target = FileUploadTarget.from_dict(_json_object(response))
216
+ target = FileUploadTarget.from_dict(json_object(response))
231
217
  except ValueError as exc:
232
218
  raise WherobotsAPIError(str(exc), status_code=response.status_code) from exc
233
219
  if not target.upload_url.startswith("https://"):
wherobots/api/tasks.py ADDED
@@ -0,0 +1,265 @@
1
+ """Tasks — typed wrappers around the ``/tasks`` and ``/task-runs`` endpoints.
2
+
3
+ A task is a named unit of work, such as RasterFlow inference, that the caller's
4
+ organization can run. Each run is a task run, addressable by its own id, so
5
+ ``Tasks`` and ``TaskRuns`` are siblings, as they are in the API.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import UTC
11
+ from typing import TYPE_CHECKING
12
+ from urllib.parse import quote
13
+
14
+ from wherobots.api.base import json_object
15
+ from wherobots.enums import Region, TaskRunState
16
+ from wherobots.exceptions import WherobotsAPIError
17
+ from wherobots.models import (
18
+ OffsetPage,
19
+ Task,
20
+ TaskRun,
21
+ TaskRunCost,
22
+ TaskRunLogs,
23
+ TaskRunSummary,
24
+ TaskSummary,
25
+ )
26
+ from wherobots.utils.logger import get_logger
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import Mapping, Sequence
30
+ from datetime import datetime
31
+
32
+ from wherobots.api.base import BaseClient
33
+
34
+ logger = get_logger("api.tasks")
35
+
36
+
37
+ def _region_name(region: Region | str) -> str:
38
+ return region.value if isinstance(region, Region) else region
39
+
40
+
41
+ def _region_names(regions: Region | str | Sequence[Region | str]) -> list[str]:
42
+ # A lone string is one region, not a sequence of one-letter regions.
43
+ items = [regions] if isinstance(regions, (Region, str)) else regions
44
+ return [_region_name(r) for r in items]
45
+
46
+
47
+ def _state_names(states: TaskRunState | str | Sequence[TaskRunState | str]) -> list[str]:
48
+ items = [states] if isinstance(states, (TaskRunState, str)) else states
49
+ return [s.value if isinstance(s, TaskRunState) else s for s in items]
50
+
51
+
52
+ def _task_route(task_name: str) -> str:
53
+ return f"/tasks/{quote(task_name, safe='')}"
54
+
55
+
56
+ class Tasks:
57
+ """The tasks the caller's organization can run.
58
+
59
+ Obtain it from ``Wherobots.tasks``; do not build it directly.
60
+
61
+ Args:
62
+ client: The shared API connection.
63
+ region: Region new runs use when ``run`` is given none; ``None`` lets the
64
+ API apply the organization's default region.
65
+ """
66
+
67
+ def __init__(self, client: BaseClient, region: str | None = None) -> None:
68
+ """Bind to a connection and default region; see the class docstring."""
69
+ self._client = client
70
+ self._region = region
71
+
72
+ def list(
73
+ self,
74
+ *,
75
+ regions: Region | str | Sequence[Region | str] | None = None,
76
+ limit: int = 50,
77
+ offset: int = 0,
78
+ ) -> OffsetPage[TaskSummary]:
79
+ """List tasks, ordered by name (``GET /tasks``).
80
+
81
+ Args:
82
+ regions: Only tasks that can run in this region, or in any of these regions.
83
+ limit: Page size, 1 to 100.
84
+ offset: How many matching tasks to skip.
85
+
86
+ Returns:
87
+ One page of tasks; ``OffsetPage.next_offset`` fetches the next.
88
+
89
+ Raises:
90
+ WherobotsAPIError: On HTTP or JSON-parsing failures.
91
+ """
92
+ params: dict[str, object] = {"limit": limit, "offset": offset}
93
+ if regions:
94
+ params["regions"] = _region_names(regions)
95
+ response = self._client.get("/tasks", params=params)
96
+ return OffsetPage.from_dict(json_object(response), TaskSummary.from_dict)
97
+
98
+ def get(self, task_name: str) -> Task:
99
+ """Fetch a task with its input and output schemas (``GET /tasks/{name}``).
100
+
101
+ Args:
102
+ task_name: The task's name.
103
+
104
+ Returns:
105
+ The task.
106
+
107
+ Raises:
108
+ WherobotsAPIError: On HTTP or JSON-parsing failures.
109
+ """
110
+ response = self._client.get(_task_route(task_name))
111
+ return Task.from_dict(json_object(response))
112
+
113
+ def run(
114
+ self,
115
+ task_name: str,
116
+ inputs: Mapping[str, object] | None = None,
117
+ *,
118
+ region: Region | str | None = None,
119
+ name: str | None = None,
120
+ ) -> TaskRunSummary:
121
+ """Start a run of a task (``POST /tasks/{name}/runs``).
122
+
123
+ Args:
124
+ task_name: The task's name.
125
+ inputs: Inputs matching the task's ``input_schema``.
126
+ region: Region to run in; defaults to the configured region, then to
127
+ the organization's default region.
128
+ name: A name for the run, shown as ``TaskRun.name``.
129
+
130
+ Returns:
131
+ The new run; poll ``TaskRuns.get`` with its ``task_run_id``.
132
+
133
+ Raises:
134
+ WherobotsAPIError: On HTTP or JSON-parsing failures, including
135
+ inputs the task's schema rejects.
136
+ """
137
+ body: dict[str, object] = {"inputs": dict(inputs or {})}
138
+ resolved_region = _region_name(region) if region else self._region
139
+ if resolved_region:
140
+ body["region"] = resolved_region
141
+ if name:
142
+ body["name"] = name
143
+ logger.info("Running task '%s' in %s", task_name, resolved_region or "org default")
144
+ response = self._client.post(f"{_task_route(task_name)}/runs", json_body=body)
145
+ return TaskRunSummary.from_dict(json_object(response))
146
+
147
+
148
+ class TaskRuns:
149
+ """Runs of the organization's tasks.
150
+
151
+ Obtain it from ``Wherobots.task_runs``; do not build it directly.
152
+
153
+ Args:
154
+ client: The shared API connection.
155
+ """
156
+
157
+ def __init__(self, client: BaseClient) -> None:
158
+ """Bind to a connection; see the class docstring."""
159
+ self._client = client
160
+
161
+ def list(
162
+ self,
163
+ *,
164
+ task_name: str | None = None,
165
+ states: TaskRunState | str | Sequence[TaskRunState | str] | None = None,
166
+ created_after: datetime | None = None,
167
+ limit: int = 50,
168
+ offset: int = 0,
169
+ ) -> OffsetPage[TaskRunSummary]:
170
+ """List task runs, newest first (``GET /task-runs``).
171
+
172
+ Args:
173
+ task_name: Only runs of this task; a trailing ``*`` matches a prefix.
174
+ states: Only runs in this state, or in any of these states.
175
+ created_after: Only runs submitted after this instant; a naive
176
+ ``datetime`` is read as local time.
177
+ limit: Page size, 1 to 100.
178
+ offset: How many matching runs to skip.
179
+
180
+ Returns:
181
+ One page of runs; ``OffsetPage.next_offset`` fetches the next.
182
+
183
+ Raises:
184
+ WherobotsAPIError: On HTTP or JSON-parsing failures.
185
+ """
186
+ params: dict[str, object] = {"limit": limit, "offset": offset}
187
+ if task_name:
188
+ params["taskName"] = task_name
189
+ if states:
190
+ params["state"] = _state_names(states)
191
+ if created_after:
192
+ params["createdAfter"] = created_after.astimezone(UTC).isoformat()
193
+ response = self._client.get("/task-runs", params=params)
194
+ return OffsetPage.from_dict(json_object(response), TaskRunSummary.from_dict)
195
+
196
+ def get(self, task_run_id: str) -> TaskRun:
197
+ """Fetch a run with its inputs and results (``GET /task-runs/{id}``).
198
+
199
+ Args:
200
+ task_run_id: The run's id.
201
+
202
+ Returns:
203
+ The run.
204
+
205
+ Raises:
206
+ WherobotsAPIError: On HTTP or JSON-parsing failures.
207
+ """
208
+ response = self._client.get(self._route(task_run_id))
209
+ return TaskRun.from_dict(json_object(response))
210
+
211
+ def logs(self, task_run_id: str, since: str | None = None) -> TaskRunLogs:
212
+ """Fetch a page of a run's logs (``GET /task-runs/{id}/logs``).
213
+
214
+ Args:
215
+ task_run_id: The run's id.
216
+ since: The previous page's ``next_since``; omit for the first page.
217
+
218
+ Returns:
219
+ Log lines logged after ``since``.
220
+
221
+ Raises:
222
+ WherobotsAPIError: On HTTP or JSON-parsing failures.
223
+ """
224
+ params = {"since": since} if since else None
225
+ response = self._client.get(self._route(task_run_id, "logs"), params=params)
226
+ return TaskRunLogs.from_dict(json_object(response))
227
+
228
+ def cost(self, task_run_id: str) -> TaskRunCost:
229
+ """Fetch what a run cost (``GET /task-runs/{id}/cost``).
230
+
231
+ Args:
232
+ task_run_id: The run's id.
233
+
234
+ Returns:
235
+ The run's cost; ``is_settling`` is ``True`` while it may still change.
236
+
237
+ Raises:
238
+ WherobotsAPIError: On HTTP or JSON-parsing failures, or a cost the
239
+ SDK cannot read (rather than reporting it as zero).
240
+ """
241
+ response = self._client.get(self._route(task_run_id, "cost"))
242
+ try:
243
+ return TaskRunCost.from_dict(json_object(response))
244
+ except (TypeError, ValueError) as exc:
245
+ raise WherobotsAPIError(str(exc), status_code=response.status_code) from exc
246
+
247
+ def cancel(self, task_run_id: str) -> None:
248
+ """Request that a run stop (``POST /task-runs/{id}/cancel``).
249
+
250
+ The run moves to ``CANCELLING`` and then ``CANCELLED`` once the stop is
251
+ confirmed.
252
+
253
+ Args:
254
+ task_run_id: The run's id.
255
+
256
+ Raises:
257
+ WherobotsAPIError: On HTTP failures.
258
+ """
259
+ self._client.post(self._route(task_run_id, "cancel"))
260
+ logger.info("Cancel requested for task run %s", task_run_id)
261
+
262
+ @staticmethod
263
+ def _route(task_run_id: str, action: str | None = None) -> str:
264
+ route = f"/task-runs/{quote(task_run_id, safe='')}"
265
+ return f"{route}/{action}" if action else route
wherobots/enums.py CHANGED
@@ -148,3 +148,36 @@ class Drive(Enum):
148
148
  """
149
149
 
150
150
  MY_FILES = "my_files"
151
+
152
+
153
+ class TaskRunState(Enum):
154
+ """Task run states (OpenAPI ``TaskRunState``)."""
155
+
156
+ PENDING = "PENDING"
157
+ SUBMITTED = "SUBMITTED"
158
+ RUNNING = "RUNNING"
159
+ SUCCEEDED = "SUCCEEDED"
160
+ FAILED = "FAILED"
161
+ TIMED_OUT = "TIMED_OUT"
162
+ CANCELLING = "CANCELLING"
163
+ CANCELLED = "CANCELLED"
164
+ UNKNOWN = "UNKNOWN"
165
+ SKIPPED = "SKIPPED"
166
+ MISSED = "MISSED"
167
+ REJECTED = "REJECTED"
168
+
169
+ @property
170
+ def is_active(self) -> bool:
171
+ """Return ``True`` while the run may still change state."""
172
+ return self in _ACTIVE_TASK_RUN_STATES
173
+
174
+
175
+ # Mirrors the server's TaskRunState.active_states.
176
+ _ACTIVE_TASK_RUN_STATES = frozenset(
177
+ {
178
+ TaskRunState.PENDING,
179
+ TaskRunState.SUBMITTED,
180
+ TaskRunState.RUNNING,
181
+ TaskRunState.CANCELLING,
182
+ }
183
+ )
wherobots/models.py CHANGED
@@ -7,8 +7,14 @@ for JSON round-tripping without requiring pydantic.
7
7
  from __future__ import annotations
8
8
 
9
9
  from dataclasses import dataclass, field
10
- from datetime import datetime, timedelta, timezone
11
- from typing import Any # noqa: TID251
10
+ from datetime import UTC, datetime, timedelta
11
+ from decimal import Decimal, InvalidOperation
12
+ from typing import (
13
+ TYPE_CHECKING,
14
+ Any, # noqa: TID251
15
+ Generic,
16
+ TypeVar,
17
+ )
12
18
 
13
19
  from wherobots.enums import (
14
20
  AppStatus,
@@ -16,8 +22,12 @@ from wherobots.enums import (
16
22
  DependencyFileType,
17
23
  DependencyType,
18
24
  JobStatus,
25
+ TaskRunState,
19
26
  )
20
27
 
28
+ if TYPE_CHECKING:
29
+ from collections.abc import Callable
30
+
21
31
  # ---------------------------------------------------------------------------
22
32
  # Dependency models
23
33
  # ---------------------------------------------------------------------------
@@ -1280,7 +1290,7 @@ class TemporaryAwsCredentials:
1280
1290
  def is_expired(self, leeway_seconds: int = 60) -> bool:
1281
1291
  """True if these creds are within *leeway_seconds* of expiry."""
1282
1292
  deadline = self.expiration - timedelta(seconds=leeway_seconds)
1283
- return datetime.now(timezone.utc) >= deadline
1293
+ return datetime.now(UTC) >= deadline
1284
1294
 
1285
1295
  def wipe(self) -> None:
1286
1296
  """Evict secret material from this object.
@@ -1295,7 +1305,7 @@ class TemporaryAwsCredentials:
1295
1305
  object.__setattr__(self, "access_key_id", "")
1296
1306
  object.__setattr__(self, "secret_access_key", "")
1297
1307
  object.__setattr__(self, "session_token", "")
1298
- object.__setattr__(self, "expiration", datetime.now(timezone.utc) - timedelta(seconds=1))
1308
+ object.__setattr__(self, "expiration", datetime.now(UTC) - timedelta(seconds=1))
1299
1309
 
1300
1310
  def __reduce__(self) -> Any: # noqa: ANN401, D105
1301
1311
  raise TypeError(
@@ -1339,11 +1349,9 @@ class TemporaryAwsCredentials:
1339
1349
  if missing:
1340
1350
  raise ValueError(f"Credential response is missing required field(s): {missing}")
1341
1351
  raw_exp = data["expiration"]
1342
- # Accept trailing ``Z`` (some servers use it instead of +00:00).
1343
- normalized = raw_exp.replace("Z", "+00:00") if isinstance(raw_exp, str) else ""
1344
- expiration = datetime.fromisoformat(normalized)
1352
+ expiration = datetime.fromisoformat(raw_exp if isinstance(raw_exp, str) else "")
1345
1353
  if expiration.tzinfo is None:
1346
- expiration = expiration.replace(tzinfo=timezone.utc)
1354
+ expiration = expiration.replace(tzinfo=UTC)
1347
1355
  return cls(
1348
1356
  access_key_id=data["accessKeyId"],
1349
1357
  secret_access_key=data["secretAccessKey"],
@@ -1369,10 +1377,10 @@ def _parse_timestamp(value: object) -> datetime | None:
1369
1377
  if not isinstance(value, str) or not value:
1370
1378
  return None
1371
1379
  try:
1372
- parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
1380
+ parsed = datetime.fromisoformat(value)
1373
1381
  except ValueError:
1374
1382
  return None
1375
- return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=timezone.utc)
1383
+ return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)
1376
1384
 
1377
1385
 
1378
1386
  @dataclass(frozen=True)
@@ -1481,3 +1489,332 @@ class FileUploadTarget:
1481
1489
  if destination is None or upload_url is None:
1482
1490
  raise ValueError("Upload URL response is missing 'destination' or 'uploadUrl'")
1483
1491
  return cls(destination=destination, upload_url=upload_url)
1492
+
1493
+
1494
+ # ---------------------------------------------------------------------------
1495
+ # Tasks
1496
+ # ---------------------------------------------------------------------------
1497
+
1498
+ T = TypeVar("T")
1499
+
1500
+
1501
+ def _optional_int(data: dict[str, object], key: str) -> int | None:
1502
+ """Return ``data[key]`` when it is an integer (not a bool), else ``None``."""
1503
+ value = data.get(key)
1504
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
1505
+
1506
+
1507
+ def _object(value: object) -> dict[str, object] | None:
1508
+ """Return ``value`` when it is a JSON object, else ``None``."""
1509
+ return value if isinstance(value, dict) else None
1510
+
1511
+
1512
+ def _objects(value: object) -> list[dict[str, object]]:
1513
+ """Return the JSON objects in ``value`` when it is a list, else ``[]``."""
1514
+ return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
1515
+
1516
+
1517
+ @dataclass(frozen=True)
1518
+ class OffsetPage(Generic[T]):
1519
+ """One page of a limit/offset listing.
1520
+
1521
+ Attributes:
1522
+ items: The items on this page.
1523
+ total: How many items match in all, across every page.
1524
+ limit: The page size the server applied.
1525
+ offset: How many matching items precede this page.
1526
+ """
1527
+
1528
+ items: list[T]
1529
+ total: int
1530
+ limit: int
1531
+ offset: int
1532
+
1533
+ @property
1534
+ def next_offset(self) -> int | None:
1535
+ """The ``offset`` of the next page, or ``None`` on the last page."""
1536
+ end = self.offset + len(self.items)
1537
+ return end if self.items and end < self.total else None
1538
+
1539
+ @classmethod
1540
+ def from_dict(
1541
+ cls, data: dict[str, object], parse_item: Callable[[dict[str, object]], T]
1542
+ ) -> OffsetPage[T]:
1543
+ """Deserialize a ``LimitOffsetPage`` body, parsing each item with ``parse_item``."""
1544
+ items = [parse_item(item) for item in _objects(data.get("items"))]
1545
+ return cls(
1546
+ items=items,
1547
+ total=_optional_int(data, "total") or 0,
1548
+ limit=_optional_int(data, "limit") or len(items),
1549
+ offset=_optional_int(data, "offset") or 0,
1550
+ )
1551
+
1552
+
1553
+ @dataclass(frozen=True)
1554
+ class TaskSummary:
1555
+ """A task the caller's organization can run, as listings show it.
1556
+
1557
+ Attributes:
1558
+ name: The task's unique name; pass it to ``Tasks.get`` and ``Tasks.run``.
1559
+ display_name: Human-readable name.
1560
+ description: What the task does, when it says.
1561
+ regions: Regions the task can run in.
1562
+ """
1563
+
1564
+ name: str
1565
+ display_name: str
1566
+ description: str | None = None
1567
+ regions: list[str] = field(default_factory=list)
1568
+
1569
+ @classmethod
1570
+ def from_dict(cls, data: dict[str, object]) -> TaskSummary:
1571
+ """Deserialize a ``TaskSummary`` body."""
1572
+ regions = data.get("regions")
1573
+ return cls(
1574
+ name=_optional_str(data, "name") or "",
1575
+ display_name=_optional_str(data, "displayName") or "",
1576
+ description=_optional_str(data, "description"),
1577
+ regions=[r for r in regions if isinstance(r, str)] if isinstance(regions, list) else [],
1578
+ )
1579
+
1580
+
1581
+ @dataclass(frozen=True)
1582
+ class TaskExample:
1583
+ """A named set of example inputs for a task.
1584
+
1585
+ Attributes:
1586
+ name: What the example shows.
1587
+ inputs: Inputs to pass to ``Tasks.run``.
1588
+ """
1589
+
1590
+ name: str
1591
+ inputs: dict[str, object] = field(default_factory=dict)
1592
+
1593
+ @classmethod
1594
+ def from_dict(cls, data: dict[str, object]) -> TaskExample:
1595
+ """Deserialize a ``TaskExample`` body."""
1596
+ return cls(
1597
+ name=_optional_str(data, "name") or "",
1598
+ inputs=_object(data.get("inputs")) or {},
1599
+ )
1600
+
1601
+
1602
+ @dataclass(frozen=True)
1603
+ class Task(TaskSummary):
1604
+ """A task with the schemas of its inputs and outputs.
1605
+
1606
+ Attributes:
1607
+ input_schema: JSON Schema the run inputs must satisfy, when the task has one.
1608
+ output_schema: JSON Schema of a successful run's results, when the task has one.
1609
+ examples: Example inputs.
1610
+ """
1611
+
1612
+ input_schema: dict[str, object] | None = None
1613
+ output_schema: dict[str, object] | None = None
1614
+ examples: list[TaskExample] = field(default_factory=list)
1615
+
1616
+ @classmethod
1617
+ def from_dict(cls, data: dict[str, object]) -> Task:
1618
+ """Deserialize a ``Task`` body."""
1619
+ summary = TaskSummary.from_dict(data)
1620
+ return cls(
1621
+ name=summary.name,
1622
+ display_name=summary.display_name,
1623
+ description=summary.description,
1624
+ regions=summary.regions,
1625
+ input_schema=_object(data.get("inputSchema")),
1626
+ output_schema=_object(data.get("outputSchema")),
1627
+ examples=[TaskExample.from_dict(e) for e in _objects(data.get("examples"))],
1628
+ )
1629
+
1630
+
1631
+ def _task_run_state(value: object) -> TaskRunState | str:
1632
+ if not isinstance(value, str):
1633
+ return TaskRunState.UNKNOWN
1634
+ try:
1635
+ return TaskRunState(value)
1636
+ except ValueError:
1637
+ # Keep a state this SDK does not know yet instead of losing it.
1638
+ return value
1639
+
1640
+
1641
+ @dataclass(frozen=True)
1642
+ class TaskRunSummary:
1643
+ """One run of a task, as listings show it.
1644
+
1645
+ Attributes:
1646
+ task_run_id: The run's id.
1647
+ task_name: The task it ran.
1648
+ state: A ``TaskRunState``, or the raw string for a state this SDK does not know.
1649
+ region: The region it runs in.
1650
+ submitted_at: When it was submitted.
1651
+ started_at: When it started executing.
1652
+ ended_at: When it ended.
1653
+ duration_seconds: How long it executed (so far, while running).
1654
+ """
1655
+
1656
+ task_run_id: str
1657
+ task_name: str
1658
+ state: TaskRunState | str
1659
+ region: str
1660
+ submitted_at: datetime | None = None
1661
+ started_at: datetime | None = None
1662
+ ended_at: datetime | None = None
1663
+ duration_seconds: int | None = None
1664
+
1665
+ @property
1666
+ def is_active(self) -> bool:
1667
+ """Return ``True`` while the run may still change state."""
1668
+ return isinstance(self.state, TaskRunState) and self.state.is_active
1669
+
1670
+ @classmethod
1671
+ def from_dict(cls, data: dict[str, object]) -> TaskRunSummary:
1672
+ """Deserialize a ``TaskRunSummary`` body."""
1673
+ return cls(
1674
+ task_run_id=_optional_str(data, "taskRunId") or "",
1675
+ task_name=_optional_str(data, "taskName") or "",
1676
+ state=_task_run_state(data.get("state")),
1677
+ region=_optional_str(data, "region") or "",
1678
+ submitted_at=_parse_timestamp(data.get("submittedAt")),
1679
+ started_at=_parse_timestamp(data.get("startedAt")),
1680
+ ended_at=_parse_timestamp(data.get("endedAt")),
1681
+ duration_seconds=_optional_int(data, "durationSeconds"),
1682
+ )
1683
+
1684
+
1685
+ @dataclass(frozen=True)
1686
+ class TaskRun(TaskRunSummary):
1687
+ """One run of a task, with its inputs and outcome.
1688
+
1689
+ Attributes:
1690
+ name: The name given to the run, if any.
1691
+ inputs: The inputs it ran with.
1692
+ results: The task's outputs by name (one output reads ``{"o0": ...}``) once
1693
+ the run has succeeded.
1694
+ cached: Whether it returned a stored result from an earlier identical run.
1695
+ error_message: Why the run did not succeed.
1696
+ """
1697
+
1698
+ name: str | None = None
1699
+ inputs: dict[str, object] = field(default_factory=dict)
1700
+ results: dict[str, object] | None = None
1701
+ cached: bool = False
1702
+ error_message: str | None = None
1703
+
1704
+ @classmethod
1705
+ def from_dict(cls, data: dict[str, object]) -> TaskRun:
1706
+ """Deserialize a ``TaskRun`` body."""
1707
+ summary = TaskRunSummary.from_dict(data)
1708
+ return cls(
1709
+ task_run_id=summary.task_run_id,
1710
+ task_name=summary.task_name,
1711
+ state=summary.state,
1712
+ region=summary.region,
1713
+ submitted_at=summary.submitted_at,
1714
+ started_at=summary.started_at,
1715
+ ended_at=summary.ended_at,
1716
+ duration_seconds=summary.duration_seconds,
1717
+ name=_optional_str(data, "name"),
1718
+ inputs=_object(data.get("inputs")) or {},
1719
+ results=_object(data.get("results")),
1720
+ cached=data.get("cached") is True,
1721
+ error_message=_optional_str(data, "errorMessage"),
1722
+ )
1723
+
1724
+
1725
+ @dataclass(frozen=True)
1726
+ class TaskRunLogEntry:
1727
+ """One log line of a task run.
1728
+
1729
+ Attributes:
1730
+ timestamp: UTC instant to the nanosecond, as a string (``datetime`` would round it).
1731
+ level: Log level.
1732
+ message: The line.
1733
+ """
1734
+
1735
+ timestamp: str
1736
+ level: str
1737
+ message: str
1738
+
1739
+ @classmethod
1740
+ def from_dict(cls, data: dict[str, object]) -> TaskRunLogEntry:
1741
+ """Deserialize a ``TaskRunLogEntry`` body."""
1742
+ return cls(
1743
+ timestamp=_optional_str(data, "timestamp") or "",
1744
+ level=_optional_str(data, "level") or "",
1745
+ message=_optional_str(data, "message") or "",
1746
+ )
1747
+
1748
+
1749
+ @dataclass(frozen=True)
1750
+ class TaskRunLogs:
1751
+ """One page of a task run's logs.
1752
+
1753
+ Attributes:
1754
+ task_run_id: The run's id.
1755
+ entries: Log lines, oldest first.
1756
+ next_since: Pass as ``since`` to fetch the lines after this page; ``None`` once
1757
+ the run has finished and every line has been returned.
1758
+ """
1759
+
1760
+ task_run_id: str
1761
+ entries: list[TaskRunLogEntry] = field(default_factory=list)
1762
+ next_since: str | None = None
1763
+
1764
+ @classmethod
1765
+ def from_dict(cls, data: dict[str, object]) -> TaskRunLogs:
1766
+ """Deserialize a ``TaskRunLogsResponse`` body."""
1767
+ return cls(
1768
+ task_run_id=_optional_str(data, "taskRunId") or "",
1769
+ entries=[TaskRunLogEntry.from_dict(e) for e in _objects(data.get("items"))],
1770
+ next_since=_optional_str(data, "nextSince"),
1771
+ )
1772
+
1773
+
1774
+ @dataclass(frozen=True)
1775
+ class TaskRunCost:
1776
+ """What a task run cost.
1777
+
1778
+ Attributes:
1779
+ task_run_id: The run's id.
1780
+ total_cost_usd: Total cost in US dollars.
1781
+ units: Billed units.
1782
+ unit_type: What a unit measures.
1783
+ is_settling: ``True`` while the cost may still change.
1784
+ """
1785
+
1786
+ task_run_id: str
1787
+ total_cost_usd: Decimal
1788
+ units: int
1789
+ unit_type: str
1790
+ is_settling: bool
1791
+
1792
+ @classmethod
1793
+ def from_dict(cls, data: dict[str, object]) -> TaskRunCost:
1794
+ """Deserialize a ``TaskRunCost`` body.
1795
+
1796
+ Strict, unlike the other models: a cost read as zero would claim the run was free.
1797
+
1798
+ Raises:
1799
+ TypeError: If ``totalCostUsd`` is not a number or ``isSettling`` is not a bool.
1800
+ ValueError: If ``totalCostUsd`` is not a finite number.
1801
+ """
1802
+ raw_total = data.get("totalCostUsd")
1803
+ is_settling = data.get("isSettling")
1804
+ if isinstance(raw_total, bool) or not isinstance(raw_total, (str, int, float)):
1805
+ raise TypeError(f"Task run cost has no readable totalCostUsd: {raw_total!r}")
1806
+ if not isinstance(is_settling, bool):
1807
+ raise TypeError(f"Task run cost has no readable isSettling: {is_settling!r}")
1808
+ try:
1809
+ total = Decimal(str(raw_total))
1810
+ except InvalidOperation as exc:
1811
+ raise ValueError(f"Task run cost has no readable totalCostUsd: {raw_total!r}") from exc
1812
+ if not total.is_finite():
1813
+ raise ValueError(f"Task run cost has no readable totalCostUsd: {raw_total!r}")
1814
+ return cls(
1815
+ task_run_id=_optional_str(data, "taskRunId") or "",
1816
+ total_cost_usd=total,
1817
+ units=_optional_int(data, "units") or 0,
1818
+ unit_type=_optional_str(data, "unitType") or "",
1819
+ is_settling=is_settling,
1820
+ )
wherobots/sdk.py CHANGED
@@ -6,6 +6,7 @@ One object, one API connection, one config::
6
6
 
7
7
  with wherobots.Wherobots() as wb:
8
8
  wb.files.my_files.list_directory()
9
+ wb.tasks.list()
9
10
  """
10
11
 
11
12
  from __future__ import annotations
@@ -15,6 +16,7 @@ from typing import TYPE_CHECKING, final
15
16
 
16
17
  from wherobots.api.base import BaseClient
17
18
  from wherobots.api.drive import Files
19
+ from wherobots.api.tasks import TaskRuns, Tasks
18
20
  from wherobots.config import WherobotsConfig
19
21
  from wherobots.enums import Region
20
22
 
@@ -56,6 +58,8 @@ class Wherobots:
56
58
  self._config = config
57
59
  self._client = BaseClient(config)
58
60
  self._files: Files | None = None
61
+ self._tasks: Tasks | None = None
62
+ self._task_runs: TaskRuns | None = None
59
63
 
60
64
  def __repr__(self) -> str:
61
65
  """Return a representation whose config masks the API key."""
@@ -73,6 +77,20 @@ class Wherobots:
73
77
  self._files = Files(self._client, self._config.region)
74
78
  return self._files
75
79
 
80
+ @property
81
+ def tasks(self) -> Tasks:
82
+ """The tasks your organization can run (``wb.tasks.list()``, ``wb.tasks.run(...)``)."""
83
+ if self._tasks is None:
84
+ self._tasks = Tasks(self._client, self._config.region)
85
+ return self._tasks
86
+
87
+ @property
88
+ def task_runs(self) -> TaskRuns:
89
+ """Runs of your organization's tasks (``wb.task_runs.get(...)``)."""
90
+ if self._task_runs is None:
91
+ self._task_runs = TaskRuns(self._client)
92
+ return self._task_runs
93
+
76
94
  def close(self) -> None:
77
95
  """Close the API connection."""
78
96
  self._client.close()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wherobots-python-sdk
3
- Version: 0.5.0
3
+ Version: 0.6.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
@@ -13,22 +13,21 @@ Classifier: Development Status :: 4 - Beta
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: Operating System :: OS Independent
15
15
  Classifier: Programming Language :: Python :: 3
16
- Classifier: Programming Language :: Python :: 3.10
17
16
  Classifier: Programming Language :: Python :: 3.11
18
17
  Classifier: Programming Language :: Python :: 3.12
19
18
  Classifier: Programming Language :: Python :: 3.13
20
19
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
- Requires-Python: >=3.10
20
+ Requires-Python: >=3.11
22
21
  Description-Content-Type: text/markdown
23
22
  License-File: LICENSE
24
- Requires-Dist: requests>=2.33.0
25
- Requires-Dist: boto3>=1.34.0
23
+ Requires-Dist: requests>=2.34.2
24
+ Requires-Dist: boto3>=1.43.108
26
25
  Dynamic: license-file
27
26
 
28
27
  # Wherobots Python SDK
29
28
 
30
29
  [![CI](https://github.com/wherobots/wherobots-python-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/wherobots/wherobots-python-sdk/actions/workflows/ci.yml)
31
- [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/)
30
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
32
31
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
33
32
  [![Version](https://img.shields.io/pypi/v/wherobots-python-sdk?color=orange)](https://pypi.org/project/wherobots-python-sdk/)
34
33
 
@@ -46,6 +45,7 @@ Additional Wherobots surfaces will be added over time.
46
45
  - Typed dataclass models for all API responses (no pydantic dependency)
47
46
  - Environment-based configuration
48
47
  - Files: list, create folders, upload, download, rename and delete in your Wherobots file area
48
+ - Tasks: list and run tasks such as RasterFlow inference, then follow their runs, logs and cost
49
49
 
50
50
  ## Installation
51
51
 
@@ -462,6 +462,47 @@ with wherobots.Wherobots() as wb:
462
462
 
463
463
  The three `Wherobots*Error` classes above subclass `WherobotsAPIError`.
464
464
 
465
+ ## Tasks
466
+
467
+ A task is a named unit of work, such as RasterFlow inference, that your organization can run.
468
+ `wb.tasks` lists and runs tasks; `wb.task_runs` follows the runs. Tasks need the Runs feature on
469
+ your organization.
470
+
471
+ ```python
472
+ import time
473
+
474
+ import wherobots
475
+
476
+ with wherobots.Wherobots() as wb:
477
+ for task in wb.tasks.list().items:
478
+ print(task.name, task.display_name, task.regions)
479
+
480
+ task = wb.tasks.get("my-task")
481
+ print(task.input_schema) # JSON Schema the inputs must satisfy
482
+
483
+ started = wb.tasks.run("my-task", {"aoi": "s3://bucket/aoi.geojson"}, name="nightly")
484
+ run = wb.task_runs.get(started.task_run_id)
485
+ while run.is_active:
486
+ time.sleep(10)
487
+ run = wb.task_runs.get(run.task_run_id)
488
+
489
+ print(run.state, run.results, run.error_message)
490
+ print(wb.task_runs.logs(run.task_run_id).entries)
491
+ print(wb.task_runs.cost(run.task_run_id).total_cost_usd)
492
+ ```
493
+
494
+ - `tasks.run` uses `region=` if passed, then the configured region, then your organization's
495
+ default region.
496
+ - Listings return an `OffsetPage`; pass `page.next_offset` as `offset=` for the next one.
497
+ - `TaskRunState.is_active` is `True` for `PENDING`, `SUBMITTED`, `RUNNING` and `CANCELLING`. A
498
+ state the SDK does not know yet is kept as its raw string and counts as not active, so a
499
+ polling loop stops on it.
500
+ - A run's state can lag the run by up to 2 minutes.
501
+ - `task_runs.logs` pages forward: pass the previous page's `next_since` as `since=`. It is `None`
502
+ once the run has finished and every line has been returned.
503
+ - `task_runs.cost` can take up to 45 minutes after a run ends to settle; `is_settling` stays
504
+ `True` until then.
505
+
465
506
  ## Enums
466
507
 
467
508
  ### JobStatus
@@ -689,6 +730,14 @@ To set up a Storage Integration, follow the
689
730
  | GET | `/runs/{id}/logs` | Get run logs |
690
731
  | GET | `/runs/{id}/metrics` | Get run metrics |
691
732
  | GET | `/runs` | List runs |
733
+ | GET | `/tasks` | List tasks |
734
+ | GET | `/tasks/{name}` | Get a task |
735
+ | POST | `/tasks/{name}/runs` | Run a task |
736
+ | GET | `/task-runs` | List task runs |
737
+ | GET | `/task-runs/{id}` | Get a task run |
738
+ | GET | `/task-runs/{id}/logs` | Get task run logs |
739
+ | GET | `/task-runs/{id}/cost` | Get task run cost |
740
+ | POST | `/task-runs/{id}/cancel` | Cancel a task run |
692
741
 
693
742
  Base URL: `https://api.cloud.wherobots.com`
694
743
 
@@ -0,0 +1,24 @@
1
+ wherobots/__init__.py,sha256=MUFjzOBaVW2lzPw35F5SBlTChJBKl1n0-p16qUnIO9k,2759
2
+ wherobots/__version__.py,sha256=WRvsZqweFyYGBAqr3rBDrYgY5bSG3iA5gCa_nhBsI1U,111
3
+ wherobots/client.py,sha256=mFShTtxXfCg9GLVMykM7jC1SvuDCZ_sY9JnHIOlOwxQ,37797
4
+ wherobots/config.py,sha256=d1QePrl7V0ooSlv_Ut0GJ5o0ShMBDH1k9yqgQ1cnxmE,7376
5
+ wherobots/enums.py,sha256=T_x0QzDCmrZZTfYuEjkdjT_tbt0DDP5S-Kr5HnC8j2o,5218
6
+ wherobots/exceptions.py,sha256=JnqMghCANt9Ci6IPmYssbysovqvxxfbB1QMV8xJ3uGY,2565
7
+ wherobots/models.py,sha256=A1KxUivCoUUzFFhhMJCfu7qJHs_2ouYJR27gk39UzWs,62256
8
+ wherobots/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ wherobots/sdk.py,sha256=AcLC_ZFqTl24Vthcu_EfK7FFUJEyftGJJkusEp-zayI,3481
10
+ wherobots/api/__init__.py,sha256=VT1nx6K777qV-PX-7hrBfCWPL1h6WYc4ljj_-qqhdko,360
11
+ wherobots/api/base.py,sha256=xBMGH_x4Ryk7dQ_EORHviINKy1R0jnlJHrt2uvTSBxA,12001
12
+ wherobots/api/drive.py,sha256=sWShECz7yjgWK_vlcB_0IWi6javG1nh-AGEKUk-jSzc,18799
13
+ wherobots/api/files.py,sha256=_v89I8tJ_ANnxaTZd4AxtxrkZfZc0BDbL2jpmPBwvWw,23294
14
+ wherobots/api/runs.py,sha256=y0yF_h74S4ScebgCxHjT07kBlj8aIHlfYN6iBaBktgs,8654
15
+ wherobots/api/tasks.py,sha256=9knNtwU0Kb2NYkuabaaNkhiOyZjscec-JT8rkZZbIiY,9144
16
+ wherobots/utils/__init__.py,sha256=cTM2QNJDauQWyGBTJPE5yH4ULc4Ucg_4KFzemjM3HaA,193
17
+ wherobots/utils/client_header.py,sha256=-zczKIl8QfFBs1uTLbzp6cGLEzit1qGgdWbV9MLYEcM,8083
18
+ wherobots/utils/logger.py,sha256=eclhD9Vf2LsSm4engRzT3jE2v0wdHJH01OfeI3ml4HM,1103
19
+ wherobots/utils/validation.py,sha256=mjWGGhNji1crdn1AhLZoTIfknSQm38X0MmgI4ow14T8,907
20
+ wherobots_python_sdk-0.6.0.dist-info/licenses/LICENSE,sha256=U1EH466XmvxbqaWOY0cax49w0R87_6w6yJLyV14v4bc,10767
21
+ wherobots_python_sdk-0.6.0.dist-info/METADATA,sha256=yypXZowpLiuYE1VhuXGcBJyoTn0Rlfe5d1WTmbXL79Y,32834
22
+ wherobots_python_sdk-0.6.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
23
+ wherobots_python_sdk-0.6.0.dist-info/top_level.txt,sha256=oL-n-9GIG0rmUeukuytUqdN6yfROIsVh8bNrgZsOdxw,10
24
+ wherobots_python_sdk-0.6.0.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,23 +0,0 @@
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,,