unitlab 3.1.0__tar.gz → 3.1.2__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 (45) hide show
  1. {unitlab-3.1.0 → unitlab-3.1.2}/PKG-INFO +1 -1
  2. {unitlab-3.1.0 → unitlab-3.1.2}/pyproject.toml +1 -1
  3. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/__init__.py +2 -0
  4. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/_downloader.py +4 -2
  5. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/_http.py +20 -2
  6. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/_scene_uploader.py +5 -3
  7. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/_uploader.py +15 -6
  8. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/_waiter.py +7 -5
  9. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/exceptions.py +38 -0
  10. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/resources/annotation_imports.py +51 -11
  11. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/resources/projects.py +5 -0
  12. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/resources/releases.py +20 -0
  13. {unitlab-3.1.0 → unitlab-3.1.2}/tests/test_annotation_imports.py +1 -1
  14. {unitlab-3.1.0 → unitlab-3.1.2}/tests/test_client.py +78 -0
  15. {unitlab-3.1.0 → unitlab-3.1.2}/tests/test_uploader.py +59 -0
  16. {unitlab-3.1.0 → unitlab-3.1.2}/.gitignore +0 -0
  17. {unitlab-3.1.0 → unitlab-3.1.2}/LICENSE.md +0 -0
  18. {unitlab-3.1.0 → unitlab-3.1.2}/README.md +0 -0
  19. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/__main__.py +0 -0
  20. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/_config.py +0 -0
  21. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/_grouping.py +0 -0
  22. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/_nuscenes.py +0 -0
  23. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/_scenes.py +0 -0
  24. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/cli.py +0 -0
  25. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/client.py +0 -0
  26. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/ontology.py +0 -0
  27. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/py.typed +0 -0
  28. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/resources/__init__.py +0 -0
  29. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/resources/_base.py +0 -0
  30. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/resources/assets.py +0 -0
  31. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/resources/cloud.py +0 -0
  32. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/resources/datasets.py +0 -0
  33. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/resources/embeddings.py +0 -0
  34. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/resources/ontologies.py +0 -0
  35. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/resources/workflow.py +0 -0
  36. {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/types.py +0 -0
  37. {unitlab-3.1.0 → unitlab-3.1.2}/tests/test_cli.py +0 -0
  38. {unitlab-3.1.0 → unitlab-3.1.2}/tests/test_downloader.py +0 -0
  39. {unitlab-3.1.0 → unitlab-3.1.2}/tests/test_embeddings.py +0 -0
  40. {unitlab-3.1.0 → unitlab-3.1.2}/tests/test_http.py +0 -0
  41. {unitlab-3.1.0 → unitlab-3.1.2}/tests/test_nuscenes.py +0 -0
  42. {unitlab-3.1.0 → unitlab-3.1.2}/tests/test_scene_cli.py +0 -0
  43. {unitlab-3.1.0 → unitlab-3.1.2}/tests/test_scene_uploads.py +0 -0
  44. {unitlab-3.1.0 → unitlab-3.1.2}/tests/test_scenes.py +0 -0
  45. {unitlab-3.1.0 → unitlab-3.1.2}/tests/test_tiled_uploads.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: unitlab
3
- Version: 3.1.0
3
+ Version: 3.1.2
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "unitlab"
7
- version = "3.1.0"
7
+ version = "3.1.2"
8
8
  description = "Python SDK for the Unitlab.ai data annotation platform"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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,
@@ -61,6 +62,7 @@ __version__ = version("unitlab")
61
62
  __all__ = [
62
63
  "AmbiguousUploadCompletionError",
63
64
  "AnnotationImport",
65
+ "AnnotationImportError",
64
66
  "Asset",
65
67
  "AssetUploadResult",
66
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
 
@@ -41,6 +41,9 @@ def _extract_error_message(response: httpx.Response) -> str:
41
41
  parts.append(f"{field}: {value}")
42
42
  if parts:
43
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)
44
47
  return str(body)
45
48
 
46
49
 
@@ -54,6 +57,21 @@ def _extract_error_code(response: httpx.Response) -> str:
54
57
  return ""
55
58
 
56
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
+
57
75
  def raise_for_response(response: httpx.Response) -> None:
58
76
  if response.is_success:
59
77
  return
@@ -138,9 +156,9 @@ class HttpApi:
138
156
  raise
139
157
  time.sleep(_CONNECT_RETRY_DELAYS[attempt])
140
158
  except httpx.TimeoutException as exc:
141
- raise RequestTimeoutError(str(exc), exc) from exc
159
+ raise RequestTimeoutError(error_text(exc), exc) from exc
142
160
  except httpx.HTTPError as exc:
143
- raise NetworkError(str(exc), exc) from exc
161
+ raise NetworkError(error_text(exc), exc) from exc
144
162
  raise_for_response(response)
145
163
  if not response.content:
146
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."""
@@ -7,12 +7,16 @@ from dataclasses import dataclass, field
7
7
  from pathlib import Path
8
8
  from typing import TYPE_CHECKING, Any, Literal
9
9
 
10
+ from ..exceptions import AnnotationImportError, ValidationError
10
11
  from ._base import Namespace, identifier
11
12
 
12
13
  if TYPE_CHECKING:
13
14
  from unitlab.client import UnitlabClient
14
15
 
15
16
  MAX_FILE_BYTES = 600 * 1024 * 1024
17
+ # Preview and commit re-check every row and item on the server; live runs on
18
+ # large files took over a minute, past the client's 60 s default.
19
+ SERVER_WORK_TIMEOUT = 300
16
20
 
17
21
 
18
22
  class AnnotationImports(Namespace):
@@ -119,9 +123,16 @@ class AnnotationImports(Namespace):
119
123
  files[name] = (path.name, stream, "application/json")
120
124
  if files:
121
125
  data["options"] = json.dumps(import_options, allow_nan=False)
122
- raw = self._api.post(self._endpoint, data=data, files=files)
126
+ raw = self._api.post(
127
+ self._endpoint,
128
+ data=data,
129
+ files=files,
130
+ timeout=SERVER_WORK_TIMEOUT,
131
+ )
123
132
  else:
124
- raw = self._api.post(self._endpoint, json=data)
133
+ raw = self._api.post(
134
+ self._endpoint, json=data, timeout=SERVER_WORK_TIMEOUT
135
+ )
125
136
 
126
137
  return AnnotationImport(
127
138
  id=str(raw["batch_id"]),
@@ -164,19 +175,48 @@ class AnnotationImport:
164
175
  *,
165
176
  decisions: dict[str, Literal["keep", "incoming", "skip"]] | None = None,
166
177
  allow_partial: bool = False,
178
+ timeout: float = SERVER_WORK_TIMEOUT,
167
179
  ) -> dict[str, Any]:
168
180
  """Save this preview as labels; approval remains a separate review step.
169
181
 
170
182
  Conflicts and human deletions default to ``keep``. Blocked records reject
171
183
  the entire commit unless ``allow_partial=True``. A stale preview raises
172
184
  ConflictError without refreshing or overwriting human work. Repeating a
173
- successful commit returns its original report without duplicating labels.
185
+ successful commit returns its original report without duplicating labels,
186
+ so after a timeout or a dropped connection call ``commit`` again with
187
+ the same arguments. ``timeout`` is in seconds (the server checks every
188
+ item again first, which can take over a minute).
189
+
190
+ Raises:
191
+ AnnotationImportError: Blocked records or file issues and
192
+ ``allow_partial=False``; ``rows`` and ``issues`` list them from
193
+ this preview, and nothing was written.
174
194
  """
175
- return self._client._api.post(
176
- f"{self._endpoint}commit/",
177
- json={
178
- "token": self.preview["token"],
179
- "decisions": decisions or {},
180
- "allow_partial": allow_partial,
181
- },
182
- )
195
+ try:
196
+ return self._client._api.post(
197
+ f"{self._endpoint}commit/",
198
+ json={
199
+ "token": self.preview["token"],
200
+ "decisions": decisions or {},
201
+ "allow_partial": allow_partial,
202
+ },
203
+ timeout=timeout,
204
+ )
205
+ except ValidationError as exc:
206
+ # The token matched, so the server judged exactly this preview:
207
+ # name its blocked rows instead of the server's one-line refusal.
208
+ rows = [
209
+ row
210
+ for row in self.preview.get("annotations") or []
211
+ if row.get("status") == "blocked"
212
+ ]
213
+ issues = self.preview.get("issues") or []
214
+ if not (rows or issues):
215
+ raise
216
+ raise AnnotationImportError(
217
+ f"{exc.message} Pass allow_partial=True to skip them.",
218
+ exc,
219
+ rows=rows,
220
+ issues=issues,
221
+ batch_id=self.id,
222
+ ) from exc
@@ -267,6 +267,7 @@ class Project:
267
267
  primary_column: str | None = None,
268
268
  batch_size: int = 100,
269
269
  split_sensor_folders: bool = False,
270
+ max_concurrent_uploads: int = _uploader.UPLOAD_CONCURRENCY,
270
271
  ) -> UploadBatch:
271
272
  """Upload local files into this Project.
272
273
 
@@ -285,6 +286,9 @@ class Project:
285
286
  split_sensor_folders: Lidar scenes only. Read sensor folders that
286
287
  share file names as one scene each instead of one scene with
287
288
  several sensors (see ``unitlab.detect_scenes``).
289
+ max_concurrent_uploads: Files sent at the same time (default 20).
290
+ Lower it for a slow or local server; a file whose request
291
+ times out is reported in ``failed``.
288
292
 
289
293
  Returns:
290
294
  Local upload results and the server-side Batch Queue ID. Server
@@ -316,6 +320,7 @@ class Project:
316
320
  primary_column=primary_column,
317
321
  batch_size=batch_size,
318
322
  show_progress=sys.stderr.isatty(),
323
+ max_concurrent_uploads=max_concurrent_uploads,
319
324
  )
