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.
Files changed (46) hide show
  1. {unitlab-3.0.5 → unitlab-3.1.1}/PKG-INFO +68 -2
  2. {unitlab-3.0.5 → unitlab-3.1.1}/README.md +66 -0
  3. {unitlab-3.0.5 → unitlab-3.1.1}/pyproject.toml +2 -2
  4. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/__init__.py +4 -0
  5. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/_downloader.py +4 -2
  6. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/_http.py +34 -4
  7. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/_scene_uploader.py +5 -3
  8. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/_uploader.py +15 -6
  9. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/_waiter.py +7 -5
  10. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/exceptions.py +38 -0
  11. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/resources/__init__.py +2 -0
  12. unitlab-3.1.1/src/unitlab/resources/annotation_imports.py +222 -0
  13. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/resources/projects.py +12 -0
  14. unitlab-3.1.1/tests/test_annotation_imports.py +326 -0
  15. {unitlab-3.0.5 → unitlab-3.1.1}/tests/test_client.py +78 -0
  16. unitlab-3.1.1/tests/test_http.py +243 -0
  17. {unitlab-3.0.5 → unitlab-3.1.1}/tests/test_uploader.py +59 -0
  18. unitlab-3.0.5/tests/test_http.py +0 -99
  19. {unitlab-3.0.5 → unitlab-3.1.1}/.gitignore +0 -0
  20. {unitlab-3.0.5 → unitlab-3.1.1}/LICENSE.md +0 -0
  21. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/__main__.py +0 -0
  22. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/_config.py +0 -0
  23. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/_grouping.py +0 -0
  24. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/_nuscenes.py +0 -0
  25. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/_scenes.py +0 -0
  26. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/cli.py +0 -0
  27. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/client.py +0 -0
  28. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/ontology.py +0 -0
  29. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/py.typed +0 -0
  30. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/resources/_base.py +0 -0
  31. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/resources/assets.py +0 -0
  32. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/resources/cloud.py +0 -0
  33. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/resources/datasets.py +0 -0
  34. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/resources/embeddings.py +0 -0
  35. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/resources/ontologies.py +0 -0
  36. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/resources/releases.py +0 -0
  37. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/resources/workflow.py +0 -0
  38. {unitlab-3.0.5 → unitlab-3.1.1}/src/unitlab/types.py +0 -0
  39. {unitlab-3.0.5 → unitlab-3.1.1}/tests/test_cli.py +0 -0
  40. {unitlab-3.0.5 → unitlab-3.1.1}/tests/test_downloader.py +0 -0
  41. {unitlab-3.0.5 → unitlab-3.1.1}/tests/test_embeddings.py +0 -0
  42. {unitlab-3.0.5 → unitlab-3.1.1}/tests/test_nuscenes.py +0 -0
  43. {unitlab-3.0.5 → unitlab-3.1.1}/tests/test_scene_cli.py +0 -0
  44. {unitlab-3.0.5 → unitlab-3.1.1}/tests/test_scene_uploads.py +0 -0
  45. {unitlab-3.0.5 → unitlab-3.1.1}/tests/test_scenes.py +0 -0
  46. {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.0.5
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.0.5"
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(f"Failed to download release: {exc}", exc) from exc
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
- response = self.client.request(method, endpoint, **kwargs)
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(str(exc), exc) from exc
159
+ raise RequestTimeoutError(error_text(exc), exc) from exc
130
160
  except httpx.HTTPError as exc:
131
- raise NetworkError(str(exc), exc) from exc
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(str(exc)) from exc
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(f"Upload of {file.path} failed: {last_error}", last_error)
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, str(exc)
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(f"Multipart part upload failed: {last_error}", last_error)
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(str(exc)) from exc
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 = str(exc)
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
- chunk_size = max(1, min(UPLOAD_CONCURRENCY, batch_size))
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 (NotFoundError, SubscriptionError):
57
- raise
58
- except (NetworkError, RequestTimeoutError):
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",