unitlab 3.0.3__tar.gz → 3.0.4__tar.gz
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.
- {unitlab-3.0.3 → unitlab-3.0.4}/PKG-INFO +15 -2
- {unitlab-3.0.3 → unitlab-3.0.4}/README.md +14 -1
- {unitlab-3.0.3 → unitlab-3.0.4}/pyproject.toml +1 -1
- {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/__init__.py +2 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/_http.py +5 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/_uploader.py +65 -2
- {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/cli.py +25 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/exceptions.py +10 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/resources/releases.py +72 -3
- {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/resources/workflow.py +104 -8
- {unitlab-3.0.3 → unitlab-3.0.4}/tests/test_cli.py +69 -1
- {unitlab-3.0.3 → unitlab-3.0.4}/tests/test_client.py +439 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/tests/test_http.py +27 -1
- {unitlab-3.0.3 → unitlab-3.0.4}/tests/test_tiled_uploads.py +158 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/.gitignore +0 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/LICENSE.md +0 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/__main__.py +0 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/_config.py +0 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/_downloader.py +0 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/_grouping.py +0 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/_waiter.py +0 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/client.py +0 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/ontology.py +0 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/py.typed +0 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/resources/__init__.py +0 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/resources/_base.py +0 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/resources/assets.py +0 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/resources/cloud.py +0 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/resources/datasets.py +0 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/resources/embeddings.py +0 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/resources/ontologies.py +0 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/resources/projects.py +0 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/types.py +0 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/tests/test_downloader.py +0 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/tests/test_embeddings.py +0 -0
- {unitlab-3.0.3 → unitlab-3.0.4}/tests/test_uploader.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: unitlab
|
|
3
|
-
Version: 3.0.
|
|
3
|
+
Version: 3.0.4
|
|
4
4
|
Summary: Python SDK for the Unitlab.ai data annotation platform
|
|
5
5
|
Project-URL: Homepage, https://unitlab.ai
|
|
6
6
|
Project-URL: Documentation, https://docs.unitlab.ai
|
|
@@ -393,6 +393,18 @@ annotation_path = release.download(split="train", dest="./release-annotations")
|
|
|
393
393
|
files_folder = release.download_files(dest="./release-files")
|
|
394
394
|
```
|
|
395
395
|
|
|
396
|
+
`client.releases.create()` waits until the Release is ready. To return immediately
|
|
397
|
+
and wait later:
|
|
398
|
+
|
|
399
|
+
```python
|
|
400
|
+
release = client.releases.create(project, wait=False)
|
|
401
|
+
release.wait(timeout=7200) # Wait before downloading.
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
Check progress with `release.refresh()`, then read `release.status` and
|
|
405
|
+
`release.progress`. Lists show ready Releases; use `client.releases.get(release_id)`
|
|
406
|
+
to check one still being prepared.
|
|
407
|
+
|
|
396
408
|
Release item download tokens are persistent Unitlab URLs. For files stored in
|
|
397
409
|
customer cloud storage, the URL redirects to a signed target URL; only that target
|
|
398
410
|
URL is temporary.
|
|
@@ -454,8 +466,9 @@ unitlab dataset unpublished-changes DATASET_ID
|
|
|
454
466
|
unitlab dataset publish DATASET_ID --title "Initial snapshot"
|
|
455
467
|
unitlab dataset versions DATASET_ID
|
|
456
468
|
|
|
457
|
-
# Releases
|
|
469
|
+
# Releases (create returns immediately; add --wait to wait until ready)
|
|
458
470
|
unitlab release create PROJECT_ID --format UUEF --splits train=100
|
|
471
|
+
unitlab release wait RELEASE_ID --timeout 7200
|
|
459
472
|
unitlab release list
|
|
460
473
|
unitlab release detail RELEASE_ID
|
|
461
474
|
unitlab release download RELEASE_ID --split-type train --dest ./release-annotations
|
|
@@ -360,6 +360,18 @@ annotation_path = release.download(split="train", dest="./release-annotations")
|
|
|
360
360
|
files_folder = release.download_files(dest="./release-files")
|
|
361
361
|
```
|
|
362
362
|
|
|
363
|
+
`client.releases.create()` waits until the Release is ready. To return immediately
|
|
364
|
+
and wait later:
|
|
365
|
+
|
|
366
|
+
```python
|
|
367
|
+
release = client.releases.create(project, wait=False)
|
|
368
|
+
release.wait(timeout=7200) # Wait before downloading.
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Check progress with `release.refresh()`, then read `release.status` and
|
|
372
|
+
`release.progress`. Lists show ready Releases; use `client.releases.get(release_id)`
|
|
373
|
+
to check one still being prepared.
|
|
374
|
+
|
|
363
375
|
Release item download tokens are persistent Unitlab URLs. For files stored in
|
|
364
376
|
customer cloud storage, the URL redirects to a signed target URL; only that target
|
|
365
377
|
URL is temporary.
|
|
@@ -421,8 +433,9 @@ unitlab dataset unpublished-changes DATASET_ID
|
|
|
421
433
|
unitlab dataset publish DATASET_ID --title "Initial snapshot"
|
|
422
434
|
unitlab dataset versions DATASET_ID
|
|
423
435
|
|
|
424
|
-
# Releases
|
|
436
|
+
# Releases (create returns immediately; add --wait to wait until ready)
|
|
425
437
|
unitlab release create PROJECT_ID --format UUEF --splits train=100
|
|
438
|
+
unitlab release wait RELEASE_ID --timeout 7200
|
|
426
439
|
unitlab release list
|
|
427
440
|
unitlab release detail RELEASE_ID
|
|
428
441
|
unitlab release download RELEASE_ID --split-type train --dest ./release-annotations
|
|
@@ -5,6 +5,7 @@ from .client import UnitlabClient
|
|
|
5
5
|
from .exceptions import (
|
|
6
6
|
AmbiguousUploadCompletionError,
|
|
7
7
|
AuthenticationError,
|
|
8
|
+
ConflictError,
|
|
8
9
|
NetworkError,
|
|
9
10
|
NotFoundError,
|
|
10
11
|
PermissionDeniedError,
|
|
@@ -67,6 +68,7 @@ __all__ = [
|
|
|
67
68
|
"ChecklistAttribute",
|
|
68
69
|
"CloudEntry",
|
|
69
70
|
"CloudStorage",
|
|
71
|
+
"ConflictError",
|
|
70
72
|
"DataUnit",
|
|
71
73
|
"Dataset",
|
|
72
74
|
"DatasetItem",
|
|
@@ -7,6 +7,7 @@ import httpx
|
|
|
7
7
|
|
|
8
8
|
from .exceptions import (
|
|
9
9
|
AuthenticationError,
|
|
10
|
+
ConflictError,
|
|
10
11
|
NetworkError,
|
|
11
12
|
NotFoundError,
|
|
12
13
|
PermissionDeniedError,
|
|
@@ -65,6 +66,10 @@ def raise_for_response(response: httpx.Response) -> None:
|
|
|
65
66
|
if code == "permission_denied":
|
|
66
67
|
raise PermissionDeniedError(message or "Forbidden", error, code)
|
|
67
68
|
raise SubscriptionError(message or "Forbidden", error, code)
|
|
69
|
+
# Must stay above the message-based NotFound fallback below: a 409 detail
|
|
70
|
+
# can mention "not found" and still has to surface as a conflict.
|
|
71
|
+
if response.status_code == 409:
|
|
72
|
+
raise ConflictError(message, error, code)
|
|
68
73
|
if response.status_code == 404 or "not found" in message.lower():
|
|
69
74
|
raise NotFoundError(message, error, code)
|
|
70
75
|
raise NetworkError(message, error, code)
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import json
|
|
5
5
|
import uuid
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
6
7
|
from concurrent.futures import ThreadPoolExecutor
|
|
7
8
|
from contextlib import suppress
|
|
8
9
|
from pathlib import Path
|
|
@@ -198,16 +199,21 @@ async def _put_tiled_part(
|
|
|
198
199
|
url: str,
|
|
199
200
|
start: int,
|
|
200
201
|
size: int,
|
|
202
|
+
get_url: Callable[[str | None], Awaitable[str]] | None = None,
|
|
201
203
|
) -> str:
|
|
202
204
|
last_error: Exception | None = None
|
|
205
|
+
rejected_url = None
|
|
203
206
|
for attempt in range(TILED_PART_MAX_ATTEMPTS):
|
|
204
207
|
try:
|
|
205
208
|
async with semaphore:
|
|
209
|
+
if get_url is not None:
|
|
210
|
+
url = await get_url(rejected_url)
|
|
206
211
|
response = await client.put(
|
|
207
212
|
url,
|
|
208
213
|
content=_FilePartStream(path, start, size),
|
|
209
214
|
headers={"Content-Length": str(size)},
|
|
210
215
|
)
|
|
216
|
+
rejected_url = url if response.status_code == 403 else None
|
|
211
217
|
response.raise_for_status()
|
|
212
218
|
etag = response.headers.get("etag")
|
|
213
219
|
if not etag:
|
|
@@ -272,6 +278,55 @@ async def _upload_tiled_file(
|
|
|
272
278
|
not part.get("url") for part in part_urls
|
|
273
279
|
):
|
|
274
280
|
raise ValueError("invalid multipart part URLs")
|
|
281
|
+
loop = asyncio.get_running_loop()
|
|
282
|
+
expires_in = float(initiated.get("expires_in", 43200))
|
|
283
|
+
renew_at = loop.time() + expires_in * 0.75
|
|
284
|
+
renewal_lock = asyncio.Lock()
|
|
285
|
+
urls = {int(part["part_number"]): str(part["url"]) for part in part_urls}
|
|
286
|
+
|
|
287
|
+
async def get_url(number: int, rejected_url: str | None = None) -> str:
|
|
288
|
+
nonlocal upload_token, renew_at
|
|
289
|
+
async with renewal_lock:
|
|
290
|
+
if loop.time() >= renew_at or (
|
|
291
|
+
rejected_url is not None and urls[number] == rejected_url
|
|
292
|
+
):
|
|
293
|
+
try:
|
|
294
|
+
response = await api_client.post(
|
|
295
|
+
f"{endpoint}/refresh/",
|
|
296
|
+
json={
|
|
297
|
+
"upload_token": upload_token,
|
|
298
|
+
"part_numbers": list(urls),
|
|
299
|
+
},
|
|
300
|
+
)
|
|
301
|
+
response.raise_for_status()
|
|
302
|
+
fresh = _response_json(response)
|
|
303
|
+
new_urls = {
|
|
304
|
+
int(part["part_number"]): str(part["url"])
|
|
305
|
+
for part in fresh["part_urls"]
|
|
306
|
+
}
|
|
307
|
+
if new_urls.keys() != urls.keys() or not fresh["upload_token"]:
|
|
308
|
+
raise ValueError("missing refreshed multipart fields")
|
|
309
|
+
urls.update(new_urls)
|
|
310
|
+
upload_token = str(fresh["upload_token"])
|
|
311
|
+
renew_at = loop.time() + float(fresh["expires_in"]) * 0.75
|
|
312
|
+
except (httpx.HTTPError, KeyError, TypeError, ValueError) as exc:
|
|
313
|
+
raise NetworkError("Multipart URL renewal failed", exc) from exc
|
|
314
|
+
return urls[number]
|
|
315
|
+
|
|
316
|
+
async def keep_upload_active():
|
|
317
|
+
# A single slow PUT can outlive the token. Renew independently of
|
|
318
|
+
# part completion; transient failures retry at the next interval.
|
|
319
|
+
event = asyncio.Event()
|
|
320
|
+
while True:
|
|
321
|
+
try:
|
|
322
|
+
await asyncio.wait_for(
|
|
323
|
+
event.wait(), timeout=max(1, min(3600, expires_in / 4))
|
|
324
|
+
)
|
|
325
|
+
except asyncio.TimeoutError:
|
|
326
|
+
with suppress(NetworkError):
|
|
327
|
+
await get_url(1)
|
|
328
|
+
|
|
329
|
+
renewal_task = asyncio.create_task(keep_upload_active())
|
|
275
330
|
part_tasks = [
|
|
276
331
|
asyncio.create_task(
|
|
277
332
|
_put_tiled_part(
|
|
@@ -279,6 +334,9 @@ async def _upload_tiled_file(
|
|
|
279
334
|
part_semaphore,
|
|
280
335
|
path,
|
|
281
336
|
url=str(part["url"]),
|
|
337
|
+
get_url=lambda rejected, number=int(part["part_number"]): get_url(
|
|
338
|
+
number, rejected
|
|
339
|
+
),
|
|
282
340
|
start=(int(part["part_number"]) - 1) * part_size,
|
|
283
341
|
size=min(
|
|
284
342
|
part_size,
|
|
@@ -290,11 +348,16 @@ async def _upload_tiled_file(
|
|
|
290
348
|
]
|
|
291
349
|
try:
|
|
292
350
|
parts = await asyncio.gather(*part_tasks)
|
|
351
|
+
await get_url(1)
|
|
293
352
|
except Exception:
|
|
294
353
|
for task in part_tasks:
|
|
295
354
|
task.cancel()
|
|
296
355
|
await asyncio.gather(*part_tasks, return_exceptions=True)
|
|
297
356
|
raise
|
|
357
|
+
finally:
|
|
358
|
+
renewal_task.cancel()
|
|
359
|
+
with suppress(asyncio.CancelledError):
|
|
360
|
+
await renewal_task
|
|
298
361
|
except (KeyError, TypeError, ValueError, NetworkError) as exc:
|
|
299
362
|
if upload_token:
|
|
300
363
|
await _abort_tiled_upload(api_client, endpoint, upload_token)
|
|
@@ -406,7 +469,7 @@ def _project_files(
|
|
|
406
469
|
} or known_extensions()
|
|
407
470
|
files = collect_files(source, accepted)
|
|
408
471
|
max_sizes = {
|
|
409
|
-
str(key): int(value)
|
|
472
|
+
str(key): None if value is None else int(value)
|
|
410
473
|
for key, value in (upload_info.get("max_file_sizes") or {}).items()
|
|
411
474
|
}
|
|
412
475
|
project_type = upload_info.get("generic_type")
|
|
@@ -418,7 +481,7 @@ def _project_files(
|
|
|
418
481
|
generic_type or detect_generic_type(path.name) or project_type
|
|
419
482
|
)
|
|
420
483
|
max_size = max_sizes.get(file_generic_type)
|
|
421
|
-
if
|
|
484
|
+
if file_generic_type not in max_sizes and project_type and fallback is not None:
|
|
422
485
|
max_size = int(fallback)
|
|
423
486
|
if max_size is None or path.stat().st_size <= max_size:
|
|
424
487
|
result.append(path)
|
|
@@ -195,6 +195,7 @@ SUMMARY_FIELDS = (
|
|
|
195
195
|
"title",
|
|
196
196
|
"file_name",
|
|
197
197
|
"kind",
|
|
198
|
+
"task_kind",
|
|
198
199
|
"type",
|
|
199
200
|
"version",
|
|
200
201
|
"status",
|
|
@@ -1249,6 +1250,17 @@ def release_create(
|
|
|
1249
1250
|
),
|
|
1250
1251
|
),
|
|
1251
1252
|
license_id: UUID | None = typer.Option(None, "--license"),
|
|
1253
|
+
# Unlike the Python API, the CLI does not wait by default: a Release can
|
|
1254
|
+
# take a long time to prepare and a shell command should not hang on it.
|
|
1255
|
+
wait: bool = typer.Option(
|
|
1256
|
+
False,
|
|
1257
|
+
"--wait/--no-wait",
|
|
1258
|
+
help=(
|
|
1259
|
+
"Wait until the Release is ready. Without it the command returns "
|
|
1260
|
+
"the pending Release; follow it with `unitlab release wait`."
|
|
1261
|
+
),
|
|
1262
|
+
),
|
|
1263
|
+
timeout: float = typer.Option(7200, help="Seconds to wait with --wait."),
|
|
1252
1264
|
api_key: API_KEY = None,
|
|
1253
1265
|
json_output: JSON_OUTPUT = False,
|
|
1254
1266
|
):
|
|
@@ -1269,10 +1281,23 @@ def release_create(
|
|
|
1269
1281
|
data_types=data_type,
|
|
1270
1282
|
include_download_tokens=include_download_tokens,
|
|
1271
1283
|
license_id=str(license_id) if license_id else None,
|
|
1284
|
+
wait=wait,
|
|
1285
|
+
timeout=timeout,
|
|
1272
1286
|
)
|
|
1273
1287
|
emit(release, json_output)
|
|
1274
1288
|
|
|
1275
1289
|
|
|
1290
|
+
@release_app.command(name="wait", help="Wait until a Release is ready")
|
|
1291
|
+
def release_wait(
|
|
1292
|
+
release_id: UUID,
|
|
1293
|
+
timeout: float = typer.Option(7200),
|
|
1294
|
+
api_key: API_KEY = None,
|
|
1295
|
+
json_output: JSON_OUTPUT = False,
|
|
1296
|
+
):
|
|
1297
|
+
release = get_client(api_key).releases.get(str(release_id))
|
|
1298
|
+
emit(release.wait(timeout=timeout), json_output)
|
|
1299
|
+
|
|
1300
|
+
|
|
1276
1301
|
@ontology_app.command(name="list", help="List Ontologies")
|
|
1277
1302
|
def ontology_list(
|
|
1278
1303
|
title_eq: str | None = typer.Option(None, "--title-eq"),
|
|
@@ -32,6 +32,16 @@ class NotFoundError(NetworkError):
|
|
|
32
32
|
"""Raised when the requested resource is not found."""
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
class ConflictError(NetworkError):
|
|
36
|
+
"""Raised when the API answers 409 because the resource is in a conflicting state.
|
|
37
|
+
|
|
38
|
+
Subclasses NetworkError so callers that already catch NetworkError keep
|
|
39
|
+
working; ``code`` tells the cases apart (``release_in_progress`` for a
|
|
40
|
+
second create while one is being prepared, ``release_not_ready`` for a
|
|
41
|
+
download before the Release is ready).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
35
45
|
class SubscriptionError(NetworkError):
|
|
36
46
|
"""Raised when a subscription error occurs."""
|
|
37
47
|
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from collections.abc import Callable
|
|
3
4
|
from dataclasses import dataclass, field
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import TYPE_CHECKING, Any
|
|
6
7
|
|
|
7
8
|
from .. import _downloader
|
|
8
|
-
from ..
|
|
9
|
+
from .._waiter import wait_for_status
|
|
10
|
+
from ..exceptions import UnitlabError
|
|
11
|
+
from ..types import ProcessingStatus, _data_type_name
|
|
9
12
|
from ._base import Namespace, identifier
|
|
10
13
|
|
|
11
14
|
if TYPE_CHECKING:
|
|
@@ -32,9 +35,20 @@ class ReleasesNamespace(Namespace):
|
|
|
32
35
|
data_types=None,
|
|
33
36
|
bundle_formats: dict[str, str] | None = None,
|
|
34
37
|
license_id: str | None = None,
|
|
38
|
+
wait: bool = True,
|
|
39
|
+
timeout: float = 7200,
|
|
35
40
|
) -> Release:
|
|
36
41
|
"""Create an annotation Release from a Project snapshot.
|
|
37
42
|
|
|
43
|
+
The backend prepares a Release in the background and answers at once
|
|
44
|
+
with a pending one. Waiting is the default so scripts that create and
|
|
45
|
+
then download keep working unchanged; pass ``wait=False`` to get the
|
|
46
|
+
pending Release back and call ``release.wait()`` later.
|
|
47
|
+
|
|
48
|
+
A second create while one is still being prepared raises
|
|
49
|
+
``ConflictError`` (code ``release_in_progress``). It is deliberately not
|
|
50
|
+
attached to the in-flight Release, whose format or splits may differ.
|
|
51
|
+
|
|
38
52
|
Args:
|
|
39
53
|
project: Project handle or ID.
|
|
40
54
|
export_type: Primary annotation export format.
|
|
@@ -47,9 +61,11 @@ class ReleasesNamespace(Namespace):
|
|
|
47
61
|
includes every available concrete type.
|
|
48
62
|
bundle_formats: Per-concrete-data-type formats for multimodal bundles.
|
|
49
63
|
license_id: Optional Dataset license ID.
|
|
64
|
+
wait: Block until the Release is ready or has failed.
|
|
65
|
+
timeout: Seconds to wait for preparation when ``wait`` is true.
|
|
50
66
|
|
|
51
67
|
Returns:
|
|
52
|
-
The created Release.
|
|
68
|
+
The created Release; ready when ``wait`` is true.
|
|
53
69
|
"""
|
|
54
70
|
payload: dict[str, Any] = {
|
|
55
71
|
"export_type": export_type,
|
|
@@ -82,12 +98,15 @@ class ReleasesNamespace(Namespace):
|
|
|
82
98
|
}
|
|
83
99
|
if license_id is not None:
|
|
84
100
|
payload["license"] = identifier(license_id)
|
|
101
|
+
# The long request timeout stays: a backend that predates background
|
|
102
|
+
# preparation still builds the whole Release inside this POST.
|
|
85
103
|
raw = self._api.post(
|
|
86
104
|
f"/api/sdk/projects/{identifier(project)}/releases/",
|
|
87
105
|
json=payload,
|
|
88
106
|
timeout=600.0,
|
|
89
107
|
)
|
|
90
|
-
|
|
108
|
+
release = Release._from_raw(self._client, raw)
|
|
109
|
+
return release.wait(timeout=timeout) if wait else release
|
|
91
110
|
|
|
92
111
|
|
|
93
112
|
@dataclass
|
|
@@ -100,6 +119,11 @@ class Release:
|
|
|
100
119
|
_client: UnitlabClient = field(repr=False, compare=False)
|
|
101
120
|
data_type: str = ""
|
|
102
121
|
is_public: bool = False
|
|
122
|
+
# "pending" | "exporting" | "ready" | "failed". None means the backend
|
|
123
|
+
# predates background preparation, where every returned Release is ready.
|
|
124
|
+
status: str | None = None
|
|
125
|
+
progress: int | None = None
|
|
126
|
+
status_error: str | None = None
|
|
103
127
|
|
|
104
128
|
@classmethod
|
|
105
129
|
def _from_raw(cls, client: UnitlabClient, raw: dict[str, Any]) -> Release:
|
|
@@ -107,13 +131,58 @@ class Release:
|
|
|
107
131
|
id=str(raw["pk"]),
|
|
108
132
|
name=str(raw.get("name", "")),
|
|
109
133
|
version=str(raw.get("version", "")),
|
|
134
|
+
# number_of_data is null until the Release has been frozen.
|
|
110
135
|
data_item_count=int(raw.get("number_of_data") or 0),
|
|
111
136
|
raw=raw,
|
|
112
137
|
_client=client,
|
|
113
138
|
data_type=_data_type_name(raw.get("generic_type")),
|
|
114
139
|
is_public=bool(raw.get("is_public", False)),
|
|
140
|
+
status=raw.get("status"),
|
|
141
|
+
progress=raw.get("progress"),
|
|
142
|
+
status_error=raw.get("status_error"),
|
|
115
143
|
)
|
|
116
144
|
|
|
145
|
+
def refresh(self) -> Release:
|
|
146
|
+
"""Re-read this Release from the API and update it in place."""
|
|
147
|
+
# Copying every field keeps handles the caller already holds current,
|
|
148
|
+
# including data_item_count, which is only known once frozen.
|
|
149
|
+
vars(self).update(vars(self._client.releases.get(self.id)))
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
def wait(
|
|
153
|
+
self,
|
|
154
|
+
*,
|
|
155
|
+
timeout: float = 7200,
|
|
156
|
+
on_progress: Callable[[ProcessingStatus], None] | None = None,
|
|
157
|
+
show_progress: bool = True,
|
|
158
|
+
) -> Release:
|
|
159
|
+
"""Block until this Release is ready.
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
UnitlabError: With code ``release_failed`` when preparation failed.
|
|
163
|
+
ProcessingTimeoutError: When it is still running after ``timeout``.
|
|
164
|
+
"""
|
|
165
|
+
# Only poll a Release the backend reported as unfinished: a backend
|
|
166
|
+
# without background preparation sends no status and has no /status/
|
|
167
|
+
# route, so polling it would turn a finished Release into a 404.
|
|
168
|
+
if self.status in ("pending", "exporting"):
|
|
169
|
+
wait_for_status(
|
|
170
|
+
self._client._api,
|
|
171
|
+
f"/api/sdk/releases/{self.id}/status/",
|
|
172
|
+
resource_name=f"Release {self.id}",
|
|
173
|
+
timeout=timeout,
|
|
174
|
+
on_progress=on_progress,
|
|
175
|
+
show_progress=show_progress,
|
|
176
|
+
)
|
|
177
|
+
self.refresh()
|
|
178
|
+
# wait_for_status returns on failure too, so failure is judged here.
|
|
179
|
+
if self.status == "failed":
|
|
180
|
+
raise UnitlabError(
|
|
181
|
+
f"Release {self.id} failed: {self.status_error or 'unknown error'}",
|
|
182
|
+
code="release_failed",
|
|
183
|
+
)
|
|
184
|
+
return self
|
|
185
|
+
|
|
117
186
|
def download(
|
|
118
187
|
self,
|
|
119
188
|
split: str | None = None,
|
|
@@ -162,6 +162,10 @@ class WorkflowTask:
|
|
|
162
162
|
data_type: str = ""
|
|
163
163
|
available_actions: list[str] = field(default_factory=list)
|
|
164
164
|
move_targets: list[dict[str, Any]] = field(default_factory=list)
|
|
165
|
+
task_kind: str = "item_state"
|
|
166
|
+
parent_item_id: str | None = None
|
|
167
|
+
assignment_id: str | None = None
|
|
168
|
+
consensus_summary: dict[str, Any] | None = None
|
|
165
169
|
|
|
166
170
|
@classmethod
|
|
167
171
|
def _from_queue(
|
|
@@ -170,8 +174,13 @@ class WorkflowTask:
|
|
|
170
174
|
project_id: str,
|
|
171
175
|
raw: dict[str, Any],
|
|
172
176
|
) -> WorkflowTask:
|
|
177
|
+
consensus_summary = (
|
|
178
|
+
raw["consensus_summary"]
|
|
179
|
+
if "consensus_summary" in raw
|
|
180
|
+
else raw.get("consensus")
|
|
181
|
+
)
|
|
173
182
|
return cls(
|
|
174
|
-
id=str(raw["item_id"]),
|
|
183
|
+
id=str(raw.get("task_id") or raw["item_id"]),
|
|
175
184
|
project_id=project_id,
|
|
176
185
|
stage_id=str(raw.get("stage_id", "")),
|
|
177
186
|
stage_type=str(raw.get("stage_type", "")),
|
|
@@ -193,6 +202,14 @@ class WorkflowTask:
|
|
|
193
202
|
else None
|
|
194
203
|
),
|
|
195
204
|
data_type=_data_type_name(raw.get("generic_type")),
|
|
205
|
+
task_kind=str(raw.get("task_kind") or "item_state"),
|
|
206
|
+
parent_item_id=(
|
|
207
|
+
str(raw["parent_item_id"]) if raw.get("parent_item_id") else None
|
|
208
|
+
),
|
|
209
|
+
assignment_id=(
|
|
210
|
+
str(raw["assignment_id"]) if raw.get("assignment_id") else None
|
|
211
|
+
),
|
|
212
|
+
consensus_summary=consensus_summary,
|
|
196
213
|
)
|
|
197
214
|
|
|
198
215
|
@classmethod
|
|
@@ -206,8 +223,15 @@ class WorkflowTask:
|
|
|
206
223
|
task = raw.get("task", raw)
|
|
207
224
|
queue = raw.get("queue") or {}
|
|
208
225
|
current_stage = task.get("current_stage") or {}
|
|
226
|
+
# Consensus task details retain the parent resource; the queue identifies
|
|
227
|
+
# the private branch. Explicit null also matters when an assignee is cleared.
|
|
228
|
+
datasource_id = queue.get("datasource_id", task.get("datasource_id"))
|
|
229
|
+
assigned_to_id = queue.get("assigned_to_id", task.get("assigned_to_id"))
|
|
230
|
+
consensus_summary = raw.get("consensus_summary")
|
|
231
|
+
if consensus_summary is None:
|
|
232
|
+
consensus_summary = task.get("consensus_summary")
|
|
209
233
|
return cls(
|
|
210
|
-
id=str(task["uuid"]),
|
|
234
|
+
id=str(task.get("task_id") or task["uuid"]),
|
|
211
235
|
project_id=str(task.get("project_id") or project_id),
|
|
212
236
|
stage_id=str(current_stage.get("id", "")),
|
|
213
237
|
stage_type=str(current_stage.get("type", "")),
|
|
@@ -217,12 +241,8 @@ class WorkflowTask:
|
|
|
217
241
|
raw=raw,
|
|
218
242
|
_client=client,
|
|
219
243
|
name=str(queue.get("name", "")),
|
|
220
|
-
assigned_to_id=(
|
|
221
|
-
|
|
222
|
-
),
|
|
223
|
-
datasource_id=(
|
|
224
|
-
str(task["datasource_id"]) if task.get("datasource_id") else None
|
|
225
|
-
),
|
|
244
|
+
assigned_to_id=str(assigned_to_id) if assigned_to_id else None,
|
|
245
|
+
datasource_id=str(datasource_id) if datasource_id else None,
|
|
226
246
|
data_group_id=(
|
|
227
247
|
str(task.get("project_data_group_id") or task.get("data_group_id"))
|
|
228
248
|
if task.get("project_data_group_id") or task.get("data_group_id")
|
|
@@ -233,6 +253,14 @@ class WorkflowTask:
|
|
|
233
253
|
str(value) for value in raw.get("available_actions", [])
|
|
234
254
|
],
|
|
235
255
|
move_targets=list(raw.get("move_targets", [])),
|
|
256
|
+
task_kind=str(task.get("task_kind") or "item_state"),
|
|
257
|
+
parent_item_id=(
|
|
258
|
+
str(task["parent_item_id"]) if task.get("parent_item_id") else None
|
|
259
|
+
),
|
|
260
|
+
assignment_id=(
|
|
261
|
+
str(task["assignment_id"]) if task.get("assignment_id") else None
|
|
262
|
+
),
|
|
263
|
+
consensus_summary=consensus_summary,
|
|
236
264
|
)
|
|
237
265
|
|
|
238
266
|
@classmethod
|
|
@@ -272,6 +300,44 @@ class WorkflowTask:
|
|
|
272
300
|
)
|
|
273
301
|
)
|
|
274
302
|
|
|
303
|
+
def get_consensus_comparison(self) -> dict[str, Any]:
|
|
304
|
+
return self._client._api.get(
|
|
305
|
+
f"/api/sdk/workflow-tasks/{self.id}/consensus-comparison/"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def copy_consensus_component(
|
|
309
|
+
self,
|
|
310
|
+
*,
|
|
311
|
+
source_assignment_id: str,
|
|
312
|
+
component_kind: str,
|
|
313
|
+
component_locator: dict[str, Any],
|
|
314
|
+
operation: str = "add",
|
|
315
|
+
panel_id: str | None = None,
|
|
316
|
+
replacement_locator: dict[str, Any] | None = None,
|
|
317
|
+
) -> WorkflowTask:
|
|
318
|
+
if operation not in {"add", "replace"}:
|
|
319
|
+
raise ValueError("operation must be 'add' or 'replace'")
|
|
320
|
+
|
|
321
|
+
payload: dict[str, Any] = {
|
|
322
|
+
"source_assignment_id": source_assignment_id,
|
|
323
|
+
"component_kind": component_kind,
|
|
324
|
+
"component_locator": component_locator,
|
|
325
|
+
"operation": operation,
|
|
326
|
+
"idempotency_key": str(uuid4()),
|
|
327
|
+
}
|
|
328
|
+
if panel_id is not None:
|
|
329
|
+
payload["panel_id"] = panel_id
|
|
330
|
+
if replacement_locator is not None:
|
|
331
|
+
payload["replacement_locator"] = replacement_locator
|
|
332
|
+
if self.stage_id:
|
|
333
|
+
payload["expected_stage_id"] = self.stage_id
|
|
334
|
+
return self._replace(
|
|
335
|
+
self._client._api.post(
|
|
336
|
+
f"/api/sdk/workflow-tasks/{self.id}/consensus-selections/",
|
|
337
|
+
json=payload,
|
|
338
|
+
)
|
|
339
|
+
)
|
|
340
|
+
|
|
275
341
|
def _perform(
|
|
276
342
|
self,
|
|
277
343
|
action: str,
|
|
@@ -315,6 +381,36 @@ class WorkflowTask:
|
|
|
315
381
|
def skip(self) -> WorkflowTask:
|
|
316
382
|
return self._perform("skip")
|
|
317
383
|
|
|
384
|
+
def complete_model_run(
|
|
385
|
+
self,
|
|
386
|
+
run_id: str,
|
|
387
|
+
result: dict[str, Any] | list[dict[str, Any]],
|
|
388
|
+
*,
|
|
389
|
+
model_version: str = "",
|
|
390
|
+
) -> WorkflowTask:
|
|
391
|
+
"""Upload one or more final GeoJSON results for a Model-stage run."""
|
|
392
|
+
result_key = "results" if isinstance(result, list) else "result"
|
|
393
|
+
return self._replace(
|
|
394
|
+
self._client._api.post(
|
|
395
|
+
f"/api/sdk/workflow-tasks/{self.id}/model-result/",
|
|
396
|
+
json={
|
|
397
|
+
"run_id": str(run_id),
|
|
398
|
+
"status": "complete",
|
|
399
|
+
result_key: result,
|
|
400
|
+
"model_version": model_version,
|
|
401
|
+
},
|
|
402
|
+
)
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def fail_model_run(self, run_id: str, error: str) -> WorkflowTask:
|
|
406
|
+
"""Report a failed asynchronous Model-stage run without advancing it."""
|
|
407
|
+
return self._replace(
|
|
408
|
+
self._client._api.post(
|
|
409
|
+
f"/api/sdk/workflow-tasks/{self.id}/model-result/",
|
|
410
|
+
json={"run_id": str(run_id), "status": "failed", "error": error},
|
|
411
|
+
)
|
|
412
|
+
)
|
|
413
|
+
|
|
318
414
|
def move(self, destination_stage, *, reason: str = "") -> WorkflowTask:
|
|
319
415
|
"""Move this task directly, bypassing normal Workflow edges.
|
|
320
416
|
|