unitlab 3.0.5__tar.gz → 3.1.1__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.5 → unitlab-3.1.1}/PKG-INFO +68 -2
- {unitlab-3.0.5 → unitlab-3.1.1}/README.md +66 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/pyproject.toml +2 -2
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/__init__.py +4 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/_downloader.py +4 -2
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/_http.py +34 -4
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/_scene_uploader.py +5 -3
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/_uploader.py +15 -6
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/_waiter.py +7 -5
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/exceptions.py +38 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/resources/__init__.py +2 -0
- unitlab-3.1.1/src/unitlab/resources/annotation_imports.py +222 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/resources/projects.py +12 -0
- unitlab-3.1.1/tests/test_annotation_imports.py +326 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/tests/test_client.py +78 -0
- unitlab-3.1.1/tests/test_http.py +243 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/tests/test_uploader.py +59 -0
- unitlab-3.0.5/tests/test_http.py +0 -99
- {unitlab-3.0.5 → unitlab-3.1.1}/.gitignore +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/LICENSE.md +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/__main__.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/_config.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/_grouping.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/_nuscenes.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/_scenes.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/cli.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/client.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/ontology.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/py.typed +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/resources/_base.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/resources/assets.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/resources/cloud.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/resources/datasets.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/resources/embeddings.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/resources/ontologies.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/resources/releases.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/resources/workflow.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/types.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/tests/test_cli.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/tests/test_downloader.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/tests/test_embeddings.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/tests/test_nuscenes.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/tests/test_scene_cli.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/tests/test_scene_uploads.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/tests/test_scenes.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.1}/tests/test_tiled_uploads.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: unitlab
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.1.1
|
|
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
|
|
@@ -19,7 +19,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.14
|
|
20
20
|
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
21
|
Requires-Python: >=3.10
|
|
22
|
-
Requires-Dist: httpx>=0.27
|
|
22
|
+
Requires-Dist: httpx[socks]>=0.27
|
|
23
23
|
Requires-Dist: tqdm>=4.60
|
|
24
24
|
Requires-Dist: typer>=0.9
|
|
25
25
|
Provides-Extra: dev
|
|
@@ -416,6 +416,72 @@ batch = project.import_cloud(
|
|
|
416
416
|
status = batch.wait()
|
|
417
417
|
```
|
|
418
418
|
|
|
419
|
+
### Import annotation JSON
|
|
420
|
+
|
|
421
|
+
Media can come from S3 while the annotation JSON stays on your computer:
|
|
422
|
+
|
|
423
|
+
```python
|
|
424
|
+
project = client.projects.get("PROJECT_ID")
|
|
425
|
+
media = project.import_cloud("CLOUD_STORAGE_ID", ["images/"])
|
|
426
|
+
status = media.wait()
|
|
427
|
+
if status.failed:
|
|
428
|
+
raise RuntimeError("Some media failed processing; inspect the Batch Queue.")
|
|
429
|
+
|
|
430
|
+
batch = project.annotation_imports.create(
|
|
431
|
+
"./predictions.json",
|
|
432
|
+
source_name="my-model", # Reuse this namespace for the same prediction source.
|
|
433
|
+
)
|
|
434
|
+
print(batch.preview["counts"], batch.preview["issues"])
|
|
435
|
+
# Inspect the preview before saving labels. Approval is a separate review step.
|
|
436
|
+
report = batch.commit()
|
|
437
|
+
print(report)
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Supported inputs are COCO JSON, Unitlab exports (UUEF item JSON, Audio JSON and
|
|
441
|
+
GeoJSON, as `.json` or `.geojson`), `unitlab-image/1` and `unitlab-video/1`. They
|
|
442
|
+
cover image, video, medical, document, audio, time series, text, HTML, geospatial
|
|
443
|
+
and pathology items. A UUEF file holds one item: import each file as its own batch.
|
|
444
|
+
Each JSON file is limited to 600 MiB. The backend matches original media paths/IDs,
|
|
445
|
+
with a unique-filename fallback; ambiguous names require an explicit mapping.
|
|
446
|
+
YOLO text/ZIP files and other tools' project files are not accepted by this importer.
|
|
447
|
+
|
|
448
|
+
For COCO video, pass `frame_mapping_file="./frames.json"` in
|
|
449
|
+
`unitlab-frame-map/1` format. Use `track_profile="attributes.track_id"` only for
|
|
450
|
+
COCO exports with that explicit track-ID convention. Import video media at its
|
|
451
|
+
original frame rate; annotations for frames omitted by sampling are rejected.
|
|
452
|
+
|
|
453
|
+
`project.annotation_imports.sources()` lists saved prediction sources, and
|
|
454
|
+
`project.annotation_imports.media(search="road.jpg")` finds media IDs for mappings.
|
|
455
|
+
Pass `source_id=` instead of `source_name=` to reuse a source by ID. Set
|
|
456
|
+
`options={"stable_ids": True}` only when your exporter keeps annotation IDs stable
|
|
457
|
+
between prediction runs. Otherwise the default protects existing objects from
|
|
458
|
+
unconfirmed ID reuse.
|
|
459
|
+
|
|
460
|
+
After processing completes or you supply mappings, call `batch.refresh()` to
|
|
461
|
+
rebuild the preview. `batch.refresh(options={...})` replaces all import options;
|
|
462
|
+
omitting `options` preserves them. Supported options are `stable_ids`,
|
|
463
|
+
`media_mapping`, `class_mapping`, `object_mapping`, and `review_mapping`.
|
|
464
|
+
|
|
465
|
+
`batch.commit()` preserves conflicts and human deletions by default. To resolve
|
|
466
|
+
a specific preview annotation, pass `decisions={annotation_key: "keep"}` (or
|
|
467
|
+
`"incoming"` / `"skip"`). Invalid records prevent the whole commit unless you
|
|
468
|
+
explicitly choose `allow_partial=True`. The report includes created, updated,
|
|
469
|
+
kept, unchanged and skipped counts when applicable. A stale preview raises
|
|
470
|
+
`ConflictError`; inspect a refreshed preview before committing again. If a
|
|
471
|
+
commit response is lost, repeat the same batch's `commit()` with the same
|
|
472
|
+
decisions; the server returns the saved report without duplicating labels.
|
|
473
|
+
|
|
474
|
+
For annotations already in connected storage, omit the local file and pass
|
|
475
|
+
`cloud_storage=storage`, `annotation_key="labels/predictions.json"`, and optionally
|
|
476
|
+
`frame_mapping_key="labels/frames.json"`. Keys are relative to that connection's
|
|
477
|
+
prefix. Media and annotations must resolve within the API key's workspace and
|
|
478
|
+
the selected project.
|
|
479
|
+
|
|
480
|
+
This requires a backend with the `/api/sdk/projects/{id}/annotation-imports/`
|
|
481
|
+
routes and the annotation-import database migration installed. Commit requires
|
|
482
|
+
a user-created API key so saved labels have an audit author. Legacy keys without
|
|
483
|
+
a creator can preview but receive `PermissionDeniedError` when committing.
|
|
484
|
+
|
|
419
485
|
### Data Groups
|
|
420
486
|
|
|
421
487
|
Let Unitlab suggest a filename pattern:
|
|
@@ -383,6 +383,72 @@ batch = project.import_cloud(
|
|
|
383
383
|
status = batch.wait()
|
|
384
384
|
```
|
|
385
385
|
|
|
386
|
+
### Import annotation JSON
|
|
387
|
+
|
|
388
|
+
Media can come from S3 while the annotation JSON stays on your computer:
|
|
389
|
+
|
|
390
|
+
```python
|
|
391
|
+
project = client.projects.get("PROJECT_ID")
|
|
392
|
+
media = project.import_cloud("CLOUD_STORAGE_ID", ["images/"])
|
|
393
|
+
status = media.wait()
|
|
394
|
+
if status.failed:
|
|
395
|
+
raise RuntimeError("Some media failed processing; inspect the Batch Queue.")
|
|
396
|
+
|
|
397
|
+
batch = project.annotation_imports.create(
|
|
398
|
+
"./predictions.json",
|
|
399
|
+
source_name="my-model", # Reuse this namespace for the same prediction source.
|
|
400
|
+
)
|
|
401
|
+
print(batch.preview["counts"], batch.preview["issues"])
|
|
402
|
+
# Inspect the preview before saving labels. Approval is a separate review step.
|
|
403
|
+
report = batch.commit()
|
|
404
|
+
print(report)
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
Supported inputs are COCO JSON, Unitlab exports (UUEF item JSON, Audio JSON and
|
|
408
|
+
GeoJSON, as `.json` or `.geojson`), `unitlab-image/1` and `unitlab-video/1`. They
|
|
409
|
+
cover image, video, medical, document, audio, time series, text, HTML, geospatial
|
|
410
|
+
and pathology items. A UUEF file holds one item: import each file as its own batch.
|
|
411
|
+
Each JSON file is limited to 600 MiB. The backend matches original media paths/IDs,
|
|
412
|
+
with a unique-filename fallback; ambiguous names require an explicit mapping.
|
|
413
|
+
YOLO text/ZIP files and other tools' project files are not accepted by this importer.
|
|
414
|
+
|
|
415
|
+
For COCO video, pass `frame_mapping_file="./frames.json"` in
|
|
416
|
+
`unitlab-frame-map/1` format. Use `track_profile="attributes.track_id"` only for
|
|
417
|
+
COCO exports with that explicit track-ID convention. Import video media at its
|
|
418
|
+
original frame rate; annotations for frames omitted by sampling are rejected.
|
|
419
|
+
|
|
420
|
+
`project.annotation_imports.sources()` lists saved prediction sources, and
|
|
421
|
+
`project.annotation_imports.media(search="road.jpg")` finds media IDs for mappings.
|
|
422
|
+
Pass `source_id=` instead of `source_name=` to reuse a source by ID. Set
|
|
423
|
+
`options={"stable_ids": True}` only when your exporter keeps annotation IDs stable
|
|
424
|
+
between prediction runs. Otherwise the default protects existing objects from
|
|
425
|
+
unconfirmed ID reuse.
|
|
426
|
+
|
|
427
|
+
After processing completes or you supply mappings, call `batch.refresh()` to
|
|
428
|
+
rebuild the preview. `batch.refresh(options={...})` replaces all import options;
|
|
429
|
+
omitting `options` preserves them. Supported options are `stable_ids`,
|
|
430
|
+
`media_mapping`, `class_mapping`, `object_mapping`, and `review_mapping`.
|
|
431
|
+
|
|
432
|
+
`batch.commit()` preserves conflicts and human deletions by default. To resolve
|
|
433
|
+
a specific preview annotation, pass `decisions={annotation_key: "keep"}` (or
|
|
434
|
+
`"incoming"` / `"skip"`). Invalid records prevent the whole commit unless you
|
|
435
|
+
explicitly choose `allow_partial=True`. The report includes created, updated,
|
|
436
|
+
kept, unchanged and skipped counts when applicable. A stale preview raises
|
|
437
|
+
`ConflictError`; inspect a refreshed preview before committing again. If a
|
|
438
|
+
commit response is lost, repeat the same batch's `commit()` with the same
|
|
439
|
+
decisions; the server returns the saved report without duplicating labels.
|
|
440
|
+
|
|
441
|
+
For annotations already in connected storage, omit the local file and pass
|
|
442
|
+
`cloud_storage=storage`, `annotation_key="labels/predictions.json"`, and optionally
|
|
443
|
+
`frame_mapping_key="labels/frames.json"`. Keys are relative to that connection's
|
|
444
|
+
prefix. Media and annotations must resolve within the API key's workspace and
|
|
445
|
+
the selected project.
|
|
446
|
+
|
|
447
|
+
This requires a backend with the `/api/sdk/projects/{id}/annotation-imports/`
|
|
448
|
+
routes and the annotation-import database migration installed. Commit requires
|
|
449
|
+
a user-created API key so saved labels have an audit author. Legacy keys without
|
|
450
|
+
a creator can preview but receive `PermissionDeniedError` when committing.
|
|
451
|
+
|
|
386
452
|
### Data Groups
|
|
387
453
|
|
|
388
454
|
Let Unitlab suggest a filename pattern:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "unitlab"
|
|
7
|
-
version = "3.
|
|
7
|
+
version = "3.1.1"
|
|
8
8
|
description = "Python SDK for the Unitlab.ai data annotation platform"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -23,7 +23,7 @@ classifiers = [
|
|
|
23
23
|
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
24
24
|
]
|
|
25
25
|
dependencies = [
|
|
26
|
-
"httpx>=0.27",
|
|
26
|
+
"httpx[socks]>=0.27",
|
|
27
27
|
"tqdm>=4.60",
|
|
28
28
|
"typer>=0.9",
|
|
29
29
|
]
|
|
@@ -5,6 +5,7 @@ from ._scene_uploader import detect_scenes
|
|
|
5
5
|
from .client import UnitlabClient
|
|
6
6
|
from .exceptions import (
|
|
7
7
|
AmbiguousUploadCompletionError,
|
|
8
|
+
AnnotationImportError,
|
|
8
9
|
AuthenticationError,
|
|
9
10
|
ConflictError,
|
|
10
11
|
NetworkError,
|
|
@@ -27,6 +28,7 @@ from .ontology import (
|
|
|
27
28
|
TextAttribute,
|
|
28
29
|
)
|
|
29
30
|
from .resources import (
|
|
31
|
+
AnnotationImport,
|
|
30
32
|
Asset,
|
|
31
33
|
AttachedSource,
|
|
32
34
|
BatchQueue,
|
|
@@ -59,6 +61,8 @@ __version__ = version("unitlab")
|
|
|
59
61
|
|
|
60
62
|
__all__ = [
|
|
61
63
|
"AmbiguousUploadCompletionError",
|
|
64
|
+
"AnnotationImport",
|
|
65
|
+
"AnnotationImportError",
|
|
62
66
|
"Asset",
|
|
63
67
|
"AssetUploadResult",
|
|
64
68
|
"AttachPreview",
|
|
@@ -8,7 +8,7 @@ from urllib.parse import urlparse
|
|
|
8
8
|
import httpx
|
|
9
9
|
import tqdm
|
|
10
10
|
|
|
11
|
-
from ._http import _safe_path
|
|
11
|
+
from ._http import _safe_path, error_text
|
|
12
12
|
from ._uploader import run_sync
|
|
13
13
|
from .exceptions import NetworkError
|
|
14
14
|
|
|
@@ -45,7 +45,9 @@ def download_annotation(
|
|
|
45
45
|
except (httpx.HTTPError, OSError) as exc:
|
|
46
46
|
if partial.exists():
|
|
47
47
|
partial.unlink()
|
|
48
|
-
raise NetworkError(
|
|
48
|
+
raise NetworkError(
|
|
49
|
+
f"Failed to download release: {error_text(exc)}", exc
|
|
50
|
+
) from exc
|
|
49
51
|
return str(target.resolve())
|
|
50
52
|
|
|
51
53
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import time
|
|
4
5
|
from typing import Any
|
|
5
6
|
|
|
6
7
|
import httpx
|
|
@@ -16,6 +17,11 @@ from .exceptions import (
|
|
|
16
17
|
ValidationError,
|
|
17
18
|
)
|
|
18
19
|
|
|
20
|
+
# Match HTTPTransport(retries=3): one initial attempt, then three retries for
|
|
21
|
+
# connection failures only. Leave transport construction to HTTPX so that its
|
|
22
|
+
# standard HTTP_PROXY/HTTPS_PROXY/ALL_PROXY and NO_PROXY handling stays active.
|
|
23
|
+
_CONNECT_RETRY_DELAYS = (0.0, 0.5, 1.0)
|
|
24
|
+
|
|
19
25
|
|
|
20
26
|
def _extract_error_message(response: httpx.Response) -> str:
|
|
21
27
|
try:
|
|
@@ -35,6 +41,9 @@ def _extract_error_message(response: httpx.Response) -> str:
|
|
|
35
41
|
parts.append(f"{field}: {value}")
|
|
36
42
|
if parts:
|
|
37
43
|
return "; ".join(parts)
|
|
44
|
+
if isinstance(body, list):
|
|
45
|
+
# DRF serializes ValidationError("...") raised in a view as ["..."].
|
|
46
|
+
return "; ".join(str(item) for item in body)
|
|
38
47
|
return str(body)
|
|
39
48
|
|
|
40
49
|
|
|
@@ -48,6 +57,21 @@ def _extract_error_code(response: httpx.Response) -> str:
|
|
|
48
57
|
return ""
|
|
49
58
|
|
|
50
59
|
|
|
60
|
+
def error_text(exc: BaseException) -> str:
|
|
61
|
+
"""``str(exc)``, or a readable stand-in when it is empty.
|
|
62
|
+
|
|
63
|
+
httpx leaves ReadTimeout, ReadError, RemoteProtocolError and ConnectError
|
|
64
|
+
without a message when the server resets or never answers; an empty
|
|
65
|
+
string then reached users as a misleading follow-up error.
|
|
66
|
+
"""
|
|
67
|
+
text = str(exc).strip()
|
|
68
|
+
if text:
|
|
69
|
+
return text
|
|
70
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
71
|
+
return f"{type(exc).__name__}: no answer before the request timed out"
|
|
72
|
+
return f"{type(exc).__name__}: the connection closed without a response"
|
|
73
|
+
|
|
74
|
+
|
|
51
75
|
def raise_for_response(response: httpx.Response) -> None:
|
|
52
76
|
if response.is_success:
|
|
53
77
|
return
|
|
@@ -90,7 +114,6 @@ class HttpApi:
|
|
|
90
114
|
self.client = httpx.Client(
|
|
91
115
|
base_url=api_url,
|
|
92
116
|
headers={"Authorization": f"Api-Key {api_key}"},
|
|
93
|
-
transport=httpx.HTTPTransport(retries=3),
|
|
94
117
|
timeout=60.0,
|
|
95
118
|
)
|
|
96
119
|
|
|
@@ -124,11 +147,18 @@ class HttpApi:
|
|
|
124
147
|
}
|
|
125
148
|
if timeout is not None:
|
|
126
149
|
kwargs["timeout"] = timeout
|
|
127
|
-
|
|
150
|
+
for attempt in range(len(_CONNECT_RETRY_DELAYS) + 1):
|
|
151
|
+
try:
|
|
152
|
+
response = self.client.request(method, endpoint, **kwargs)
|
|
153
|
+
break
|
|
154
|
+
except (httpx.ConnectError, httpx.ConnectTimeout):
|
|
155
|
+
if attempt == len(_CONNECT_RETRY_DELAYS):
|
|
156
|
+
raise
|
|
157
|
+
time.sleep(_CONNECT_RETRY_DELAYS[attempt])
|
|
128
158
|
except httpx.TimeoutException as exc:
|
|
129
|
-
raise RequestTimeoutError(
|
|
159
|
+
raise RequestTimeoutError(error_text(exc), exc) from exc
|
|
130
160
|
except httpx.HTTPError as exc:
|
|
131
|
-
raise NetworkError(
|
|
161
|
+
raise NetworkError(error_text(exc), exc) from exc
|
|
132
162
|
raise_for_response(response)
|
|
133
163
|
if not response.content:
|
|
134
164
|
return None
|
|
@@ -43,7 +43,7 @@ import httpx
|
|
|
43
43
|
import tqdm
|
|
44
44
|
|
|
45
45
|
from . import _uploader
|
|
46
|
-
from ._http import _extract_error_message, raise_for_response
|
|
46
|
+
from ._http import _extract_error_message, error_text, raise_for_response
|
|
47
47
|
from ._scenes import (
|
|
48
48
|
Scene,
|
|
49
49
|
SceneDetection,
|
|
@@ -221,7 +221,7 @@ async def _api_post(
|
|
|
221
221
|
try:
|
|
222
222
|
response = await client.post(url, json=body)
|
|
223
223
|
except (httpx.HTTPError, httpx.TimeoutException) as exc:
|
|
224
|
-
raise _SceneRefusedError(
|
|
224
|
+
raise _SceneRefusedError(error_text(exc)) from exc
|
|
225
225
|
if response.status_code in (401, 403):
|
|
226
226
|
raise_for_response(response)
|
|
227
227
|
if response.status_code == 404:
|
|
@@ -306,7 +306,9 @@ async def _put_scene_file(
|
|
|
306
306
|
await asyncio.sleep(
|
|
307
307
|
_uploader.TILED_PART_RETRY_DELAY_SECONDS * (attempt + 1)
|
|
308
308
|
)
|
|
309
|
-
raise NetworkError(
|
|
309
|
+
raise NetworkError(
|
|
310
|
+
f"Upload of {file.path} failed: {error_text(last_error)}", last_error
|
|
311
|
+
)
|
|
310
312
|
|
|
311
313
|
|
|
312
314
|
async def _upload_scene_folder(
|
|
@@ -12,7 +12,7 @@ from typing import Any
|
|
|
12
12
|
import httpx
|
|
13
13
|
import tqdm
|
|
14
14
|
|
|
15
|
-
from ._http import _extract_error_message, raise_for_response
|
|
15
|
+
from ._http import _extract_error_message, error_text, raise_for_response
|
|
16
16
|
from .exceptions import (
|
|
17
17
|
AmbiguousUploadCompletionError,
|
|
18
18
|
NetworkError,
|
|
@@ -200,7 +200,7 @@ async def _post_file(
|
|
|
200
200
|
except SubscriptionError:
|
|
201
201
|
raise
|
|
202
202
|
except (httpx.TransportError, httpx.TimeoutException) as exc:
|
|
203
|
-
return None,
|
|
203
|
+
return None, error_text(exc)
|
|
204
204
|
except ValueError:
|
|
205
205
|
return None, "Unexpected response format"
|
|
206
206
|
|
|
@@ -297,7 +297,9 @@ async def _put_tiled_part(
|
|
|
297
297
|
last_error = exc
|
|
298
298
|
if attempt + 1 < TILED_PART_MAX_ATTEMPTS:
|
|
299
299
|
await asyncio.sleep(TILED_PART_RETRY_DELAY_SECONDS * (attempt + 1))
|
|
300
|
-
raise NetworkError(
|
|
300
|
+
raise NetworkError(
|
|
301
|
+
f"Multipart part upload failed: {error_text(last_error)}", last_error
|
|
302
|
+
)
|
|
301
303
|
|
|
302
304
|
|
|
303
305
|
async def _abort_tiled_upload(
|
|
@@ -332,7 +334,7 @@ async def _upload_tiled_file(
|
|
|
332
334
|
try:
|
|
333
335
|
response = await api_client.post(f"{endpoint}/initiate/", json=initiate)
|
|
334
336
|
except (httpx.HTTPError, httpx.TimeoutException) as exc:
|
|
335
|
-
raise _TiledUploadRejectedError(
|
|
337
|
+
raise _TiledUploadRejectedError(error_text(exc)) from exc
|
|
336
338
|
if _can_fallback_from_initiate(response):
|
|
337
339
|
raise _FallbackToSimpleUploadError(_extract_error_message(response))
|
|
338
340
|
if response.status_code in (401, 403):
|
|
@@ -549,7 +551,7 @@ async def _finalize_medical_upload(client, endpoint: str, batch_queue_id: str) -
|
|
|
549
551
|
raise_for_response(response)
|
|
550
552
|
return
|
|
551
553
|
except (httpx.TransportError, httpx.TimeoutException) as exc:
|
|
552
|
-
last_error =
|
|
554
|
+
last_error = error_text(exc)
|
|
553
555
|
if attempt < RETRY_COUNT:
|
|
554
556
|
await asyncio.sleep(RETRY_DELAY_SECONDS)
|
|
555
557
|
continue
|
|
@@ -620,12 +622,15 @@ async def _upload_project_async(
|
|
|
620
622
|
primary_column: str | None,
|
|
621
623
|
batch_size: int,
|
|
622
624
|
show_progress: bool,
|
|
625
|
+
max_concurrent_uploads: int = UPLOAD_CONCURRENCY,
|
|
623
626
|
) -> UploadResult:
|
|
624
627
|
failures: list[UploadFailure] = []
|
|
625
628
|
responses: list[dict[str, Any]] = []
|
|
626
629
|
successful_medical = False
|
|
627
630
|
batch_error: Exception | None = None
|
|
628
|
-
|
|
631
|
+
# Files in flight at once; a slow server (one local web process) answers
|
|
632
|
+
# each upload in seconds, so fewer at a time avoids long queues.
|
|
633
|
+
chunk_size = max(1, min(max_concurrent_uploads, batch_size))
|
|
629
634
|
progress = tqdm.tqdm(total=len(files), ncols=80, disable=not show_progress)
|
|
630
635
|
try:
|
|
631
636
|
part_semaphore = asyncio.Semaphore(TILED_PART_CONCURRENCY)
|
|
@@ -740,9 +745,12 @@ def upload_project(
|
|
|
740
745
|
primary_column: str | None = None,
|
|
741
746
|
batch_size: int = 100,
|
|
742
747
|
show_progress: bool = True,
|
|
748
|
+
max_concurrent_uploads: int = UPLOAD_CONCURRENCY,
|
|
743
749
|
) -> tuple[UploadResult, str]:
|
|
744
750
|
if batch_size < 1:
|
|
745
751
|
raise ValueError("batch_size must be at least 1")
|
|
752
|
+
if max_concurrent_uploads < 1:
|
|
753
|
+
raise ValueError("max_concurrent_uploads must be at least 1")
|
|
746
754
|
upload_info = api.get(f"/api/sdk/projects/{project_id}/upload-info/")
|
|
747
755
|
files, preflight_failures = _project_files(source, upload_info, generic_type)
|
|
748
756
|
if not files and not preflight_failures:
|
|
@@ -769,6 +777,7 @@ def upload_project(
|
|
|
769
777
|
primary_column=primary_column,
|
|
770
778
|
batch_size=batch_size,
|
|
771
779
|
show_progress=show_progress,
|
|
780
|
+
max_concurrent_uploads=max_concurrent_uploads,
|
|
772
781
|
)
|
|
773
782
|
)
|
|
774
783
|
result.total += len(preflight_failures)
|
|
@@ -8,10 +8,8 @@ import tqdm
|
|
|
8
8
|
|
|
9
9
|
from .exceptions import (
|
|
10
10
|
NetworkError,
|
|
11
|
-
NotFoundError,
|
|
12
11
|
ProcessingTimeoutError,
|
|
13
12
|
RequestTimeoutError,
|
|
14
|
-
SubscriptionError,
|
|
15
13
|
)
|
|
16
14
|
from .types import ProcessingStatus
|
|
17
15
|
|
|
@@ -53,9 +51,13 @@ def wait_for_status(
|
|
|
53
51
|
progress.refresh()
|
|
54
52
|
if status.processing == 0:
|
|
55
53
|
return status
|
|
56
|
-
except (
|
|
57
|
-
|
|
58
|
-
|
|
54
|
+
except (NetworkError, RequestTimeoutError) as exc:
|
|
55
|
+
# A NetworkError subclass is an answer (permission refused,
|
|
56
|
+
# invalid, not found, conflict, billing) and is raised at once,
|
|
57
|
+
# as in the uploaders. Only a lost answer (timeout, dropped
|
|
58
|
+
# connection) or a server error is asked again.
|
|
59
|
+
if isinstance(exc, NetworkError) and type(exc) is not NetworkError:
|
|
60
|
+
raise
|
|
59
61
|
transient_errors += 1
|
|
60
62
|
if transient_errors > 3:
|
|
61
63
|
raise
|
|
@@ -65,5 +65,43 @@ class ProcessingTimeoutError(TimeoutError):
|
|
|
65
65
|
self.status = status
|
|
66
66
|
|
|
67
67
|
|
|
68
|
+
class AnnotationImportError(UnitlabError):
|
|
69
|
+
"""Raised when an annotation import cannot be written as previewed.
|
|
70
|
+
|
|
71
|
+
``rows`` lists the blocked annotations (``key``, ``external_key``,
|
|
72
|
+
``media_key``, ``reason``) and ``issues`` the file-level problems
|
|
73
|
+
(``row``, ``reason``) of the preview the server checked. ``batch_id``
|
|
74
|
+
is the import batch. Nothing was written when this is raised.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
message: str,
|
|
80
|
+
detail: Exception | None = None,
|
|
81
|
+
code: str = "annotation_import_failed",
|
|
82
|
+
*,
|
|
83
|
+
rows: list | None = None,
|
|
84
|
+
issues: list | None = None,
|
|
85
|
+
batch_id: str | None = None,
|
|
86
|
+
):
|
|
87
|
+
super().__init__(message, detail, code)
|
|
88
|
+
self.rows = rows or []
|
|
89
|
+
self.issues = issues or []
|
|
90
|
+
self.batch_id = batch_id
|
|
91
|
+
|
|
92
|
+
def __str__(self) -> str:
|
|
93
|
+
# Show the first reasons inline: the server's commit message alone
|
|
94
|
+
# ("Resolve blocked records ...") does not say which rows.
|
|
95
|
+
lines = [self.message]
|
|
96
|
+
reasons = [
|
|
97
|
+
f"{row.get('external_key') or row.get('key')}: {row.get('reason')}"
|
|
98
|
+
for row in self.rows
|
|
99
|
+
] + [f"{issue.get('row')}: {issue.get('reason')}" for issue in self.issues]
|
|
100
|
+
lines += [f" - {reason}" for reason in reasons[:5]]
|
|
101
|
+
if len(reasons) > 5:
|
|
102
|
+
lines.append(f" ... and {len(reasons) - 5} more (see .rows / .issues)")
|
|
103
|
+
return "\n".join(lines)
|
|
104
|
+
|
|
105
|
+
|
|
68
106
|
class AmbiguousUploadCompletionError(NetworkError):
|
|
69
107
|
"""Raised when multipart completion may have succeeded server-side."""
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from .annotation_imports import AnnotationImport
|
|
1
2
|
from .assets import Asset, Folder, GroupingResult
|
|
2
3
|
from .cloud import CloudStorage
|
|
3
4
|
from .datasets import Dataset, DatasetItem, DatasetVersion
|
|
@@ -8,6 +9,7 @@ from .releases import Release
|
|
|
8
9
|
from .workflow import Workflow, WorkflowStage, WorkflowTask
|
|
9
10
|
|
|
10
11
|
__all__ = [
|
|
12
|
+
"AnnotationImport",
|
|
11
13
|
"Asset",
|
|
12
14
|
"AttachedSource",
|
|
13
15
|
"BatchQueue",
|