unitlab 3.0.2__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.2 → unitlab-3.0.4}/PKG-INFO +15 -2
- {unitlab-3.0.2 → unitlab-3.0.4}/README.md +14 -1
- {unitlab-3.0.2 → unitlab-3.0.4}/pyproject.toml +1 -1
- {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/__init__.py +2 -0
- {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/_http.py +5 -0
- {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/_uploader.py +110 -8
- {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/cli.py +25 -0
- {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/exceptions.py +10 -0
- {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/ontology.py +2 -0
- {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/resources/assets.py +27 -4
- {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/resources/projects.py +11 -1
- {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/resources/releases.py +72 -3
- {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/resources/workflow.py +104 -8
- {unitlab-3.0.2 → unitlab-3.0.4}/tests/test_cli.py +69 -1
- {unitlab-3.0.2 → unitlab-3.0.4}/tests/test_client.py +451 -0
- {unitlab-3.0.2 → unitlab-3.0.4}/tests/test_http.py +27 -1
- {unitlab-3.0.2 → unitlab-3.0.4}/tests/test_tiled_uploads.py +197 -2
- {unitlab-3.0.2 → unitlab-3.0.4}/tests/test_uploader.py +163 -0
- {unitlab-3.0.2 → unitlab-3.0.4}/.gitignore +0 -0
- {unitlab-3.0.2 → unitlab-3.0.4}/LICENSE.md +0 -0
- {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/__main__.py +0 -0
- {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/_config.py +0 -0
- {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/_downloader.py +0 -0
- {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/_grouping.py +0 -0
- {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/_waiter.py +0 -0
- {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/client.py +0 -0
- {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/py.typed +0 -0
- {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/resources/__init__.py +0 -0
- {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/resources/_base.py +0 -0
- {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/resources/cloud.py +0 -0
- {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/resources/datasets.py +0 -0
- {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/resources/embeddings.py +0 -0
- {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/resources/ontologies.py +0 -0
- {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/types.py +0 -0
- {unitlab-3.0.2 → unitlab-3.0.4}/tests/test_downloader.py +0 -0
- {unitlab-3.0.2 → unitlab-3.0.4}/tests/test_embeddings.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
|
|
@@ -56,10 +57,12 @@ TILED_EXTENSIONS = (
|
|
|
56
57
|
EXTENSIONS_BY_GENERIC_TYPE: dict[str, set[str]] = {
|
|
57
58
|
"img": {"jpg", "png", "jpeg", "webp", "gif", "bmp", "ico", "svg"},
|
|
58
59
|
"text": {"txt"},
|
|
60
|
+
"html": {"html", "htm"},
|
|
59
61
|
"video": {"mp4", "avi", "mov", "webm", "mkv", "m4v", "wmv", "flv"},
|
|
60
62
|
"audio": {"mp3", "wav", "ogg", "aac", "flac", "m4a"},
|
|
61
63
|
"medical": {"dcm", "nii", "nii.gz", "nrrd"},
|
|
62
64
|
"document": {"pdf"},
|
|
65
|
+
"timeseries": {"csv"},
|
|
63
66
|
"geospatial": AMBIGUOUS_TIFF_EXTENSIONS | GEOSPATIAL_TILED_EXTENSIONS,
|
|
64
67
|
"pathology": PATHOLOGY_TILED_EXTENSIONS,
|
|
65
68
|
}
|
|
@@ -196,16 +199,21 @@ async def _put_tiled_part(
|
|
|
196
199
|
url: str,
|
|
197
200
|
start: int,
|
|
198
201
|
size: int,
|
|
202
|
+
get_url: Callable[[str | None], Awaitable[str]] | None = None,
|
|
199
203
|
) -> str:
|
|
200
204
|
last_error: Exception | None = None
|
|
205
|
+
rejected_url = None
|
|
201
206
|
for attempt in range(TILED_PART_MAX_ATTEMPTS):
|
|
202
207
|
try:
|
|
203
208
|
async with semaphore:
|
|
209
|
+
if get_url is not None:
|
|
210
|
+
url = await get_url(rejected_url)
|
|
204
211
|
response = await client.put(
|
|
205
212
|
url,
|
|
206
213
|
content=_FilePartStream(path, start, size),
|
|
207
214
|
headers={"Content-Length": str(size)},
|
|
208
215
|
)
|
|
216
|
+
rejected_url = url if response.status_code == 403 else None
|
|
209
217
|
response.raise_for_status()
|
|
210
218
|
etag = response.headers.get("etag")
|
|
211
219
|
if not etag:
|
|
@@ -270,6 +278,55 @@ async def _upload_tiled_file(
|
|
|
270
278
|
not part.get("url") for part in part_urls
|
|
271
279
|
):
|
|
272
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())
|
|
273
330
|
part_tasks = [
|
|
274
331
|
asyncio.create_task(
|
|
275
332
|
_put_tiled_part(
|
|
@@ -277,6 +334,9 @@ async def _upload_tiled_file(
|
|
|
277
334
|
part_semaphore,
|
|
278
335
|
path,
|
|
279
336
|
url=str(part["url"]),
|
|
337
|
+
get_url=lambda rejected, number=int(part["part_number"]): get_url(
|
|
338
|
+
number, rejected
|
|
339
|
+
),
|
|
280
340
|
start=(int(part["part_number"]) - 1) * part_size,
|
|
281
341
|
size=min(
|
|
282
342
|
part_size,
|
|
@@ -288,11 +348,16 @@ async def _upload_tiled_file(
|
|
|
288
348
|
]
|
|
289
349
|
try:
|
|
290
350
|
parts = await asyncio.gather(*part_tasks)
|
|
351
|
+
await get_url(1)
|
|
291
352
|
except Exception:
|
|
292
353
|
for task in part_tasks:
|
|
293
354
|
task.cancel()
|
|
294
355
|
await asyncio.gather(*part_tasks, return_exceptions=True)
|
|
295
356
|
raise
|
|
357
|
+
finally:
|
|
358
|
+
renewal_task.cancel()
|
|
359
|
+
with suppress(asyncio.CancelledError):
|
|
360
|
+
await renewal_task
|
|
296
361
|
except (KeyError, TypeError, ValueError, NetworkError) as exc:
|
|
297
362
|
if upload_token:
|
|
298
363
|
await _abort_tiled_upload(api_client, endpoint, upload_token)
|
|
@@ -396,6 +461,7 @@ async def _finalize_medical_upload(client, endpoint: str, batch_queue_id: str) -
|
|
|
396
461
|
def _project_files(
|
|
397
462
|
source: str | Path,
|
|
398
463
|
upload_info: dict[str, Any],
|
|
464
|
+
generic_type: str | None = None,
|
|
399
465
|
) -> tuple[list[Path], list[UploadFailure]]:
|
|
400
466
|
accepted = {
|
|
401
467
|
str(value).lower().lstrip(".")
|
|
@@ -403,7 +469,7 @@ def _project_files(
|
|
|
403
469
|
} or known_extensions()
|
|
404
470
|
files = collect_files(source, accepted)
|
|
405
471
|
max_sizes = {
|
|
406
|
-
str(key): int(value)
|
|
472
|
+
str(key): None if value is None else int(value)
|
|
407
473
|
for key, value in (upload_info.get("max_file_sizes") or {}).items()
|
|
408
474
|
}
|
|
409
475
|
project_type = upload_info.get("generic_type")
|
|
@@ -411,9 +477,11 @@ def _project_files(
|
|
|
411
477
|
result = []
|
|
412
478
|
failures = []
|
|
413
479
|
for path in files:
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
480
|
+
file_generic_type = (
|
|
481
|
+
generic_type or detect_generic_type(path.name) or project_type
|
|
482
|
+
)
|
|
483
|
+
max_size = max_sizes.get(file_generic_type)
|
|
484
|
+
if file_generic_type not in max_sizes and project_type and fallback is not None:
|
|
417
485
|
max_size = int(fallback)
|
|
418
486
|
if max_size is None or path.stat().st_size <= max_size:
|
|
419
487
|
result.append(path)
|
|
@@ -435,6 +503,8 @@ async def _upload_project_async(
|
|
|
435
503
|
*,
|
|
436
504
|
session_id: str,
|
|
437
505
|
fps: float,
|
|
506
|
+
generic_type: str | None,
|
|
507
|
+
primary_column: str | None,
|
|
438
508
|
batch_size: int,
|
|
439
509
|
show_progress: bool,
|
|
440
510
|
) -> UploadResult:
|
|
@@ -455,11 +525,15 @@ async def _upload_project_async(
|
|
|
455
525
|
tasks = []
|
|
456
526
|
generic_types = []
|
|
457
527
|
for path in chunk:
|
|
458
|
-
|
|
459
|
-
generic_types.append(
|
|
528
|
+
file_generic_type = generic_type or detect_generic_type(path.name)
|
|
529
|
+
generic_types.append(file_generic_type)
|
|
460
530
|
fields = {"session_id": session_id}
|
|
461
|
-
if
|
|
531
|
+
if file_generic_type == "video":
|
|
462
532
|
fields["fps"] = str(fps)
|
|
533
|
+
if generic_type is not None:
|
|
534
|
+
fields["generic_type"] = generic_type
|
|
535
|
+
if primary_column is not None:
|
|
536
|
+
fields["primary_column"] = primary_column
|
|
463
537
|
tasks.append(
|
|
464
538
|
_upload_file(
|
|
465
539
|
client,
|
|
@@ -533,13 +607,15 @@ def upload_project(
|
|
|
533
607
|
source: str | Path,
|
|
534
608
|
*,
|
|
535
609
|
fps: float = 1.0,
|
|
610
|
+
generic_type: str | None = None,
|
|
611
|
+
primary_column: str | None = None,
|
|
536
612
|
batch_size: int = 100,
|
|
537
613
|
show_progress: bool = True,
|
|
538
614
|
) -> tuple[UploadResult, str]:
|
|
539
615
|
if batch_size < 1:
|
|
540
616
|
raise ValueError("batch_size must be at least 1")
|
|
541
617
|
upload_info = api.get(f"/api/sdk/projects/{project_id}/upload-info/")
|
|
542
|
-
files, preflight_failures = _project_files(source, upload_info)
|
|
618
|
+
files, preflight_failures = _project_files(source, upload_info, generic_type)
|
|
543
619
|
if not files and not preflight_failures:
|
|
544
620
|
accepted = upload_info.get("accepted_formats") or sorted(known_extensions())
|
|
545
621
|
raise ValueError(
|
|
@@ -560,6 +636,8 @@ def upload_project(
|
|
|
560
636
|
files,
|
|
561
637
|
session_id=session_id,
|
|
562
638
|
fps=fps,
|
|
639
|
+
generic_type=generic_type,
|
|
640
|
+
primary_column=primary_column,
|
|
563
641
|
batch_size=batch_size,
|
|
564
642
|
show_progress=show_progress,
|
|
565
643
|
)
|
|
@@ -576,6 +654,9 @@ def _asset_form_data(
|
|
|
576
654
|
path: str | None,
|
|
577
655
|
tags: list[str] | None,
|
|
578
656
|
custom_metadata: dict[str, Any] | None,
|
|
657
|
+
defer_timeseries_configuration: bool,
|
|
658
|
+
generic_type: str | None,
|
|
659
|
+
primary_column: str | None,
|
|
579
660
|
) -> dict[str, str | list[str]]:
|
|
580
661
|
fields: dict[str, str | list[str]] = {}
|
|
581
662
|
if folder:
|
|
@@ -592,6 +673,12 @@ def _asset_form_data(
|
|
|
592
673
|
ensure_ascii=False,
|
|
593
674
|
separators=(",", ":"),
|
|
594
675
|
)
|
|
676
|
+
if defer_timeseries_configuration:
|
|
677
|
+
fields["defer_timeseries_configuration"] = "true"
|
|
678
|
+
if generic_type is not None:
|
|
679
|
+
fields["generic_type"] = generic_type
|
|
680
|
+
if primary_column is not None:
|
|
681
|
+
fields["primary_column"] = primary_column
|
|
595
682
|
return fields
|
|
596
683
|
|
|
597
684
|
|
|
@@ -640,6 +727,9 @@ async def _upload_assets_async(
|
|
|
640
727
|
path: str | None,
|
|
641
728
|
tags: list[str] | None,
|
|
642
729
|
custom_metadata: dict[str, Any] | None,
|
|
730
|
+
defer_timeseries_configuration: bool,
|
|
731
|
+
generic_type: str | None,
|
|
732
|
+
primary_column: str | None,
|
|
643
733
|
show_progress: bool,
|
|
644
734
|
) -> tuple[UploadResult, str, str]:
|
|
645
735
|
failures: list[UploadFailure] = []
|
|
@@ -668,6 +758,9 @@ async def _upload_assets_async(
|
|
|
668
758
|
path=path,
|
|
669
759
|
tags=tags,
|
|
670
760
|
custom_metadata=custom_metadata,
|
|
761
|
+
defer_timeseries_configuration=defer_timeseries_configuration,
|
|
762
|
+
generic_type=generic_type,
|
|
763
|
+
primary_column=primary_column,
|
|
671
764
|
),
|
|
672
765
|
tiled_endpoint="/api/sdk/data-assets/tiled-uploads",
|
|
673
766
|
complete_fields=_asset_complete_data(
|
|
@@ -708,6 +801,9 @@ async def _upload_assets_async(
|
|
|
708
801
|
path=path,
|
|
709
802
|
tags=tags,
|
|
710
803
|
custom_metadata=custom_metadata,
|
|
804
|
+
defer_timeseries_configuration=defer_timeseries_configuration,
|
|
805
|
+
generic_type=generic_type,
|
|
806
|
+
primary_column=primary_column,
|
|
711
807
|
),
|
|
712
808
|
tiled_endpoint="/api/sdk/data-assets/tiled-uploads",
|
|
713
809
|
complete_fields=_asset_complete_data(
|
|
@@ -756,6 +852,9 @@ def upload_assets(
|
|
|
756
852
|
path: str | None = None,
|
|
757
853
|
tags: list[str] | None = None,
|
|
758
854
|
custom_metadata: dict[str, Any] | None = None,
|
|
855
|
+
defer_timeseries_configuration: bool = False,
|
|
856
|
+
generic_type: str | None = None,
|
|
857
|
+
primary_column: str | None = None,
|
|
759
858
|
show_progress: bool = True,
|
|
760
859
|
) -> tuple[UploadResult, str, str]:
|
|
761
860
|
if folder and folder_id:
|
|
@@ -774,6 +873,9 @@ def upload_assets(
|
|
|
774
873
|
path=path,
|
|
775
874
|
tags=tags,
|
|
776
875
|
custom_metadata=custom_metadata,
|
|
876
|
+
defer_timeseries_configuration=defer_timeseries_configuration,
|
|
877
|
+
generic_type=generic_type,
|
|
878
|
+
primary_column=primary_column,
|
|
777
879
|
show_progress=show_progress,
|
|
778
880
|
)
|
|
779
881
|
)
|
|
@@ -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
|
|
|
@@ -26,6 +26,9 @@ class AssetsNamespace(Namespace):
|
|
|
26
26
|
path: str | None = None,
|
|
27
27
|
tags: list[str] | None = None,
|
|
28
28
|
custom_metadata: dict[str, Any] | None = None,
|
|
29
|
+
defer_timeseries_configuration: bool = False,
|
|
30
|
+
generic_type: str | None = None,
|
|
31
|
+
primary_column: str | None = None,
|
|
29
32
|
) -> AssetUploadResult:
|
|
30
33
|
"""Upload local files into workspace Assets.
|
|
31
34
|
|
|
@@ -36,6 +39,12 @@ class AssetsNamespace(Namespace):
|
|
|
36
39
|
path: Logical path created below the destination folder.
|
|
37
40
|
tags: Tags applied to uploaded Assets.
|
|
38
41
|
custom_metadata: User-owned JSON metadata applied to every upload.
|
|
42
|
+
defer_timeseries_configuration: Keep the legacy deferred chart flow
|
|
43
|
+
when ``generic_type="timeseries"`` is explicitly selected.
|
|
44
|
+
generic_type: Explicit upload family, such as ``tabular`` for CSV
|
|
45
|
+
row mode. Omitted CSV Assets remain family-neutral until they
|
|
46
|
+
are attached to a project.
|
|
47
|
+
primary_column: Preferred row-mode CSV text column.
|
|
39
48
|
|
|
40
49
|
Returns:
|
|
41
50
|
Successful Assets and per-file failures without discarding partial
|
|
@@ -49,6 +58,9 @@ class AssetsNamespace(Namespace):
|
|
|
49
58
|
path=path,
|
|
50
59
|
tags=tags,
|
|
51
60
|
custom_metadata=custom_metadata,
|
|
61
|
+
defer_timeseries_configuration=defer_timeseries_configuration,
|
|
62
|
+
generic_type=generic_type,
|
|
63
|
+
primary_column=primary_column,
|
|
52
64
|
show_progress=sys.stderr.isatty(),
|
|
53
65
|
)
|
|
54
66
|
assets = [
|
|
@@ -269,9 +281,7 @@ class Asset:
|
|
|
269
281
|
)
|
|
270
282
|
|
|
271
283
|
def status(self) -> ProcessingStatus:
|
|
272
|
-
raw = self._client._api.get(
|
|
273
|
-
f"/api/sdk/data-assets/assets/{self.id}/tiled-status/"
|
|
274
|
-
)
|
|
284
|
+
raw = self._client._api.get(f"/api/sdk/data-assets/assets/{self.id}/status/")
|
|
275
285
|
self.upload_status = str(raw.get("upload_status", self.upload_status))
|
|
276
286
|
return ProcessingStatus._from_raw(raw)
|
|
277
287
|
|
|
@@ -284,7 +294,7 @@ class Asset:
|
|
|
284
294
|
) -> ProcessingStatus:
|
|
285
295
|
status = wait_for_status(
|
|
286
296
|
self._client._api,
|
|
287
|
-
f"/api/sdk/data-assets/assets/{self.id}/
|
|
297
|
+
f"/api/sdk/data-assets/assets/{self.id}/status/",
|
|
288
298
|
resource_name=f"Asset {self.id}",
|
|
289
299
|
timeout=timeout,
|
|
290
300
|
on_progress=on_progress,
|
|
@@ -302,6 +312,19 @@ class Asset:
|
|
|
302
312
|
self.upload_status = str(raw.get("status", "processing"))
|
|
303
313
|
return raw
|
|
304
314
|
|
|
315
|
+
def configure_timeseries(
|
|
316
|
+
self,
|
|
317
|
+
*,
|
|
318
|
+
x_column: str,
|
|
319
|
+
channels: list[str],
|
|
320
|
+
) -> dict[str, Any]:
|
|
321
|
+
raw = self._client._api.post(
|
|
322
|
+
f"/api/sdk/data-assets/assets/{self.id}/timeseries-configuration/",
|
|
323
|
+
json={"x_column": x_column, "channels": channels},
|
|
324
|
+
)
|
|
325
|
+
self.upload_status = str(raw.get("upload_status", self.upload_status))
|
|
326
|
+
return raw
|
|
327
|
+
|
|
305
328
|
def update_custom_metadata(
|
|
306
329
|
self,
|
|
307
330
|
custom_metadata: dict[str, Any] | None,
|
|
@@ -256,6 +256,8 @@ class Project:
|
|
|
256
256
|
source: str | Path,
|
|
257
257
|
*,
|
|
258
258
|
fps: float = 1.0,
|
|
259
|
+
generic_type: str | None = None,
|
|
260
|
+
primary_column: str | None = None,
|
|
259
261
|
batch_size: int = 100,
|
|
260
262
|
) -> UploadBatch:
|
|
261
263
|
"""Upload local files into this Project.
|
|
@@ -263,17 +265,25 @@ class Project:
|
|
|
263
265
|
Args:
|
|
264
266
|
source: File or directory to upload.
|
|
265
267
|
fps: Frame rate used for uploaded video.
|
|
268
|
+
generic_type: Explicit upload family, such as ``tabular`` for CSV
|
|
269
|
+
row mode. Omitted CSV uploads remain pending until configured
|
|
270
|
+
in the target project.
|
|
271
|
+
primary_column: Preferred row-mode CSV text column.
|
|
266
272
|
batch_size: Files scheduled per local upload batch.
|
|
267
273
|
|
|
268
274
|
Returns:
|
|
269
275
|
Local upload results and the server-side Batch Queue ID. Server
|
|
270
|
-
processing may still be running.
|
|
276
|
+
processing may still be running. Declared ``tabular`` uploads are
|
|
277
|
+
accepted asynchronously and their datasource IDs are returned
|
|
278
|
+
immediately.
|
|
271
279
|
"""
|
|
272
280
|
result, session_id = _uploader.upload_project(
|
|
273
281
|
self._client._api,
|
|
274
282
|
self.id,
|
|
275
283
|
source,
|
|
276
284
|
fps=fps,
|
|
285
|
+
generic_type=generic_type,
|
|
286
|
+
primary_column=primary_column,
|
|
277
287
|
batch_size=batch_size,
|
|
278
288
|
show_progress=sys.stderr.isatty(),
|
|
279
289
|
)
|