intellabel 0.1.0rc2__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 (44) hide show
  1. intellabel-0.1.0rc2/PKG-INFO +1869 -0
  2. intellabel-0.1.0rc2/README.md +1853 -0
  3. intellabel-0.1.0rc2/intellabel/__init__.py +159 -0
  4. intellabel-0.1.0rc2/intellabel/__main__.py +6 -0
  5. intellabel-0.1.0rc2/intellabel/_auth.py +425 -0
  6. intellabel-0.1.0rc2/intellabel/_client.py +610 -0
  7. intellabel-0.1.0rc2/intellabel/_deployment.py +8 -0
  8. intellabel-0.1.0rc2/intellabel/_progress.py +156 -0
  9. intellabel-0.1.0rc2/intellabel/_types.py +53 -0
  10. intellabel-0.1.0rc2/intellabel/cli.py +111 -0
  11. intellabel-0.1.0rc2/intellabel/exceptions.py +147 -0
  12. intellabel-0.1.0rc2/intellabel/resources/__init__.py +40 -0
  13. intellabel-0.1.0rc2/intellabel/resources/_base.py +129 -0
  14. intellabel-0.1.0rc2/intellabel/resources/_chunked.py +163 -0
  15. intellabel-0.1.0rc2/intellabel/resources/analytics.py +510 -0
  16. intellabel-0.1.0rc2/intellabel/resources/annotations.py +371 -0
  17. intellabel-0.1.0rc2/intellabel/resources/cloud_storages.py +307 -0
  18. intellabel-0.1.0rc2/intellabel/resources/custom_models.py +504 -0
  19. intellabel-0.1.0rc2/intellabel/resources/datasets.py +603 -0
  20. intellabel-0.1.0rc2/intellabel/resources/labels.py +459 -0
  21. intellabel-0.1.0rc2/intellabel/resources/projects.py +305 -0
  22. intellabel-0.1.0rc2/intellabel/resources/tasks.py +1190 -0
  23. intellabel-0.1.0rc2/intellabel/resources/workflows.py +460 -0
  24. intellabel-0.1.0rc2/intellabel.egg-info/PKG-INFO +1869 -0
  25. intellabel-0.1.0rc2/intellabel.egg-info/SOURCES.txt +42 -0
  26. intellabel-0.1.0rc2/intellabel.egg-info/dependency_links.txt +1 -0
  27. intellabel-0.1.0rc2/intellabel.egg-info/entry_points.txt +2 -0
  28. intellabel-0.1.0rc2/intellabel.egg-info/requires.txt +9 -0
  29. intellabel-0.1.0rc2/intellabel.egg-info/top_level.txt +1 -0
  30. intellabel-0.1.0rc2/pyproject.toml +27 -0
  31. intellabel-0.1.0rc2/setup.cfg +4 -0
  32. intellabel-0.1.0rc2/tests/test_analytics.py +513 -0
  33. intellabel-0.1.0rc2/tests/test_annotations.py +338 -0
  34. intellabel-0.1.0rc2/tests/test_auth.py +610 -0
  35. intellabel-0.1.0rc2/tests/test_client.py +397 -0
  36. intellabel-0.1.0rc2/tests/test_cloud_storages.py +171 -0
  37. intellabel-0.1.0rc2/tests/test_datasets.py +390 -0
  38. intellabel-0.1.0rc2/tests/test_labels.py +346 -0
  39. intellabel-0.1.0rc2/tests/test_live.py +62 -0
  40. intellabel-0.1.0rc2/tests/test_models.py +307 -0
  41. intellabel-0.1.0rc2/tests/test_progress.py +158 -0
  42. intellabel-0.1.0rc2/tests/test_projects.py +444 -0
  43. intellabel-0.1.0rc2/tests/test_tasks.py +772 -0
  44. intellabel-0.1.0rc2/tests/test_workflows.py +421 -0
