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.
- {unitlab-3.1.0 → unitlab-3.1.2}/PKG-INFO +1 -1
- {unitlab-3.1.0 → unitlab-3.1.2}/pyproject.toml +1 -1
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/__init__.py +2 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/_downloader.py +4 -2
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/_http.py +20 -2
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/_scene_uploader.py +5 -3
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/_uploader.py +15 -6
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/_waiter.py +7 -5
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/exceptions.py +38 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/resources/annotation_imports.py +51 -11
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/resources/projects.py +5 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/resources/releases.py +20 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/tests/test_annotation_imports.py +1 -1
- {unitlab-3.1.0 → unitlab-3.1.2}/tests/test_client.py +78 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/tests/test_uploader.py +59 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/.gitignore +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/LICENSE.md +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/README.md +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/__main__.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/_config.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/_grouping.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/_nuscenes.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/_scenes.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/cli.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/client.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/ontology.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/py.typed +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/resources/__init__.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/resources/_base.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/resources/assets.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/resources/cloud.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/resources/datasets.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/resources/embeddings.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/resources/ontologies.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/resources/workflow.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/src/unitlab/types.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/tests/test_cli.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/tests/test_downloader.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/tests/test_embeddings.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/tests/test_http.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/tests/test_nuscenes.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/tests/test_scene_cli.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/tests/test_scene_uploads.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/tests/test_scenes.py +0 -0
- {unitlab-3.1.0 → unitlab-3.1.2}/tests/test_tiled_uploads.py +0 -0
|
@@ -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(
|
|
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(
|
|
159
|
+
raise RequestTimeoutError(error_text(exc), exc) from exc
|
|
142
160
|
except httpx.HTTPError as exc:
|
|
143
|
-
raise NetworkError(
|
|
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(
|
|
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."""
|
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
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
|