labeltify 0.2.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.
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: labeltify
3
+ Version: 0.2.0
4
+ Summary: Upload frames and optional annotations into LabelTify with a pipeline token.
5
+ Author: CleverTify
6
+ Project-URL: Homepage, https://labeltify.com
7
+ Project-URL: Source, https://github.com/CleverTify/LabelTify/tree/main/clients/python
8
+ Keywords: labeltify,annotation,computer-vision,deepstream,active-learning,dataset
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Topic :: Scientific/Engineering :: Image Recognition
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest>=8; extra == "test"
19
+
20
+ # labeltify
21
+
22
+ Upload frames, and optionally their annotations, into [LabelTify](https://labeltify.com) from DeepStream, OpenCV, Ultralytics, or any Python pipeline. The only thing it needs is a pipeline token.
23
+
24
+ ```sh
25
+ pip install labeltify
26
+ ```
27
+
28
+ 1. On the dataset **Upload** page, create a pipeline token.
29
+ 2. Upload:
30
+
31
+ ```python
32
+ from labeltify import LabelTifyClient
33
+
34
+ client = LabelTifyClient("labeltify_…") # or set LABELTIFY_TOKEN and call LabelTifyClient()
35
+
36
+ img = client.upload_image(
37
+ "DATASET_ID",
38
+ jpeg_bytes,
39
+ filename="cam01.jpg",
40
+ camera="cam-01",
41
+ pipeline="ds-prod",
42
+ trigger="low_confidence",
43
+ )
44
+ client.upload_file("DATASET_ID", "frames/cam02.jpg", camera="cam-02")
45
+ ```
46
+
47
+ The token knows its org, so you don't pass one. Timeouts, `429`, and `5xx` responses are retried with exponential backoff (`max_retries=4` by default). Anything else raises `LabelTifyError` with the API's message and `.status`.
48
+
49
+ Annotations are optional. Pass `labels` (COCO, LabelTify, or YOLO JSON bytes) and `work_state` (`done`, `in_progress`, `unsure`, or `untouched`). Images with no labels stay untouched. If labels are sent and `work_state` is omitted, those images are marked Done.
50
+
51
+ Send model proposals to the Loop review queue:
52
+
53
+ ```python
54
+ client.propose(
55
+ "DATASET_ID",
56
+ img["id"],
57
+ [{"type": "bbox", "id": "p1", "classId": "car", "box": {"x": 0.1, "y": 0.1, "w": 0.2, "h": 0.2}, "source": "model", "confidence": 0.4}],
58
+ model_ref="deepstream@1",
59
+ uncertainty=0.8,
60
+ )
61
+ ```
62
+
63
+ Boxes are normalized `[0,1]`. Camera, pipeline, and trigger are stored on the image so Loop can filter by them.
64
+
65
+ Local API: `LabelTifyClient("labeltify_…", base_url="http://localhost:8787")`.
66
+
67
+ ## Develop
68
+
69
+ ```sh
70
+ pip install -e "clients/python[test]"
71
+ pytest clients/python/tests
72
+ ```
73
+
74
+ Bump `version` in `pyproject.toml` to publish. Each new version that lands on `main` is published to PyPI by `.github/workflows/publish-python.yml`.
@@ -0,0 +1,55 @@
1
+ # labeltify
2
+
3
+ Upload frames, and optionally their annotations, into [LabelTify](https://labeltify.com) from DeepStream, OpenCV, Ultralytics, or any Python pipeline. The only thing it needs is a pipeline token.
4
+
5
+ ```sh
6
+ pip install labeltify
7
+ ```
8
+
9
+ 1. On the dataset **Upload** page, create a pipeline token.
10
+ 2. Upload:
11
+
12
+ ```python
13
+ from labeltify import LabelTifyClient
14
+
15
+ client = LabelTifyClient("labeltify_…") # or set LABELTIFY_TOKEN and call LabelTifyClient()
16
+
17
+ img = client.upload_image(
18
+ "DATASET_ID",
19
+ jpeg_bytes,
20
+ filename="cam01.jpg",
21
+ camera="cam-01",
22
+ pipeline="ds-prod",
23
+ trigger="low_confidence",
24
+ )
25
+ client.upload_file("DATASET_ID", "frames/cam02.jpg", camera="cam-02")
26
+ ```
27
+
28
+ The token knows its org, so you don't pass one. Timeouts, `429`, and `5xx` responses are retried with exponential backoff (`max_retries=4` by default). Anything else raises `LabelTifyError` with the API's message and `.status`.
29
+
30
+ Annotations are optional. Pass `labels` (COCO, LabelTify, or YOLO JSON bytes) and `work_state` (`done`, `in_progress`, `unsure`, or `untouched`). Images with no labels stay untouched. If labels are sent and `work_state` is omitted, those images are marked Done.
31
+
32
+ Send model proposals to the Loop review queue:
33
+
34
+ ```python
35
+ client.propose(
36
+ "DATASET_ID",
37
+ img["id"],
38
+ [{"type": "bbox", "id": "p1", "classId": "car", "box": {"x": 0.1, "y": 0.1, "w": 0.2, "h": 0.2}, "source": "model", "confidence": 0.4}],
39
+ model_ref="deepstream@1",
40
+ uncertainty=0.8,
41
+ )
42
+ ```
43
+
44
+ Boxes are normalized `[0,1]`. Camera, pipeline, and trigger are stored on the image so Loop can filter by them.
45
+
46
+ Local API: `LabelTifyClient("labeltify_…", base_url="http://localhost:8787")`.
47
+
48
+ ## Develop
49
+
50
+ ```sh
51
+ pip install -e "clients/python[test]"
52
+ pytest clients/python/tests
53
+ ```
54
+
55
+ Bump `version` in `pyproject.toml` to publish. Each new version that lands on `main` is published to PyPI by `.github/workflows/publish-python.yml`.
@@ -0,0 +1,12 @@
1
+ """LabelTify client for DeepStream, OpenCV, and any pipeline that uploads frames."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from .client import DEFAULT_BASE_URL, LabelTifyClient, LabelTifyError
6
+
7
+ try:
8
+ __version__ = version("labeltify")
9
+ except PackageNotFoundError: # running from a checkout
10
+ __version__ = "0.0.0"
11
+
12
+ __all__ = ["DEFAULT_BASE_URL", "LabelTifyClient", "LabelTifyError", "__version__"]
@@ -0,0 +1,188 @@
1
+ """Upload frames and optional annotations into LabelTify.
2
+
3
+ Auth is a pipeline token from the dataset Upload page. The token already knows its org.
4
+
5
+ from labeltify import LabelTifyClient
6
+ client = LabelTifyClient("labeltify_…")
7
+ image = client.upload_image(dataset_id, jpeg_bytes, filename="cam01.jpg", camera="cam-01", pipeline="ds-prod", trigger="low_confidence")
8
+ client.upload_file(dataset_id, "frames/cam01.jpg")
9
+ client.propose(dataset_id, image["id"], boxes, model_ref="deepstream@1")
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import mimetypes
16
+ import os
17
+ import random
18
+ import time
19
+ import urllib.error
20
+ import urllib.request
21
+ import uuid
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ DEFAULT_BASE_URL = "https://api.labeltify.com"
26
+ RETRY_STATUS = {408, 425, 429, 500, 502, 503, 504}
27
+
28
+
29
+ class LabelTifyError(RuntimeError):
30
+ """The API answered with an error, or kept failing after retries."""
31
+
32
+ def __init__(self, message: str, status: int | None = None, body: str | None = None) -> None:
33
+ super().__init__(message)
34
+ self.status = status
35
+ self.body = body
36
+
37
+
38
+ class LabelTifyClient:
39
+ """Pipeline-token client. Only the token is required.
40
+
41
+ ``token`` falls back to the ``LABELTIFY_TOKEN`` environment variable. ``org`` is looked up from the
42
+ token on first use. Transient failures (timeouts, 429, 5xx) are retried with exponential backoff.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ token: str | None = None,
48
+ base_url: str = DEFAULT_BASE_URL,
49
+ org: str | None = None,
50
+ *,
51
+ timeout: float = 60.0,
52
+ max_retries: int = 4,
53
+ backoff: float = 0.5,
54
+ ) -> None:
55
+ # Older callers passed (base_url, token, org) positionally.
56
+ if token and token.startswith(("http://", "https://")) and base_url and not base_url.startswith(("http://", "https://")):
57
+ token, base_url = base_url, token
58
+ token = token or os.environ.get("LABELTIFY_TOKEN")
59
+ if not token:
60
+ raise ValueError("Pass a pipeline token or set LABELTIFY_TOKEN.")
61
+ self.token = token
62
+ self.base_url = base_url.rstrip("/")
63
+ self._org = org
64
+ self.timeout = timeout
65
+ self.max_retries = max_retries
66
+ self.backoff = backoff
67
+
68
+ @property
69
+ def org(self) -> str:
70
+ """The org slug the token belongs to."""
71
+ if self._org is None:
72
+ me = self._request("GET", "/api/me")
73
+ orgs = me.get("orgs") if isinstance(me, dict) else None
74
+ if not orgs:
75
+ raise LabelTifyError("This token is not linked to an org.")
76
+ self._org = orgs[0]["slug"]
77
+ return self._org
78
+
79
+ def _request(self, method: str, path: str, body: bytes | None = None, content_type: str | None = None) -> Any:
80
+ headers = {"authorization": f"Bearer {self.token}", "user-agent": "labeltify-python"}
81
+ if content_type:
82
+ headers["content-type"] = content_type
83
+ attempt = 0
84
+ while True:
85
+ req = urllib.request.Request(f"{self.base_url}{path}", data=body, method=method, headers=headers)
86
+ try:
87
+ with urllib.request.urlopen(req, timeout=self.timeout) as res:
88
+ raw = res.read()
89
+ return json.loads(raw.decode("utf-8")) if raw else None
90
+ except urllib.error.HTTPError as e:
91
+ text = e.read().decode("utf-8", "replace")
92
+ if e.code in RETRY_STATUS and attempt < self.max_retries:
93
+ self._sleep(attempt, e.headers.get("retry-after"))
94
+ attempt += 1
95
+ continue
96
+ raise LabelTifyError(f"LabelTify API {e.code}: {_message(text)}", e.code, text) from e
97
+ except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
98
+ if attempt < self.max_retries:
99
+ self._sleep(attempt, None)
100
+ attempt += 1
101
+ continue
102
+ raise LabelTifyError(f"Could not reach {self.base_url}: {e}") from e
103
+
104
+ def _sleep(self, attempt: int, retry_after: str | None) -> None:
105
+ try:
106
+ wait = float(retry_after) if retry_after else self.backoff * (2**attempt)
107
+ except ValueError:
108
+ wait = self.backoff * (2**attempt)
109
+ time.sleep(min(wait, 30.0) + random.uniform(0, self.backoff / 2))
110
+
111
+ def upload_image(
112
+ self,
113
+ dataset_id: str,
114
+ data: bytes,
115
+ filename: str,
116
+ *,
117
+ camera: str | None = None,
118
+ pipeline: str | None = None,
119
+ trigger: str | None = None,
120
+ content_type: str = "image/jpeg",
121
+ labels: bytes | None = None,
122
+ labels_filename: str = "labels.json",
123
+ work_state: str | None = None,
124
+ ) -> dict[str, Any]:
125
+ """Upload one image. ``labels`` is optional COCO, LabelTify, or YOLO JSON for it."""
126
+ boundary = f"----labeltify{uuid.uuid4().hex}"
127
+ parts: list[bytes] = []
128
+
129
+ def field(name: str, value: str) -> None:
130
+ parts.append(f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n".encode())
131
+
132
+ def file(name: str, fname: str, ctype: str, payload: bytes) -> None:
133
+ head = (
134
+ f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"; filename=\"{fname}\"\r\n"
135
+ f"Content-Type: {ctype}\r\n\r\n"
136
+ ).encode()
137
+ parts.append(head + payload + b"\r\n")
138
+
139
+ if camera:
140
+ field("camera", camera)
141
+ if pipeline:
142
+ field("pipeline", pipeline)
143
+ if trigger:
144
+ field("trigger", trigger)
145
+ if work_state:
146
+ field("workState", work_state)
147
+ file("file", filename, content_type, data)
148
+ if labels is not None:
149
+ file("file", labels_filename, "application/json", labels)
150
+ parts.append(f"--{boundary}--\r\n".encode())
151
+ result = self._request(
152
+ "POST",
153
+ f"/api/orgs/{self.org}/datasets/{dataset_id}/images",
154
+ b"".join(parts),
155
+ f"multipart/form-data; boundary={boundary}",
156
+ )
157
+ items = result.get("items") if isinstance(result, dict) else None
158
+ if not items:
159
+ raise LabelTifyError("upload returned no items")
160
+ return items[0]
161
+
162
+ def upload_file(self, dataset_id: str, path: str | os.PathLike[str], **kwargs: Any) -> dict[str, Any]:
163
+ """Upload an image file from disk. Keyword arguments are passed to ``upload_image``."""
164
+ p = Path(path)
165
+ kwargs.setdefault("content_type", mimetypes.guess_type(p.name)[0] or "image/jpeg")
166
+ return self.upload_image(dataset_id, p.read_bytes(), p.name, **kwargs)
167
+
168
+ def propose(
169
+ self,
170
+ dataset_id: str,
171
+ image_id: str,
172
+ proposals: list[dict[str, Any]],
173
+ *,
174
+ model_ref: str,
175
+ uncertainty: float = 0.5,
176
+ ) -> dict[str, Any]:
177
+ """Send model proposals for an image to the Loop review queue."""
178
+ payload = json.dumps(
179
+ {"imageId": image_id, "modelRef": model_ref, "proposals": proposals, "uncertainty": uncertainty}
180
+ ).encode()
181
+ return self._request("POST", f"/api/orgs/{self.org}/datasets/{dataset_id}/al/tasks", payload, "application/json")
182
+
183
+
184
+ def _message(text: str) -> str:
185
+ try:
186
+ return json.loads(text)["error"]["message"]
187
+ except (ValueError, KeyError, TypeError):
188
+ return text[:500]
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: labeltify
3
+ Version: 0.2.0
4
+ Summary: Upload frames and optional annotations into LabelTify with a pipeline token.
5
+ Author: CleverTify
6
+ Project-URL: Homepage, https://labeltify.com
7
+ Project-URL: Source, https://github.com/CleverTify/LabelTify/tree/main/clients/python
8
+ Keywords: labeltify,annotation,computer-vision,deepstream,active-learning,dataset
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Topic :: Scientific/Engineering :: Image Recognition
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest>=8; extra == "test"
19
+
20
+ # labeltify
21
+
22
+ Upload frames, and optionally their annotations, into [LabelTify](https://labeltify.com) from DeepStream, OpenCV, Ultralytics, or any Python pipeline. The only thing it needs is a pipeline token.
23
+
24
+ ```sh
25
+ pip install labeltify
26
+ ```
27
+
28
+ 1. On the dataset **Upload** page, create a pipeline token.
29
+ 2. Upload:
30
+
31
+ ```python
32
+ from labeltify import LabelTifyClient
33
+
34
+ client = LabelTifyClient("labeltify_…") # or set LABELTIFY_TOKEN and call LabelTifyClient()
35
+
36
+ img = client.upload_image(
37
+ "DATASET_ID",
38
+ jpeg_bytes,
39
+ filename="cam01.jpg",
40
+ camera="cam-01",
41
+ pipeline="ds-prod",
42
+ trigger="low_confidence",
43
+ )
44
+ client.upload_file("DATASET_ID", "frames/cam02.jpg", camera="cam-02")
45
+ ```
46
+
47
+ The token knows its org, so you don't pass one. Timeouts, `429`, and `5xx` responses are retried with exponential backoff (`max_retries=4` by default). Anything else raises `LabelTifyError` with the API's message and `.status`.
48
+
49
+ Annotations are optional. Pass `labels` (COCO, LabelTify, or YOLO JSON bytes) and `work_state` (`done`, `in_progress`, `unsure`, or `untouched`). Images with no labels stay untouched. If labels are sent and `work_state` is omitted, those images are marked Done.
50
+
51
+ Send model proposals to the Loop review queue:
52
+
53
+ ```python
54
+ client.propose(
55
+ "DATASET_ID",
56
+ img["id"],
57
+ [{"type": "bbox", "id": "p1", "classId": "car", "box": {"x": 0.1, "y": 0.1, "w": 0.2, "h": 0.2}, "source": "model", "confidence": 0.4}],
58
+ model_ref="deepstream@1",
59
+ uncertainty=0.8,
60
+ )
61
+ ```
62
+
63
+ Boxes are normalized `[0,1]`. Camera, pipeline, and trigger are stored on the image so Loop can filter by them.
64
+
65
+ Local API: `LabelTifyClient("labeltify_…", base_url="http://localhost:8787")`.
66
+
67
+ ## Develop
68
+
69
+ ```sh
70
+ pip install -e "clients/python[test]"
71
+ pytest clients/python/tests
72
+ ```
73
+
74
+ Bump `version` in `pyproject.toml` to publish. Each new version that lands on `main` is published to PyPI by `.github/workflows/publish-python.yml`.
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ labeltify/__init__.py
4
+ labeltify/client.py
5
+ labeltify.egg-info/PKG-INFO
6
+ labeltify.egg-info/SOURCES.txt
7
+ labeltify.egg-info/dependency_links.txt
8
+ labeltify.egg-info/requires.txt
9
+ labeltify.egg-info/top_level.txt
10
+ tests/test_client.py
@@ -0,0 +1,3 @@
1
+
2
+ [test]
3
+ pytest>=8
@@ -0,0 +1 @@
1
+ labeltify
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "labeltify"
7
+ # Bump this to publish. .github/workflows/publish-python.yml releases each new version pushed to main.
8
+ version = "0.2.0"
9
+ description = "Upload frames and optional annotations into LabelTify with a pipeline token."
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "CleverTify" }]
13
+ keywords = ["labeltify", "annotation", "computer-vision", "deepstream", "active-learning", "dataset"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Topic :: Scientific/Engineering :: Image Recognition",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.optional-dependencies]
25
+ test = ["pytest>=8"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://labeltify.com"
29
+ Source = "https://github.com/CleverTify/LabelTify/tree/main/clients/python"
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["."]
33
+ include = ["labeltify*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,127 @@
1
+ import json
2
+ import threading
3
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
4
+
5
+ import pytest
6
+
7
+ from labeltify import LabelTifyClient, LabelTifyError
8
+
9
+
10
+ class Api:
11
+ """A tiny stand-in for the LabelTify API that records requests and can fail on demand."""
12
+
13
+ def __init__(self):
14
+ self.requests = []
15
+ self.fail_next = [] # status codes to return before succeeding
16
+
17
+ def handle(self, handler: BaseHTTPRequestHandler):
18
+ length = int(handler.headers.get("content-length") or 0)
19
+ body = handler.rfile.read(length) if length else b""
20
+ self.requests.append((handler.command, handler.path, handler.headers, body))
21
+ if handler.headers.get("authorization") != "Bearer labeltify_test":
22
+ return self.reply(handler, 401, {"error": {"message": "bad token"}})
23
+ if self.fail_next:
24
+ return self.reply(handler, self.fail_next.pop(0), {"error": {"message": "busy"}})
25
+ if handler.path == "/api/me":
26
+ return self.reply(handler, 200, {"orgs": [{"slug": "acme"}]})
27
+ if handler.path.endswith("/images"):
28
+ return self.reply(handler, 201, {"items": [{"id": "img-1"}]})
29
+ if handler.path.endswith("/al/tasks"):
30
+ return self.reply(handler, 201, {"id": "task-1", "echo": json.loads(body)})
31
+ return self.reply(handler, 404, {"error": {"message": "not found"}})
32
+
33
+ @staticmethod
34
+ def reply(handler, status, payload):
35
+ raw = json.dumps(payload).encode()
36
+ handler.send_response(status)
37
+ handler.send_header("content-type", "application/json")
38
+ handler.send_header("content-length", str(len(raw)))
39
+ if status == 429:
40
+ handler.send_header("retry-after", "0")
41
+ handler.end_headers()
42
+ handler.wfile.write(raw)
43
+
44
+
45
+ @pytest.fixture
46
+ def api():
47
+ state = Api()
48
+
49
+ class Handler(BaseHTTPRequestHandler):
50
+ def do_GET(self):
51
+ state.handle(self)
52
+
53
+ def do_POST(self):
54
+ state.handle(self)
55
+
56
+ def log_message(self, *args):
57
+ pass
58
+
59
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
60
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
61
+ thread.start()
62
+ state.url = f"http://127.0.0.1:{server.server_address[1]}"
63
+ yield state
64
+ server.shutdown()
65
+
66
+
67
+ def test_token_is_all_it_needs(api):
68
+ client = LabelTifyClient("labeltify_test", base_url=api.url, backoff=0)
69
+ image = client.upload_image("ds-1", b"\xff\xd8jpeg", "cam01.jpg", camera="cam-01", pipeline="ds", trigger="motion")
70
+ assert image == {"id": "img-1"}
71
+ assert client.org == "acme"
72
+ method, path, headers, body = api.requests[-1]
73
+ assert (method, path) == ("POST", "/api/orgs/acme/datasets/ds-1/images")
74
+ assert headers["content-type"].startswith("multipart/form-data; boundary=")
75
+ for part in (b'name="camera"\r\n\r\ncam-01', b'name="pipeline"\r\n\r\nds', b'name="trigger"\r\n\r\nmotion', b'filename="cam01.jpg"'):
76
+ assert part in body
77
+
78
+
79
+ def test_token_from_environment(api, monkeypatch):
80
+ monkeypatch.setenv("LABELTIFY_TOKEN", "labeltify_test")
81
+ client = LabelTifyClient(base_url=api.url)
82
+ assert client.org == "acme"
83
+
84
+
85
+ def test_missing_token_is_a_clear_error(monkeypatch):
86
+ monkeypatch.delenv("LABELTIFY_TOKEN", raising=False)
87
+ with pytest.raises(ValueError, match="LABELTIFY_TOKEN"):
88
+ LabelTifyClient()
89
+
90
+
91
+ def test_old_positional_order_still_works(api):
92
+ client = LabelTifyClient(api.url, "labeltify_test", "acme")
93
+ assert client.base_url == api.url
94
+ assert client.upload_image("ds-1", b"x", "a.jpg")["id"] == "img-1"
95
+
96
+
97
+ def test_retries_busy_responses(api):
98
+ api.fail_next = [503, 429]
99
+ client = LabelTifyClient("labeltify_test", base_url=api.url, org="acme", backoff=0)
100
+ assert client.upload_image("ds-1", b"x", "a.jpg")["id"] == "img-1"
101
+ assert len(api.requests) == 3
102
+
103
+
104
+ def test_gives_up_with_the_api_message(api):
105
+ client = LabelTifyClient("labeltify_wrong", base_url=api.url, org="acme", backoff=0)
106
+ with pytest.raises(LabelTifyError, match="401: bad token") as err:
107
+ client.upload_image("ds-1", b"x", "a.jpg")
108
+ assert err.value.status == 401
109
+ assert len(api.requests) == 1
110
+
111
+
112
+ def test_labels_and_upload_file(api, tmp_path):
113
+ frame = tmp_path / "frame.png"
114
+ frame.write_bytes(b"\x89PNG")
115
+ client = LabelTifyClient("labeltify_test", base_url=api.url, org="acme", backoff=0)
116
+ client.upload_file("ds-1", frame, labels=b'{"images": []}', work_state="done")
117
+ body = api.requests[-1][3]
118
+ assert b"Content-Type: image/png" in body
119
+ assert b'filename="labels.json"' in body
120
+ assert b'name="workState"\r\n\r\ndone' in body
121
+
122
+
123
+ def test_propose(api):
124
+ client = LabelTifyClient("labeltify_test", base_url=api.url, org="acme")
125
+ task = client.propose("ds-1", "img-1", [{"classId": "car"}], model_ref="deepstream@1")
126
+ assert task["echo"]["modelRef"] == "deepstream@1"
127
+ assert api.requests[-1][1] == "/api/orgs/acme/datasets/ds-1/al/tasks"