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.
- intellabel-0.1.0rc2/PKG-INFO +1869 -0
- intellabel-0.1.0rc2/README.md +1853 -0
- intellabel-0.1.0rc2/intellabel/__init__.py +159 -0
- intellabel-0.1.0rc2/intellabel/__main__.py +6 -0
- intellabel-0.1.0rc2/intellabel/_auth.py +425 -0
- intellabel-0.1.0rc2/intellabel/_client.py +610 -0
- intellabel-0.1.0rc2/intellabel/_deployment.py +8 -0
- intellabel-0.1.0rc2/intellabel/_progress.py +156 -0
- intellabel-0.1.0rc2/intellabel/_types.py +53 -0
- intellabel-0.1.0rc2/intellabel/cli.py +111 -0
- intellabel-0.1.0rc2/intellabel/exceptions.py +147 -0
- intellabel-0.1.0rc2/intellabel/resources/__init__.py +40 -0
- intellabel-0.1.0rc2/intellabel/resources/_base.py +129 -0
- intellabel-0.1.0rc2/intellabel/resources/_chunked.py +163 -0
- intellabel-0.1.0rc2/intellabel/resources/analytics.py +510 -0
- intellabel-0.1.0rc2/intellabel/resources/annotations.py +371 -0
- intellabel-0.1.0rc2/intellabel/resources/cloud_storages.py +307 -0
- intellabel-0.1.0rc2/intellabel/resources/custom_models.py +504 -0
- intellabel-0.1.0rc2/intellabel/resources/datasets.py +603 -0
- intellabel-0.1.0rc2/intellabel/resources/labels.py +459 -0
- intellabel-0.1.0rc2/intellabel/resources/projects.py +305 -0
- intellabel-0.1.0rc2/intellabel/resources/tasks.py +1190 -0
- intellabel-0.1.0rc2/intellabel/resources/workflows.py +460 -0
- intellabel-0.1.0rc2/intellabel.egg-info/PKG-INFO +1869 -0
- intellabel-0.1.0rc2/intellabel.egg-info/SOURCES.txt +42 -0
- intellabel-0.1.0rc2/intellabel.egg-info/dependency_links.txt +1 -0
- intellabel-0.1.0rc2/intellabel.egg-info/entry_points.txt +2 -0
- intellabel-0.1.0rc2/intellabel.egg-info/requires.txt +9 -0
- intellabel-0.1.0rc2/intellabel.egg-info/top_level.txt +1 -0
- intellabel-0.1.0rc2/pyproject.toml +27 -0
- intellabel-0.1.0rc2/setup.cfg +4 -0
- intellabel-0.1.0rc2/tests/test_analytics.py +513 -0
- intellabel-0.1.0rc2/tests/test_annotations.py +338 -0
- intellabel-0.1.0rc2/tests/test_auth.py +610 -0
- intellabel-0.1.0rc2/tests/test_client.py +397 -0
- intellabel-0.1.0rc2/tests/test_cloud_storages.py +171 -0
- intellabel-0.1.0rc2/tests/test_datasets.py +390 -0
- intellabel-0.1.0rc2/tests/test_labels.py +346 -0
- intellabel-0.1.0rc2/tests/test_live.py +62 -0
- intellabel-0.1.0rc2/tests/test_models.py +307 -0
- intellabel-0.1.0rc2/tests/test_progress.py +158 -0
- intellabel-0.1.0rc2/tests/test_projects.py +444 -0
- intellabel-0.1.0rc2/tests/test_tasks.py +772 -0
- 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.
|