wherobots-python-sdk 0.5.1__py3-none-any.whl → 0.7.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 +30 -7
- wherobots/api/drive.py +4 -18
- wherobots/api/files.py +8 -1
- wherobots/api/tasks.py +265 -0
- wherobots/api/workload_auth.py +116 -0
- wherobots/config.py +12 -2
- wherobots/enums.py +33 -0
- wherobots/exceptions.py +1 -1
- wherobots/models.py +347 -10
- wherobots/sdk.py +18 -0
- {wherobots_python_sdk-0.5.1.dist-info → wherobots_python_sdk-0.7.0.dist-info}/METADATA +66 -8
- wherobots_python_sdk-0.7.0.dist-info/RECORD +25 -0
- wherobots_python_sdk-0.5.1.dist-info/RECORD +0 -23
- {wherobots_python_sdk-0.5.1.dist-info → wherobots_python_sdk-0.7.0.dist-info}/WHEEL +0 -0
- {wherobots_python_sdk-0.5.1.dist-info → wherobots_python_sdk-0.7.0.dist-info}/licenses/LICENSE +0 -0
- {wherobots_python_sdk-0.5.1.dist-info → wherobots_python_sdk-0.7.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
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""Low-level HTTP client for the Wherobots REST API.
|
|
2
2
|
|
|
3
3
|
``BaseClient`` wraps a ``requests.Session`` with:
|
|
4
|
-
* Auth
|
|
4
|
+
* Auth: ``X-API-Key`` when an API key is configured, otherwise the
|
|
5
|
+
workload-identity token mounted in Wherobots notebook and job pods.
|
|
5
6
|
* Client attribution via the advisory ``X-Wherobots-Client`` header,
|
|
6
7
|
set once on the session so every outgoing request carries it.
|
|
7
8
|
* Automatic retry with exponential back-off for transient errors
|
|
@@ -20,6 +21,7 @@ from requests.adapters import HTTPAdapter
|
|
|
20
21
|
from urllib3.util.retry import Retry
|
|
21
22
|
|
|
22
23
|
from wherobots.__version__ import __version__
|
|
24
|
+
from wherobots.api.workload_auth import shared_workload_identity_auth
|
|
23
25
|
from wherobots.config import WherobotsConfig # noqa: TC001
|
|
24
26
|
from wherobots.exceptions import WherobotsAPIError, WherobotsConfigError
|
|
25
27
|
from wherobots.utils.client_header import CLIENT_HEADER, client_header_value
|
|
@@ -35,6 +37,21 @@ _RETRIABLE_STATUS_CODES = (429, 502, 503)
|
|
|
35
37
|
_REDIRECT_STATUS_CODES = frozenset({301, 302, 303, 307, 308})
|
|
36
38
|
|
|
37
39
|
|
|
40
|
+
def json_object(response: requests.Response) -> dict[str, object]:
|
|
41
|
+
"""Parse a JSON object body, or raise ``WherobotsAPIError``."""
|
|
42
|
+
try:
|
|
43
|
+
data = response.json()
|
|
44
|
+
except ValueError as exc:
|
|
45
|
+
raise WherobotsAPIError(
|
|
46
|
+
"Wherobots API returned a non-JSON body", status_code=response.status_code
|
|
47
|
+
) from exc
|
|
48
|
+
if not isinstance(data, dict):
|
|
49
|
+
raise WherobotsAPIError(
|
|
50
|
+
"Wherobots API returned an unexpected body", status_code=response.status_code
|
|
51
|
+
)
|
|
52
|
+
return data
|
|
53
|
+
|
|
54
|
+
|
|
38
55
|
class BaseClient:
|
|
39
56
|
"""Thin HTTP wrapper around ``requests.Session``.
|
|
40
57
|
|
|
@@ -42,8 +59,8 @@ class BaseClient:
|
|
|
42
59
|
back-off, and unified error mapping to ``WherobotsAPIError``.
|
|
43
60
|
|
|
44
61
|
Args:
|
|
45
|
-
config: A populated ``WherobotsConfig``. Must contain
|
|
46
|
-
``api_key``
|
|
62
|
+
config: A populated ``WherobotsConfig``. Must contain ``base_url``
|
|
63
|
+
and either ``api_key`` or ``id_token_path``.
|
|
47
64
|
max_retries: Maximum number of retries for transient failures.
|
|
48
65
|
backoff_factor: Multiplier for exponential back-off between
|
|
49
66
|
retries.
|
|
@@ -60,10 +77,11 @@ class BaseClient:
|
|
|
60
77
|
# session is built.
|
|
61
78
|
self._closed = False
|
|
62
79
|
|
|
63
|
-
|
|
80
|
+
api_key = (config.api_key or "").strip()
|
|
81
|
+
if not api_key and not config.id_token_path:
|
|
64
82
|
raise WherobotsConfigError(
|
|
65
|
-
"
|
|
66
|
-
"WHEROBOTS_API_KEY
|
|
83
|
+
"No Wherobots credentials found. Provide the api_key parameter, set "
|
|
84
|
+
"WHEROBOTS_API_KEY, or run inside a Wherobots notebook or job."
|
|
67
85
|
)
|
|
68
86
|
|
|
69
87
|
self._config = config
|
|
@@ -107,9 +125,14 @@ class BaseClient:
|
|
|
107
125
|
"Content-Type": "application/json",
|
|
108
126
|
"User-Agent": f"wherobots-python-sdk/{__version__}",
|
|
109
127
|
CLIENT_HEADER: client_header_value(config.client_chain),
|
|
110
|
-
**self._auth_headers(config.api_key),
|
|
111
128
|
}
|
|
112
129
|
)
|
|
130
|
+
if api_key:
|
|
131
|
+
self._session.headers.update(self._auth_headers(api_key))
|
|
132
|
+
elif config.id_token_path:
|
|
133
|
+
self._session.auth = shared_workload_identity_auth(
|
|
134
|
+
self._base_url, config.id_token_path, self._timeout
|
|
135
|
+
)
|
|
113
136
|
|
|
114
137
|
@property
|
|
115
138
|
def timeout(self) -> int:
|
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://"):
|
|
@@ -350,7 +336,7 @@ class FilesDrive:
|
|
|
350
336
|
"""
|
|
351
337
|
if err.status_code == _HTTP_UNAUTHORIZED:
|
|
352
338
|
return WherobotsNotAuthenticatedError(
|
|
353
|
-
"The Wherobots API
|
|
339
|
+
"The Wherobots API did not accept the credentials (HTTP 401)",
|
|
354
340
|
status_code=err.status_code,
|
|
355
341
|
response=err.response,
|
|
356
342
|
request_id=err.request_id,
|
wherobots/api/files.py
CHANGED
|
@@ -29,7 +29,11 @@ from botocore.session import Session as BotocoreSession
|
|
|
29
29
|
|
|
30
30
|
from wherobots.api.base import BaseClient
|
|
31
31
|
from wherobots.config import WherobotsConfig # noqa: TC001
|
|
32
|
-
from wherobots.exceptions import
|
|
32
|
+
from wherobots.exceptions import (
|
|
33
|
+
WherobotsAPIError,
|
|
34
|
+
WherobotsNotAuthenticatedError,
|
|
35
|
+
WherobotsValidationError,
|
|
36
|
+
)
|
|
33
37
|
from wherobots.models import StorageIntegration, TemporaryAwsCredentials
|
|
34
38
|
from wherobots.utils.logger import get_logger
|
|
35
39
|
|
|
@@ -110,6 +114,9 @@ class FilesAPI:
|
|
|
110
114
|
"/storage/credentials",
|
|
111
115
|
json_body={"path": path, "access_mode": access_mode},
|
|
112
116
|
)
|
|
117
|
+
except WherobotsNotAuthenticatedError:
|
|
118
|
+
# The workload token exchange failed before this request was sent (ADR-0003).
|
|
119
|
+
raise
|
|
113
120
|
except WherobotsAPIError as exc:
|
|
114
121
|
# Scrub: never let the credentials endpoint's error
|
|
115
122
|
# response body bubble out — it could contain partial
|
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
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Workload-identity auth for code running inside Wherobots (ADR-0003).
|
|
2
|
+
|
|
3
|
+
Wherobots notebook and job pods mount a workload-identity token and set
|
|
4
|
+
``WHEROBOTS_ID_TOKEN_PATH`` to it. The API accepts that token as an OAuth
|
|
5
|
+
refresh token at ``/ste/oauth2/token`` and returns a short-lived bearer token
|
|
6
|
+
that acts as the user who launched the workload.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import functools
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
from requests.auth import AuthBase
|
|
18
|
+
|
|
19
|
+
from wherobots.exceptions import WherobotsConfigError, WherobotsNotAuthenticatedError
|
|
20
|
+
|
|
21
|
+
_EXCHANGE_PATH = "/ste/oauth2/token"
|
|
22
|
+
# Descriptive only: the API does not enforce scope on these tokens.
|
|
23
|
+
_SCOPE = "api"
|
|
24
|
+
_REFRESH_MARGIN_SECONDS = 60
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WorkloadIdentityAuth(AuthBase):
|
|
28
|
+
"""Sets ``Authorization: Bearer`` from the mounted workload-identity token.
|
|
29
|
+
|
|
30
|
+
The bearer token is cached and swapped for a new one shortly before it
|
|
31
|
+
expires, so long-running jobs keep working past its one-hour lifetime.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
base_url: Wherobots API base URL (already validated as HTTPS).
|
|
35
|
+
id_token_path: Path to the mounted workload-identity token.
|
|
36
|
+
timeout: Timeout in seconds for the token exchange request.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, base_url: str, id_token_path: str, timeout: int) -> None:
|
|
40
|
+
"""Build the auth; see the class docstring."""
|
|
41
|
+
self._token_url = f"{base_url}{_EXCHANGE_PATH}"
|
|
42
|
+
self._id_token_path = Path(id_token_path)
|
|
43
|
+
self._timeout = timeout
|
|
44
|
+
self._lock = threading.Lock()
|
|
45
|
+
self._access_token: str | None = None
|
|
46
|
+
self._refresh_at = 0.0
|
|
47
|
+
|
|
48
|
+
def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest:
|
|
49
|
+
"""Attach the current bearer token to *request*."""
|
|
50
|
+
request.headers["Authorization"] = f"Bearer {self._current_token()}"
|
|
51
|
+
return request
|
|
52
|
+
|
|
53
|
+
def _current_token(self) -> str:
|
|
54
|
+
with self._lock:
|
|
55
|
+
if self._access_token is None or time.monotonic() >= self._refresh_at:
|
|
56
|
+
token, expires_in = self._exchange()
|
|
57
|
+
self._access_token = token
|
|
58
|
+
self._refresh_at = time.monotonic() + max(0, expires_in - _REFRESH_MARGIN_SECONDS)
|
|
59
|
+
return self._access_token
|
|
60
|
+
|
|
61
|
+
def _exchange(self) -> tuple[str, int]:
|
|
62
|
+
try:
|
|
63
|
+
id_token = self._id_token_path.read_text(encoding="utf-8").strip()
|
|
64
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
65
|
+
raise WherobotsConfigError(
|
|
66
|
+
f"Cannot read the workload identity token at {self._id_token_path}: {exc}"
|
|
67
|
+
) from exc
|
|
68
|
+
if not id_token:
|
|
69
|
+
raise WherobotsConfigError(
|
|
70
|
+
f"The workload identity token at {self._id_token_path} is empty"
|
|
71
|
+
)
|
|
72
|
+
# Errors carry no status_code: it would read as the status of the caller's own request.
|
|
73
|
+
try:
|
|
74
|
+
response = requests.post(
|
|
75
|
+
self._token_url,
|
|
76
|
+
data={"grant_type": "refresh_token", "refresh_token": id_token, "scope": _SCOPE},
|
|
77
|
+
timeout=self._timeout,
|
|
78
|
+
allow_redirects=False,
|
|
79
|
+
)
|
|
80
|
+
response.raise_for_status()
|
|
81
|
+
payload = response.json()
|
|
82
|
+
except requests.HTTPError as exc:
|
|
83
|
+
raise WherobotsNotAuthenticatedError(_rejection_message(exc.response)) from exc
|
|
84
|
+
except (requests.RequestException, ValueError) as exc:
|
|
85
|
+
raise WherobotsNotAuthenticatedError(
|
|
86
|
+
f"Workload identity token exchange failed: {exc}"
|
|
87
|
+
) from exc
|
|
88
|
+
|
|
89
|
+
token = payload.get("access_token") if isinstance(payload, dict) else None
|
|
90
|
+
expires_in = payload.get("expires_in") if isinstance(payload, dict) else None
|
|
91
|
+
if not isinstance(token, str) or not isinstance(expires_in, int):
|
|
92
|
+
raise WherobotsNotAuthenticatedError(
|
|
93
|
+
"Workload identity token exchange returned an unexpected body"
|
|
94
|
+
)
|
|
95
|
+
return token, expires_in
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@functools.cache
|
|
99
|
+
def shared_workload_identity_auth(
|
|
100
|
+
base_url: str, id_token_path: str, timeout: int
|
|
101
|
+
) -> WorkloadIdentityAuth:
|
|
102
|
+
"""Return one auth per process and token, so every client reuses one bearer token."""
|
|
103
|
+
return WorkloadIdentityAuth(base_url, id_token_path, timeout)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _rejection_message(response: requests.Response | None) -> str:
|
|
107
|
+
message = "Workload identity token exchange was rejected"
|
|
108
|
+
if response is None:
|
|
109
|
+
return message
|
|
110
|
+
message = f"{message} (status {response.status_code})"
|
|
111
|
+
try:
|
|
112
|
+
body = response.json()
|
|
113
|
+
except ValueError:
|
|
114
|
+
return message
|
|
115
|
+
reason = body.get("message") if isinstance(body, dict) else None
|
|
116
|
+
return f"{message}: {reason}" if isinstance(reason, str) else message
|
wherobots/config.py
CHANGED
|
@@ -25,6 +25,9 @@ class WherobotsConfig:
|
|
|
25
25
|
embed this SDK (e.g. the Airflow provider). The SDK appends its
|
|
26
26
|
own hop to the right of it, so the origin client stays leftmost.
|
|
27
27
|
Advisory only — it never affects authentication.
|
|
28
|
+
id_token_path: Workload-identity token mounted in Wherobots notebook
|
|
29
|
+
and job pods. Used for auth only when no ``api_key`` is set
|
|
30
|
+
(ADR-0003).
|
|
28
31
|
"""
|
|
29
32
|
|
|
30
33
|
api_key: str | None = None
|
|
@@ -35,6 +38,7 @@ class WherobotsConfig:
|
|
|
35
38
|
version: str = "latest"
|
|
36
39
|
request_timeout_seconds: int = 30
|
|
37
40
|
client_chain: str | None = None
|
|
41
|
+
id_token_path: str | None = None
|
|
38
42
|
|
|
39
43
|
def __repr__(self) -> str:
|
|
40
44
|
"""Return a string representation with the API key masked."""
|
|
@@ -54,7 +58,8 @@ class WherobotsConfig:
|
|
|
54
58
|
f"base_url={self.base_url!r}, "
|
|
55
59
|
f"version={self.version!r}, "
|
|
56
60
|
f"request_timeout_seconds={self.request_timeout_seconds!r}, "
|
|
57
|
-
f"client_chain={self.client_chain!r}
|
|
61
|
+
f"client_chain={self.client_chain!r}, "
|
|
62
|
+
f"id_token_path={self.id_token_path!r})"
|
|
58
63
|
)
|
|
59
64
|
|
|
60
65
|
def __post_init__(self) -> None:
|
|
@@ -90,6 +95,7 @@ class WherobotsConfig:
|
|
|
90
95
|
version: str | None = None,
|
|
91
96
|
request_timeout_seconds: int | None = None,
|
|
92
97
|
client_chain: str | None = None,
|
|
98
|
+
id_token_path: str | None = None,
|
|
93
99
|
) -> WherobotsConfig:
|
|
94
100
|
"""Build configuration from environment variables with optional overrides.
|
|
95
101
|
|
|
@@ -108,6 +114,8 @@ class WherobotsConfig:
|
|
|
108
114
|
client_chain: Override for ``WHEROBOTS_CLIENT_CHAIN`` — the
|
|
109
115
|
upstream ``X-Wherobots-Client`` chain this SDK appends its
|
|
110
116
|
own hop to.
|
|
117
|
+
id_token_path: Override for ``WHEROBOTS_ID_TOKEN_PATH``, which
|
|
118
|
+
Wherobots notebook and job pods set.
|
|
111
119
|
|
|
112
120
|
Returns:
|
|
113
121
|
A validated ``WherobotsConfig`` instance.
|
|
@@ -163,7 +171,8 @@ class WherobotsConfig:
|
|
|
163
171
|
)
|
|
164
172
|
|
|
165
173
|
return cls(
|
|
166
|
-
|
|
174
|
+
# A blank key must not hide WHEROBOTS_API_KEY behind workload identity.
|
|
175
|
+
api_key=(api_key or "").strip() or _env("WHEROBOTS_API_KEY"),
|
|
167
176
|
region=region or _env("WHEROBOTS_REGION"),
|
|
168
177
|
s3_bucket=s3_bucket or _env("WHEROBOTS_S3_BUCKET"),
|
|
169
178
|
s3_prefix=s3_prefix or _env("WHEROBOTS_S3_PREFIX") or "wherobots-jobs",
|
|
@@ -173,4 +182,5 @@ class WherobotsConfig:
|
|
|
173
182
|
version=version or _env("WHEROBOTS_VERSION") or "latest",
|
|
174
183
|
request_timeout_seconds=timeout,
|
|
175
184
|
client_chain=client_chain or _env("WHEROBOTS_CLIENT_CHAIN"),
|
|
185
|
+
id_token_path=id_token_path or _env("WHEROBOTS_ID_TOKEN_PATH"),
|
|
176
186
|
)
|