@@ -0,0 +1,1869 @@
1
+ Metadata-Version: 2.4
2
+ Name: intellabel
3
+ Version: 0.1.0rc2
4
+ Summary: Official Python SDK for the INTELLABEL platform API
5
+ Author: INTELLABEL
6
+ License: Proprietary
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: httpx<1.0,>=0.27
10
+ Requires-Dist: python-dotenv>=1.0
11
+ Provides-Extra: progress
12
+ Requires-Dist: tqdm>=4.66; extra == "progress"
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=8.0; extra == "dev"
15
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
16
+
17
+ # INTELLABEL Python SDK
18
+
19
+ Python client for the INTELLABEL platform API, built on [httpx](https://www.python-httpx.org/)
20
+ with sensible timeouts, automatic retries and typed errors. Ships a sync client (`IntelLabel`) and
21
+ an async one (`AsyncIntelLabel`) with identical methods.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pip install -r requirements.txt # or: pip install -e .
27
+ ```
28
+
29
+ Requires Python 3.9+.
30
+
31
+ ## Authentication
32
+
33
+ The SDK authenticates with an **API key**. Create one in the INTELLABEL dashboard
34
+ (**Settings → API Keys**, Super Admin / Admin only). Keys look like `ilk_<prefix>_<secret>`
35
+ and are shown only once at creation time. On the **Explore** plan a company can hold a single API
36
+ key: delete it in the dashboard to create another. The other plans have no limit.
37
+
38
+ There are three ways to give the key to the SDK. Nothing else is needed: the server address is part
39
+ of the deployment, not something you set.
40
+
41
+ | Way | How | Best for |
42
+ |---|---|---|
43
+ | **Log in once** | `intellabel login` in a terminal, or `intellabel.login()` in Python. Checks the key, lets you pick a default workspace, saves both in `~/.intellabel/credentials.json`. Change the workspace later with `intellabel switch-workspace`. | Laptops, notebooks |
44
+ | **Environment variables / `.env`** | `API_KEY` (or `INTELLABEL_API_KEY`) and, optionally, `WORKSPACE_ID` (or `INTELLABEL_WORKSPACE_ID`), in the shell environment or a `.env` file in the project folder. | CI, containers, servers |
45
+ | **Constructor arguments** | `IntelLabel(api_key="ilk_...", workspace_id="...")`. | Keys loaded from your own secret store |
46
+
47
+ When several are present the order is: constructor argument, then environment / `.env`, then the
48
+ saved login (details in [Where the key comes from](#where-the-key-comes-from)).
49
+
50
+ > API keys **cannot** be created, listed, deactivated or deleted through the SDK. The client refuses
51
+ > any `/api-keys` path with `PermissionDeniedError` before sending, and the backend independently
52
+ > rejects API keys on those routes (403). Manage keys from the dashboard.
53
+
54
+ Pick the scopes the key needs when you create it:
55
+
56
+ | Scope | Allows |
57
+ |---------|-----------------------------------------------|
58
+ | `read` | GET requests |
59
+ | `write` | POST / PUT / PATCH / DELETE requests |
60
+ | `admin` | Admin-only routes (also covers read + write) |
61
+
62
+ A call outside the key's scopes raises `PermissionDeniedError`.
63
+
64
+ ### Log in from the terminal
65
+
66
+ Run `intellabel login` once (or `python -m intellabel login`). It asks for the key with hidden
67
+ input, checks it against the backend, prints the result, lets you pick a default workspace, and
68
+ saves both. After that, `IntelLabel()` works with no `.env` and no `workspace_id`.
69
+
70
+ ```text
71
+ $ intellabel login
72
+ INTELLABEL API key:
73
+ Checking the key against https://... ...
74
+
75
+ Workspaces available to this key:
76
+ 1. Production (c2b3cd06-...)
77
+ 2. Staging (3e6697d8-...)
78
+ Default workspace [1-2, Enter to skip]: 1
79
+
80
+ Success: the SDK is authenticated.
81
+ Account: Jane Doe (Admin), Acme
82
+ API key: CI key (ilk_97230dc9_********)
83
+ Scopes: read, write
84
+ Expires: 05-11-2026 01:21 PM
85
+ Workspace: Production (c2b3cd06-...)
86
+ Saved to: /home/jane/.intellabel/credentials.json
87
+ ```
88
+
89
+ A rejected key prints the backend's reason and the prompt asks again, up to 3 times:
90
+
91
+ ```text
92
+ Error: Authentication failed: This API key has expired. Try again.
93
+ ```
94
+
95
+ The backend reasons are `Invalid API key.`, `This API key is inactive.`, `This API key has expired.`
96
+ and `The account that created this API key no longer has access.`.
97
+
98
+ | Command | What it does |
99
+ |---|---|
100
+ | `intellabel login` | Ask for the key (hidden), check it, choose a workspace, save. Options: `--workspace-id ID` (no workspace question), `--no-save` (only check), `--api-key KEY` (no prompt; the key ends up in your shell history) |
101
+ | `intellabel whoami` | Check the key the SDK would use right now and show the account, key name, scopes, expiry and where the key came from |
102
+ | `intellabel switch-workspace [ID or NAME]` | Change the saved default workspace. Checks the saved key, matches the id or name against the workspaces it may use, saves it. Without an argument it shows a numbered list. Only while logged in: after `intellabel logout` it fails with `Not logged in` |
103
+ | `intellabel logout` | Delete the saved key. A key in the environment or `.env` is not touched |
104
+
105
+ Exit status is `0` on success, `1` when authentication fails and `2` for a usage error, so the
106
+ commands can be used in scripts. Without a terminal (CI), the key is read from standard input:
107
+ `echo "$INTELLABEL_KEY" | intellabel login --workspace-id <uuid>`.
108
+
109
+ The same from Python, for notebooks:
110
+
111
+ ```python
112
+ import intellabel
113
+
114
+ info = intellabel.login() # hidden prompt, prints success or the error, saves
115
+ info.name, info.role, info.scopes # who the key belongs to
116
+ intellabel.logout()
117
+ ```
118
+
119
+ `login()` raises `intellabel.LoginError` after printing why it failed. With an existing client,
120
+ `client.whoami()` returns the same `AuthInfo` (`account`, `api_key`, plus the properties `name`,
121
+ `role`, `company_name`, `scopes`, `masked_key`, `expires_at`). It raises `AuthenticationError` with
122
+ the backend's reason when the key is not accepted. It calls `GET /api/auth/whoami`, which any valid
123
+ key can reach whatever its scopes.
124
+
125
+ `workspaces`, `workspace_id` and `workspace_name` on `AuthInfo` are filled by `login()` and
126
+ `switch_workspace()`.
127
+
128
+ ### Switch workspace
129
+
130
+ After logging in, the saved default workspace can be changed without logging in again:
131
+
132
+ ```python
133
+ import intellabel
134
+
135
+ info = intellabel.switch_workspace("Staging") # by name, or by workspace id
136
+ info.workspace_id, info.workspace_name
137
+ intellabel.switch_workspace() # numbered list in a terminal; Enter keeps the current one
138
+ ```
139
+
140
+ Or on a client that runs with the logged-in key:
141
+
142
+ ```python
143
+ with IntelLabel() as client:
144
+ client.switch_workspace("3e6697d8-...") # updates client.workspace_id and the saved default
145
+ ```
146
+
147
+ Rules:
148
+
149
+ - **Only a saved login can switch.** After `intellabel logout` / `intellabel.logout()` there is no
150
+ saved login and `switch_workspace` raises `LoginError` (`Not logged in`). A key from `API_KEY`,
151
+ `.env` or `api_key=` is not a login, unless it is the very key `intellabel login` saved.
152
+ - The workspace must be one the key may use: for a Super Admin key the active workspaces of the
153
+ company, for an Admin key the account's own workspace. Anything else raises `LoginError` (module
154
+ function) or `ValueError` (client method), listing what is available. Two workspaces with the same
155
+ name: use the id.
156
+ - A revoked or expired key raises `LoginError` (`The saved key was rejected: ...`) from the module
157
+ function and `AuthenticationError` from the client method. The saved workspace is left as it was.
158
+
159
+ The key is saved in `~/.intellabel/credentials.json`. Set `INTELLABEL_CONFIG_DIR` to use another
160
+ folder. The file holds the key in plain text and only the current user can read it, as with
161
+ `~/.netrc` or the Hugging Face token file. The secret is never printed, only the masked key.
162
+
163
+ ### Where the key comes from
164
+
165
+ The clients take the first key they find:
166
+
167
+ 1. the `api_key=` argument
168
+ 2. `API_KEY` (or `INTELLABEL_API_KEY`) in the environment or `.env`
169
+ 3. the key saved by `intellabel login`
170
+
171
+ The default workspace follows the same order (`workspace_id=`, then `WORKSPACE_ID`, then the
172
+ saved workspace). The saved workspace is used whenever the active key is the one you logged in
173
+ with, even if that same key also sits in `.env`. A different key never inherits it.
174
+ `client.api_key_source` says which source the key came from. When no key is found, the `ValueError` points to `intellabel login`.
175
+
176
+ ## Quick start
177
+
178
+ ```python
179
+ from intellabel import IntelLabel
180
+
181
+ with IntelLabel() as client: # key from `intellabel login` or API_KEY in .env
182
+ print(client.health()) # {'app_status': 'WORKING', ...}
183
+ page = client.projects.list(workspace_id="<workspace uuid>")
184
+ print(page.total_count, [p.name for p in page])
185
+ ```
186
+
187
+ Async, same API with `await`:
188
+
189
+ ```python
190
+ import asyncio
191
+ from intellabel import AsyncIntelLabel
192
+
193
+ async def main():
194
+ async with AsyncIntelLabel() as client:
195
+ print(await client.health())
196
+ pages = await asyncio.gather(*(client.projects.list(ws) for ws in workspace_ids))
197
+
198
+ asyncio.run(main())
199
+ ```
200
+
201
+ Pick the sync client for scripts, notebooks and CLIs. Pick the async client inside services that
202
+ already run on asyncio or when fanning out many calls. Outside a `with` block call `client.close()`
203
+ (sync) or `await client.aclose()` (async) when done.
204
+
205
+ Generic access for routes that don't have a resource module yet:
206
+
207
+ ```python
208
+ res = client.get("/workspaces") # .get/.post/.put/.patch/.delete
209
+ print(res.output) # the `output` field of the response envelope
210
+ ```
211
+
212
+ ## Configuration
213
+
214
+ ### `.env` file
215
+
216
+ A `.env` file is an alternative to `intellabel login`. Copy `.env.example` to `.env` in your
217
+ project folder and fill it in:
218
+
219
+ ```dotenv
220
+ API_KEY=ilk_<prefix>_<secret>
221
+ WORKSPACE_ID=workspace_uuid
222
+ ```
223
+
224
+ | Variable | Fallback name | Purpose |
225
+ |----------------|---------------------------|---------|
226
+ | `API_KEY` | `INTELLABEL_API_KEY` | The `ilk_...` key from the dashboard (Settings → API Keys) |
227
+ | `WORKSPACE_ID` | `INTELLABEL_WORKSPACE_ID` | Optional. Default workspace UUID for resource calls; any call can still pass `workspace_id=` |
228
+
229
+ The client loads the `.env` automatically: the nearest one from the working directory upwards,
230
+ never one next to the installed SDK. A variable is used only when the matching constructor argument
231
+ is omitted. Precedence is: explicit argument, then shell environment, then `.env`, then the key saved
232
+ by `intellabel login`. Keep `.env` out of version control: the repo's `.gitignore` excludes `.env`
233
+ and `.env.*` (except `.env.example`). Use `dotenv_path="path/to/.env"` to load a specific file or
234
+ `dotenv_path=False` to skip loading.
235
+
236
+ You never set the server address. The package carries the production URL; operators can point a
237
+ deployment elsewhere with the `BASE` environment variable, and `base_url=` exists only to reach a
238
+ different INTELLABEL server from one script.
239
+
240
+ ### Constructor options
241
+
242
+ ```python
243
+ import httpx
244
+ from intellabel import IntelLabel
245
+
246
+ client = IntelLabel(
247
+ api_key="ilk_...", # or API_KEY in .env
248
+ timeout=httpx.Timeout(30.0, connect=10.0), # default; a float sets every phase
249
+ max_retries=2, # default
250
+ default_headers={"X-Trace": "my-job"},
251
+ verify=True, # TLS verification, or a CA bundle path
252
+ )
253
+ ```
254
+
255
+ Same options for `AsyncIntelLabel`. Every call also accepts a per-request `timeout=`. Pass
256
+ `http_client=httpx.Client(...)` (or `httpx.AsyncClient` for the async client) to supply your own
257
+ transport, proxies or mock; the SDK won't close a client it didn't create.
258
+
259
+ ## Retries
260
+
261
+ | Condition | Retried? |
262
+ |---------------------------------------------|---------------------------------------|
263
+ | Connection refused / connect timeout | Yes, any method (request never sent) |
264
+ | Read timeout | Only GET / HEAD / OPTIONS / PUT / DELETE |
265
+ | HTTP 429 | Yes, honours `Retry-After` |
266
+ | HTTP 408 / 5xx | Only GET / HEAD / OPTIONS / PUT / DELETE |
267
+
268
+ POST requests are never replayed once the server may have received them.
269
+ Backoff is exponential with jitter, capped at 8 s.
270
+
271
+ ## Conventions
272
+
273
+ Every resource method exists on both clients with the same name and arguments. On `IntelLabel` it is
274
+ called directly; on `AsyncIntelLabel` it is awaited, and generators become `async for`.
275
+
276
+ Conventions used throughout:
277
+
278
+ - `workspace_id` is optional everywhere. When omitted, the client default applies
279
+ (`IntelLabel(workspace_id=...)` or `WORKSPACE_ID` in `.env`). With neither, a `ValueError` is
280
+ raised before any request is sent.
281
+ - Argument mistakes the SDK can detect itself (empty ids, unknown statuses, bad dates, missing
282
+ files) raise `ValueError` locally, with no request sent.
283
+ - Server rejections raise the `APIError` subclass for the HTTP status (see [Errors](#errors)).
284
+ The common ones are listed per method.
285
+ - Pagination is **0-based**. The backend clamps a page past the end to the last page instead of
286
+ returning an empty page, so `iterate` stops on `total_count`, not on an empty result.
287
+ - Dates passed as filters accept `datetime.date`, `datetime.datetime` or a `"YYYY-MM-DD"` string and
288
+ match one calendar day. Dates returned by the backend are strings in the company timezone,
289
+ formatted `"DD-MM-YYYY HH:MM AM/PM"`.
290
+ - Every model keeps unknown fields the backend may add later in `.extra` and exposes `.to_dict()`.
291
+
292
+ ---
293
+
294
+ ## Client — `IntelLabel` / `AsyncIntelLabel`
295
+
296
+ #### `IntelLabel(api_key=None, *, base_url=None, timeout=..., max_retries=2, api_prefix="/api", default_headers=None, verify=True, http_client=None, dotenv_path=None, workspace_id=None)`
297
+
298
+ | Parameter | Type | Default | Description |
299
+ |---|---|---|---|
300
+ | `api_key` | `str` | `API_KEY` from env / `.env` | `ilk_<prefix>_<secret>`. Required. |
301
+ | `base_url` | `str` | packaged production URL | Server origin. Operators override it per deployment with the `BASE` environment variable; pass it only to reach a different INTELLABEL server. |
302
+ | `timeout` | `float \| httpx.Timeout` | 30 s total, 10 s connect | A float applies to every phase. |
303
+ | `max_retries` | `int` | `2` | Retry budget for transient failures, see [Retries](#retries). |
304
+ | `api_prefix` | `str` | `"/api"` | Prefix every route is mounted under. |
305
+ | `default_headers` | `Mapping[str, str]` | `None` | Extra headers on every request. |
306
+ | `verify` | `bool \| str` | `True` | TLS verification, or a CA bundle path. |
307
+ | `http_client` | `httpx.Client` / `httpx.AsyncClient` | `None` | Bring your own transport, proxies or mock. The SDK never closes a client it did not create. |
308
+ | `dotenv_path` | `str \| PathLike \| False` | nearest `.env` | A specific `.env` to load, or `False` to skip loading. |
309
+ | `workspace_id` | `str` | `WORKSPACE_ID` from env / `.env` | Default workspace for resource calls. |
310
+
311
+ Raises `ValueError` when the key is missing or malformed, and `TypeError`
312
+ when `http_client` is the wrong httpx class for the client.
313
+
314
+ #### Lifecycle
315
+
316
+ | Method | Description |
317
+ |---|---|
318
+ | `with IntelLabel() as client:` / `async with AsyncIntelLabel() as client:` | Context managers that close the connection pool on exit. |
319
+ | `close()` / `await aclose()` | Explicit close when not using a context block. No-op for an injected `http_client`. |
320
+
321
+ #### Properties and key rotation
322
+
323
+ | Member | Returns | Description |
324
+ |---|---|---|
325
+ | `base_url` | `str` | Normalised origin without trailing slash. |
326
+ | `workspace_id` | `str \| None` | Current default workspace; assignable at runtime. |
327
+ | `api_key_prefix` | `str` | The public `<prefix>` segment of the key. Safe to log. |
328
+ | `masked_api_key` | `str` | `ilk_<prefix>_********`. Used by `repr()`. |
329
+ | `set_api_key(api_key)` | `None` | Swap the key, e.g. after rotation. `ValueError` on a malformed key. |
330
+ | `switch_workspace(workspace)` | `str` | Change the default workspace of a logged-in SDK, see [Switch workspace](#switch-workspace). Updates `workspace_id` and the saved default; returns the new id. `LoginError` when this key is not the saved login. |
331
+
332
+ #### Raw requests
333
+
334
+ ```python
335
+ request(method, path, *, params=None, json=None, data=None, files=None, headers=None, timeout=...) -> APIResponse
336
+ get(path, **kwargs) / post(path, **kwargs) / put(path, **kwargs) / patch(path, **kwargs) / delete(path, **kwargs)
337
+ ```
338
+
339
+ Sends to `<base_url><api_prefix><path>`, adds the `Authorization: Bearer` header, drops `None`
340
+ query parameters, applies the retry policy and unwraps the response envelope. Use these for routes
341
+ without a resource module yet.
342
+
343
+ Returns `APIResponse` with `.success`, `.status`, `.output` and `.extra`. Raises an `APIError`
344
+ subclass on error statuses, `APIConnectionError` / `APITimeoutError` on transport failures once
345
+ retries are exhausted, and `PermissionDeniedError` locally for any `/api-keys` path (key management
346
+ is dashboard-only).
347
+
348
+ #### `health() -> dict`
349
+
350
+ `GET /api/health`. Returns the backend's status dict (`app_status`, `db_status`,
351
+ `websocket_status`, `ping_status`, `response_time`). Unauthenticated, so it proves connectivity,
352
+ not that the key is valid.
353
+
354
+ #### `download(path, dest, *, params=None, on_bytes=None, fallback_name="download", timeout=...) -> Path`
355
+
356
+ Streams `GET <path>` to disk without loading it into memory. `dest` with a file extension
357
+ (`out.zip`) is the file to write; any other `dest` is a folder, created if missing (relative to the
358
+ current directory), and the file inside it keeps the server's `Content-Disposition` name; an
359
+ existing file is never replaced (`name (1).zip`, `name (2).zip`, ...). An explicit file path is
360
+ overwritten. `on_bytes(written, total)` reports
361
+ progress. Error statuses raise the usual typed errors and leave no partial file.
362
+
363
+ ---
364
+
365
+ ## Projects — `client.projects`
366
+
367
+ ### Usage
368
+
369
+ Every project call is scoped to a workspace. Pass `workspace_id=` per call, or set a default once
370
+ with `IntelLabel(workspace_id=...)` or `WORKSPACE_ID` in `.env`.
371
+
372
+ ```python
373
+ from intellabel import IntelLabel, ProjectStatus
374
+
375
+ with IntelLabel(workspace_id="<workspace uuid>") as client:
376
+ page = client.projects.list(status=ProjectStatus.IN_PROGRESS, page=0, limit=20) # pages are 0-based
377
+ print(page.total_count, [p.name for p in page])
378
+
379
+ for project in client.projects.iterate(page_size=50): # walks every page
380
+ ...
381
+
382
+ project = client.projects.create("Street signs", ProjectStatus.NOT_STARTED)
383
+ project = client.projects.get(project.project_id) # None if in trash
384
+ project = client.projects.set_status(project.project_id, ProjectStatus.IN_PROGRESS)
385
+ project = client.projects.rename(project.project_id, "Street signs v2")
386
+ project = client.projects.update(project.project_id, name="...", status="...")
387
+ ```
388
+
389
+ With `AsyncIntelLabel` every call is awaited and `iterate` is an async generator
390
+ (`async for project in client.projects.iterate()`).
391
+
392
+ Statuses: `Not Started`, `In Progress`, `Quality Check`, `Completed`, `Skipped`, `Returned`
393
+ (constants on `ProjectStatus`). The API has no permanent delete; `move_to_trash` is the deletion
394
+ the platform offers, and the dashboard trash can restore.
395
+
396
+ ### Methods
397
+
398
+ Backend router `app/api/project_apis.py`. All calls are workspace-scoped.
399
+
400
+ #### `list(workspace_id=None, *, name=None, project_id=None, status=None, created_by=None, creation_date=None, last_updation_date=None, page=None, limit=None) -> ProjectList`
401
+
402
+ `GET /projects`. Lists the workspace's projects, newest first, excluding trashed ones.
403
+
404
+ | Parameter | Description |
405
+ |---|---|
406
+ | `name`, `project_id`, `created_by` | Fuzzy, case-insensitive matches (partial ratio above 70). |
407
+ | `status` | Exact match; one of `ProjectStatus.ALL`. |
408
+ | `creation_date`, `last_updation_date` | Match a calendar day. |
409
+ | `page`, `limit` | 0-based page and page size. Must be given together; without them every match is returned. |
410
+
411
+ Returns `ProjectList(projects: list[Project], total_count: int)`. `total_count` is the number of
412
+ matches before pagination. The object is iterable and supports `len()`.
413
+
414
+ Raises `ValueError` for `page` without `limit`, `page < 0`, `limit < 1`, an unknown status or a bad
415
+ date. Server errors: `BadRequestError` (no workspace), `NotFoundError` (unknown or trashed
416
+ workspace), `PermissionDeniedError` (deactivated workspace or not a member).
417
+
418
+ #### `iterate(workspace_id=None, *, page_size=50, **filters) -> Iterator[Project]`
419
+
420
+ Generator over every matching project, fetching `page_size` per request from page 0 and stopping
421
+ when `total_count` is reached. Accepts the same filters as `list`. `async for` on the async client.
422
+ `ValueError` when `page_size < 1`.
423
+
424
+ #### `get(project_id, workspace_id=None) -> Project | None`
425
+
426
+ `GET /projects/{project_id}`. Returns the project, or `None` when it exists but is in the trash
427
+ (the backend answers with an empty list for that case). Raises `NotFoundError` when the id does not
428
+ exist in the workspace, `ValueError` for an empty id.
429
+
430
+ #### `create(name, status="Not Started", workspace_id=None) -> Project`
431
+
432
+ `POST /projects`. Names are trimmed and must be unique inside the workspace.
433
+
434
+ | Parameter | Description |
435
+ |---|---|
436
+ | `name` | Required, non-empty. |
437
+ | `status` | One of `ProjectStatus.ALL`. Default `"Not Started"`. |
438
+
439
+ Raises `ValueError` for an empty name or unknown status; `ConflictError` on a duplicate name;
440
+ `PermissionDeniedError` when the key lacks the `write` scope or the account is below Workforce
441
+ Manager.
442
+
443
+ #### `update(project_id, *, name=None, status=None, workspace_id=None) -> Project`
444
+
445
+ `PUT /projects/{project_id}`. Changes the name and/or the status. The backend requires both fields
446
+ on every update, so when only one is passed the current project is fetched first and the other
447
+ field is sent back unchanged.
448
+
449
+ Raises `ValueError` when neither field is given, the name is empty, the status is unknown, or the
450
+ project is in the trash; `ConflictError` on a duplicate name; `NotFoundError` for an unknown id.
451
+
452
+ #### `rename(project_id, name, workspace_id=None) -> Project`
453
+
454
+ Shortcut for `update(project_id, name=name)`.
455
+
456
+ #### `set_status(project_id, status, workspace_id=None) -> Project`
457
+
458
+ Shortcut for `update(project_id, status=status)`.
459
+
460
+ #### `move_to_trash(project_id, workspace_id=None) -> str`
461
+
462
+ `POST /move-to-trash` with `{"project_id": ...}`. Soft-deletes the project and its tasks: they
463
+ leave every listing, `get` returns `None`, and the dashboard trash can restore them. The platform
464
+ has no permanent delete; a trash-deletion worker purges trashed items later. Returns the server
465
+ message (`"Move to trash operation successful."`). `BadRequestError` when already in trash.
466
+
467
+ #### Models
468
+
469
+ `Project` fields: `id`, `project_id` (8-character public id used in URLs), `project_name`,
470
+ `status`, `workspace_id`, `created_by`, `created_user_id`, `role`, `creation_date`,
471
+ `last_updation_date`, `s3_key`, `in_trash`, `restored`. Property `name` aliases `project_name`.
472
+
473
+ `ProjectStatus` constants: `NOT_STARTED`, `IN_PROGRESS`, `QUALITY_CHECK`, `COMPLETED`, `SKIPPED`,
474
+ `RETURNED`; tuple `ALL`; `validate(value)` returns the value or raises `ValueError`. Matching is
475
+ case-sensitive, like the backend.
476
+
477
+ ---
478
+
479
+ ## Tasks — `client.tasks`
480
+
481
+ ### Usage
482
+
483
+ Tasks live inside a project, and every call needs the project id plus the workspace (default or
484
+ per call). Dataset routes live on `client.datasets`.
485
+
486
+ ```python
487
+ from intellabel import IntelLabel, TaskStatus, StorageType, CloudProvider, CloudKey
488
+
489
+ with IntelLabel(workspace_id="<workspace uuid>") as client:
490
+ page = client.tasks.list("a1b2c3d4", status=TaskStatus.IN_PROGRESS, page=0, limit=20)
491
+ task = client.tasks.get("a1b2c3d4", "t1t1t1t1") # None if absent
492
+ refs = client.tasks.names(["a1b2c3d4"]) # [TaskRef(task_id, task_name), ...]
493
+ task = client.tasks.set_status("a1b2c3d4", task.task_id, TaskStatus.COMPLETED)
494
+ client.tasks.move_to_trash("a1b2c3d4", task.task_id)
495
+ ```
496
+
497
+ #### Uploading files
498
+
499
+ Creating a task opens an upload batch; the files follow in a second step and a worker processes
500
+ them asynchronously. One call does the whole thing for local files:
501
+
502
+ ```python
503
+ task, progress = client.tasks.create_and_upload(
504
+ "a1b2c3d4", "Batch A",
505
+ files=["img/001.jpg", "img/002.png", "clips/a.mp4"],
506
+ no_of_files_per_batch=50, # or no_of_batches=...
507
+ video_fps={"a.mp4": 5}, # required for videos (1-30), or one int for all
508
+ show_progress=True, # terminal bar while sending, status line while processing
509
+ timeout=600,
510
+ )
511
+ ```
512
+
513
+ `show_progress=True` draws a byte-accurate bar for the transfer, then a `Processing on server...`
514
+ line until the worker finishes. It uses [tqdm](https://tqdm.github.io/) when installed
515
+ (`pip install intellabel[progress]`) and a plain stderr bar otherwise. Everything is computed on
516
+ the client; nothing extra is asked of the backend. For your own UI use the callbacks instead:
517
+ `on_bytes(sent, total)` per streamed chunk, `on_chunk(files_sent, files_total, bytes_sent)` per
518
+ accepted request, `on_progress(state)` per server-side state change.
519
+
520
+ Or point it at a folder instead of listing files (recursive by default; non-media files are skipped):
521
+
522
+ ```python
523
+ task, progress = client.tasks.create_and_upload("a1b2c3d4", "Batch B", directory="datasets/batch-b", no_of_batches=4, video_fps=5)
524
+ ```
525
+
526
+ Or step by step:
527
+
528
+ ```python
529
+ task = client.tasks.create("a1b2c3d4", "Batch A", total_files=len(paths), total_size=total_bytes, no_of_batches=4)
530
+ client.tasks.upload_files("a1b2c3d4", task.task_id, paths, task=task, show_progress=True) # streams from disk
531
+ final = client.tasks.wait_for_upload("a1b2c3d4", task.task_id, timeout=600, show_progress=True)
532
+ ```
533
+
534
+ - **Large uploads.** Files are streamed in several multipart requests that share the batch, the
535
+ same way the dashboard does it: 50 files or 512 MB per request by default (`files_per_request`,
536
+ `bytes_per_request`). The async client sends up to `concurrency=2` requests at once. Tens of
537
+ thousands of files or many gigabytes are fine; nothing is read into memory up front.
538
+ - **Formats.** Images jpg, jpeg, png, webp; videos mp4, avi, mov, mkv, wmv, flv, webm; a zip archive
539
+ (extracted server-side) must be the only file of the call.
540
+ - **Progress.** The backend only exposes two states over its REST API: `queued` (files accepted,
541
+ worker processing) and `completed` (worker finished and wrote `total_files` / `corrupted_files`
542
+ on the task). `progress()` returns that as an `UploadProgress`; `iter_progress()` yields each
543
+ change (`async for` on the async client); `wait_for_upload()` blocks until completion and raises
544
+ `UploadTimeoutError` with the last state after `timeout`. Per-file percentages are only pushed on
545
+ the dashboard's websocket, which API keys cannot open.
546
+
547
+ #### Importing from cloud storage
548
+
549
+ ```python
550
+ task = client.tasks.create("a1b2c3d4", "S3 batch", total_files=2, total_size=1_048_576, no_of_batches=1)
551
+ client.tasks.upload_from_cloud(
552
+ "a1b2c3d4", task.task_id,
553
+ keys=[CloudKey("imgs/a.jpg", size=524_288), CloudKey("imgs/b.jpg", size=524_288), CloudKey("more/", type="DIR")],
554
+ cloud_storage="prod-bucket", # display name configured in the workspace
555
+ provider=CloudProvider.AWS_S3, # AWS S3 only for now; Azure and GCS coming soon
556
+ task=task, fps=30,
557
+ )
558
+ client.tasks.wait_for_upload("a1b2c3d4", task.task_id)
559
+ ```
560
+
561
+ For S3 the backend verifies the key sizes and that their sum equals the `total_size` given at create.
562
+
563
+ `TaskStatus` has the same values as `ProjectStatus`. Task names are unique inside a project.
564
+
565
+ ### Methods
566
+
567
+ Backend router `app/api/task_apis.py`, excluding dataset routes. Every call takes the `project_id`
568
+ the task belongs to plus the workspace.
569
+
570
+ #### `list(project_id, workspace_id=None, *, task_id=None, name=None, status=None, created_by=None, creation_date=None, last_updation_date=None, page=None, limit=None) -> TaskList`
571
+
572
+ `GET /projects/{project_id}/tasks`. Lists the project's tasks, newest first, excluding trashed ones.
573
+ Filters behave as for projects (`task_id`, `name`, `created_by` fuzzy; `status` exact; dates by
574
+ day; 0-based `page` with `limit`). Each task carries at most 5 preview files in total across
575
+ `images` and `videos`.
576
+
577
+ Returns `TaskList(tasks: list[Task], total_count: int)`, iterable with `len()`.
578
+
579
+ Raises `ValueError` for an empty project id or bad filter arguments; `NotFoundError` when the
580
+ project is not in the workspace; `PermissionDeniedError` for a worker account not assigned to any
581
+ task of the project.
582
+
583
+ #### `iterate(project_id, workspace_id=None, *, page_size=50, **filters) -> Iterator[Task]`
584
+
585
+ Generator over every matching task, paged from 0 and stopping on `total_count`. Same filters as
586
+ `list`.
587
+
588
+ #### `get(project_id, task_id, workspace_id=None) -> Task | None`
589
+
590
+ The backend has no single-task route, so this calls `list` with the `task_id` filter (which is
591
+ fuzzy server-side) and keeps only the exact id. Returns `None` when the project has no such task.
592
+ `ValueError` for an empty id.
593
+
594
+ #### `names(project_ids=None, workspace_id=None) -> list[TaskRef]`
595
+
596
+ `GET /tasks`. Returns `TaskRef(task_id, task_name)` for every non-trashed task in the workspace, or
597
+ only in the given projects. `project_ids` may be a list or a single string. `NotFoundError` names
598
+ any project id that does not exist (`"Project(s) not found: ..."`).
599
+
600
+ #### `create(project_id, name, *, total_files, status="Not Started", total_size=0, no_of_batches=None, no_of_files_per_batch=None, target_storage="local", cloud_display_name=None, cloud_provider=None, workspace_id=None) -> Task`
601
+
602
+ `POST /projects/{project_id}/validate/task`. Creates the task row and opens its upload batch. The
603
+ returned `Task` carries `batch_id` and `progress_id`, which the upload methods need. Files are
604
+ uploaded afterwards with `upload_files` or `upload_from_cloud`.
605
+
606
+ | Parameter | Description |
607
+ |---|---|
608
+ | `name` | Required; unique inside the project. |
609
+ | `total_files` | Number of files that will be uploaded. Non-negative integer. |
610
+ | `status` | One of `TaskStatus.ALL`. Default `"Not Started"`. |
611
+ | `total_size` | Total size in bytes. Checked against the plan's remaining storage. For cloud imports it must equal the sum of the key sizes. |
612
+ | `no_of_batches` / `no_of_files_per_batch` | Exactly one of them, a positive integer: how the worker splits the files into datasets. |
613
+ | `target_storage` | `StorageType.LOCAL` (default) or `StorageType.CLOUD`, where the files are stored. |
614
+ | `cloud_display_name`, `cloud_provider` | Required with `target_storage="cloud"`: the display name of a storage configured in the workspace and one of `CloudProvider.ALL`. Not allowed with `local`. |
615
+
616
+ Raises `ValueError` for any inconsistent combination above; `ConflictError` on a duplicate name;
617
+ `BadRequestError` when the plan's storage limit is reached; `NotFoundError` when the project, the
618
+ cloud storage or the subscription cannot be found; `PermissionDeniedError` without the `write`
619
+ scope.
620
+
621
+ #### `update(project_id, task_id, *, name=None, status=None, workspace_id=None) -> Task`
622
+
623
+ `PUT /projects/{project_id}/tasks/{task_id}`. Same semantics as the project update: both fields are
624
+ required server-side, so a missing one is fetched from the current task first. `ValueError` when
625
+ neither is given, the name is empty, the status is unknown, or the task is not found;
626
+ `ConflictError` on a duplicate name.
627
+
628
+ #### `rename(project_id, task_id, name, workspace_id=None) -> Task`
629
+
630
+ Shortcut for `update(..., name=name)`.
631
+
632
+ #### `set_status(project_id, task_id, status, workspace_id=None) -> Task`
633
+
634
+ Shortcut for `update(..., status=status)`.
635
+
636
+ #### `upload_files(project_id, task_id, files=None, *, directory=None, recursive=True, batch_id=None, progress_id=None, task=None, video_fps=None, files_per_request=50, bytes_per_request=512 MiB, on_chunk=None, on_bytes=None, show_progress=False, workspace_id=None) -> UploadBatch`
637
+
638
+ `POST /projects/{project_id}/tasks/{task_id}/upload/files`, multipart. Streams local files into
639
+ the task's upload batch and returns once the backend has queued every request for its worker.
640
+ Give exactly one of `files` or `directory`; passing both, or neither, raises `ValueError`.
641
+
642
+ | Parameter | Description |
643
+ |---|---|
644
+ | `files` | Sequence of file paths, or tuples `(filename, data)` / `(filename, data, mime)` where `data` is `bytes` or a binary file object. Paths are opened lazily and streamed; caller-owned file objects are left open. |
645
+ | `directory` | A folder to upload instead of `files`. Every file with a supported extension is included, sorted by path; other files are skipped. `ValueError` when the folder is missing, contains no supported files, or two files share a name (the backend identifies files by name). |
646
+ | `recursive` | Include sub-folders of `directory`. Default `True`. |
647
+ | `batch_id`, `progress_id` | From the created task. Either pass both, or pass `task=` instead. |
648
+ | `task` | The `Task` returned by `create`; supplies `batch_id` and `progress_id`. |
649
+ | `video_fps` | Required when any video is included. Either one `int` for every video or a `{filename: fps}` mapping. Range 1 to 30. Sent to the backend as its `config` form field. |
650
+ | `files_per_request`, `bytes_per_request` | Chunking limits. Files are split into several requests that share the batch, the way the dashboard uploads. Defaults 50 files / 512 MiB; the server accepts up to 5 GB per request. |
651
+ | `on_chunk` | `callback(files_sent, files_total, bytes_sent)` after each accepted request. |
652
+ | `on_bytes` | `callback(bytes_sent, bytes_total)` as httpx streams the files; fires many times per file. |
653
+ | `show_progress` | Draw a terminal progress bar for the bytes being sent (tqdm if installed, else a plain stderr bar). Set `INTELLABEL_NO_TQDM=1` to force the plain bar. |
654
+
655
+ Accepted formats by extension: images `.jpg .jpeg .png .webp`; videos `.mp4 .avi .mov .mkv .wmv
656
+ .flv .webm`; archives `.zip`. The MIME type is derived from the extension in the way the backend
657
+ expects (for example `.mkv` is sent as `video/mkv`). A zip archive must be the only file of the
658
+ call, because the backend re-derives the batch total from the archive's contents.
659
+
660
+ Returns `UploadBatch(queued: bool, batch_id: str)`.
661
+
662
+ Raises `ValueError` for an unsupported extension, a missing path, missing `video_fps` or an fps
663
+ out of range, a zip mixed with other files, or missing batch ids. `BadRequestError` when the batch
664
+ has already completed or expired (`"uploading has already completed for this batch_id ..."`).
665
+ Files the SDK opened are always closed, also on errors.
666
+
667
+ On `AsyncIntelLabel` there is one extra keyword, `concurrency=2`: the maximum number of chunk
668
+ requests in flight at once. `on_chunk` may be a coroutine function.
669
+
670
+ #### `upload_from_cloud(project_id, task_id, keys, *, cloud_storage, provider, batch_id=None, progress_id=None, task=None, fps=30, workspace_id=None) -> UploadBatch`
671
+
672
+ `POST /projects/{project_id}/tasks/{task_id}/upload/cloud/files`. Imports objects from a cloud
673
+ storage configured in the workspace into the task's batch. The backend worker reads the objects
674
+ itself; nothing passes through the SDK.
675
+
676
+ | Parameter | Description |
677
+ |---|---|
678
+ | `keys` | Non-empty sequence of `CloudKey` objects, plain key strings (treated as `FILE` with size 0), or dicts with `key`, `name`, `type`, `mime_type`, `size` as the dashboard sends them. |
679
+ | `cloud_storage` | The storage's display name in the workspace. |
680
+ | `provider` | `CloudProvider.AWS_S3` (`"AWS S3"`). AWS S3 is the only supported cloud provider today; Azure Blob Storage and Google Cloud Storage are coming soon. |
681
+ | `batch_id`, `progress_id`, `task` | As for `upload_files`. |
682
+ | `fps` | Frame rate applied to every imported video, 1 to 30. Default 30. |
683
+
684
+ `CloudKey(key, size=0, mime_type=None, name=None, type="FILE")`: `type="DIR"` imports every media
685
+ object under the prefix; `size` in bytes is verified against the real object for S3, and the sum
686
+ of sizes must equal the `total_size` given at `create`. `mime_type` defaults from the extension.
687
+
688
+ Raises `ValueError` for empty keys, an unknown provider, an fps out of range, an invalid key type
689
+ or size, or an unsupported extension. `NotFoundError` when the cloud storage is not configured
690
+ (`"Cloud Provider does not exists with the cloud storage name ..."`); `BadRequestError` when S3
691
+ sizes do not match or the batch has completed.
692
+
693
+ #### `progress(project_id, task_id, workspace_id=None) -> UploadProgress`
694
+
695
+ Reads the task row and derives the upload state. The backend exposes exactly two states over its
696
+ REST API: `queued` (files accepted, worker processing) and `completed` (the worker finalised the
697
+ batch and wrote `total_files` and `corrupted_files` on the task). Per-file percentages are pushed
698
+ only on the dashboard websocket, which API keys cannot open, and `GET /get/progress` is never
699
+ updated by the upload worker, so neither is used.
700
+
701
+ Returns `UploadProgress` with `project_id`, `task_id`, `done`, `total_files`, `uploaded_files`,
702
+ `corrupted_files` (both known only once done), `preview_files` (0 to 5), `last_updation_date`, and
703
+ properties `status` (`"queued"` / `"completed"`), `processed` and `progress_percentage` (0 or 100).
704
+ Raises `ValueError` when the task is not found.
705
+
706
+ #### `iter_progress(project_id, task_id, *, interval=2.0, timeout=None, workspace_id=None) -> Iterator[UploadProgress]`
707
+
708
+ Polls `progress` every `interval` seconds and yields each state change until `done`. `async for` on
709
+ the async client. Raises `ValueError` for `interval <= 0` or `timeout <= 0`, and
710
+ `UploadTimeoutError` after `timeout` seconds (`None` waits forever); the error's `.progress` holds
711
+ the last state seen.
712
+
713
+ #### `wait_for_upload(project_id, task_id, *, interval=2.0, timeout=None, on_progress=None, show_progress=False, workspace_id=None) -> UploadProgress`
714
+
715
+ Blocks until the worker finishes and returns the final state. `on_progress(state)` is called on
716
+ every change; on the async client it may be a coroutine function. `show_progress=True` prints a
717
+ `Processing on server...` status line meanwhile. Same errors as `iter_progress`.
718
+
719
+ #### `create_and_upload(project_id, name, files=None, *, directory=None, recursive=True, status="Not Started", no_of_batches=None, no_of_files_per_batch=None, video_fps=None, files_per_request=50, bytes_per_request=512 MiB, on_chunk=None, on_bytes=None, show_progress=False, wait=True, interval=2.0, timeout=None, on_progress=None, workspace_id=None) -> tuple[Task, UploadProgress | None]`
720
+
721
+ Convenience for local files. Takes exactly one of `files` or `directory` (as in `upload_files`),
722
+ computes `total_files` and `total_size` from the resolved files, calls
723
+ `create`, streams the files with `upload_files`, and, unless `wait=False`, blocks in
724
+ `wait_for_upload`. With `show_progress=True` the byte bar is followed by a `Processing on server...`
725
+ status line that updates on every state change until completion. Validation of videos and chunking
726
+ happens before the task is created, so a bad argument never leaves an empty task behind. Returns the task and the final progress (`None` when
727
+ not waiting). `concurrency` is available on the async client. Errors as for the three underlying
728
+ methods.
729
+
730
+ #### `move_to_trash(project_id, task_id, workspace_id=None) -> str`
731
+
732
+ `POST /move-to-trash` with `{"project_id", "task_id"}`. Soft-deletes the task; it leaves `list`,
733
+ `get` returns `None`, and the dashboard trash can restore it. Returns the server message.
734
+
735
+ #### Models and constants
736
+
737
+ `Task` fields: `id`, `task_id`, `task_name`, `project_id`, `company_id`, `status`, `created_by`,
738
+ `created_user_id`, `creation_date`, `last_updation_date`, `total_files`, `corrupted_files` (null
739
+ until the upload worker finalises), `batch_id`, `progress_id`, `s3_key`, `target_storage`,
740
+ `in_trash`, `restored`, `images`, `videos` (preview lists, at most 5 together), `labels`,
741
+ `workflow` (raw backend structures). Property `name` aliases `task_name`.
742
+
743
+ `TaskRef(task_id, task_name)`, `TaskList(tasks, total_count)`, `UploadBatch(queued, batch_id)`,
744
+ `UploadProgress` (see `progress`), `CloudKey` (see `upload_from_cloud`).
745
+
746
+ `TaskStatus` is the same object as `ProjectStatus`. `StorageType.LOCAL` / `StorageType.CLOUD`.
747
+ `CloudProvider.AWS_S3` is the supported cloud provider; Azure Blob Storage and Google Cloud Storage are coming soon.
748
+
749
+ ---
750
+
751
+ ## Datasets — `client.datasets`
752
+
753
+ ### Usage
754
+
755
+ The upload worker splits a task's files into *datasets* (`no_of_batches` / `no_of_files_per_batch`
756
+ at task creation). Each dataset sits in one workflow stage, has a status and a priority, and is
757
+ worked on by at most one assigned worker at a time. Listings are per stage and pages are **1-based**.
758
+
759
+ ```python
760
+ from intellabel import IntelLabel, Selection, DatasetStatus, FileType, ReviewTab
761
+
762
+ with IntelLabel(workspace_id="<workspace uuid>") as client:
763
+ page = client.datasets.list("a1b2c3d4", "t1t1t1t1", "Annotator 1", dataset_status=DatasetStatus.NOT_STARTED)
764
+ for ds in client.datasets.iterate("a1b2c3d4", "t1t1t1t1", "Annotator 1"):
765
+ print(ds.dataset_name, ds.status, ds.priority, ds.assigned_user_id)
766
+
767
+ detail = client.datasets.get("a1b2c3d4", "t1t1t1t1", ds.dataset_id, tab=ReviewTab.REJECTED)
768
+ print(client.datasets.actions("a1b2c3d4", "t1t1t1t1", ds.dataset_id)) # what you may do now
769
+ print(client.datasets.file_tabs("a1b2c3d4", "t1t1t1t1", ds.dataset_id)) # {"All": 20, "Rejected": 2, "Approved": 5}
770
+
771
+ client.datasets.initiate("a1b2c3d4", "t1t1t1t1", ds.dataset_id, "usr_annotator_1") # assign to a worker
772
+ client.datasets.set_priority("a1b2c3d4", "t1t1t1t1", 90, [ds.dataset_id])
773
+ client.datasets.change_stage("a1b2c3d4", "t1t1t1t1", "Annotator 1", "Review 1", Selection.all_except())
774
+ client.datasets.approve_file("a1b2c3d4", "t1t1t1t1", ds.dataset_id, "img_123", FileType.IMAGE)
775
+ client.datasets.move_to_trash("a1b2c3d4", "t1t1t1t1", "Review 1", [ds.dataset_id])
776
+ ```
777
+
778
+ **Selections.** Bulk methods take `datasets` (or `files`) as either a list of ids, meaning exactly
779
+ those, or `Selection.all_except(exclude=[...])`, meaning everything the matching listing shows minus
780
+ the excluded ids. For `release` and `set_priority` the listing filters (`stage`, `dataset_status`,
781
+ `search`, ...) passed as keyword arguments scope an all-selection; `change_stage` and
782
+ `move_to_trash` are always scoped by `current_stage`.
783
+
784
+ ### Methods
785
+
786
+ Backend routes under `/projects/{pid}/tasks/{tid}/datasets` (`app/api/task_apis.py`). All calls
787
+ take `project_id` and `task_id` plus the workspace. Stage arguments are stage *names* as shown in
788
+ the workflow (`"Annotator 1"`, `"Review 1"`, ...).
789
+
790
+ #### `list(project_id, task_id, stage, *, page=1, limit=20, workspace_id=None, **filters) -> DatasetList`
791
+
792
+ `GET ...?stage=`. Datasets in one stage, highest priority first then newest. `stage` is required.
793
+ Filters: `dataset_id`, `dataset_name`, `dataset_status` (one of `DatasetStatus.ALL`), `priority`
794
+ (0-100), `last_action_by`, `search`. Returns `DatasetList(datasets, pagination, project_id,
795
+ project_name, task_id, task_name)` with `total_items` / `total_pages` properties. Workers only see
796
+ datasets of stages they are assigned to.
797
+
798
+ #### `iterate(project_id, task_id, stage, *, page_size=50, workspace_id=None, **filters) -> Iterator[Dataset]`
799
+
800
+ Generator over every dataset of the stage, paging from page 1 to `total_pages`.
801
+
802
+ #### `get(project_id, task_id, dataset_id, *, page=None, limit=None, tab=None, workspace_id=None) -> DatasetDetail | None`
803
+
804
+ `GET .../{dataset_id}`. The dataset with its files in `images` / `videos` (property `files` joins
805
+ them) and the task's `labels`. `tab` narrows the files to `ReviewTab.REJECTED` or `APPROVED`;
806
+ `page` and `limit` page the files. `None` when the dataset is in the trash; `NotFoundError` when
807
+ unknown; `PermissionDeniedError` for a worker not assigned to it.
808
+
809
+ #### `actions(project_id, task_id, dataset_id, workspace_id=None) -> DatasetActions`
810
+
811
+ `GET .../{dataset_id}/actions`. Booleans `submit`, `complete`, `reject`, `skip`, `file_approve`,
812
+ `file_reject` for the caller in the dataset's current stage.
813
+
814
+ #### `events(project_id, task_id, dataset_id, *, page=1, limit=20, workspace_id=None) -> DatasetEvents`
815
+
816
+ `GET .../{dataset_id}/events`. Event trail newest first (`created`, `assigned`, `released`,
817
+ `submitted`, `rejected`, `stage_changed`, `priority_changed`, ...), each with `actor`, `details`,
818
+ `created_at`. Workforce Manager and above.
819
+
820
+ #### `file_tabs(project_id, task_id, dataset_id, workspace_id=None) -> dict`
821
+
822
+ `GET .../{dataset_id}/files/tabs`. File counts per review tab; annotators see `All` and `Rejected`
823
+ only.
824
+
825
+ #### `initiate(project_id, task_id, dataset_id, user_id, workspace_id=None, *, stage=None) -> str`
826
+
827
+ `POST .../initiate[?stage=]`. Assigns one dataset to one worker who is listed on the dataset's current
828
+ stage. A dataset is initiated once: `ConflictError` when already initiated, `BadRequestError` when
829
+ the task's workflow has not been saved yet or the worker is not on the stage, `NotFoundError` for an
830
+ unknown user. Workers may only initiate for themselves.
831
+
832
+ #### `initiate_many(project_id, task_id, stage, datasets, user_ids, *, workspace_id=None, **filters) -> str`
833
+
834
+ `POST .../initiate?stage=`. Assigns several datasets of `stage` in one call, **one user per dataset**:
835
+ `user_ids[i]` gets the i-th selected dataset, and the counts must match (`BadRequestError` from the
836
+ backend otherwise; checked locally when `datasets` is a list of ids). A user id may repeat to give
837
+ that user several datasets. With a list of ids the pairing follows the list; with
838
+ `Selection.all_except(...)` it follows the stage listing order (priority, then newest first),
839
+ narrowed by the list filters. Workforce Manager and above.
840
+
841
+ ```python
842
+ client.datasets.initiate_many("a1b2c3d4", "t1t1t1t1", "Annotator 1",
843
+ Selection.all_except(), ["384f9", "394ff", "dc466"])
844
+ ```
845
+
846
+ #### `release(project_id, task_id, datasets, workspace_id=None, *, stage=None, **filters) -> str`
847
+
848
+ `POST .../release[?stage=]`. Un-assigns initiated datasets and marks them `Released`. Managers may
849
+ release many at once; a worker only the one assigned to them. `stage` scopes an all-selection.
850
+
851
+ #### `skip` / `submit` / `complete` / `reject` `(project_id, task_id, dataset_id, workspace_id=None) -> DatasetTransition`
852
+
853
+ `POST .../skip|submit|complete|reject` with `{"dataset_id"}`. Only the dataset's assigned worker may
854
+ act (supervisors may also reject). `skip` gives the dataset back in place; `submit` moves it to the
855
+ next working stage; `complete` moves it into the complete stage when that is next; `reject` sends it
856
+ down the reject edge. The result carries `message`, `auto_assigned` and `next_dataset`, the dataset
857
+ the backend handed to the worker next, if any. `BadRequestError` when the dataset is not initiated
858
+ or the move is not allowed from its stage.
859
+
860
+ #### `change_stage(project_id, task_id, current_stage, target_stage, datasets, workspace_id=None, **filters) -> str`
861
+
862
+ `POST .../stage?current_stage=`. Moves selected datasets of `current_stage` to `target_stage`;
863
+ `ValueError` when both are equal. Workforce Manager and above.
864
+
865
+ #### `set_priority(project_id, task_id, priority, datasets, workspace_id=None, **filters) -> str`
866
+
867
+ `POST .../priority`. `priority` is an int from 0 to 100; higher sorts first.
868
+
869
+ #### `move_to_trash(project_id, task_id, current_stage, datasets, workspace_id=None) -> str`
870
+
871
+ `POST .../trash?current_stage=`. Trashes the datasets and every file in them; restorable from the
872
+ dashboard trash. Returns `"<n> TaskDataset(s) moved to trash."`.
873
+
874
+ #### `trash_files(project_id, task_id, dataset_id, files, *, tab=None, workspace_id=None) -> str`
875
+
876
+ `POST .../{dataset_id}/files/trash[?tab=]`. `files` is a list of file ids or `Selection.all_except`,
877
+ which `tab` scopes to the rejected or approved files.
878
+
879
+ #### `approve_file` / `reject_file` `(project_id, task_id, dataset_id, file_id, file_type, workspace_id=None) -> str`
880
+
881
+ `POST .../{dataset_id}/files/approve?file_id=&file_type=`. `file_type` is `FileType.IMAGE` or
882
+ `FileType.VIDEO`. Approving clears a rejection and vice versa.
883
+
884
+ #### Models and constants
885
+
886
+ `Dataset`: `dataset_id`, `dataset_name`, `status`, `stage`, `priority`, `total_files`, `in_trash`,
887
+ `restored`, `created_by`, `assigned_to`, `last_action_by`, `created_at`, `updated_at`; properties
888
+ `name`, `assigned_user_id`. `DatasetDetail` adds `project_id`, `project_name`, `task_id`,
889
+ `task_name`, `images`, `videos`, `labels` and property `files`. `DatasetList`, `DatasetEvents`
890
+ (iterable, with `pagination`), `DatasetEvent`, `DatasetActions`, `DatasetTransition`, `Selection`.
891
+ `DatasetStatus.ALL`: `Not Started`, `Assigned`, `In Progress`, `Completed`, `Released`,
892
+ `Submitted`, `Rejected`, `Skipped`. `ReviewTab`: `All`, `Rejected`, `Approved`. `FileType`:
893
+ `Image`, `Video`.
894
+
895
+ ---
896
+
897
+ ## Labels — `client.labels`
898
+
899
+ ### Usage
900
+
901
+ Labels belong to a task and are unique by name inside it.
902
+
903
+ ```python
904
+ from intellabel import IntelLabel, LabelType
905
+
906
+ with IntelLabel(workspace_id="<workspace uuid>") as client:
907
+ labels = client.labels.list("a1b2c3d4", "t1t1t1t1") # LabelList, iterable
908
+ car = client.labels.create("a1b2c3d4", "t1t1t1t1", "Car", [LabelType.RECTANGLE, LabelType.POLYGON])
909
+ client.labels.add_attribute("a1b2c3d4", "t1t1t1t1", car.label_id, ["red", "blue", "white"])
910
+ car = client.labels.rename("a1b2c3d4", "t1t1t1t1", car.label_id, "Vehicle")
911
+ client.labels.create_many("a1b2c3d4", "t1t1t1t1", [
912
+ {"name": "Person", "label_types": ["rectangle"], "color_code": "#FF0000"},
913
+ {"name": "Pose", "label_types": ["keypoint_skeleton"],
914
+ "skeleton": {"keypoints": [{"point_id": 1, "name": "head", "x": 0.5, "y": 0.1}, {"point_id": 2, "name": "neck", "x": 0.5, "y": 0.3}],
915
+ "edges": [[1, 2]]}},
916
+ ])
917
+ client.labels.delete("a1b2c3d4", "t1t1t1t1", car.label_id)
918
+ ```
919
+
920
+ ### Methods
921
+
922
+ Backend router `app/api/label_apis.py`. All calls take `project_id` and `task_id` plus the workspace.
923
+ `label_id` is the integer `id` of the label (also exposed as `label_id`).
924
+
925
+ #### `list(project_id, task_id, *, compact=False, workspace_id=None) -> LabelList`
926
+
927
+ `GET /project/{pid}/task/{tid}/labels`. Returns `LabelList(labels, task_name)`, newest first.
928
+ `compact=True` sends the backend's `exclude` flag and returns the reduced form used by annotation
929
+ tools: name, types, colour and a flat list of attribute tags.
930
+
931
+ #### `list_all(project_ids=None, task_ids=None, workspace_id=None) -> list[Label]`
932
+
933
+ `GET /labels`. Every label in the workspace, narrowed to the given projects and/or tasks (lists or
934
+ single strings). Super Admin or Admin only; `PermissionDeniedError` otherwise.
935
+
936
+ #### `get(project_id, task_id, label_id, workspace_id=None) -> Label`
937
+
938
+ `GET /project/{pid}/task/{tid}/label/{label_id}`. `NotFoundError` for an unknown id.
939
+
940
+ #### `random_color(project_id, task_id, workspace_id=None) -> str`
941
+
942
+ `GET .../get/random_hex_code`. A `#RRGGBB` colour not yet used by the task's labels.
943
+
944
+ #### `create(project_id, task_id, name, label_types, *, color_code=None, attributes=None, skeleton=None, workspace_id=None) -> Label`
945
+
946
+ `POST .../make_label`.
947
+
948
+ | Parameter | Description |
949
+ |---|---|
950
+ | `name` | Required; unique in the task (`ConflictError` on a duplicate). |
951
+ | `label_types` | One or more of `LabelType.ALL`: `polygon`, `rectangle`, `polyline`, `dot`, `ellipse`, `circle`, `mask`, `keypoint_skeleton`. |
952
+ | `color_code` | `#RRGGBB`. When omitted, `random_color` is called first. |
953
+ | `attributes` | Optional list of `{"attribute_name", "attribute_type", "attribute_values"}` dicts created with the label. |
954
+ | `skeleton` | Required for `keypoint_skeleton`: `{"keypoints": [{"point_id": int, "name", "x", "y", "color_code"?}], "edges": [[from, to], ...], "reference": {"width", "height"}?}`. Validated locally like the backend does (unique integer ids, both coordinates, edges between existing distinct points). |
955
+
956
+ #### `create_many(project_id, task_id, labels, workspace_id=None) -> list[Label]`
957
+
958
+ One request creating several labels. Each item is a dict with `name` (or `label_name`),
959
+ `label_types`, optional `color_code`, `attributes`, `skeleton`; colours are fetched for items
960
+ without one.
961
+
962
+ #### `update(project_id, task_id, label_id, *, name=None, label_types=None, color_code=None, attributes=None, skeleton=None, workspace_id=None) -> Label`
963
+
964
+ `PUT .../label/{label_id}`. Pass any subset of fields. The backend requires name, types and colour on
965
+ every update, so missing ones are read from the current label first. `ConflictError` on a duplicate
966
+ name. Renaming or retyping a label also updates the annotations that use it.
967
+
968
+ #### `rename(project_id, task_id, label_id, name, workspace_id=None) -> Label`
969
+
970
+ Shortcut for `update(..., name=name)`.
971
+
972
+ #### `delete(project_id, task_id, label_id, workspace_id=None) -> str`
973
+
974
+ `DELETE .../label/{label_id}`. Removes the label and strips it from existing annotations. Returns
975
+ the server message.
976
+
977
+ #### `add_attribute(project_id, task_id, label_id, values, *, attribute_type="checkbox", workspace_id=None) -> str`
978
+
979
+ `POST .../label/{label_id}/add/attribute`. Adds an attribute group with the given tags. Returns the
980
+ server message; fetch the label to see the new `attribute_id`.
981
+
982
+ #### `update_attribute(project_id, task_id, label_id, attribute_id, values, *, attribute_type="checkbox", workspace_id=None) -> Label`
983
+
984
+ `PUT .../update/attribute/{attribute_id}`. Replaces the group's tags. Returns the updated label.
985
+
986
+ #### `delete_attribute(project_id, task_id, label_id, attribute_id, workspace_id=None) -> str`
987
+
988
+ `DELETE .../delete/attribute/{attribute_id}`.
989
+
990
+ #### `import_labels(project_id, task_id, data, workspace_id=None) -> str`
991
+
992
+ `POST .../make_labels`. Bulk import of raw label definitions (`label_name`, `label_types`,
993
+ `color_code`, `attributes`, `skeleton`), for example copied from another task's compact listing.
994
+
995
+ #### Models and constants
996
+
997
+ `Label`: `id`, `label_id`, `task_id`, `project_id`, `label_name`, `label_types`, `color_code`,
998
+ `skeleton`, `attributes` (list of `Attribute`), `created_by`, `company_id`, `creation_date`;
999
+ property `name`. `Attribute`: `id`, `attribute_id`, `attribute_type`, `attribute_values`
1000
+ (`[{"id", "tag"}]`), property `tags`. `LabelType.ALL`, `AttributeType.CHECKBOX`.
1001
+
1002
+ ---
1003
+
1004
+ ## Workflows — `client.workflows`
1005
+
1006
+ ### Usage
1007
+
1008
+ A workflow is the stage graph of a task: a dataset stage, one or more annotation stages, review
1009
+ stages and a complete stage, with edges between them and worker user ids on each stage's
1010
+ `user_data`. Every task receives the default layout once its files are uploaded.
1011
+
1012
+ ```python
1013
+ from intellabel import IntelLabel, StageType, default_layout
1014
+
1015
+ with IntelLabel(workspace_id="<workspace uuid>") as client:
1016
+ wf = client.workflows.get("a1b2c3d4", "t1t1t1t1")
1017
+ print([s["name"] for s in wf.stages], wf.user_ids("Annotator 1"))
1018
+
1019
+ annotators = client.workflows.workers(stage=StageType.ANNOTATION) # who can be assigned
1020
+ wf = client.workflows.assign("a1b2c3d4", "t1t1t1t1", "Annotator 1", [annotators[0].user_id])
1021
+ wf = client.workflows.assign("a1b2c3d4", "t1t1t1t1", "Review 1", ["usr_qa_1"], replace=True)
1022
+
1023
+ stages, edges = default_layout() # build a custom graph
1024
+ stages[1]["user_data"] = ["usr_annotator_1", "usr_annotator_2"]
1025
+ wf = client.workflows.create("a1b2c3d4", "t1t1t1t1", stages, edges)
1026
+
1027
+ counts = client.workflows.stages("a1b2c3d4", "t1t1t1t1") # datasets per stage
1028
+ users = client.workflows.stage_users("a1b2c3d4", "t1t1t1t1", stage="Review 1")
1029
+
1030
+ progress = client.workflows.progress("a1b2c3d4", "t1t1t1t1") # live file-based progress
1031
+ print(f"{progress.progress_percentage}% approved, {progress.rejected_files} rejected")
1032
+ for stage in progress.stages:
1033
+ print(stage.name, stage.files, f"{stage.share_percentage}% of files", f"{stage.approved_percentage}% approved")
1034
+
1035
+ for snapshot in client.workflows.watch_progress("a1b2c3d4", "t1t1t1t1", interval=10):
1036
+ print(snapshot.progress_percentage) # each change, until all approved
1037
+ ```
1038
+
1039
+ ### Methods
1040
+
1041
+ Backend router `app/api/workflows.py`. All calls take `project_id` and `task_id` plus the workspace
1042
+ unless noted.
1043
+
1044
+ #### `get(project_id, task_id, workspace_id=None) -> Workflow`
1045
+
1046
+ `GET /projects/{pid}/tasks/{tid}/workflow`. Stage `user_data` entries come back expanded to
1047
+ `{user_id, first_name, last_name}` and the dataset stage carries `total_files` and a file `preview`.
1048
+ A task without a workflow receives the default layout on this read, so the call succeeds for any
1049
+ existing task.
1050
+
1051
+ #### `create(project_id, task_id, stages, edges, *, workflow_id=None, workspace_id=None) -> Workflow`
1052
+
1053
+ `POST /projects/{pid}/tasks/{tid}/workflow`. Creates the workflow or replaces the existing one.
1054
+
1055
+ | Parameter | Description |
1056
+ |---|---|
1057
+ | `stages` | Non-empty list of dicts with unique `id`, a `type` in `StageType.ALL` (`dataset_stage`, `annotation_stage`, `review_stage`, `complete_stage`), `name`, `position` and `user_data` (worker user id strings; expanded user dicts from `get` are accepted and reduced to ids). |
1058
+ | `edges` | List of dicts with `source` and `target` stage ids (plus `id`, `type`, `style`, `sourceHandle` as in `default_layout`). Must reference existing stages. |
1059
+ | `workflow_id` | The backend requires one in every payload. Pass the existing id when replacing a workflow; when omitted the SDK mints a new 6-character id, as the backend does. |
1060
+
1061
+ The backend validates assignments: annotators (and annotator/QA hybrids) on annotation stages,
1062
+ QAs on review stages, every id must exist in the workspace (`NotFoundError` otherwise, `BadRequestError`
1063
+ for a wrong role). Saving moves all of the task's datasets back to the entry stage.
1064
+
1065
+ #### `update(...)`
1066
+
1067
+ Alias of `create`; the backend upserts on POST.
1068
+
1069
+ #### `assign(project_id, task_id, stage, user_ids, *, replace=False, workspace_id=None) -> Workflow`
1070
+
1071
+ Convenience: fetches the workflow, adds `user_ids` to the stage named or identified by `stage`
1072
+ (`replace=True` overwrites the stage's list), and saves. `ValueError` for an unknown stage or a
1073
+ non-assignable stage type.
1074
+
1075
+ #### `delete(project_id, task_id, workspace_id=None) -> str`
1076
+
1077
+ `DELETE /projects/{pid}/tasks/{tid}/workflow`. Returns `"Workflow has been deleted."`. The next read
1078
+ recreates the default layout with empty assignments, so this effectively resets the workflow.
1079
+
1080
+ #### `stages(project_id, task_id, workspace_id=None, **filters) -> dict`
1081
+
1082
+ `GET .../workflow/stages`. Dataset counts per stage plus `total_datasets` and `workflow_id`. Filters:
1083
+ `stage`, `dataset_id`, `dataset_name`, `dataset_status`, `priority`, `last_action_by`, `search`.
1084
+ Workers only see the stages they are assigned to.
1085
+
1086
+ #### `progress(project_id, task_id, workspace_id=None) -> WorkflowProgress`
1087
+
1088
+ `GET .../workflow/progress`. Live, file-based progress of the task through its workflow, computed on
1089
+ each call. The task's files are counted per dataset from their approved / rejected flags, and the
1090
+ datasets are grouped by the stage they sit in now. Workforce Manager and above.
1091
+
1092
+ `summary` holds the task totals:
1093
+
1094
+ | Key | Meaning |
1095
+ |---|---|
1096
+ | `total_datasets`, `completed_datasets` | Live datasets; completed = status `Completed` or in a complete stage |
1097
+ | `total_files`, `approved_files`, `rejected_files`, `pending_files` | File counts. A file that is approved is never counted as rejected. Pending = neither. |
1098
+ | `files_without_dataset` | Files not in any live dataset (tasks created before datasets existed) |
1099
+ | `progress_percentage` | Approved files / files. This is the percentage the analytics dashboard shows, so it equals `client.analytics.progress(...)` for the same task. |
1100
+ | `rejected_percentage`, `pending_percentage` | Shares of all files |
1101
+ | `complete_stage_percentage` | Files sitting in a complete stage / files |
1102
+ | `dataset_completion_percentage` | Completed datasets / datasets |
1103
+
1104
+ `stages` lists the workflow's stages in flow order (the dataset pool stage is left out, as in
1105
+ `stages()`), each a `StageProgress` with `datasets`, `assigned_datasets`, `files`, `approved_files`,
1106
+ `rejected_files`, `pending_files`, `dataset_status` (datasets per status) and four percentages:
1107
+ `share_percentage` (this stage's files / the task's files) and `approved_percentage`,
1108
+ `rejected_percentage`, `pending_percentage` (shares of this stage's own files). A dataset whose
1109
+ stage is no longer part of the workflow is counted in `outside_workflow` (otherwise `None`).
1110
+ Per-stage dataset counts equal those of `stages()`.
1111
+
1112
+ #### `watch_progress(project_id, task_id, *, interval=5.0, timeout=None, until_complete=True, workspace_id=None) -> Iterator[WorkflowProgress]`
1113
+
1114
+ Polls `progress` every `interval` seconds and yields the first snapshot and then each one whose
1115
+ numbers changed. It stops once every file is approved (`until_complete=False` keeps watching) or,
1116
+ without raising, after `timeout` seconds. On the async client use `async for`.
1117
+
1118
+ #### `stage_users(project_id, task_id, stage=None, workspace_id=None) -> list[StageUser]`
1119
+
1120
+ `GET .../workflow/users`. Supervisors (Super Admin, Admin, Workforce Managers) plus the workers of
1121
+ the given stage. `BadRequestError` for an unknown stage name.
1122
+
1123
+ #### `workers(stage=None, workspace_id=None) -> list[Worker]`
1124
+
1125
+ `GET /get/workers`. Workers of the workspace who can be placed on workflows. `stage` must be
1126
+ `annotation_stage` or `review_stage` and narrows to the matching worker kind. Workforce Manager and above.
1127
+
1128
+ #### Models and helpers
1129
+
1130
+ `Workflow`: `id`, `workflow_id`, `project_id`, `task_id`, `task_name`, `company_id`,
1131
+ `workflow_creator_id`, `created_at`, `updated_at`, `stages`, `edges`; helpers `stage(name_or_id)`,
1132
+ `stages_of_type(type)`, `user_ids(name_or_id)`. `StageUser(user_id, first_name, last_name, role)`,
1133
+ `Worker(id, user_id, first_name, last_name, role, checked)`. `default_layout()` returns the
1134
+ default `(stages, edges)` to start a custom graph from. `WorkflowProgress`: `project_id`, `task_id`,
1135
+ `task_name`, `workflow_id`, `generated_at` (UTC ISO time of the snapshot), `summary`, `stages`,
1136
+ `outside_workflow`; properties `progress_percentage`, `total_files`, `approved_files`,
1137
+ `rejected_files`, `pending_files`, `is_complete`; helper `stage(name_or_id)`. `StageProgress` as
1138
+ described under `progress`.
1139
+
1140
+ ---
1141
+
1142
+ ## Custom models — `client.models`
1143
+
1144
+ ### Usage
1145
+
1146
+ A workspace's custom models are **add-on models** linked from Hugging Face or Roboflow, and
1147
+ **deployed models** (your own `.pt` weights hosted by INTELLABEL, Growth / Enterprise plans). The
1148
+ built-in SAM2 is editor-only and not part of `client.models`.
1149
+
1150
+ ```python
1151
+ from intellabel import IntelLabel, ModelTask, ModelFramework, Selection
1152
+
1153
+ with IntelLabel(workspace_id="<workspace uuid>") as client:
1154
+ # add-on models: provider is inferred from the link
1155
+ client.models.add("https://huggingface.co/facebook/detr-resnet-50", "hf_xxx")
1156
+ client.models.add("https://app.roboflow.com/my-ws/cars/model/3", "<roboflow api key>")
1157
+
1158
+ detr = client.models.get("facebook/detr-resnet-50")
1159
+ print(detr.task, detr.labels) # object-detection ['person', 'car', ...]
1160
+
1161
+ # one file: returns the predicted shapes, nothing is saved
1162
+ shapes = client.models.predict("a1b2c3d4", "t1t1t1t1", "img_123", detr, {"car": "Car", "person": "Person"},
1163
+ threshold=0.5)
1164
+
1165
+ # many datasets: queued; the worker saves the annotations
1166
+ client.models.predict_bulk("a1b2c3d4", "t1t1t1t1", detr, {"car": "Car"}, Selection.all_except(),
1167
+ stage="Annotator 1")
1168
+
1169
+ # deploy your own weights, then wait until the model is listed
1170
+ job = client.models.deploy("runs/best.pt", model_name="cars-yolo", model_type="yolov8n",
1171
+ task=ModelTask.OBJECT_DETECTION, framework=ModelFramework.ULTRALYTICS,
1172
+ show_progress=True)
1173
+ model = client.models.wait_for_model("cars-yolo")
1174
+ ```
1175
+
1176
+ **Label mapping.** Predictions take `label_mapping = {model_class: task_label_name}`. Every class the
1177
+ model can return must be a key; values are label names of the task. Detection and segmentation
1178
+ need a non-empty mapping; bulk prediction always does.
1179
+
1180
+ **Background jobs.** `predict_bulk` and `deploy` return once the backend has queued the job. The
1181
+ workers report progress and errors only over the dashboard websocket, which the SDK can't join, so a
1182
+ failed deployment surfaces as `DeployTimeoutError` from `wait_for_model` (the reason is in the
1183
+ dashboard notifications).
1184
+
1185
+ ### Add-on model links (Hugging Face and Roboflow)
1186
+
1187
+ An **add-on model** is a model that already lives on Hugging Face or Roboflow. INTELLABEL stores its
1188
+ link and an access token, and every prediction calls the provider's hosted inference. This section is
1189
+ about add-on models only. Deployed models (your own `.pt` weights uploaded through
1190
+ `POST /api/deploy/model`) don't use a link.
1191
+
1192
+ The backend checks the link's shape before it contacts the provider. A link in the wrong shape is
1193
+ rejected with `400` and nothing is saved.
1194
+
1195
+ #### Roboflow: the link must include the model ID
1196
+
1197
+ Roboflow identifies a trained model by its **model ID**, `<project>/<version>`, for example
1198
+ `hard-hat-sample-x1y2z/3`. The version number is part of the model ID, so the link must end with it.
1199
+ A link to the project, the dataset or the workspace is rejected.
1200
+
1201
+ ```
1202
+ https://app.roboflow.com/acme-safety/hard-hat-sample-x1y2z/model/3
1203
+ \______________/ \_________/ \___________________/ |
1204
+ host workspace project slug version
1205
+
1206
+ model ID = hard-hat-sample-x1y2z/3
1207
+ name in INTELLABEL = acme-safety/hard-hat-sample-x1y2z/3
1208
+ ```
1209
+
1210
+ | Part | Example | Notes |
1211
+ |---|---|---|
1212
+ | Host | `app.roboflow.com` or `universe.roboflow.com` | `app` for models in your own workspace, `universe` for public Universe models. No other host is accepted. |
1213
+ | Workspace | `acme-safety` | The **Roboflow** workspace slug, not the INTELLABEL workspace. |
1214
+ | Project | `hard-hat-sample-x1y2z` | The project slug. Universe slugs often end in a random suffix; keep it. |
1215
+ | Version | `3` | The trained version. Digits only. |
1216
+
1217
+ Accepted forms:
1218
+
1219
+ | Link | Use for |
1220
+ |---|---|
1221
+ | `https://app.roboflow.com/<workspace>/<project>/model/<version>` | Private model (recommended form) |
1222
+ | `https://app.roboflow.com/<workspace>/<project>/models/<model_name>/<version>` | Private model, model page link. `<model_name>` is ignored. |
1223
+ | `https://app.roboflow.com/<workspace>/<project>/<version>` | Private model, version page link |
1224
+ | `https://universe.roboflow.com/<workspace>/<project>/model/<version>` | Public Universe model (recommended form) |
1225
+ | `https://universe.roboflow.com/<workspace>/<project>/<version>` | Public Universe model |
1226
+
1227
+ Rejected examples:
1228
+
1229
+ | Link | Why |
1230
+ |---|---|
1231
+ | `https://universe.roboflow.com/acme-safety/hard-hat-sample-x1y2z` | No version, so no model ID |
1232
+ | `https://universe.roboflow.com/acme-safety/hard-hat-sample-x1y2z/dataset/3` | Dataset tab. Open the **Model** tab instead, which gives `.../model/3`. |
1233
+ | `https://serverless.roboflow.com/hard-hat-sample-x1y2z/3`, `https://detect.roboflow.com/...` | Inference URLs. Paste the app or Universe link; the backend builds the inference URL itself. |
1234
+ | `acme-safety/hard-hat-sample-x1y2z-3-rfdetr-t1` | Newer training-specific model ID. Use the project slug and version number from the legacy `<project>/<version>` ID instead. |
1235
+
1236
+ What the backend does with the link:
1237
+
1238
+ 1. Reads `https://api.roboflow.com/<workspace>/<project>/<version>` with the key to get the project type,
1239
+ the classes and whether a trained model exists.
1240
+ 2. Accepts the project types `object-detection`, `instance-segmentation` and `classification`. Any other
1241
+ type is rejected with `400`.
1242
+ 3. Saves the model as `<workspace>/<project>/<version>` and sends predictions to
1243
+ `https://serverless.roboflow.com/<project>/<version>`.
1244
+ 4. If the version has no trained model, the add still succeeds, but `inference` is `false` and
1245
+ predictions are refused.
1246
+ 5. If a version has several trainings, `<project>/<version>` runs the first successful one (Roboflow's
1247
+ legacy alias). The `<model_name>` in a `/models/<model_name>/<version>` link doesn't pick another
1248
+ training.
1249
+
1250
+ Which key to use:
1251
+
1252
+ - **Private model:** the **Private API Key** of the Roboflow workspace that owns the project. A key from
1253
+ another workspace gets `403`.
1254
+ - **Public Universe model:** any valid Roboflow Private API Key, normally your own. You don't need the
1255
+ owner's key. A `403` means the project isn't public.
1256
+ - Don't use the **Publishable** key.
1257
+ - Inference runs on Roboflow's hosted API under your key, so Roboflow counts the usage against that
1258
+ key's workspace. Commercial use of Universe models may need a paid Roboflow plan.
1259
+
1260
+ #### Roboflow: getting the link
1261
+
1262
+ **Public model (Roboflow Universe)**
1263
+
1264
+ 1. Go to [universe.roboflow.com](https://universe.roboflow.com) and search for a model. Pick a project
1265
+ that has a trained model; such projects show a **Model** tab or a "Try This Model" button.
1266
+ 2. Open the project and click **Model** in the left sidebar, not **Dataset** or **Images**.
1267
+ 3. Choose the version in the version selector. The page shows the model ID as `<project>/<version>`.
1268
+ 4. Copy the URL from the address bar. It should end in `/model/<version>`, for example
1269
+ `https://universe.roboflow.com/acme-safety/hard-hat-sample-x1y2z/model/3`. If it ends at the project
1270
+ slug, append `/model/<version>` using the number from the model ID.
1271
+ 5. Get a key: sign in at [app.roboflow.com](https://app.roboflow.com) (a free account works), open
1272
+ **Settings → API Keys** and copy the **Private API Key**.
1273
+
1274
+ **Private model (your own workspace)**
1275
+
1276
+ 1. Sign in at [app.roboflow.com](https://app.roboflow.com) and switch to the workspace that owns the
1277
+ project. The workspace slug is the first path segment of every URL in it
1278
+ (`app.roboflow.com/<workspace>/...`).
1279
+ 2. Open the project, then **Models** (**Versions** in older layouts), and open a version whose training
1280
+ has finished.
1281
+ 3. Copy the model ID shown on that page, `<project>/<version>`. The deploy code snippets show it too, as
1282
+ `model_id="<project>/<version>"`.
1283
+ 4. Build the link from the workspace slug and the model ID:
1284
+ `https://app.roboflow.com/<workspace>/<project>/model/<version>`.
1285
+ 5. Get the key from the **same** workspace: **Settings → API Keys → Private API Key**.
1286
+
1287
+ #### Hugging Face: link structure
1288
+
1289
+ ```
1290
+ https://huggingface.co/facebook/detr-resnet-50
1291
+ \____________/ \______/ \____________/
1292
+ host owner repo
1293
+
1294
+ name in INTELLABEL = facebook/detr-resnet-50 (the repo ID)
1295
+ ```
1296
+
1297
+ | Part | Example | Rules |
1298
+ |---|---|---|
1299
+ | Scheme and host | `https://huggingface.co` | `https` only. No `www.`, and not the short `hf.co` host. |
1300
+ | Owner | `facebook` | User or organization name: letters, digits, `_` and `-`. |
1301
+ | Repo | `detr-resnet-50` | Letters, digits, `_`, `-` and `.`. |
1302
+
1303
+ The link is exactly `https://huggingface.co/<owner>/<repo>`. A trailing `/` is fine; anything else after
1304
+ the repo name is rejected.
1305
+
1306
+ | Link | Why it's rejected |
1307
+ |---|---|
1308
+ | `https://huggingface.co/facebook/detr-resnet-50/tree/main` | Extra path (files tab) |
1309
+ | `https://huggingface.co/facebook/detr-resnet-50/blob/main/config.json` | Link to a file, not the repo |
1310
+ | `https://huggingface.co/facebook/detr-resnet-50?library=transformers` | Query string |
1311
+ | `facebook/detr-resnet-50` | Repo ID only. Prefix it with `https://huggingface.co/`. |
1312
+ | `https://huggingface.co/spaces/<owner>/<space>`, `https://huggingface.co/datasets/<owner>/<name>` | Spaces and datasets aren't models (`400` or `404`) |
1313
+
1314
+ What the backend does with the link:
1315
+
1316
+ 1. Reads the repo's model info with the token. A missing repo, or one the token can't see, fails with
1317
+ `404` or `401`.
1318
+ 2. The repo's **pipeline tag** (the task shown under the model name) must be `object-detection`,
1319
+ `image-segmentation` or `image-classification`. Other tasks are rejected with `400`.
1320
+ 3. The repo must have a model config (`config.json`), as Transformers models do. Labels come from it.
1321
+ 4. Predictions run on Hugging Face's hosted inference. At prediction time the backend asks Hugging Face
1322
+ for the model's inference status and runs only when it is `warm`, meaning a Hugging Face Inference
1323
+ Provider serves it. Otherwise the request is refused with "Inference can't be done at this moment".
1324
+
1325
+ The token must start with `hf_` (20 to 100 characters after it) and is required for public models too.
1326
+
1327
+ #### Hugging Face: getting the link
1328
+
1329
+ **Public model**
1330
+
1331
+ 1. Go to [huggingface.co/models](https://huggingface.co/models). Under **Tasks**, pick **Object
1332
+ Detection**, **Image Segmentation** or **Image Classification**. To list only models that can run,
1333
+ also filter **Inference Providers → HF Inference**.
1334
+ 2. Open the model. Check that the task tag under its name is one of the three above, and that the
1335
+ **Inference Providers** panel on the right lists it as available.
1336
+ 3. Copy the link. Either copy the address bar URL and cut everything after the repo name, or click the
1337
+ copy icon next to the model name (it copies `<owner>/<repo>`) and prefix `https://huggingface.co/`.
1338
+ 4. If the model is gated (the page asks you to accept its conditions), accept them with the same account
1339
+ the token belongs to.
1340
+ 5. Get a token: avatar → **Settings → Access Tokens → Create new token**. A **Read** token works. For a
1341
+ **Fine-grained** token, enable "Read access to contents of all public gated repos you can access".
1342
+ Copy the `hf_...` value; Hugging Face shows it only once.
1343
+
1344
+ **Private model**
1345
+
1346
+ 1. Sign in with an account that owns the repo or belongs to the organization that does.
1347
+ 2. Open your profile or the organization page, go to **Models** and open the repo (marked **Private**).
1348
+ 3. Copy the link the same way as for a public model. It has the same shape,
1349
+ `https://huggingface.co/<owner>/<repo>`.
1350
+ 4. Create the token on that account. A **Read** token works. For a **Fine-grained** token, add the repo
1351
+ (or the organization) under repository permissions with read access to its contents. A token from an
1352
+ account without access fails with `401` or `404`.
1353
+ 5. Hugging Face Inference Providers usually don't serve private repos, so the add can succeed while
1354
+ predictions are refused (see the inference check above). To run your own private weights, use a deployed model
1355
+ instead.
1356
+
1357
+ #### Adding the model from the SDK
1358
+
1359
+ ```python
1360
+ with IntelLabel(workspace_id="<workspace uuid>") as client:
1361
+ # Roboflow, private: key of the workspace that owns the project
1362
+ client.models.add("https://app.roboflow.com/acme-safety/hard-hat-sample-x1y2z/model/3", "<private api key>")
1363
+
1364
+ # Roboflow, public Universe model: your own key
1365
+ client.models.add("https://universe.roboflow.com/acme-safety/hard-hat-sample-x1y2z/model/3", "<private api key>")
1366
+
1367
+ # Hugging Face, public or private: hf_ token with read access to the repo
1368
+ client.models.add("https://huggingface.co/facebook/detr-resnet-50", "hf_xxx")
1369
+ ```
1370
+
1371
+ The provider is inferred from the host. A link the backend rejects raises `BadRequestError` (`400`),
1372
+ a bad key `AuthenticationError` or `PermissionDeniedError`, and a model that is already added
1373
+ `ConflictError`.
1374
+
1375
+ ### Methods
1376
+
1377
+ Backend routes in `app/api/custom_model.py`, all under `/api`.
1378
+
1379
+ #### `list(workspace_id=None) -> list[CustomModel]`
1380
+
1381
+ `GET /get/models`. The workspace's add-on and deployed models. The built-in SAM2, which the backend
1382
+ also lists, is left out: it is editor-only and not a custom model.
1383
+
1384
+ #### `get(model_name, workspace_id=None) -> CustomModel | None`
1385
+
1386
+ Exact-name lookup over `list()` (there is no single-model route).
1387
+
1388
+ #### `add(link, access_token, *, provider=None, workspace_id=None) -> str`
1389
+
1390
+ `POST /add/model`. Add-on models only. Hugging Face: `https://huggingface.co/<org>/<repo>` with an
1391
+ `hf_...` token; the model is named `<org>/<repo>` and must be an object-detection, image-segmentation
1392
+ or image-classification pipeline. Roboflow: the link must end with the model ID (`<project>/<version>`),
1393
+ as in `https://app.roboflow.com/<ws>/<project>/model/<version>` or
1394
+ `https://universe.roboflow.com/<ws>/<project>/model/<version>`, with a Private API Key; the model is named
1395
+ `<ws>/<project>/<version>`. Link formats, key rules and how to find the link are in
1396
+ [Add-on model links](#add-on-model-links-hugging-face-and-roboflow). `ConflictError` when already
1397
+ added. Workforce Manager and above.
1398
+
1399
+ #### `delete(model_name, workspace_id=None) -> str`
1400
+
1401
+ `DELETE /delete/models`. A deployed model's hosted weights are removed too.
1402
+
1403
+ #### `update_access_token(model_name, access_token, provider, workspace_id=None) -> str`
1404
+
1405
+ `PUT /update/access_token`. The backend takes the token as a query parameter, so it can show up in
1406
+ server access logs.
1407
+
1408
+ #### `predict(project_id, task_id, file_id, model, label_mapping, *, provider=None, task_type=None, file_type="Image", threshold=None, timeout=120, workspace_id=None) -> list[dict]`
1409
+
1410
+ `POST /{pid}/{tid}/predict/model`. Runs the model on one file and returns shapes (`tool_type`,
1411
+ `tool_values`, `label_data`, `score`, ...). Nothing is persisted. `model` is a `CustomModel` (provider
1412
+ and task are read from it) or a name with `provider` and `task_type`. Uses prediction credits.
1413
+
1414
+ #### `predict_bulk(project_id, task_id, model, label_mapping, datasets, *, provider=None, task_type=None, threshold=None, clean_annotations=False, stage=None, workspace_id=None, **filters) -> ModelJob`
1415
+
1416
+ `POST /{pid}/{tid}/predict/images`. Auto-annotates every image of the selected datasets (videos are
1417
+ skipped). `datasets` is a list of ids or `Selection.all_except(...)`, narrowed by `stage` and the
1418
+ dataset list filters. `clean_annotations=True` first **deletes** those images' existing annotations.
1419
+
1420
+ #### `delete_task_annotations(project_id, task_id, workspace_id=None) -> str`
1421
+
1422
+ `DELETE /projects/{pid}/tasks/{tid}/delete/task/annotations`. Deletes **every** annotation of the
1423
+ task. Admin and above. Not reversible.
1424
+
1425
+ #### `deploy(file, *, model_name, model_type, task, framework, input_size=640, confidence_threshold=0.25, iou_threshold=0.45, chunk_size=20 MB, on_bytes=None, show_progress=False, workspace_id=None) -> ModelJob`
1426
+
1427
+ `POST /deploy/model/upload-chunk` (once, or per 20 MB chunk for larger files, like the dashboard;
1428
+ failed chunks are retried) then `POST /deploy/model`. `file` is a path or
1429
+ `(filename, bytes | seekable file object)` and must be a `.pt`. `model_type` is a free-form
1430
+ architecture name (`"yolov8n"`); `task` is a `ModelTask`; `framework` a `ModelFramework`; thresholds
1431
+ are in (0, 1]. Checks `model_name` against the workspace's models before uploading.
1432
+ `PermissionDeniedError` when the plan has no deployments or its cap is reached.
1433
+
1434
+ #### `wait_for_model(model_name, *, interval=10, timeout=900, on_poll=None, workspace_id=None) -> CustomModel`
1435
+
1436
+ Polls `list()` until the model appears. `DeployTimeoutError` after `timeout` seconds.
1437
+
1438
+ #### Models and constants
1439
+
1440
+ `CustomModel`: `model_name`, `model_provider`, `model_type`, `model_link`, `inference`, `labels`,
1441
+ `data`, `has_separate_endpoint`, `model_key`, `added_by`, `created_at`, `id`; properties
1442
+ `name`, `provider`, `task`, `is_deployed`. `ModelJob(status, message, job_id)`. `ModelProvider`
1443
+ (`HUGGINGFACE`, `ROBOFLOW`), `ModelTask` (`OBJECT_DETECTION`, `IMAGE_SEGMENTATION`,
1444
+ `IMAGE_CLASSIFICATION`), `ModelFramework` (`TRANSFORMERS`, `ULTRALYTICS`, `PYTORCH`, `TENSORFLOW`,
1445
+ `JAX`, `ONNX`).
1446
+
1447
+ ---
1448
+
1449
+ ## Cloud storages — `client.cloud_storages`
1450
+
1451
+ ### Usage
1452
+
1453
+ A cloud storage is a named bucket connection, referred to by `display_name` (for example by
1454
+ `tasks.upload_from_cloud`). Display names are unique across the company. Only **AWS S3** with an
1455
+ access key pair is implemented by the backend; other providers are rejected locally because the
1456
+ backend answers them with a 200 that does nothing.
1457
+
1458
+ ```python
1459
+ from intellabel import IntelLabel
1460
+
1461
+ with IntelLabel(workspace_id="<workspace uuid>") as client:
1462
+ print(client.cloud_storages.aws_regions())
1463
+ client.cloud_storages.add("raw-images", bucket_name="my-bucket", region="ap-south-1",
1464
+ access_key_id="AKIA...", secret_access_key="...", description="Raw captures")
1465
+
1466
+ for s in client.cloud_storages.list():
1467
+ print(s.display_name, s.bucket_name, s.region)
1468
+
1469
+ page = client.cloud_storages.contents("raw-images", prefix="2026/09")
1470
+ images = [k for k in page.files if k.mime_type and k.mime_type.startswith("image/")]
1471
+ # CloudKey items go straight into an upload
1472
+ client.tasks.upload_from_cloud("a1b2c3d4", "t1t1t1t1", images, cloud_storage="raw-images", provider="AWS S3")
1473
+ ```
1474
+
1475
+ ### Methods
1476
+
1477
+ Backend routes in `app/api/cloud_services.py`, all under `/api`. Reads never return credentials
1478
+ (`provider_data["authentication_data"]` is `None`).
1479
+
1480
+ #### `list(workspace_id=None) -> list[CloudStorage]`
1481
+
1482
+ `GET /get/storages`. Newest first. Any role.
1483
+
1484
+ #### `get(display_name, workspace_id=None) -> CloudStorage | None`
1485
+
1486
+ `GET /get/storage`. `None` when the workspace has no storage by that name.
1487
+
1488
+ #### `add(display_name, *, bucket_name, region, access_key_id, secret_access_key, description=None, prefix=None, endpoint_url=None, provider="AWS S3", workspace_id=None) -> str`
1489
+
1490
+ `POST /add/storage`. The backend validates the region, the keys (`sts:GetCallerIdentity`) and bucket
1491
+ access (`s3:HeadBucket`) before saving: `BadRequestError` for a malformed key, `NotFoundError` for an
1492
+ unknown region or bucket, `PermissionDeniedError` when the keys can't reach the bucket,
1493
+ `ConflictError` when the name is taken. Workforce Manager and above.
1494
+
1495
+ #### `update(display_name, *, bucket_name, region, access_key_id, secret_access_key, new_display_name=None, description=None, endpoint_url=None, provider="AWS S3", workspace_id=None) -> str`
1496
+
1497
+ `PUT /update/storage?display_name=`. Replaces the settings; the bucket can't change and the keys are
1498
+ re-validated, so pass them again. `description` and `endpoint_url` are overwritten (omitting them
1499
+ clears them).
1500
+
1501
+ #### `delete(display_name, *, provider="AWS S3", workspace_id=None) -> str`
1502
+
1503
+ `DELETE /delete/storage`. Removes the configuration; the bucket is untouched.
1504
+
1505
+ #### `contents(display_name, *, prefix=None, continuation_token=None, max_keys=100, provider="AWS S3", workspace_id=None) -> CloudContents`
1506
+
1507
+ `GET /cloud_storage/{display_name}/content`. One level of the bucket under `prefix`: `items` are
1508
+ `CloudKey`s (`type` `"DIR"` with the summed size below it, or `"FILE"`), plus `total_size`, `next`
1509
+ (continuation token) and `is_truncated`; properties `files` and `dirs`. Workforce Manager and above.
1510
+
1511
+ #### `iter_contents(display_name, *, prefix=None, page_size=100, provider="AWS S3", workspace_id=None) -> Iterator[CloudKey]`
1512
+
1513
+ Every item at `prefix`, following continuation tokens. Not recursive.
1514
+
1515
+ #### `aws_regions(workspace_id=None) -> list[str]`
1516
+
1517
+ `GET /get/aws_regions`. Region names accepted for `region`, sorted.
1518
+
1519
+ #### Models
1520
+
1521
+ `CloudStorage`: `id`, `display_name`, `description`, `provider`, `prefix`, `provider_data`,
1522
+ `workspace_id`, `company_id`, `created_by`, `user_id`, `role`, `created_at`, `updated_at`; properties
1523
+ `name`, `bucket_name`, `region`. `CloudContents(items, total_size, next, is_truncated)`.
1524
+
1525
+ ---
1526
+
1527
+ ## Annotations — `client.annotations`
1528
+
1529
+ ### Usage
1530
+
1531
+ Export, download and import of a task's annotations. Both export and import run as backend jobs.
1532
+
1533
+ ```python
1534
+ from intellabel import IntelLabel, AnnotationFormat
1535
+
1536
+ with IntelLabel(workspace_id="<workspace uuid>") as client:
1537
+ job = client.annotations.export("a1b2c3d4", "t1t1t1t1", AnnotationFormat.YOLO_DETECTION,
1538
+ file_name="cars", include_images=True)
1539
+ export = client.annotations.wait_for_export(job.job_id) # polls the export history
1540
+ path = client.annotations.download_export(export, "exports/", show_progress=True)
1541
+
1542
+ client.annotations.import_annotations("a1b2c3d4", "t9t9t9t9", path, AnnotationFormat.YOLO_DETECTION)
1543
+ ```
1544
+
1545
+ **Downloads.** A finished export into local storage gets a token-signed link
1546
+ (`.../api/download/annotation?token=...`). The SDK sends only the token to your client's `base_url`,
1547
+ so it works even when the server builds links with an internal host name. Links and files expire
1548
+ (server settings): an expired link raises `PermissionDeniedError`. Exports whose task targets a cloud
1549
+ bucket are written to that bucket instead and have nothing to download.
1550
+
1551
+ **Background jobs.** Workers report progress and errors only over the dashboard websocket. A failed
1552
+ export keeps `status="PENDING"`, so `wait_for_export` ends with `ExportTimeoutError`; an import's
1553
+ outcome is visible in the task, not through the API.
1554
+
1555
+ ### Methods
1556
+
1557
+ Backend routes in `app/api/annotations.py`, all under `/api`. Formats: `AnnotationFormat.ALL`
1558
+ (`COCO_JSON`, `COCO_KEYPOINTS`, `PASCAL_VOC`, `IMAGE_MASK_BINARY`, `YOLO_DETECTION`,
1559
+ `YOLO_DETECTION_TRACK`, `YOLO_ORIENTED_BOUNDING_BOXES`, `YOLO_POSE`, `YOLO_SEGMENTATION`,
1560
+ `YOLO_CLASSIFICATION`, `TAG_EXPORT`).
1561
+
1562
+ #### `export(project_id, task_id, export_format, *, file_name=None, include_images=False, include_videos=False, workspace_id=None) -> AnnotationJob`
1563
+
1564
+ `POST /projects/{pid}/tasks/{tid}/export/annotation`. Queues the export and returns
1565
+ `AnnotationJob(job_id, queued, job_status, message, ...)`. `file_name` names the export folder in the
1566
+ zip (default `"<task_id>-export"`); `include_images` / `include_videos` add the media.
1567
+
1568
+ #### `list_exports(*, project_id=None, task_id=None, job_id=None, workspace_id=None) -> list[Export]`
1569
+
1570
+ `GET /exports`. The workspace's export history, newest first, optionally filtered. Workforce Manager
1571
+ and above.
1572
+
1573
+ #### `get_export(job_id, workspace_id=None) -> Export | None`
1574
+
1575
+ The history row of one export job.
1576
+
1577
+ #### `wait_for_export(job_id, *, interval=3, timeout=600, on_poll=None, workspace_id=None) -> Export`
1578
+
1579
+ Polls until the row is `SUCCESS`. `ExportTimeoutError` (with `.export`, the last row) after
1580
+ `timeout` seconds.
1581
+
1582
+ #### `download_export(export, dest=".", *, on_bytes=None, show_progress=False) -> Path`
1583
+
1584
+ `GET /download/annotation?token=`. Streams the zip to `dest`: a folder (default `"."`, created if missing,
1585
+ relative to the current directory) where the server's file name is used, or a file path such as
1586
+ `"cars.zip"`. Downloading again into the same folder keeps the earlier files: the new one is saved as
1587
+ `name (1).zip`, `name (2).zip`, ... An explicit file path is overwritten. `export` is an `Export` or its download URL. `ValueError`
1588
+ for an export that is not ready or went to cloud storage.
1589
+
1590
+ #### `import_annotations(project_id, task_id, file, import_format, *, chunk_size=10 MB, on_bytes=None, show_progress=False, workspace_id=None) -> AnnotationJob`
1591
+
1592
+ `POST .../import/annotation/zip-chunk` (once, or per 10 MB chunk for larger files, like the
1593
+ dashboard; failed chunks are retried) then `POST .../import/annotation?import_format=`. `file` is a
1594
+ `.zip` path or `(filename, bytes | seekable file object)`. Returns the queued job (`status`, `message`).
1595
+
1596
+ #### Models
1597
+
1598
+ `Export`: `id`, `job_id`, `project_id`, `task_id`, `task_name`, `export_format`, `file_name`,
1599
+ `status`, `path`, `target_storage`, `progress_id`, `workspace_id`, `created_at`, `updated_at`,
1600
+ `expiry_time`; properties `is_ready`, `storage_type`, `download_url`. `AnnotationJob(job_id, status,
1601
+ message, queued, worker_status, job_status)`. `ExportStatus` (`PENDING`, `SUCCESS`).
1602
+
1603
+ ---
1604
+
1605
+ ## Analytics — `client.analytics`
1606
+
1607
+ ### Usage
1608
+
1609
+ Every figure is the one the INTELLABEL analytics dashboard shows. Durations are in
1610
+ **milliseconds**. Time comes from the annotation tool's timer, which records each annotation
1611
+ session per file and user.
1612
+
1613
+ ```python
1614
+ import datetime as dt
1615
+ from intellabel import IntelLabel, AnalyticsSection
1616
+
1617
+ with IntelLabel(workspace_id="<workspace uuid>") as client:
1618
+ # the main dashboard: workspace, one project, one project's tasks, or one user
1619
+ overview = client.analytics.overview()
1620
+ print(overview.mode, overview.summary["total_projects"], overview.total_time_spent_ms)
1621
+ project_view = client.analytics.overview("a1b2c3d4", start=dt.date(2026, 10, 1))
1622
+ task_view = client.analytics.overview("a1b2c3d4", ["t1t1t1t1", "t2t2t2t2"])
1623
+ user_view = client.analytics.overview(user_id="usr_annotator_1")
1624
+
1625
+ # file-based progress per task (approved files / files)
1626
+ for row in client.analytics.progress("a1b2c3d4"):
1627
+ print(row["task_name"], row["files_completed"], "/", row["total_files"], row["progress_percentage"])
1628
+
1629
+ # annotation timers: totals per user, task and file, plus a daily series
1630
+ timers = client.analytics.timers("a1b2c3d4", start="2026-10-01", end="2026-10-09")
1631
+ print(timers.summary["total_time_ms"], timers.summary["avg_session_ms"])
1632
+ for user in timers.users:
1633
+ print(user.name, user.time_ms, user.idle_ms, user.sessions, user.files)
1634
+ slowest = timers.files[0] # files are sorted by time, slowest first
1635
+ print(slowest.file_name, slowest.time_ms, slowest.users)
1636
+
1637
+ # the timer of one file, per user (what the annotation tool shows)
1638
+ entries = client.analytics.file_timer("a1b2c3d4", "t1t1t1t1", "62ff2944", "Image")
1639
+
1640
+ # the dashboard's time charts: daily time, idle vs active per hour, time per tool
1641
+ time = client.analytics.annotation_time("a1b2c3d4", user_id="usr_annotator_1")
1642
+ print(time.over_time["change_pct"], time.idle_by_hour["total_idle_ms"], time.by_tool)
1643
+
1644
+ # annotations per class and time per task for chosen tasks (admin-scoped key)
1645
+ report = client.analytics.project("a1b2c3d4", ["t1t1t1t1"], label_names=["car"])
1646
+ print(report.label_counts("t1t1t1t1"), report.total_time_taken_per_task)
1647
+
1648
+ # analytics access of Workforce Managers
1649
+ client.analytics.set_access("usr_wfm_1", {AnalyticsSection.ANNOTATION_TIME: True, AnalyticsSection.TEAM_WORKLOAD: True})
1650
+ print(client.analytics.get_access("usr_wfm_1").enabled_sections)
1651
+ ```
1652
+
1653
+ Live progress of a task through its workflow stages is `client.workflows.progress` (see
1654
+ [Workflows](#workflows--clientworkflows)).
1655
+
1656
+ ### Access and ranges
1657
+
1658
+ - Super Admins and Admins see all analytics. A Workforce Manager needs at least one section switched
1659
+ on by an admin (`set_access`) and receives only the sections switched on. `timers` needs the
1660
+ `annotation_time` section. An API key acts as its creator, so in practice a key is a Super Admin's
1661
+ or an Admin's. `project` and `set_access` also need the key's `admin` scope.
1662
+ - `start` and `end` accept a `datetime.date`, a `datetime.datetime` or an ISO-8601 string
1663
+ (`"2026-10-01"`, `"2026-10-01T09:00:00Z"`). A date covers the whole day and an end date includes
1664
+ that day. Naive datetimes are read in the company timezone and aware ones are exact. Either bound
1665
+ can be left out. An unparsable value raises `ValueError` locally. A start after the end raises
1666
+ `BadRequestError`.
1667
+ - `project_ids` and `task_ids` take one id or a list of ids. Projects must belong to the workspace
1668
+ (`NotFoundError` otherwise). With no projects given, every project of the workspace is included.
1669
+ - Analytics tables are paginated the backend's way: `*_page` is **1-based** and `*_limit=0` (the
1670
+ default) returns every row. The page info is returned with each table.
1671
+
1672
+ ### Methods
1673
+
1674
+ Backend routes in `app/api/analytics.py` (plus the file timer in `app/api/annotations.py`), all under `/api`.
1675
+
1676
+ #### `overview(project_ids=None, task_ids=None, *, user_id=None, start=None, end=None, workspace_id=None, **pagination) -> AnalyticsOverview`
1677
+
1678
+ `GET /analytics/overview`, the main analytics API that the dashboard calls. The filters choose the
1679
+ view, which `mode` names:
1680
+
1681
+ | Filters | `mode` | Main sections |
1682
+ |---|---|---|
1683
+ | none, or several projects | `"overview"` | `summary`, `project_progress`, `project_status_distribution`, `tasks`, `team_workload` |
1684
+ | one project | `"project"` | `project`, `summary`, `task_progress`, `task_status_distribution`, `tasks`, `team_workload` |
1685
+ | one project + `task_ids` | `"task"` | `project`, `tasks_info`, `summary`, `task_progress`, `file_status_distribution`, `tasks`, `team_workload` |
1686
+ | `user_id` | `"user"` | `user`, `summary`, `assigned_work`, `file_status_distribution`, `productivity`, `review_overview` |
1687
+ | workspace without projects or tasks | `"empty"` | none |
1688
+
1689
+ Every view also has `annotations_over_time`, `annotation_time_over_time`, `idle_by_hour`,
1690
+ `annotation_distribution` (`by_tool`, `by_class`) and `auto_annotation`. A section the caller may
1691
+ not see is `None`. The daily charts cover the range, or the last 7 days without one. Pagination
1692
+ keywords: `progress_page` / `progress_limit` (the progress table of the view), `workload_*`,
1693
+ `unassigned_*`, `tool_*`, `class_*`, `auto_user_*`. An unknown keyword raises `ValueError`.
1694
+ Annotators and QAs may only request their own `user_id` (`PermissionDeniedError` otherwise).
1695
+
1696
+ #### `progress(project_ids=None, task_ids=None, *, user_id=None, start=None, end=None, workspace_id=None) -> list[dict]`
1697
+
1698
+ The view's progress table with every row: one row per project for the workspace (`total_tasks`,
1699
+ `tasks_completed`, `progress_percentage`, `annotation_time_ms`, `status`), one row per task for a
1700
+ project (`total_files`, `files_completed`, `progress_percentage`, `annotation_time_ms`, `status`), or
1701
+ one row per assigned task for a user. `progress_percentage` is approved files / files.
1702
+
1703
+ #### `timers(project_ids=None, task_ids=None, *, user_id=None, file_id=None, start=None, end=None, workspace_id=None, **pagination) -> AnnotationTimers`
1704
+
1705
+ `GET /analytics/timers`. Annotation timer analytics of the selection:
1706
+
1707
+ - `summary`: `total_time_ms`, `total_idle_ms`, `sessions`, `files`, `users`, `tasks`, `projects`,
1708
+ `avg_session_ms`, `avg_time_per_file_ms`.
1709
+ - `users` (`TimerUser`), `tasks` (`TimerTask`) and `files` (`TimerFile`, with a per-user split in
1710
+ `users`), each sorted by time, longest first.
1711
+ - `over_time`: `points: [{date, duration_ms, sessions}]` per company-local day over the range (last 7
1712
+ days without one), with `total_ms` and `sessions`.
1713
+
1714
+ Without a range, the stored per-file totals are summed. These are the same totals as the
1715
+ dashboard's `total_time_spent_ms` and the per-task `annotation_time_ms`. With a range, only timer
1716
+ sessions that started inside it count. Idle time comes from the idle events the annotation tool
1717
+ reports. `user_id` and `file_id` narrow to one user or one file. Pagination keywords:
1718
+ `user_page` / `user_limit`, `task_*`, `file_*`. Page info is in `pagination["users" | "tasks" | "files"]`.
1719
+
1720
+ #### `annotation_time(project_ids=None, task_ids=None, *, user_id=None, start=None, end=None, workspace_id=None) -> AnnotationTime`
1721
+
1722
+ The time sections of `overview` for the same filters: `total_time_ms`, `over_time` (daily
1723
+ `duration_ms` with `previous_total_ms` and `change_pct` against the period before), `idle_by_hour`
1724
+ (`points: [{hour, idle_ms, active_ms, days}]`, 24 hours) and `by_tool` (`{tool: total_time_ms}`).
1725
+
1726
+ #### `file_timer(project_id, task_id, file_id, file_type="Image", workspace_id=None) -> list[FileTimerEntry]`
1727
+
1728
+ `GET /projects/{pid}/tasks/{tid}/get/timestamp`. The timer of one file: one entry per user with
1729
+ `total_duration_ms`, `name` and `updated_at`. `file_type` is `"Image"` or `"Video"`. Workers can
1730
+ read only the files of datasets assigned to them.
1731
+
1732
+ #### `project(project_id, task_ids, *, label_names=None, start=None, end=None, workspace_id=None) -> ProjectAnalytics`
1733
+
1734
+ `GET /project/{pid}/analytics`. For the given tasks of one project: `total_annotations`,
1735
+ `total_annotations_per_class` (per task: `task_name` and `label_annotations`), `total_time_taken`
1736
+ and `total_time_taken_per_task` (ms). `label_names` limits the counts to those classes. `task_ids` is
1737
+ required. Needs the `admin` scope.
1738
+
1739
+ #### `users(workspace_id=None) -> list[AnalyticsUser]`
1740
+
1741
+ `GET /analytics/users`. The users that can be picked for `overview(user_id=...)`: everyone except
1742
+ Workforce Managers for a Super Admin, and Annotators and QAs (plus the caller) for an Admin.
1743
+
1744
+ #### `get_access(user_id, workspace_id=None) -> AnalyticsPermissions`
1745
+
1746
+ `GET /analytics/access`. The section toggles of a Workforce Manager.
1747
+
1748
+ #### `set_access(user_id, permissions, workspace_id=None) -> AnalyticsPermissions`
1749
+
1750
+ `POST /analytics/access`. Switches sections on or off (`{section: bool}`). Sections left out keep
1751
+ their state. Sections are checked locally against `AnalyticsSection.ALL`. The user must be a
1752
+ Workforce Manager (`BadRequestError` otherwise). Needs the `admin` scope. `grant_all(user_id)` and
1753
+ `revoke_all(user_id)` switch every section on or off.
1754
+
1755
+ #### Models and constants
1756
+
1757
+ `AnalyticsOverview`: one optional field per section listed above, with properties `mode`,
1758
+ `is_empty`, `total_time_spent_ms` and the helper `progress_rows()`. `AnnotationTimers(timezone, range, summary,
1759
+ users, tasks, files, over_time, pagination)`; properties and helpers `total_time_ms`,
1760
+ `user(account_id)`, `task(task_id)`, `file(file_id)`. `TimerUser(account_id, name, role, time_ms,
1761
+ idle_ms, sessions, files, tasks, avg_session_ms)`. `TimerTask(project_id, project_name, task_id,
1762
+ task_name, time_ms, sessions, files, users, avg_time_per_file_ms)`. `TimerFile(file_id, file_type,
1763
+ file_name, project_id, task_id, time_ms, sessions, users)`. `AnnotationTime(total_time_ms, over_time,
1764
+ idle_by_hour, by_tool)`. `FileTimerEntry(user_id, name, total_duration_ms, updated_at)`.
1765
+ `ProjectAnalytics` with `total_time_ms` and `label_counts(task_id)`. `AnalyticsUser(id, role,
1766
+ full_name)`. `AnalyticsPermissions(user_id, full_name, role, permissions)` with `enabled_sections`
1767
+ and `has_access`. `AnalyticsSection`: the 18 section names (`KPI_OVERVIEW`, `PROJECT_PROGRESS`,
1768
+ `TASK_PROGRESS`, `TEAM_WORKLOAD`, `ANNOTATION_TIME`, `IDLE_BY_HOUR`, ...) and `ALL`.
1769
+
1770
+ ---
1771
+
1772
+ ## Errors
1773
+
1774
+ ```python
1775
+ from intellabel import IntelLabel, AuthenticationError, PermissionDeniedError, NotFoundError, APIError, APITimeoutError
1776
+
1777
+ try:
1778
+ client.get("/projects/123")
1779
+ except AuthenticationError as e: # 401: invalid / inactive / expired key
1780
+ ...
1781
+ except PermissionDeniedError as e: # 403: missing scope or role
1782
+ ...
1783
+ except NotFoundError as e: # 404
1784
+ ...
1785
+ except APITimeoutError: # transport timeout after retries
1786
+ ...
1787
+ except APIError as e: # anything else from the server
1788
+ print(e.status_code, e.detail)
1789
+ ```
1790
+
1791
+ All inherit from `intellabel.IntelLabelError`.
1792
+
1793
+ | Exception | When |
1794
+ |---|---|
1795
+ | `APIConnectionError` | The request never reached the server after retries. |
1796
+ | `APITimeoutError` | Connect, read, write or pool timeout after retries. Subclass of `APIConnectionError`. |
1797
+ | `APIError` | Any non-success HTTP status. Attributes `status_code`, `detail`, `body`, `response`. |
1798
+ | `BadRequestError` (400) | Malformed input the SDK could not catch, missing workspace, storage limit, batch already completed. |
1799
+ | `AuthenticationError` (401) | Invalid, inactive or expired API key, or the owning account lost access. |
1800
+ | `PermissionDeniedError` (403) | Missing scope, insufficient role, deactivated or foreign workspace, or a `/api-keys` path (raised locally). |
1801
+ | `NotFoundError` (404) | Unknown project, task, workspace or cloud storage. |
1802
+ | `ConflictError` (409) | Duplicate project or task name. |
1803
+ | `UnprocessableEntityError` (422) | Request-validation failure on the server. |
1804
+ | `RateLimitError` (429) | Too many requests; retried automatically with `Retry-After`. |
1805
+ | `ServerError` (5xx) | Backend failure. |
1806
+ | `UploadTimeoutError` | `wait_for_upload` / `iter_progress` gave up; `.progress` is the last state. |
1807
+ | `ExportTimeoutError` | `annotations.wait_for_export` gave up (or the export failed); `.export` is the last row. |
1808
+ | `DeployTimeoutError` | `models.wait_for_model` gave up (or the deployment failed); `.model_name` is set. |
1809
+
1810
+ ## Response envelope
1811
+
1812
+ The backend wraps successful responses as `{"success": true, "status": 200, "output": ...}`.
1813
+ `client.request()` and the verb helpers return an `APIResponse` with `.success`, `.status`,
1814
+ `.output` and `.extra` (any additional top-level fields). Resource modules return typed objects
1815
+ built from `output`.
1816
+
1817
+ ## Development
1818
+
1819
+ ```bash
1820
+ pip install -r requirements.txt
1821
+ cp .env.example .env # fill in API_KEY and WORKSPACE_ID
1822
+ pytest # unit tests (mocked transport) + live tests
1823
+ pytest tests/test_live.py # live tests only
1824
+ ```
1825
+
1826
+ All tests read `API_KEY` from `.env`. Unit tests replace the network with `httpx.MockTransport`;
1827
+ live tests call the server and are skipped automatically when it is not reachable. Live tests move everything they create to the trash when they finish.
1828
+
1829
+ ## Project layout
1830
+
1831
+ ```
1832
+ intellabel/
1833
+ _client.py _BaseClient (auth, retries, envelope) + IntelLabel (sync) + AsyncIntelLabel
1834
+ _auth.py login() / logout(), saved credentials file, AuthInfo (whoami)
1835
+ cli.py, __main__.py `intellabel login | whoami | logout` command line
1836
+ _types.py APIResponse + dataclass base
1837
+ _progress.py terminal progress bar / status line / byte-counting file proxy
1838
+ exceptions.py error hierarchy
1839
+ resources/
1840
+ _base.py Request spec, SyncResource / AsyncResource, shared validators
1841
+ projects.py _ProjectsSpec (build + parse, no I/O), Projects, AsyncProjects
1842
+ tasks.py _TasksSpec, Tasks, AsyncTasks <- app/api/task_apis.py (+ uploads, progress; no datasets)
1843
+ datasets.py _DatasetsSpec, Datasets, AsyncDatasets <- task_apis.py dataset routes
1844
+ labels.py _LabelsSpec, Labels, AsyncLabels <- app/api/label_apis.py
1845
+ workflows.py _WorkflowsSpec, Workflows, AsyncWorkflows <- app/api/workflows.py
1846
+ custom_models.py _ModelsSpec, Models, AsyncModels <- app/api/custom_model.py
1847
+ cloud_storages.py _CloudStoragesSpec, CloudStorages, AsyncCloudStorages <- app/api/cloud_services.py
1848
+ annotations.py _AnnotationsSpec, Annotations, AsyncAnnotations <- app/api/annotations.py (export/import/download)
1849
+ analytics.py _AnalyticsSpec, Analytics, AsyncAnalytics <- app/api/analytics.py (+ the file timer route)
1850
+ _chunked.py chunked single-file upload shared by models.deploy and annotations import
1851
+ tests/
1852
+ conftest.py API_KEY from .env; `mode` fixture runs each test on both clients
1853
+ test_client.py core clients (mocked)
1854
+ test_live.py core clients (live)
1855
+ test_auth.py login / logout / whoami, credentials file, key resolution, CLI (mocked + live)
1856
+ test_projects.py projects (mocked + live)
1857
+ test_tasks.py tasks (mocked + live)
1858
+ test_datasets.py datasets (mocked + live)
1859
+ test_labels.py labels (mocked + live)
1860
+ test_workflows.py workflows (mocked + live)
1861
+ test_models.py custom models (mocked + live read-only)
1862
+ test_cloud_storages.py cloud storages (mocked + live read-only)
1863
+ test_annotations.py annotation export / download / import (mocked + live round trip)
1864
+ test_analytics.py analytics, timers (mocked + live read-only cross-checks with workflow progress)
1865
+ ```
1866
+
1867
+ Each backend router gets one module under `resources/`: a spec class that builds requests and
1868
+ parses responses once, plus a sync and an async wrapper of a few lines each. One test file covers
1869
+ both wrappers. Next up: workspaces, trash.