labtasker-client 2.0.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.
- labtasker/__init__.py +77 -0
- labtasker/__main__.py +3 -0
- labtasker/api.py +122 -0
- labtasker/binding.py +184 -0
- labtasker/cli.py +506 -0
- labtasker/client.py +725 -0
- labtasker/command_template.py +173 -0
- labtasker/command_worker.py +492 -0
- labtasker/config.py +193 -0
- labtasker/errors.py +56 -0
- labtasker/execution.py +380 -0
- labtasker/journal.py +314 -0
- labtasker/local.py +190 -0
- labtasker/models.py +218 -0
- labtasker/paths.py +34 -0
- labtasker/py.typed +1 -0
- labtasker/tee.py +128 -0
- labtasker/types.py +31 -0
- labtasker/validation.py +207 -0
- labtasker/worker.py +473 -0
- labtasker_client-2.0.0.dist-info/METADATA +13 -0
- labtasker_client-2.0.0.dist-info/RECORD +25 -0
- labtasker_client-2.0.0.dist-info/WHEEL +4 -0
- labtasker_client-2.0.0.dist-info/entry_points.txt +2 -0
- labtasker_client-2.0.0.dist-info/licenses/LICENSE +201 -0
labtasker/client.py
ADDED
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TypeVar
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from pydantic import TypeAdapter, ValidationError
|
|
12
|
+
|
|
13
|
+
from labtasker.config import ResolvedConfig, resolve_config
|
|
14
|
+
from labtasker.errors import APIError, TransportError
|
|
15
|
+
from labtasker.local import (
|
|
16
|
+
ensure_local_server,
|
|
17
|
+
local_paths,
|
|
18
|
+
require_local_capabilities,
|
|
19
|
+
socket_transport,
|
|
20
|
+
)
|
|
21
|
+
from labtasker.models import (
|
|
22
|
+
BulkUpdateResult,
|
|
23
|
+
ClaimResponse,
|
|
24
|
+
CountResponse,
|
|
25
|
+
HealthResponse,
|
|
26
|
+
HeartbeatResponse,
|
|
27
|
+
Queue,
|
|
28
|
+
ResponseModel,
|
|
29
|
+
Task,
|
|
30
|
+
TaskPage,
|
|
31
|
+
)
|
|
32
|
+
from labtasker.types import JSONValue, TaskOrderField, TaskStatus, TaskUpdate
|
|
33
|
+
from labtasker.validation import (
|
|
34
|
+
RequestValidationError,
|
|
35
|
+
validate_filter,
|
|
36
|
+
validate_identifier,
|
|
37
|
+
validate_int64,
|
|
38
|
+
validate_json_object,
|
|
39
|
+
validate_order_field,
|
|
40
|
+
validate_routes,
|
|
41
|
+
validate_run_id,
|
|
42
|
+
validate_status,
|
|
43
|
+
validate_task_id,
|
|
44
|
+
validate_task_name,
|
|
45
|
+
validate_task_update,
|
|
46
|
+
validate_unicode_scalar,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
T = TypeVar("T")
|
|
50
|
+
ModelT = TypeVar("ModelT", bound=ResponseModel)
|
|
51
|
+
REQUEST_TIMEOUT_SECONDS = 10.0
|
|
52
|
+
MAX_RETRY_ATTEMPTS = 3
|
|
53
|
+
RETRY_BACKOFF_SECONDS = (0.05, 0.1)
|
|
54
|
+
QUEUE_LIST_ADAPTER = TypeAdapter(list[Queue])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Client:
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
url: str | None = None,
|
|
61
|
+
token: str | None = None,
|
|
62
|
+
queue: str | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
self._initialize(resolve_config(url=url, token=token, queue=queue))
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def _from_local_directory(cls, directory: Path, *, queue: str) -> Client:
|
|
68
|
+
require_local_capabilities()
|
|
69
|
+
client = cls.__new__(cls)
|
|
70
|
+
client._initialize(
|
|
71
|
+
ResolvedConfig(
|
|
72
|
+
url=None,
|
|
73
|
+
queue=validate_identifier(queue, field="queue"),
|
|
74
|
+
token=None,
|
|
75
|
+
local=local_paths(directory),
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
return client
|
|
79
|
+
|
|
80
|
+
def _initialize(self, config: ResolvedConfig) -> None:
|
|
81
|
+
self._config = config
|
|
82
|
+
headers = {}
|
|
83
|
+
if self._config.token is not None:
|
|
84
|
+
headers["Authorization"] = f"Bearer {self._config.token}"
|
|
85
|
+
if self._config.local is None:
|
|
86
|
+
assert self._config.url is not None
|
|
87
|
+
self._http = httpx.Client(
|
|
88
|
+
base_url=f"{self._config.url}/api/v2/",
|
|
89
|
+
headers=headers,
|
|
90
|
+
timeout=REQUEST_TIMEOUT_SECONDS,
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
self._http = httpx.Client(
|
|
94
|
+
base_url="http://labtasker/api/v2/",
|
|
95
|
+
transport=socket_transport(self._config.local),
|
|
96
|
+
timeout=REQUEST_TIMEOUT_SECONDS,
|
|
97
|
+
)
|
|
98
|
+
self._closed = False
|
|
99
|
+
self._endpoint_announced = False
|
|
100
|
+
self._local_ready = False
|
|
101
|
+
|
|
102
|
+
def __enter__(self) -> Client:
|
|
103
|
+
self._ensure_open()
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
def __exit__(self, *_: object) -> None:
|
|
107
|
+
self.close()
|
|
108
|
+
|
|
109
|
+
def close(self) -> None:
|
|
110
|
+
if self._closed:
|
|
111
|
+
return
|
|
112
|
+
self._closed = True
|
|
113
|
+
self._http.close()
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def configuration(self) -> ResolvedConfig:
|
|
117
|
+
return self._config
|
|
118
|
+
|
|
119
|
+
def submit_task(
|
|
120
|
+
self,
|
|
121
|
+
args: dict[str, JSONValue] | None = None,
|
|
122
|
+
*,
|
|
123
|
+
name: str | None = None,
|
|
124
|
+
metadata: dict[str, JSONValue] | None = None,
|
|
125
|
+
priority: int = 0,
|
|
126
|
+
max_attempts: int = 3,
|
|
127
|
+
routes: list[str] | None = None,
|
|
128
|
+
task_id: str | None = None,
|
|
129
|
+
queue: str | None = None,
|
|
130
|
+
) -> Task:
|
|
131
|
+
self._ensure_open()
|
|
132
|
+
normalized_args = validate_json_object({} if args is None else args, field="args")
|
|
133
|
+
normalized_metadata = validate_json_object(
|
|
134
|
+
{} if metadata is None else metadata,
|
|
135
|
+
field="metadata",
|
|
136
|
+
)
|
|
137
|
+
normalized_routes = validate_routes(["default"] if routes is None else routes)
|
|
138
|
+
body: dict[str, object] = {
|
|
139
|
+
"name": validate_task_name(name),
|
|
140
|
+
"args": normalized_args,
|
|
141
|
+
"metadata": normalized_metadata,
|
|
142
|
+
"priority": validate_int64(priority, field="priority"),
|
|
143
|
+
"max_attempts": validate_int64(
|
|
144
|
+
max_attempts,
|
|
145
|
+
field="max_attempts",
|
|
146
|
+
positive=True,
|
|
147
|
+
),
|
|
148
|
+
"routes": normalized_routes,
|
|
149
|
+
}
|
|
150
|
+
queue_name = self._queue(queue)
|
|
151
|
+
if task_id is not None:
|
|
152
|
+
selected_id = validate_task_id(task_id)
|
|
153
|
+
return self._submit_with_id(queue_name, selected_id, body)
|
|
154
|
+
|
|
155
|
+
for _ in range(MAX_RETRY_ATTEMPTS):
|
|
156
|
+
selected_id = _generate_task_id()
|
|
157
|
+
try:
|
|
158
|
+
return self._submit_with_id(queue_name, selected_id, body)
|
|
159
|
+
except APIError as error:
|
|
160
|
+
if error.code != "task_id_conflict":
|
|
161
|
+
raise
|
|
162
|
+
raise TransportError(
|
|
163
|
+
"Could not allocate a unique Task ID.",
|
|
164
|
+
{"operation": "submit_task", **self._operation_endpoint_details},
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def get_task(self, task_id: str, *, queue: str | None = None) -> Task:
|
|
168
|
+
self._ensure_open()
|
|
169
|
+
queue_name = self._queue(queue)
|
|
170
|
+
task_id = validate_task_id(task_id)
|
|
171
|
+
return self._call(
|
|
172
|
+
operation="get_task",
|
|
173
|
+
method="GET",
|
|
174
|
+
path=f"queues/{queue_name}/tasks/{task_id}",
|
|
175
|
+
parser=lambda response: _parse_model(response, Task, {200}),
|
|
176
|
+
retry=True,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def list_tasks(
|
|
180
|
+
self,
|
|
181
|
+
*,
|
|
182
|
+
status: TaskStatus | None = None,
|
|
183
|
+
name: str | None = None,
|
|
184
|
+
filter: str | None = None,
|
|
185
|
+
order_by: TaskOrderField = "created_at",
|
|
186
|
+
descending: bool = True,
|
|
187
|
+
limit: int = 100,
|
|
188
|
+
cursor: str | None = None,
|
|
189
|
+
queue: str | None = None,
|
|
190
|
+
) -> TaskPage:
|
|
191
|
+
self._ensure_open()
|
|
192
|
+
queue_name = self._queue(queue)
|
|
193
|
+
status = validate_status(status)
|
|
194
|
+
order_by = validate_order_field(order_by)
|
|
195
|
+
filter = validate_filter(filter)
|
|
196
|
+
if not isinstance(descending, bool):
|
|
197
|
+
raise RequestValidationError("descending must be a Boolean.")
|
|
198
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 1000:
|
|
199
|
+
raise RequestValidationError("limit must be an integer from 1 through 1000.")
|
|
200
|
+
if name is not None and not isinstance(name, str):
|
|
201
|
+
raise RequestValidationError("name selector must be a string or None.")
|
|
202
|
+
if cursor is not None and not isinstance(cursor, str):
|
|
203
|
+
raise RequestValidationError("cursor must be a string or None.")
|
|
204
|
+
params = _without_none(
|
|
205
|
+
{
|
|
206
|
+
"status": status,
|
|
207
|
+
"name": name,
|
|
208
|
+
"filter": filter,
|
|
209
|
+
"order_by": order_by,
|
|
210
|
+
"descending": "true" if descending else "false",
|
|
211
|
+
"limit": limit,
|
|
212
|
+
"cursor": cursor,
|
|
213
|
+
}
|
|
214
|
+
)
|
|
215
|
+
return self._call(
|
|
216
|
+
operation="list_tasks",
|
|
217
|
+
method="GET",
|
|
218
|
+
path=f"queues/{queue_name}/tasks",
|
|
219
|
+
params=params,
|
|
220
|
+
parser=lambda response: _parse_model(response, TaskPage, {200}),
|
|
221
|
+
retry=True,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def count_tasks(
|
|
225
|
+
self,
|
|
226
|
+
*,
|
|
227
|
+
status: TaskStatus | None = None,
|
|
228
|
+
name: str | None = None,
|
|
229
|
+
filter: str | None = None,
|
|
230
|
+
queue: str | None = None,
|
|
231
|
+
) -> int:
|
|
232
|
+
self._ensure_open()
|
|
233
|
+
queue_name = self._queue(queue)
|
|
234
|
+
status = validate_status(status)
|
|
235
|
+
filter = validate_filter(filter)
|
|
236
|
+
if name is not None and not isinstance(name, str):
|
|
237
|
+
raise RequestValidationError("name selector must be a string or None.")
|
|
238
|
+
result = self._call(
|
|
239
|
+
operation="count_tasks",
|
|
240
|
+
method="GET",
|
|
241
|
+
path=f"queues/{queue_name}/tasks/count",
|
|
242
|
+
params=_without_none({"status": status, "name": name, "filter": filter}),
|
|
243
|
+
parser=lambda response: _parse_model(response, CountResponse, {200}),
|
|
244
|
+
retry=True,
|
|
245
|
+
)
|
|
246
|
+
return result.count
|
|
247
|
+
|
|
248
|
+
def update_task(
|
|
249
|
+
self,
|
|
250
|
+
task_id: str,
|
|
251
|
+
changes: TaskUpdate,
|
|
252
|
+
*,
|
|
253
|
+
queue: str | None = None,
|
|
254
|
+
) -> Task:
|
|
255
|
+
self._ensure_open()
|
|
256
|
+
queue_name = self._queue(queue)
|
|
257
|
+
task_id = validate_task_id(task_id)
|
|
258
|
+
normalized = validate_task_update(changes)
|
|
259
|
+
return self._call(
|
|
260
|
+
operation="update_task",
|
|
261
|
+
method="PATCH",
|
|
262
|
+
path=f"queues/{queue_name}/tasks/{task_id}",
|
|
263
|
+
json=normalized,
|
|
264
|
+
parser=lambda response: _parse_model(response, Task, {200}),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def update_tasks(
|
|
268
|
+
self,
|
|
269
|
+
*,
|
|
270
|
+
filter: str,
|
|
271
|
+
changes: TaskUpdate,
|
|
272
|
+
queue: str | None = None,
|
|
273
|
+
) -> BulkUpdateResult:
|
|
274
|
+
self._ensure_open()
|
|
275
|
+
queue_name = self._queue(queue)
|
|
276
|
+
normalized_filter = validate_filter(filter, required=True)
|
|
277
|
+
normalized_changes = validate_task_update(changes)
|
|
278
|
+
return self._call(
|
|
279
|
+
operation="update_tasks",
|
|
280
|
+
method="PATCH",
|
|
281
|
+
path=f"queues/{queue_name}/tasks",
|
|
282
|
+
json={"filter": normalized_filter, "changes": normalized_changes},
|
|
283
|
+
parser=lambda response: _parse_model(response, BulkUpdateResult, {200}),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def cancel_task(self, task_id: str, *, queue: str | None = None) -> Task:
|
|
287
|
+
return self._task_action("cancel", task_id, queue=queue)
|
|
288
|
+
|
|
289
|
+
def requeue_task(self, task_id: str, *, queue: str | None = None) -> Task:
|
|
290
|
+
return self._task_action("requeue", task_id, queue=queue)
|
|
291
|
+
|
|
292
|
+
def delete_task(self, task_id: str, *, queue: str | None = None) -> None:
|
|
293
|
+
self._ensure_open()
|
|
294
|
+
queue_name = self._queue(queue)
|
|
295
|
+
task_id = validate_task_id(task_id)
|
|
296
|
+
self._call(
|
|
297
|
+
operation="delete_task",
|
|
298
|
+
method="DELETE",
|
|
299
|
+
path=f"queues/{queue_name}/tasks/{task_id}",
|
|
300
|
+
parser=lambda response: _parse_none(response, {204}),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def create_queue(self, name: str) -> Queue:
|
|
304
|
+
self._ensure_open()
|
|
305
|
+
name = validate_identifier(name, field="queue")
|
|
306
|
+
return self._call(
|
|
307
|
+
operation="create_queue",
|
|
308
|
+
method="PUT",
|
|
309
|
+
path=f"queues/{name}",
|
|
310
|
+
parser=lambda response: _parse_model(response, Queue, {200, 201}),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def list_queues(self) -> list[Queue]:
|
|
314
|
+
self._ensure_open()
|
|
315
|
+
return self._call(
|
|
316
|
+
operation="list_queues",
|
|
317
|
+
method="GET",
|
|
318
|
+
path="queues",
|
|
319
|
+
parser=lambda response: _parse_queue_list(response, {200}),
|
|
320
|
+
retry=True,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
def delete_queue(self, name: str, *, cascade: bool = False) -> None:
|
|
324
|
+
self._ensure_open()
|
|
325
|
+
name = validate_identifier(name, field="queue")
|
|
326
|
+
if not isinstance(cascade, bool):
|
|
327
|
+
raise RequestValidationError("cascade must be a Boolean.")
|
|
328
|
+
self._call(
|
|
329
|
+
operation="delete_queue",
|
|
330
|
+
method="DELETE",
|
|
331
|
+
path=f"queues/{name}",
|
|
332
|
+
params={"cascade": "true" if cascade else "false"},
|
|
333
|
+
parser=lambda response: _parse_none(response, {204}),
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
def _claim(
|
|
337
|
+
self,
|
|
338
|
+
*,
|
|
339
|
+
route: str,
|
|
340
|
+
run_id: str,
|
|
341
|
+
queue: str | None = None,
|
|
342
|
+
) -> ClaimResponse | None:
|
|
343
|
+
"""Claim one Task using a caller-generated idempotency token."""
|
|
344
|
+
self._ensure_open()
|
|
345
|
+
queue_name = self._queue(queue)
|
|
346
|
+
normalized_route = validate_identifier(route, field="route")
|
|
347
|
+
normalized_run_id = validate_run_id(run_id)
|
|
348
|
+
return self._call(
|
|
349
|
+
operation="claim_task",
|
|
350
|
+
method="POST",
|
|
351
|
+
path=f"queues/{queue_name}/tasks/claim",
|
|
352
|
+
json={"route": normalized_route, "run_id": normalized_run_id},
|
|
353
|
+
parser=_parse_claim,
|
|
354
|
+
retry=True,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
def _health(self) -> HealthResponse:
|
|
358
|
+
"""Validate the deployment health and v2 protocol before Worker claims."""
|
|
359
|
+
return self._call(
|
|
360
|
+
operation="worker_health",
|
|
361
|
+
method="GET",
|
|
362
|
+
path=(
|
|
363
|
+
f"{self._config.url}/health"
|
|
364
|
+
if self._config.url is not None
|
|
365
|
+
else "http://labtasker/health"
|
|
366
|
+
),
|
|
367
|
+
parser=lambda response: _parse_model(response, HealthResponse, {200}),
|
|
368
|
+
retry=True,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
def _heartbeat(
|
|
372
|
+
self,
|
|
373
|
+
*,
|
|
374
|
+
task_id: str,
|
|
375
|
+
run_id: str,
|
|
376
|
+
queue: str | None = None,
|
|
377
|
+
) -> HeartbeatResponse:
|
|
378
|
+
return self._run_action(
|
|
379
|
+
"heartbeat",
|
|
380
|
+
task_id=task_id,
|
|
381
|
+
run_id=run_id,
|
|
382
|
+
queue=queue,
|
|
383
|
+
body={},
|
|
384
|
+
parser=lambda response: _parse_model(response, HeartbeatResponse, {200}),
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
def _complete(
|
|
388
|
+
self,
|
|
389
|
+
*,
|
|
390
|
+
task_id: str,
|
|
391
|
+
run_id: str,
|
|
392
|
+
result: dict[str, JSONValue],
|
|
393
|
+
queue: str | None = None,
|
|
394
|
+
) -> None:
|
|
395
|
+
normalized = validate_json_object(result, field="result")
|
|
396
|
+
self._run_action(
|
|
397
|
+
"complete",
|
|
398
|
+
task_id=task_id,
|
|
399
|
+
run_id=run_id,
|
|
400
|
+
queue=queue,
|
|
401
|
+
body={"result": normalized},
|
|
402
|
+
parser=lambda response: _parse_none(response, {204}),
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def _fail(
|
|
406
|
+
self,
|
|
407
|
+
*,
|
|
408
|
+
task_id: str,
|
|
409
|
+
run_id: str,
|
|
410
|
+
error_type: str,
|
|
411
|
+
message: str,
|
|
412
|
+
traceback: str | None,
|
|
413
|
+
queue: str | None = None,
|
|
414
|
+
) -> None:
|
|
415
|
+
error = {
|
|
416
|
+
"type": validate_unicode_scalar(error_type, field="error.type"),
|
|
417
|
+
"message": validate_unicode_scalar(message, field="error.message"),
|
|
418
|
+
"traceback": (
|
|
419
|
+
None
|
|
420
|
+
if traceback is None
|
|
421
|
+
else validate_unicode_scalar(traceback, field="error.traceback")
|
|
422
|
+
),
|
|
423
|
+
}
|
|
424
|
+
self._run_action(
|
|
425
|
+
"fail",
|
|
426
|
+
task_id=task_id,
|
|
427
|
+
run_id=run_id,
|
|
428
|
+
queue=queue,
|
|
429
|
+
body={"error": error},
|
|
430
|
+
parser=lambda response: _parse_none(response, {204}),
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
def _unclaim(
|
|
434
|
+
self,
|
|
435
|
+
*,
|
|
436
|
+
task_id: str,
|
|
437
|
+
run_id: str,
|
|
438
|
+
queue: str | None = None,
|
|
439
|
+
) -> None:
|
|
440
|
+
self._run_action(
|
|
441
|
+
"unclaim",
|
|
442
|
+
task_id=task_id,
|
|
443
|
+
run_id=run_id,
|
|
444
|
+
queue=queue,
|
|
445
|
+
body={},
|
|
446
|
+
parser=lambda response: _parse_none(response, {204}),
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
def _submit_with_id(
|
|
450
|
+
self,
|
|
451
|
+
queue: str,
|
|
452
|
+
task_id: str,
|
|
453
|
+
body: dict[str, object],
|
|
454
|
+
) -> Task:
|
|
455
|
+
return self._call(
|
|
456
|
+
operation="submit_task",
|
|
457
|
+
method="PUT",
|
|
458
|
+
path=f"queues/{queue}/tasks/{task_id}",
|
|
459
|
+
json=body,
|
|
460
|
+
parser=lambda response: _parse_model(response, Task, {200, 201}),
|
|
461
|
+
retry=True,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
def _task_action(self, action: str, task_id: str, *, queue: str | None) -> Task:
|
|
465
|
+
self._ensure_open()
|
|
466
|
+
queue_name = self._queue(queue)
|
|
467
|
+
task_id = validate_task_id(task_id)
|
|
468
|
+
return self._call(
|
|
469
|
+
operation=f"{action}_task",
|
|
470
|
+
method="POST",
|
|
471
|
+
path=f"queues/{queue_name}/tasks/{task_id}/{action}",
|
|
472
|
+
parser=lambda response: _parse_model(response, Task, {200}),
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
def _run_action(
|
|
476
|
+
self,
|
|
477
|
+
action: str,
|
|
478
|
+
*,
|
|
479
|
+
task_id: str,
|
|
480
|
+
run_id: str,
|
|
481
|
+
queue: str | None,
|
|
482
|
+
body: dict[str, object],
|
|
483
|
+
parser: Callable[[httpx.Response], T],
|
|
484
|
+
) -> T:
|
|
485
|
+
self._ensure_open()
|
|
486
|
+
queue_name = self._queue(queue)
|
|
487
|
+
normalized_task_id = validate_task_id(task_id)
|
|
488
|
+
normalized_run_id = validate_run_id(run_id)
|
|
489
|
+
return self._call(
|
|
490
|
+
operation=f"{action}_run",
|
|
491
|
+
method="POST",
|
|
492
|
+
path=f"queues/{queue_name}/tasks/{normalized_task_id}/{action}",
|
|
493
|
+
json={"run_id": normalized_run_id, **body},
|
|
494
|
+
parser=parser,
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
def _queue(self, queue: str | None) -> str:
|
|
498
|
+
return validate_identifier(self._config.queue if queue is None else queue, field="queue")
|
|
499
|
+
|
|
500
|
+
def _call(
|
|
501
|
+
self,
|
|
502
|
+
*,
|
|
503
|
+
operation: str,
|
|
504
|
+
method: str,
|
|
505
|
+
path: str,
|
|
506
|
+
parser: Callable[[httpx.Response], T],
|
|
507
|
+
json: object | None = None,
|
|
508
|
+
params: dict[str, str | int] | None = None,
|
|
509
|
+
retry: bool = False,
|
|
510
|
+
) -> T:
|
|
511
|
+
self._ensure_open()
|
|
512
|
+
self._prepare_endpoint()
|
|
513
|
+
attempts = MAX_RETRY_ATTEMPTS if retry else 1
|
|
514
|
+
last_transport_error: TransportError | None = None
|
|
515
|
+
local_connect_recovery_used = False
|
|
516
|
+
attempt = 0
|
|
517
|
+
while attempt < attempts:
|
|
518
|
+
try:
|
|
519
|
+
response = self._http.request(method, path, json=json, params=params)
|
|
520
|
+
except httpx.RequestError as error:
|
|
521
|
+
last_transport_error = self._connection_error(operation)
|
|
522
|
+
can_recover_local_connect = (
|
|
523
|
+
self._config.local is not None
|
|
524
|
+
and isinstance(error, (httpx.ConnectError, httpx.ConnectTimeout))
|
|
525
|
+
and not local_connect_recovery_used
|
|
526
|
+
)
|
|
527
|
+
if can_recover_local_connect:
|
|
528
|
+
local_connect_recovery_used = True
|
|
529
|
+
self._local_ready = False
|
|
530
|
+
self._ensure_local_available()
|
|
531
|
+
if attempt + 1 == attempts:
|
|
532
|
+
attempts += 1
|
|
533
|
+
elif attempt + 1 == attempts:
|
|
534
|
+
raise last_transport_error from error
|
|
535
|
+
else:
|
|
536
|
+
self._announce_http_endpoint()
|
|
537
|
+
if response.is_error:
|
|
538
|
+
try:
|
|
539
|
+
api_error = _parse_api_error(response)
|
|
540
|
+
except TransportError as error:
|
|
541
|
+
last_transport_error = _with_operation(
|
|
542
|
+
error,
|
|
543
|
+
operation,
|
|
544
|
+
self._operation_endpoint_details,
|
|
545
|
+
)
|
|
546
|
+
if attempt + 1 == attempts:
|
|
547
|
+
raise last_transport_error from error
|
|
548
|
+
_backoff(attempt)
|
|
549
|
+
attempt += 1
|
|
550
|
+
continue
|
|
551
|
+
if retry and api_error.code == "database_busy" and attempt + 1 < attempts:
|
|
552
|
+
_backoff(attempt)
|
|
553
|
+
attempt += 1
|
|
554
|
+
continue
|
|
555
|
+
raise api_error
|
|
556
|
+
try:
|
|
557
|
+
return parser(response)
|
|
558
|
+
except TransportError as error:
|
|
559
|
+
last_transport_error = _with_operation(
|
|
560
|
+
error, operation, self._operation_endpoint_details
|
|
561
|
+
)
|
|
562
|
+
if attempt + 1 == attempts:
|
|
563
|
+
raise last_transport_error from error
|
|
564
|
+
_backoff(attempt)
|
|
565
|
+
attempt += 1
|
|
566
|
+
if last_transport_error is None:
|
|
567
|
+
raise AssertionError("Request loop ended without a result or error.")
|
|
568
|
+
raise last_transport_error
|
|
569
|
+
|
|
570
|
+
def _ensure_open(self) -> None:
|
|
571
|
+
if self._closed:
|
|
572
|
+
raise RuntimeError("Client is closed.")
|
|
573
|
+
|
|
574
|
+
@property
|
|
575
|
+
def _operation_endpoint_details(self) -> dict[str, object]:
|
|
576
|
+
if self._config.local is None:
|
|
577
|
+
return {"url": self._config.url}
|
|
578
|
+
return {
|
|
579
|
+
"directory": str(self._config.local.directory),
|
|
580
|
+
"socket": str(self._config.local.socket),
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
def _prepare_endpoint(self) -> None:
|
|
584
|
+
if self._config.local is not None and not self._local_ready:
|
|
585
|
+
self._ensure_local_available()
|
|
586
|
+
|
|
587
|
+
def _announce_http_endpoint(self) -> None:
|
|
588
|
+
if self._endpoint_announced or self._config.local is not None:
|
|
589
|
+
return
|
|
590
|
+
assert self._config.url is not None
|
|
591
|
+
transport = self._config.url.partition(":")[0]
|
|
592
|
+
print(
|
|
593
|
+
f"[labtasker] connected server=remote transport={transport} url={self._config.url}",
|
|
594
|
+
file=sys.stderr,
|
|
595
|
+
)
|
|
596
|
+
self._endpoint_announced = True
|
|
597
|
+
|
|
598
|
+
def _ensure_local_available(self) -> None:
|
|
599
|
+
paths = self._config.local
|
|
600
|
+
if paths is None:
|
|
601
|
+
return
|
|
602
|
+
result = ensure_local_server(paths, emit=self._emit_local_transition)
|
|
603
|
+
pid = result.pid if result.pid is not None else "unknown"
|
|
604
|
+
version = result.server_version if result.server_version is not None else "unknown"
|
|
605
|
+
print(
|
|
606
|
+
"[labtasker] connected server=local transport=unix "
|
|
607
|
+
f"directory={paths.directory} database={paths.database} socket={paths.socket} "
|
|
608
|
+
f"pid={pid} version={version}",
|
|
609
|
+
file=sys.stderr,
|
|
610
|
+
)
|
|
611
|
+
self._endpoint_announced = True
|
|
612
|
+
self._local_ready = True
|
|
613
|
+
|
|
614
|
+
@staticmethod
|
|
615
|
+
def _emit_local_transition(message: str) -> None:
|
|
616
|
+
print(f"[labtasker] {message}", file=sys.stderr)
|
|
617
|
+
|
|
618
|
+
def _connection_error(self, operation: str) -> TransportError:
|
|
619
|
+
details: dict[str, object] = {"operation": operation}
|
|
620
|
+
if self._config.local is None:
|
|
621
|
+
details["url"] = self._config.url
|
|
622
|
+
else:
|
|
623
|
+
details.update(
|
|
624
|
+
{
|
|
625
|
+
"state": "unhealthy",
|
|
626
|
+
"directory": str(self._config.local.directory),
|
|
627
|
+
"database": str(self._config.local.database),
|
|
628
|
+
"socket": str(self._config.local.socket),
|
|
629
|
+
"log": str(self._config.local.log),
|
|
630
|
+
}
|
|
631
|
+
)
|
|
632
|
+
return TransportError("The Labtasker Server could not be reached.", details)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _parse_model(
|
|
636
|
+
response: httpx.Response,
|
|
637
|
+
model: type[ModelT],
|
|
638
|
+
statuses: set[int],
|
|
639
|
+
) -> ModelT:
|
|
640
|
+
_require_status(response, statuses)
|
|
641
|
+
try:
|
|
642
|
+
return model.model_validate_json(response.content, strict=True)
|
|
643
|
+
except (ValidationError, ValueError, TypeError) as error:
|
|
644
|
+
raise TransportError(
|
|
645
|
+
"The Server returned an invalid success response.",
|
|
646
|
+
{"http_status": response.status_code},
|
|
647
|
+
) from error
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _parse_queue_list(response: httpx.Response, statuses: set[int]) -> list[Queue]:
|
|
651
|
+
_require_status(response, statuses)
|
|
652
|
+
try:
|
|
653
|
+
return QUEUE_LIST_ADAPTER.validate_json(response.content, strict=True)
|
|
654
|
+
except (ValidationError, ValueError, TypeError) as error:
|
|
655
|
+
raise TransportError(
|
|
656
|
+
"The Server returned an invalid Queue list.",
|
|
657
|
+
{"http_status": response.status_code},
|
|
658
|
+
) from error
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _parse_none(response: httpx.Response, statuses: set[int]) -> None:
|
|
662
|
+
_require_status(response, statuses)
|
|
663
|
+
if response.content:
|
|
664
|
+
raise TransportError(
|
|
665
|
+
"The Server returned an unexpected response body.",
|
|
666
|
+
{"http_status": response.status_code},
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _parse_claim(response: httpx.Response) -> ClaimResponse | None:
|
|
671
|
+
if response.status_code == 204:
|
|
672
|
+
_parse_none(response, {204})
|
|
673
|
+
return None
|
|
674
|
+
return _parse_model(response, ClaimResponse, {200})
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _require_status(response: httpx.Response, statuses: set[int]) -> None:
|
|
678
|
+
if response.status_code not in statuses:
|
|
679
|
+
raise TransportError(
|
|
680
|
+
"The Server returned an unexpected success status.",
|
|
681
|
+
{"http_status": response.status_code},
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _parse_api_error(response: httpx.Response) -> APIError:
|
|
686
|
+
try:
|
|
687
|
+
payload = response.json()
|
|
688
|
+
if not isinstance(payload, dict) or set(payload) != {"error"}:
|
|
689
|
+
raise ValueError
|
|
690
|
+
error = payload["error"]
|
|
691
|
+
if not isinstance(error, dict) or set(error) != {"code", "message", "details"}:
|
|
692
|
+
raise ValueError
|
|
693
|
+
code = error["code"]
|
|
694
|
+
message = error["message"]
|
|
695
|
+
details = error["details"]
|
|
696
|
+
if not isinstance(code, str) or not isinstance(message, str):
|
|
697
|
+
raise ValueError
|
|
698
|
+
normalized_details = validate_json_object(details, field="error.details")
|
|
699
|
+
except (ValueError, TypeError) as error:
|
|
700
|
+
raise TransportError(
|
|
701
|
+
"The Server returned an invalid error response.",
|
|
702
|
+
{"http_status": response.status_code},
|
|
703
|
+
) from error
|
|
704
|
+
return APIError(response.status_code, code, message, normalized_details)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _with_operation(
|
|
708
|
+
error: TransportError,
|
|
709
|
+
operation: str,
|
|
710
|
+
endpoint: dict[str, object],
|
|
711
|
+
) -> TransportError:
|
|
712
|
+
return TransportError(error.message, {**error.details, "operation": operation, **endpoint})
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _generate_task_id() -> str:
|
|
716
|
+
return f"t_{secrets.token_urlsafe(9)}"
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _without_none(values: dict[str, str | int | None]) -> dict[str, str | int]:
|
|
720
|
+
return {key: value for key, value in values.items() if value is not None}
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _backoff(attempt: int) -> None:
|
|
724
|
+
if attempt < len(RETRY_BACKOFF_SECONDS):
|
|
725
|
+
time.sleep(RETRY_BACKOFF_SECONDS[attempt])
|