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 +23 -0
- wherobots/__version__.py +1 -1
- wherobots/api/__init__.py +2 -1
- wherobots/api/base.py +15 -0
- wherobots/api/drive.py +3 -17
- wherobots/api/tasks.py +265 -0
- wherobots/enums.py +33 -0
- wherobots/models.py +347 -10
- wherobots/sdk.py +18 -0
- {wherobots_python_sdk-0.5.0.dist-info → wherobots_python_sdk-0.6.0.dist-info}/METADATA +55 -6
- wherobots_python_sdk-0.6.0.dist-info/RECORD +24 -0
- {wherobots_python_sdk-0.5.0.dist-info → wherobots_python_sdk-0.6.0.dist-info}/WHEEL +1 -1
- wherobots_python_sdk-0.5.0.dist-info/RECORD +0 -23
- {wherobots_python_sdk-0.5.0.dist-info → wherobots_python_sdk-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {wherobots_python_sdk-0.5.0.dist-info → wherobots_python_sdk-0.6.0.dist-info}/top_level.txt +0 -0
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
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(
|
|
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(
|
|
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
|
|
11
|
-
from
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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=
|
|
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
|
|
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=
|
|
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.
|
|
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.
|
|
20
|
+
Requires-Python: >=3.11
|
|
22
21
|
Description-Content-Type: text/markdown
|
|
23
22
|
License-File: LICENSE
|
|
24
|
-
Requires-Dist: requests>=2.
|
|
25
|
-
Requires-Dist: boto3>=1.
|
|
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
|
[](https://github.com/wherobots/wherobots-python-sdk/actions/workflows/ci.yml)
|
|
31
|
-
[](https://www.python.org/downloads/)
|
|
32
31
|
[](LICENSE)
|
|
33
32
|
[](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,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,,
|
{wherobots_python_sdk-0.5.0.dist-info → wherobots_python_sdk-0.6.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|