unitlab 3.0.5__tar.gz → 3.1.0__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.0}/PKG-INFO +68 -2
  2. {unitlab-3.0.5 → unitlab-3.1.0}/README.md +66 -0
  3. {unitlab-3.0.5 → unitlab-3.1.0}/pyproject.toml +2 -2
  4. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/__init__.py +2 -0
  5. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/_http.py +14 -2
  6. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/resources/__init__.py +2 -0
  7. unitlab-3.1.0/src/unitlab/resources/annotation_imports.py +182 -0
  8. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/resources/projects.py +7 -0
  9. unitlab-3.1.0/tests/test_annotation_imports.py +326 -0
  10. unitlab-3.1.0/tests/test_http.py +243 -0
  11. unitlab-3.0.5/tests/test_http.py +0 -99
  12. {unitlab-3.0.5 → unitlab-3.1.0}/.gitignore +0 -0
  13. {unitlab-3.0.5 → unitlab-3.1.0}/LICENSE.md +0 -0
  14. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/__main__.py +0 -0
  15. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/_config.py +0 -0
  16. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/_downloader.py +0 -0
  17. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/_grouping.py +0 -0
  18. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/_nuscenes.py +0 -0
  19. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/_scene_uploader.py +0 -0
  20. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/_scenes.py +0 -0
  21. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/_uploader.py +0 -0
  22. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/_waiter.py +0 -0
  23. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/cli.py +0 -0
  24. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/client.py +0 -0
  25. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/exceptions.py +0 -0
  26. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/ontology.py +0 -0
  27. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/py.typed +0 -0
  28. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/resources/_base.py +0 -0
  29. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/resources/assets.py +0 -0
  30. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/resources/cloud.py +0 -0
  31. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/resources/datasets.py +0 -0
  32. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/resources/embeddings.py +0 -0
  33. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/resources/ontologies.py +0 -0
  34. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/resources/releases.py +0 -0
  35. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/resources/workflow.py +0 -0
  36. {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/types.py +0 -0
  37. {unitlab-3.0.5 → unitlab-3.1.0}/tests/test_cli.py +0 -0
  38. {unitlab-3.0.5 → unitlab-3.1.0}/tests/test_client.py +0 -0
  39. {unitlab-3.0.5 → unitlab-3.1.0}/tests/test_downloader.py +0 -0
  40. {unitlab-3.0.5 → unitlab-3.1.0}/tests/test_embeddings.py +0 -0
  41. {unitlab-3.0.5 → unitlab-3.1.0}/tests/test_nuscenes.py +0 -0
  42. {unitlab-3.0.5 → unitlab-3.1.0}/tests/test_scene_cli.py +0 -0
  43. {unitlab-3.0.5 → unitlab-3.1.0}/tests/test_scene_uploads.py +0 -0
  44. {unitlab-3.0.5 → unitlab-3.1.0}/tests/test_scenes.py +0 -0
  45. {unitlab-3.0.5 → unitlab-3.1.0}/tests/test_tiled_uploads.py +0 -0
  46. {unitlab-3.0.5 → unitlab-3.1.0}/tests/test_uploader.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.0
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.0"
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
  ]
@@ -27,6 +27,7 @@ from .ontology import (
27
27
  TextAttribute,
28
28
  )
