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.
- {unitlab-3.0.5 → unitlab-3.1.0}/PKG-INFO +68 -2
- {unitlab-3.0.5 → unitlab-3.1.0}/README.md +66 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/pyproject.toml +2 -2
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/__init__.py +2 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/_http.py +14 -2
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/resources/__init__.py +2 -0
- unitlab-3.1.0/src/unitlab/resources/annotation_imports.py +182 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/resources/projects.py +7 -0
- unitlab-3.1.0/tests/test_annotation_imports.py +326 -0
- unitlab-3.1.0/tests/test_http.py +243 -0
- unitlab-3.0.5/tests/test_http.py +0 -99
- {unitlab-3.0.5 → unitlab-3.1.0}/.gitignore +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/LICENSE.md +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/__main__.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/_config.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/_downloader.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/_grouping.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/_nuscenes.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/_scene_uploader.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/_scenes.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/_uploader.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/_waiter.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/cli.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/client.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/exceptions.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/ontology.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/py.typed +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/resources/_base.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/resources/assets.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/resources/cloud.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/resources/datasets.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/resources/embeddings.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/resources/ontologies.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/resources/releases.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/resources/workflow.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/src/unitlab/types.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/tests/test_cli.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/tests/test_client.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/tests/test_downloader.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/tests/test_embeddings.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/tests/test_nuscenes.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/tests/test_scene_cli.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/tests/test_scene_uploads.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/tests/test_scenes.py +0 -0
- {unitlab-3.0.5 → unitlab-3.1.0}/tests/test_tiled_uploads.py +0 -0
- {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
|
|
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
|
|
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
|
-
|
|
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 == []
|
unitlab-3.0.5/tests/test_http.py
DELETED
|
@@ -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
|
|
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
|