320
325
  batch = UploadBatch(
321
326
  project_id=self.id,
@@ -35,6 +35,7 @@ class ReleasesNamespace(Namespace):
35
35
  data_types=None,
36
36
  bundle_formats: dict[str, str] | None = None,
37
37
  license_id: str | None = None,
38
+ completed_only: bool = False,
38
39
  wait: bool = True,
39
40
  timeout: float = 7200,
40
41
  ) -> Release:
@@ -61,6 +62,11 @@ class ReleasesNamespace(Namespace):
61
62
  includes every available concrete type.
62
63
  bundle_formats: Per-concrete-data-type formats for multimodal bundles.
63
64
  license_id: Optional Dataset license ID.
65
+ completed_only: Only items whose current workflow status is
66
+ Complete (a Data Group by its own status), with their latest
67
+ labels. Raises ``UnitlabError`` (code
68
+ ``completed_only_unsupported``) if the server does not know the
69
+ option, instead of returning a Release of every item.
64
70
  wait: Block until the Release is ready or has failed.
65
71
  timeout: Seconds to wait for preparation when ``wait`` is true.
66
72
 
@@ -98,6 +104,9 @@ class ReleasesNamespace(Namespace):
98
104
  }
99
105
  if license_id is not None:
100
106
  payload["license"] = identifier(license_id)