29
29
  from .resources import (
30
+ AnnotationImport,
30
31
  Asset,
31
32
  AttachedSource,
32
33
  BatchQueue,
@@ -59,6 +60,7 @@ __version__ = version("unitlab")
59
60
 
60
61
  __all__ = [
61
62
  "AmbiguousUploadCompletionError",
63
+ "AnnotationImport",
62
64
  "Asset",
63
65
  "AssetUploadResult",
64
66
  "AttachPreview",
@@ -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:
@@ -90,7 +96,6 @@ class HttpApi:
90
96
  self.client = httpx.Client(
91
97
  base_url=api_url,
92
98
  headers={"Authorization": f"Api-Key {api_key}"},
93
- transport=httpx.HTTPTransport(retries=3),
94
99
  timeout=60.0,
95
100
  )
96
101
 
@@ -124,7 +129,14 @@ class HttpApi:
124
129
  }
125
130
  if timeout is not None:
126
131
  kwargs["timeout"] = timeout
127
- response = self.client.request(method, endpoint, **kwargs)
132
+ for attempt in range(len(_CONNECT_RETRY_DELAYS) + 1):
133
+ try:
134
+ response = self.client.request(method, endpoint, **kwargs)
135
+ break
136
+ except (httpx.ConnectError, httpx.ConnectTimeout):
137
+ if attempt == len(_CONNECT_RETRY_DELAYS):
138
+ raise
139
+ time.sleep(_CONNECT_RETRY_DELAYS[attempt])
128
140
  except httpx.TimeoutException as exc:
129
141
  raise RequestTimeoutError(str(exc), exc) from exc
130
142
  except httpx.HTTPError as exc:
@@ -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",
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from contextlib import ExitStack
5
+ from copy import deepcopy
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Any, Literal
9
+
10
+ from ._base import Namespace, identifier
11
+
12
+ if TYPE_CHECKING:
13
+ from unitlab.client import UnitlabClient
14
+
15
+ MAX_FILE_BYTES = 600 * 1024 * 1024
16
+
17
+
18
+ class AnnotationImports(Namespace):
19
+ """Preview and commit labels for media already linked to a Project."""
20
+
21
+ def __init__(self, client: UnitlabClient, project_id: str):
22
+ super().__init__(client)
23
+ self.project_id = project_id
24
+
25
+ @property
26
+ def _endpoint(self) -> str:
27
+ return f"/api/sdk/projects/{self.project_id}/annotation-imports/"
28
+
29
+ def sources(self) -> list[dict[str, Any]]:
30
+ return self._api.get(f"{self._endpoint}sources/")["sources"]
31
+
32
+ def media(self, search: str = "") -> list[dict[str, Any]]:
33
+ """Find up to 50 candidates for explicit media mappings."""
34
+ return self._api.get(f"{self._endpoint}media/", params={"search": search})[
35
+ "media"
36
+ ]
37
+
38
+ def create(
39
+ self,
40
+ annotation_file: str | Path | None = None,
41
+ *,
42
+ source_name: str | None = None,
43
+ source_id: str | None = None,
44
+ frame_mapping_file: str | Path | None = None,
45
+ cloud_storage=None,
46
+ annotation_key: str | None = None,
47
+ frame_mapping_key: str | None = None,
48
+ track_profile: Literal["none", "attributes.track_id"] = "none",
49
+ options: dict[str, Any] | None = None,
50
+ ) -> AnnotationImport:
51
+ """Upload COCO, Unitlab export or native JSON and return a label preview.
52
+
53
+ Supply a local ``annotation_file`` or a saved ``cloud_storage`` handle/ID
54
+ and ``annotation_key``. JSON files are limited to 600 MiB each. Media must
55
+ already belong to this project; use ``UploadBatch.wait()`` after upload.
56
+
57
+ Exactly one prediction namespace, ``source_name`` or ``source_id``, is
58
+ required. Reuse it for subsequent predictions from the same model.
59
+ ``options`` accepts media/class/object/review mappings and ``stable_ids``
60
+ (false by default). Enable stable IDs only if the exporter preserves them.
61
+ COCO video requires an explicit frame mapping file or storage key.
62
+ """
63
+ local = annotation_file is not None
64
+ cloud = cloud_storage is not None and bool(annotation_key)
65
+ if local == cloud or (
66
+ not cloud
67
+ and any(
68
+ value is not None
69
+ for value in (cloud_storage, annotation_key, frame_mapping_key)
70
+ )
71
+ ):
72
+ raise ValueError(
73
+ "Supply a local JSON file or a cloud storage and JSON key."
74
+ )
75
+ if bool(source_name) == bool(source_id):
76
+ raise ValueError("Supply exactly one of source_name or source_id.")
77
+ if frame_mapping_file is not None and frame_mapping_key is not None:
78
+ raise ValueError("Supply one frame mapping file or storage key.")
79
+ if track_profile not in ("none", "attributes.track_id"):
80
+ raise ValueError("Unknown track_profile.")
81
+
82
+ import_options = deepcopy(options or {})
83
+ data: dict[str, Any] = {
84
+ "track_profile": track_profile,
85
+ "options": import_options,
86
+ }
87
+ if source_id:
88
+ data["source_id"] = identifier(source_id)
89
+ else:
90
+ data["source_name"] = source_name
91
+ if cloud:
92
+ data.update(
93
+ cloud_storage_id=identifier(cloud_storage),
94
+ annotation_key=annotation_key,
95
+ )
96
+ if frame_mapping_key is not None:
97
+ data["frame_mapping_key"] = frame_mapping_key
98
+
99
+ with ExitStack() as stack:
100
+ files = {}
101
+ for name, value in (
102
+ ("annotation_file", annotation_file),
103
+ ("frame_mapping_file", frame_mapping_file),
104
+ ):
105
+ if value is None:
106
+ continue
107
+ path = Path(value)
108
+ # GeoJSON exports keep their own extension; frame maps are .json.
109
+ suffixes = (
110
+ (".json", ".geojson") if name == "annotation_file" else (".json",)
111
+ )
112
+ if not path.is_file() or path.suffix.lower() not in suffixes:
113
+ raise ValueError(f"{name} must be a JSON file: {path}")
114
+ stream = stack.enter_context(path.open("rb"))
115
+ stream.seek(0, 2)
116
+ if stream.tell() > MAX_FILE_BYTES:
117
+ raise ValueError(f"{name} must be 600 MiB or smaller.")
118
+ stream.seek(0)
119
+ files[name] = (path.name, stream, "application/json")
120
+ if files:
121
+ data["options"] = json.dumps(import_options, allow_nan=False)
122
+ raw = self._api.post(self._endpoint, data=data, files=files)
123
+ else:
124
+ raw = self._api.post(self._endpoint, json=data)
125
+
126
+ return AnnotationImport(
127
+ id=str(raw["batch_id"]),
128
+ project_id=self.project_id,
129
+ source_id=str(raw["source_id"]),
130
+ preview=raw,
131
+ _client=self._client,
132
+ _options=import_options,
133
+ )
134
+
135
+
136
+ @dataclass
137
+ class AnnotationImport:
138
+ id: str
139
+ project_id: str
140
+ source_id: str
141
+ preview: dict[str, Any] = field(repr=False)
142
+ _client: UnitlabClient = field(repr=False, compare=False)
143
+ _options: dict[str, Any] = field(repr=False)
144
+
145
+ @property
146
+ def _endpoint(self) -> str:
147
+ return f"/api/sdk/projects/{self.project_id}/annotation-imports/{self.id}/"
148
+
149
+ def refresh(self, *, options: dict[str, Any] | None = None) -> AnnotationImport:
150
+ """Re-preview after media processing or mapping changes.
151
+
152
+ Omit ``options`` to preserve them; supply a dict to replace them entirely.
153
+ Examine the new preview before committing it. Committed batches cannot
154
+ be refreshed; create another batch with the same source for reimports.
155
+ """
156
+ value = deepcopy(self._options if options is None else options)
157
+ preview = self._client._api.post(f"{self._endpoint}preview/", json=value)
158
+ self.preview = preview
159
+ self._options = value
160
+ return self
161
+
162
+ def commit(
163
+ self,
164
+ *,
165
+ decisions: dict[str, Literal["keep", "incoming", "skip"]] | None = None,
166
+ allow_partial: bool = False,
167
+ ) -> dict[str, Any]:
168
+ """Save this preview as labels; approval remains a separate review step.
169
+
170
+ Conflicts and human deletions default to ``keep``. Blocked records reject
171
+ the entire commit unless ``allow_partial=True``. A stale preview raises
172
+ ConflictError without refreshing or overwriting human work. Repeating a
173
+ successful commit returns its original report without duplicating labels.
174
+ """
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
+ )
@@ -20,6 +20,7 @@ from ._base import Namespace, identifier
20
20
  if TYPE_CHECKING:
21
21
  from unitlab.client import UnitlabClient
22
22
 
23
+ from .annotation_imports import AnnotationImports
23
24
  from .workflow import Workflow
24
25
 
25
26
 
@@ -167,6 +168,12 @@ class Project:
167
168
  data_item_count: int = 0
168
169
  ontology_hash: str | None = None
169
170
 
171
+ @property
172
+ def annotation_imports(self) -> AnnotationImports:
173
+ from .annotation_imports import AnnotationImports
174
+
175
+ return AnnotationImports(self._client, self.id)
176
+
170
177
  @property
171
178
  def workflow(self) -> Workflow:
172
179
  from .workflow import Workflow
@@ -0,0 +1,326 @@
1
+ import json
2
+ from email import policy
3
+ from email.parser import BytesParser
4
+ from types import SimpleNamespace
5
+
6
+ import httpx
7
+ import pytest
8
+
9
+ from unitlab import (
10
+ AnnotationImport,
11
+ ConflictError,
12
+ PermissionDeniedError,
13
+ RequestTimeoutError,
14
+ UnitlabClient,
15
+ )
16
+ from unitlab.resources.projects import Project
17
+
18
+ BASE = "/api/sdk/projects/p1/annotation-imports/"
19
+ PREVIEW = {
20
+ "batch_id": "b1",
21
+ "source_id": "s1",
22
+ "token": "reviewed-token",
23
+ "counts": {"create": 1},
24
+ "media": [{"status": "matched"}],
25
+ "classes": [],
26
+ "annotations": [],
27
+ "issues": [],
28
+ }
29
+
30
+
31
+ @pytest.fixture
32
+ def labels(tmp_path):
33
+ path = tmp_path / "labels.json"
34
+ path.write_text(json.dumps({"images": [], "annotations": [], "categories": []}))
35
+ return path
36
+
37
+
38
+ @pytest.fixture
39
+ def project_factory():
40
+ clients = []
41
+
42
+ def make(handler):
43
+ client = UnitlabClient(api_key="key", api_url="http://testserver")
44
+ client._api.client.close()
45
+ client._api.client = httpx.Client(
46
+ base_url="http://testserver",
47
+ headers={"Authorization": "Api-Key key"},
48
+ transport=httpx.MockTransport(handler),
49
+ )
50
+ clients.append(client)
51
+ return Project("p1", "Project", {"pk": "p1"}, client)
52
+
53
+ yield make
54
+ for client in clients:
55
+ client.close()
56
+
57
+
58
+ def multipart(request):
59
+ message = BytesParser(policy=policy.default).parsebytes(
60
+ f"Content-Type: {request.headers['content-type']}\r\n\r\n".encode()
61
+ + request.content
62
+ )
63
+ return {
64
+ part.get_param("name", header="content-disposition"): part.get_payload(
65
+ decode=True
66
+ )
67
+ for part in message.iter_parts()
68
+ }
69
+
70
+
71
+ def test_s3_media_then_local_json_uses_preview_and_explicit_commit(
72
+ project_factory, labels
73
+ ):
74
+ calls = []
75
+
76
+ def handler(request):
77
+ calls.append(request.url.path)
78
+ assert request.headers["Authorization"] == "Api-Key key"
79
+ assert "Company" not in request.headers
80
+ if request.url.path.endswith("/cloud-storages/storage1/import/"):
81
+ assert json.loads(request.content) == {"server_files": ["images/"]}
82
+ return httpx.Response(
83
+ 202, json={"upload_session_id": "q1", "file_count": 1}
84
+ )
85
+ if request.url.path.endswith("/upload-sessions/q1/status/"):
86
+ return httpx.Response(
87
+ 200, json={"total": 1, "completed": 1, "processing": 0, "failed": 0}
88
+ )
89
+ if request.url.path == BASE:
90
+ fields = multipart(request)
91
+ assert fields["annotation_file"] == labels.read_bytes()
92
+ assert fields["source_name"] == b"detector-v1"
93
+ assert json.loads(fields["options"]) == {}
94
+ return httpx.Response(201, json=PREVIEW)
95
+ if request.url.path == BASE + "b1/commit/":
96
+ assert json.loads(request.content) == {
97
+ "token": "reviewed-token",
98
+ "decisions": {},
99
+ "allow_partial": False,
100
+ }
101
+ return httpx.Response(200, json={"created": 1, "batch_id": "b1"})
102
+ raise AssertionError(request.url)
103
+
104
+ project = project_factory(handler)
105
+ media = project.import_cloud("storage1", ["images/"])
106
+ assert media.wait().failed == 0
107
+ batch = project.annotation_imports.create(labels, source_name="detector-v1")
108
+ assert isinstance(batch, AnnotationImport)
109
+ assert batch.preview["counts"] == {"create": 1}
110
+ assert len(calls) == 3 # Preview does not implicitly commit.
111
+ assert batch.commit() == batch.commit()
112
+ assert calls[-2:] == [BASE + "b1/commit/"] * 2
113
+
114
+
115
+ def test_video_upload_sends_explicit_frame_map(project_factory, labels, tmp_path):
116
+ frame_map = tmp_path / "frames.json"
117
+ frame_map.write_text('{"schema_version":"unitlab-frame-map/1","frames":[]}')
118
+
119
+ def handler(request):
120
+ fields = multipart(request)
121
+ assert fields["frame_mapping_file"] == frame_map.read_bytes()
122
+ assert fields["track_profile"] == b"attributes.track_id"
123
+ assert fields["source_id"] == b"s1"
124
+ assert "source_name" not in fields
125
+ return httpx.Response(201, json=PREVIEW)
126
+
127
+ project_factory(handler).annotation_imports.create(
128
+ labels,
129
+ source_id="s1",
130
+ frame_mapping_file=frame_map,
131
+ track_profile="attributes.track_id",
132
+ )
133
+
134
+
135
+ def test_cloud_annotation_document_uses_json_request(project_factory):
136
+ def handler(request):
137
+ assert json.loads(request.content) == {
138
+ "source_name": "model",
139
+ "cloud_storage_id": "storage1",
140
+ "annotation_key": "labels/predictions.json",
141
+ "frame_mapping_key": "labels/frames.json",
142
+ "options": {"stable_ids": True},
143
+ "track_profile": "none",
144
+ }
145
+ return httpx.Response(201, json=PREVIEW)
146
+
147
+ batch = project_factory(handler).annotation_imports.create(
148
+ cloud_storage=SimpleNamespace(id="storage1"),
149
+ annotation_key="labels/predictions.json",
150
+ frame_mapping_key="labels/frames.json",
151
+ source_name="model",
152
+ options={"stable_ids": True},
153
+ )
154
+ assert batch.source_id == "s1"
155
+
156
+
157
+ def test_refresh_preserves_options_and_uses_only_new_reviewed_token(
158
+ project_factory, labels
159
+ ):
160
+ calls = []
161
+
162
+ def handler(request):
163
+ calls.append(request.url.path)
164
+ if request.url.path == BASE:
165
+ return httpx.Response(201, json=PREVIEW)
166
+ if request.url.path.endswith("/preview/"):
167
+ assert json.loads(request.content) == {
168
+ "stable_ids": True,
169
+ "media_mapping": {"road.jpg": "m1"},
170
+ }
171
+ return httpx.Response(200, json={**PREVIEW, "token": "new-token"})
172
+ assert json.loads(request.content)["token"] == "new-token"
173
+ return httpx.Response(200, json={"created": 1})
174
+
175
+ options = {"stable_ids": True, "media_mapping": {"road.jpg": "m1"}}
176
+ batch = project_factory(handler).annotation_imports.create(
177
+ labels, source_name="model", options=options
178
+ )
179
+ options["media_mapping"]["road.jpg"] = "mutated"
180
+ assert batch.refresh() is batch
181
+ assert batch.commit()["created"] == 1
182
+ assert calls == [BASE, BASE + "b1/preview/", BASE + "b1/commit/"]
183
+
184
+
185
+ def test_stale_preview_is_not_automatically_refreshed_or_committed(
186
+ project_factory, labels
187
+ ):
188
+ calls = []
189
+
190
+ def handler(request):
191
+ calls.append(request.url.path)
192
+ if request.url.path == BASE:
193
+ return httpx.Response(201, json=PREVIEW)
194
+ return httpx.Response(
195
+ 409, json={"detail": "Preview changed.", "code": "CONFLICT"}
196
+ )
197
+
198
+ batch = project_factory(handler).annotation_imports.create(
199
+ labels, source_name="model"
200
+ )
201
+ with pytest.raises(ConflictError):
202
+ batch.commit()
203
+ assert calls == [BASE, BASE + "b1/commit/"]
204
+ assert batch.preview["token"] == "reviewed-token"
205
+
206
+
207
+ def test_uncertain_commit_can_be_retried_with_identical_request(
208
+ project_factory, labels
209
+ ):
210
+ commits = []
211
+
212
+ def handler(request):
213
+ if request.url.path == BASE:
214
+ return httpx.Response(201, json=PREVIEW)
215
+ commits.append(json.loads(request.content))
216
+ if len(commits) == 1:
217
+ raise httpx.ReadTimeout("Response lost", request=request)
218
+ return httpx.Response(200, json={"created": 1})
219
+
220
+ batch = project_factory(handler).annotation_imports.create(
221
+ labels, source_name="model"
222
+ )
223
+ decisions = {"a1": "keep"}
224
+ with pytest.raises(RequestTimeoutError):
225
+ batch.commit(decisions=decisions)
226
+ assert batch.commit(decisions=decisions)["created"] == 1
227
+ assert commits[0] == commits[1]
228
+
229
+
230
+ @pytest.mark.parametrize(
231
+ "extra",
232
+ [
233
+ {},
234
+ {"source_name": "model", "source_id": "s1"},
235
+ {"source_name": "model", "cloud_storage": "storage1"},
236
+ {
237
+ "source_name": "model",
238
+ "cloud_storage": "storage1",
239
+ "annotation_key": "labels.json",
240
+ },
241
+ {"source_name": "model", "frame_mapping_key": "frames.json"},
242
+ {"source_name": "model", "track_profile": "guess"},
243
+ ],
244
+ )
245
+ def test_invalid_source_combinations_do_not_send_requests(
246
+ project_factory, labels, extra
247
+ ):
248
+ def handler(request):
249
+ raise AssertionError("Invalid input must not reach the server.")
250
+
251
+ with pytest.raises(ValueError):
252
+ project_factory(handler).annotation_imports.create(labels, **extra)
253
+
254
+
255
+ def test_file_size_limit_and_closed_streams_on_request_failure(
256
+ project_factory, labels, monkeypatch
257
+ ):
258
+ project = project_factory(lambda request: httpx.Response(500))
259
+ opened = []
260
+
261
+ def post(endpoint, *, data, files):
262
+ opened.extend(item[1] for item in files.values())
263
+ raise RuntimeError("request failed")
264
+
265
+ monkeypatch.setattr(project._client._api, "post", post)
266
+ with pytest.raises(RuntimeError, match="request failed"):
267
+ project.annotation_imports.create(labels, source_name="model")
268
+ assert opened and all(stream.closed for stream in opened)
269
+ monkeypatch.setattr("unitlab.resources.annotation_imports.MAX_FILE_BYTES", 1)
270
+ with pytest.raises(ValueError, match="600 MiB"):
271
+ project.annotation_imports.create(labels, source_name="model")
272
+ assert len(opened) == 1
273
+
274
+
275
+ def test_geojson_export_is_uploaded_but_not_as_a_frame_map(
276
+ project_factory, labels, tmp_path
277
+ ):
278
+ geojson = tmp_path / "release.geojson"
279
+ geojson.write_text(json.dumps({"type": "FeatureCollection", "features": []}))
280
+
281
+ def handler(request):
282
+ parts = multipart(request)
283
+ assert json.loads(parts["annotation_file"])["type"] == "FeatureCollection"
284
+ assert "frame_mapping_file" not in parts
285
+ return httpx.Response(201, json=PREVIEW)
286
+
287
+ imports = project_factory(handler).annotation_imports
288
+ assert imports.create(geojson, source_name="raster").id == "b1"
289
+ with pytest.raises(ValueError, match="frame_mapping_file"):
290
+ imports.create(labels, frame_mapping_file=geojson, source_name="raster")
291
+
292
+
293
+ def test_creatorless_key_commit_raises_permission_denied(project_factory, labels):
294
+ def handler(request):
295
+ if request.url.path == BASE:
296
+ return httpx.Response(201, json=PREVIEW)
297
+ return httpx.Response(
298
+ 403,
299
+ json={
300
+ "detail": "A user-created API key is required to commit.",
301
+ "code": "permission_denied",
302
+ },
303
+ )
304
+
305
+ batch = project_factory(handler).annotation_imports.create(
306
+ labels, source_name="model"
307
+ )
308
+ with pytest.raises(PermissionDeniedError, match="user-created API key"):
309
+ batch.commit()
310
+
311
+
312
+ def test_source_and_media_helpers_use_project_scoped_routes(project_factory):
313
+ def handler(request):
314
+ if request.url.path == BASE + "sources/":
315
+ return httpx.Response(
316
+ 200, json={"sources": [{"id": "s1", "name": "model"}]}
317
+ )
318
+ assert request.url.path == BASE + "media/"
319
+ assert request.url.params["search"] == "images/road.jpg"
320
+ return httpx.Response(
321
+ 200, json={"media": [{"id": "m1", "name": "images/road.jpg"}]}
322
+ )
323
+
324
+ imports = project_factory(handler).annotation_imports
325
+ assert imports.sources()[0]["id"] == "s1"
326
+ assert imports.media("images/road.jpg")[0]["id"] == "m1"
@@ -0,0 +1,243 @@
1
+ import httpx
2
+ import pytest
3
+
4
+ from unitlab import (
5
+ ConflictError,
6
+ NetworkError,
7
+ NotFoundError,
8
+ PermissionDeniedError,
9
+ RequestTimeoutError,
10
+ SubscriptionError,
11
+ )
12
+ from unitlab._http import HttpApi, raise_for_response
13
+
14
+
15
+ def test_requests_keep_client_timeout_unless_explicitly_overridden():
16
+ seen = []
17
+
18
+ def handler(request):
19
+ seen.append(request.extensions["timeout"])
20
+ return httpx.Response(200, json={"ok": True})
21
+
22
+ api = HttpApi("key", "http://testserver")
23
+ api.client.close()
24
+ api.client = httpx.Client(
25
+ base_url="http://testserver",
26
+ transport=httpx.MockTransport(handler),
27
+ timeout=12,
28
+ )
29
+
30
+ api.get("/default")
31
+ api.get("/override", timeout=7)
32
+
33
+ assert set(seen[0].values()) == {12}
34
+ assert set(seen[1].values()) == {7}
35
+ api.close()
36
+
37
+
38
+ def test_forbidden_responses_distinguish_permission_and_subscription_errors():
39
+ request = httpx.Request("POST", "http://testserver/action")
40
+ permission = httpx.Response(
41
+ 403,
42
+ request=request,
43
+ json={
44
+ "detail": "A user-created API key is required.",
45
+ "code": "permission_denied",
46
+ },
47
+ )
48
+ subscription = httpx.Response(
49
+ 403,
50
+ request=request,
51
+ json={"detail": "Resource Limit Exceeded"},
52
+ )
53
+
54
+ with pytest.raises(PermissionDeniedError, match="user-created API key") as exc:
55
+ raise_for_response(permission)
56
+ assert exc.value.code == "permission_denied"
57
+
58
+ with pytest.raises(SubscriptionError, match="Resource Limit Exceeded") as exc:
59
+ raise_for_response(subscription)
60
+ assert exc.value.code == ""
61
+
62
+
63
+ @pytest.mark.parametrize(
64
+ "code",
65
+ ["workspace_read_only", "workspace_license_required"],
66
+ )
67
+ def test_commercial_error_code_is_preserved(code):
68
+ response = httpx.Response(
69
+ 403,
70
+ request=httpx.Request("POST", "http://testserver/action"),
71
+ json={
72
+ "detail": "Workspace is read-only.",
73
+ "code": code,
74
+ },
75
+ )
76
+
77
+ with pytest.raises(SubscriptionError) as exc:
78
+ raise_for_response(response)
79
+
80
+ assert exc.value.code == code
81
+
82
+
83
+ def test_conflict_responses_raise_conflict_error_even_when_detail_says_not_found():
84
+ # A 409 detail may legitimately contain "not found"; the message-based
85
+ # NotFound fallback must never reclassify a conflict.
86
+ response = httpx.Response(
87
+ 409,
88
+ request=httpx.Request("POST", "http://testserver/action"),
89
+ json={
90
+ "detail": "Release 0.3 is still being prepared; archive not found yet.",
91
+ "code": "release_not_ready",
92
+ },
93
+ )
94
+
95
+ with pytest.raises(ConflictError, match="still being prepared") as exc:
96
+ raise_for_response(response)
97
+
98
+ assert isinstance(exc.value, NetworkError)
99
+ assert not isinstance(exc.value, NotFoundError)
100
+ assert exc.value.code == "release_not_ready"
101
+
102
+
103
+ @pytest.mark.parametrize(
104
+ ("api_url", "expected_proxy"),
105
+ [
106
+ ("http://api.example.test", "http://http-proxy.test:8080"),
107
+ ("https://api.example.test", "http://https-proxy.test:8081"),
108
+ ("http://internal.example.test", None),
109
+ ("https://internal.example.test", None),
110
+ ],
111
+ )
112
+ def test_environment_proxies_and_no_proxy_route_requests(
113
+ monkeypatch, api_url, expected_proxy
114
+ ):
115
+ # Observe the actual public transport selected by HTTPX, without opening
116
+ # sockets or inspecting its private client/mount/pool attributes.
117
+ for name in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"):
118
+ monkeypatch.delenv(name, raising=False)
119
+ monkeypatch.delenv(name.lower(), raising=False)
120
+ monkeypatch.setenv("HTTP_PROXY", "http://http-proxy.test:8080")
121
+ monkeypatch.setenv("HTTPS_PROXY", "http://https-proxy.test:8081")
122
+ monkeypatch.setenv("NO_PROXY", "internal.example.test")
123
+
124
+ original_init = httpx.HTTPTransport.__init__
125
+ transports = {}
126
+ requests = []
127
+
128
+ def initialize(transport, *args, **kwargs):
129
+ transports[transport] = kwargs.get("proxy")
130
+ original_init(transport, *args, **kwargs)
131
+
132
+ def handle_request(transport, request):
133
+ proxy = transports[transport]
134
+ requests.append((request, proxy.url if proxy is not None else None))
135
+ return httpx.Response(200, json={"ok": True})
136
+
137
+ monkeypatch.setattr(httpx.HTTPTransport, "__init__", initialize)
138
+ monkeypatch.setattr(httpx.HTTPTransport, "handle_request", handle_request)
139
+ api = HttpApi("synthetic-key", api_url)
140
+ try:
141
+ assert api.get("/health") == {"ok": True}
142
+ finally:
143
+ api.close()
144
+
145
+ assert len(requests) == 1
146
+ request, proxy = requests[0]
147
+ assert request.url == httpx.URL(api_url + "/health")
148
+ assert proxy == (httpx.URL(expected_proxy) if expected_proxy else None)
149
+
150
+
151
+ @pytest.fixture
152
+ def http_api():
153
+ api = HttpApi("synthetic-key", "https://api.example.test")
154
+ yield api
155
+ api.close()
156
+
157
+
158
+ @pytest.mark.parametrize(
159
+ ("error_type", "sdk_error"),
160
+ [(httpx.ConnectError, NetworkError), (httpx.ConnectTimeout, RequestTimeoutError)],
161
+ )
162
+ @pytest.mark.parametrize("failures", [3, 4])
163
+ def test_only_connection_failures_receive_three_retries(
164
+ monkeypatch, http_api, error_type, sdk_error, failures
165
+ ):
166
+ attempts = []
167
+ delays = []
168
+
169
+ def request(method, endpoint, **kwargs):
170
+ attempts.append((method, endpoint, kwargs))
171
+ if len(attempts) <= failures:
172
+ raise error_type("Connection could not be established")
173
+ return httpx.Response(200, json={"created": True})
174
+
175
+ monkeypatch.setattr(http_api.client, "request", request)
176
+ monkeypatch.setattr("unitlab._http.time.sleep", delays.append)
177
+
178
+ if failures == 3:
179
+ assert http_api.post("/imports", json={"name": "fixture"}) == {"created": True}
180
+ else:
181
+ with pytest.raises(sdk_error) as exc:
182
+ http_api.post("/imports", json={"name": "fixture"})
183
+ assert isinstance(exc.value.detail, error_type)
184
+ assert len(attempts) == 4
185
+ assert delays == [0.0, 0.5, 1.0]
186
+ assert all(
187
+ method == "POST"
188
+ and endpoint == "/imports"
189
+ and kwargs["json"] == {"name": "fixture"}
190
+ for method, endpoint, kwargs in attempts
191
+ )
192
+
193
+
194
+ @pytest.mark.parametrize(
195
+ ("error_type", "sdk_error"),
196
+ [
197
+ (httpx.ReadError, NetworkError),
198
+ (httpx.WriteError, NetworkError),
199
+ (httpx.ReadTimeout, RequestTimeoutError),
200
+ (httpx.WriteTimeout, RequestTimeoutError),
201
+ (httpx.PoolTimeout, RequestTimeoutError),
202
+ (httpx.ProxyError, NetworkError),
203
+ (httpx.RemoteProtocolError, NetworkError),
204
+ ],
205
+ )
206
+ def test_post_is_not_retried_after_other_transport_failures(
207
+ monkeypatch, http_api, error_type, sdk_error
208
+ ):
209
+ attempts = []
210
+ delays = []
211
+
212
+ def request(method, endpoint, **kwargs):
213
+ attempts.append(method)
214
+ raise error_type("Request failed")
215
+
216
+ monkeypatch.setattr(http_api.client, "request", request)
217
+ monkeypatch.setattr("unitlab._http.time.sleep", delays.append)
218
+ with pytest.raises(sdk_error) as exc:
219
+ http_api.post("/imports", json={"name": "fixture"})
220
+ assert isinstance(exc.value.detail, error_type)
221
+ assert attempts == ["POST"]
222
+ assert delays == []
223
+
224
+
225
+ @pytest.mark.parametrize("status_code", [400, 409, 429, 503])
226
+ def test_post_error_status_is_not_retried(monkeypatch, http_api, status_code):
227
+ attempts = []
228
+ delays = []
229
+
230
+ def request(method, endpoint, **kwargs):
231
+ attempts.append(method)
232
+ return httpx.Response(
233
+ status_code,
234
+ request=httpx.Request(method, "https://api.example.test" + endpoint),
235
+ json={"detail": "Synthetic error"},
236
+ )
237
+
238
+ monkeypatch.setattr(http_api.client, "request", request)
239
+ monkeypatch.setattr("unitlab._http.time.sleep", delays.append)
240
+ with pytest.raises(NetworkError):
241
+ http_api.post("/imports", json={"name": "fixture"})
242
+ assert attempts == ["POST"]
243
+ assert delays == []
@@ -1,99 +0,0 @@
1
- import httpx
2
- import pytest
3
-
4
- from unitlab import (
5
- ConflictError,
6
- NetworkError,
7
- NotFoundError,
8
- PermissionDeniedError,
9
- SubscriptionError,
10
- )
11
- from unitlab._http import HttpApi, raise_for_response
12
-
13
-
14
- def test_requests_keep_client_timeout_unless_explicitly_overridden():
15
- seen = []
16
-
17
- def handler(request):
18
- seen.append(request.extensions["timeout"])
19
- return httpx.Response(200, json={"ok": True})
20
-
21
- api = HttpApi("key", "http://testserver")
22
- api.client.close()
23
- api.client = httpx.Client(
24
- base_url="http://testserver",
25
- transport=httpx.MockTransport(handler),
26
- timeout=12,
27
- )
28
-
29
- api.get("/default")
30
- api.get("/override", timeout=7)
31
-
32
- assert set(seen[0].values()) == {12}
33
- assert set(seen[1].values()) == {7}
34
- api.close()
35
-
36
-
37
- def test_forbidden_responses_distinguish_permission_and_subscription_errors():
38
- request = httpx.Request("POST", "http://testserver/action")
39
- permission = httpx.Response(
40
- 403,
41
- request=request,
42
- json={
43
- "detail": "A user-created API key is required.",
44
- "code": "permission_denied",
45
- },
46
- )
47
- subscription = httpx.Response(
48
- 403,
49
- request=request,
50
- json={"detail": "Resource Limit Exceeded"},
51
- )
52
-
53
- with pytest.raises(PermissionDeniedError, match="user-created API key") as exc:
54
- raise_for_response(permission)
55
- assert exc.value.code == "permission_denied"
56
-
57
- with pytest.raises(SubscriptionError, match="Resource Limit Exceeded") as exc:
58
- raise_for_response(subscription)
59
- assert exc.value.code == ""
60
-
61
-
62
- @pytest.mark.parametrize(
63
- "code",
64
- ["workspace_read_only", "workspace_license_required"],
65
- )
66
- def test_commercial_error_code_is_preserved(code):
67
- response = httpx.Response(
68
- 403,
69
- request=httpx.Request("POST", "http://testserver/action"),
70
- json={
71
- "detail": "Workspace is read-only.",
72
- "code": code,
73
- },
74
- )
75
-
76
- with pytest.raises(SubscriptionError) as exc:
77
- raise_for_response(response)
78
-
79
- assert exc.value.code == code
80
-
81
-
82
- def test_conflict_responses_raise_conflict_error_even_when_detail_says_not_found():
83
- # A 409 detail may legitimately contain "not found"; the message-based
84
- # NotFound fallback must never reclassify a conflict.
85
- response = httpx.Response(
86
- 409,
87
- request=httpx.Request("POST", "http://testserver/action"),
88
- json={
89
- "detail": "Release 0.3 is still being prepared; archive not found yet.",
90
- "code": "release_not_ready",
91
- },
92
- )
93
-
94
- with pytest.raises(ConflictError, match="still being prepared") as exc:
95
- raise_for_response(response)
96
-
97
- assert isinstance(exc.value, NetworkError)
98
- assert not isinstance(exc.value, NotFoundError)
99
- assert exc.value.code == "release_not_ready"
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