107
+ if completed_only:
108
+ # Sent only when asked, so every other request body stays as before.
109
+ payload["completed_only"] = True
101
110
  # The long request timeout stays: a backend that predates background
102
111
  # preparation still builds the whole Release inside this POST.
103
112
  raw = self._api.post(
@@ -106,6 +115,14 @@ class ReleasesNamespace(Namespace):
106
115
  timeout=600.0,
107
116
  )
108
117
  release = Release._from_raw(self._client, raw)
118
+ if completed_only and not release.completed_only:
119
+ # An older server drops the unknown field and releases every item;
120
+ # its answer then lacks the echo. Say so rather than hand that back.
121
+ raise UnitlabError(
122
+ "This Unitlab server does not support completed_only releases; "
123
+ f"Release {release.id} was created with every labelled item.",
124
+ code="completed_only_unsupported",
125
+ )
109
126
  return release.wait(timeout=timeout) if wait else release
110
127
 
111
128
 
@@ -124,6 +141,8 @@ class Release:
124
141
  status: str | None = None
125
142
  progress: int | None = None
126
143
  status_error: str | None = None
144
+ # True when the Release holds only items that were Complete.
145
+ completed_only: bool = False
127
146
 
128
147
  @classmethod
129
148
  def _from_raw(cls, client: UnitlabClient, raw: dict[str, Any]) -> Release:
@@ -140,6 +159,7 @@ class Release:
140
159
  status=raw.get("status"),
141
160
  progress=raw.get("progress"),
142
161
  status_error=raw.get("status_error"),
162
+ completed_only=bool(raw.get("completed_only", False)),
143
163
  )
144
164
 
145
165
  def refresh(self) -> Release:
@@ -258,7 +258,7 @@ def test_file_size_limit_and_closed_streams_on_request_failure(
258
258
  project = project_factory(lambda request: httpx.Response(500))
259
259
  opened = []
260
260
 
261
- def post(endpoint, *, data, files):
261
+ def post(endpoint, *, data, files, **kwargs):
262
262
  opened.extend(item[1] for item in files.values())
263
263
  raise RuntimeError("request failed")
264
264
 
@@ -34,6 +34,84 @@ def client_with_handler(handler):
34
34
  return client
35
35
 
36
36
 
37
+ def test_step_by_step_commit_lists_blocked_rows(tmp_path):
38
+ from unitlab.exceptions import AnnotationImportError
39
+
40
+ source = tmp_path / "predictions.json"
41
+ source.write_text("{}")
42
+ blocked = {
43
+ "key": "3",
44
+ "external_key": "ann-3",
45
+ "status": "blocked",
46
+ "reason": "Geometry lies outside original media dimensions.",
47
+ }
48
+ timeouts = []
49
+
50
+ def handler(request):
51
+ timeouts.append(request.extensions["timeout"]["read"])
52
+ if request.url.path.endswith("/commit/"):
53
+ return httpx.Response(
54
+ 400, json=["Resolve blocked records or explicitly choose to skip."]
55
+ )
56
+ return httpx.Response(
57
+ 201,
58
+ json={
59
+ "batch_id": "b1",
60
+ "source_id": "s1",
61
+ "token": "t1",
62
+ "annotations": [blocked],
63
+ },
64
+ )
65
+
66
+ client = client_with_handler(handler)
67
+ project = Project(id="project-id", name="Images", raw={}, _client=client)
68
+ batch = project.annotation_imports.create(source, source_name="model-a")
69
+ with pytest.raises(AnnotationImportError) as error:
70
+ batch.commit()
71
+ assert error.value.rows == [blocked]
72
+ assert error.value.batch_id == "b1"
73
+ # The server's list body reads as text, not as "['...']".
74
+ assert str(error.value).startswith("Resolve blocked records")
75
+ assert "ann-3: Geometry lies outside" in str(error.value)
76
+ assert "allow_partial=True" in str(error.value)
77
+ # Preview and commit re-check every item on the server: past 60 s.
78
+ assert timeouts == [300, 300]
79
+ client.close()
80
+
81
+
82
+ def test_wait_raises_refusals_at_once_and_retries_server_errors(monkeypatch):
83
+ from unitlab import _waiter
84
+ from unitlab.exceptions import PermissionDeniedError, ValidationError
85
+
86
+ monkeypatch.setattr(_waiter.time, "sleep", lambda _: None)
87
+ answers = []
88
+ done = {"total": 1, "completed": 1, "processing": 0, "failed": 0}
89
+
90
+ def handler(request):
91
+ return answers.pop(0)
92
+
93
+ client = client_with_handler(handler)
94
+ for refusal, error in (
95
+ (
96
+ httpx.Response(403, json={"detail": "No.", "code": "permission_denied"}),
97
+ PermissionDeniedError,
98
+ ),
99
+ (httpx.Response(400, json={"detail": "Bad."}), ValidationError),
100
+ ):
101
+ answers[:] = [refusal, httpx.Response(200, json=done)]
102
+ with pytest.raises(error):
103
+ _waiter.wait_for_status(client._api, "/status/", resource_name="Q")
104
+ assert len(answers) == 1 # asked once, not retried
105
+
106
+ answers[:] = [
107
+ httpx.Response(503, json={"detail": "Busy."}),
108
+ httpx.Response(200, json=done),
109
+ ]
110
+ status = _waiter.wait_for_status(client._api, "/status/", resource_name="Q")
111
+ assert status.completed == 1 and not answers
112
+ client.close()
113
+
114
+
37
115
  def test_duplicate_public_methods_are_removed():
38
116
  removed = (
39
117
  (
@@ -49,6 +49,65 @@ def configured_client(handler):
49
49
  return client
50
50
 
51
51
 
52
+ def test_empty_httpx_errors_get_a_readable_message():
53
+ from unitlab._http import error_text
54
+
55
+ assert error_text(httpx.ReadTimeout("")) == (
56
+ "ReadTimeout: no answer before the request timed out"
57
+ )
58
+ assert error_text(httpx.RemoteProtocolError("")).startswith(
59
+ "RemoteProtocolError: the connection closed"
60
+ )
61
+ assert error_text(httpx.ConnectError("refused")) == "refused"
62
+
63
+
64
+ def test_upload_reports_dropped_connections_and_caps_files_in_flight(tmp_path):
65
+ import asyncio
66
+
67
+ for index in range(5):
68
+ (tmp_path / f"img{index}.png").write_bytes(b"png")
69
+ in_flight = peak = 0
70
+
71
+ def info(request):
72
+ return httpx.Response(
73
+ 200,
74
+ json={
75
+ "accepted_formats": ["png"],
76
+ "max_file_sizes": {"img": 1_000_000},
77
+ "max_file_size": 1_000_000,
78
+ "generic_type": None,
79
+ },
80
+ )
81
+
82
+ async def upload(request):
83
+ nonlocal in_flight, peak
84
+ in_flight += 1
85
+ peak = max(peak, in_flight)
86
+ await asyncio.sleep(0.01)
87
+ in_flight -= 1
88
+ if b'filename="img0.png"' in request.content:
89
+ # What a restarting server does: httpx raises with no message.
90
+ raise httpx.RemoteProtocolError("")
91
+ return httpx.Response(201, json={"datasource_id": "ds", "generic_type": "img"})
92
+
93
+ client = UnitlabClient(api_key="key", api_url="http://testserver")
94
+ client._api.client.close()
95
+ client._api.client = httpx.Client(
96
+ base_url="http://testserver", transport=httpx.MockTransport(info)
97
+ )
98
+ client._api.async_client = lambda *, timeout=600.0: httpx.AsyncClient(
99
+ base_url="http://testserver", transport=httpx.MockTransport(upload)
100
+ )
101
+ project = Project(id="project-id", name="Images", raw={}, _client=client)
102
+ batch = project.upload(tmp_path, max_concurrent_uploads=2)
103
+ assert peak == 2
104
+ assert batch.uploaded == 4
105
+ assert [failure.path.name for failure in batch.failed] == ["img0.png"]
106
+ assert "connection closed" in batch.failed[0].error
107
+ assert "missing datasource_id" not in batch.failed[0].error
108
+ client.close()
109
+
110
+
52
111
  def test_project_upload_uses_one_batch_queue_and_chunks(tmp_path):
53
112
  session_ids = []
54
113
  uploads = 0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes