unitlab 3.0.3__tar.gz → 3.0.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {unitlab-3.0.3 → unitlab-3.0.4}/PKG-INFO +15 -2
  2. {unitlab-3.0.3 → unitlab-3.0.4}/README.md +14 -1
  3. {unitlab-3.0.3 → unitlab-3.0.4}/pyproject.toml +1 -1
  4. {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/__init__.py +2 -0
  5. {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/_http.py +5 -0
  6. {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/_uploader.py +65 -2
  7. {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/cli.py +25 -0
  8. {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/exceptions.py +10 -0
  9. {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/resources/releases.py +72 -3
  10. {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/resources/workflow.py +104 -8
  11. {unitlab-3.0.3 → unitlab-3.0.4}/tests/test_cli.py +69 -1
  12. {unitlab-3.0.3 → unitlab-3.0.4}/tests/test_client.py +439 -0
  13. {unitlab-3.0.3 → unitlab-3.0.4}/tests/test_http.py +27 -1
  14. {unitlab-3.0.3 → unitlab-3.0.4}/tests/test_tiled_uploads.py +158 -0
  15. {unitlab-3.0.3 → unitlab-3.0.4}/.gitignore +0 -0
  16. {unitlab-3.0.3 → unitlab-3.0.4}/LICENSE.md +0 -0
  17. {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/__main__.py +0 -0
  18. {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/_config.py +0 -0
  19. {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/_downloader.py +0 -0
  20. {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/_grouping.py +0 -0
  21. {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/_waiter.py +0 -0
  22. {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/client.py +0 -0
  23. {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/ontology.py +0 -0
  24. {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/py.typed +0 -0
  25. {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/resources/__init__.py +0 -0
  26. {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/resources/_base.py +0 -0
  27. {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/resources/assets.py +0 -0
  28. {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/resources/cloud.py +0 -0
  29. {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/resources/datasets.py +0 -0
  30. {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/resources/embeddings.py +0 -0
  31. {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/resources/ontologies.py +0 -0
  32. {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/resources/projects.py +0 -0
  33. {unitlab-3.0.3 → unitlab-3.0.4}/src/unitlab/types.py +0 -0
  34. {unitlab-3.0.3 → unitlab-3.0.4}/tests/test_downloader.py +0 -0
  35. {unitlab-3.0.3 → unitlab-3.0.4}/tests/test_embeddings.py +0 -0
  36. {unitlab-3.0.3 → unitlab-3.0.4}/tests/test_uploader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: unitlab
3
- Version: 3.0.3
3
+ Version: 3.0.4
4
4
  Summary: Python SDK for the Unitlab.ai data annotation platform
5
5
  Project-URL: Homepage, https://unitlab.ai
6
6
  Project-URL: Documentation, https://docs.unitlab.ai
@@ -393,6 +393,18 @@ annotation_path = release.download(split="train", dest="./release-annotations")
393
393
  files_folder = release.download_files(dest="./release-files")
394
394
  ```
395
395
 
396
+ `client.releases.create()` waits until the Release is ready. To return immediately
397
+ and wait later:
398
+
399
+ ```python
400
+ release = client.releases.create(project, wait=False)
401
+ release.wait(timeout=7200) # Wait before downloading.
402
+ ```
403
+
404
+ Check progress with `release.refresh()`, then read `release.status` and
405
+ `release.progress`. Lists show ready Releases; use `client.releases.get(release_id)`
406
+ to check one still being prepared.
407
+
396
408
  Release item download tokens are persistent Unitlab URLs. For files stored in
397
409
  customer cloud storage, the URL redirects to a signed target URL; only that target
398
410
  URL is temporary.
@@ -454,8 +466,9 @@ unitlab dataset unpublished-changes DATASET_ID
454
466
  unitlab dataset publish DATASET_ID --title "Initial snapshot"
455
467
  unitlab dataset versions DATASET_ID
456
468
 
457
- # Releases
469
+ # Releases (create returns immediately; add --wait to wait until ready)
458
470
  unitlab release create PROJECT_ID --format UUEF --splits train=100
471
+ unitlab release wait RELEASE_ID --timeout 7200
459
472
  unitlab release list
460
473
  unitlab release detail RELEASE_ID
461
474
  unitlab release download RELEASE_ID --split-type train --dest ./release-annotations
@@ -360,6 +360,18 @@ annotation_path = release.download(split="train", dest="./release-annotations")
360
360
  files_folder = release.download_files(dest="./release-files")
361
361
  ```
362
362
 
363
+ `client.releases.create()` waits until the Release is ready. To return immediately
364
+ and wait later:
365
+
366
+ ```python
367
+ release = client.releases.create(project, wait=False)
368
+ release.wait(timeout=7200) # Wait before downloading.
369
+ ```
370
+
371
+ Check progress with `release.refresh()`, then read `release.status` and
372
+ `release.progress`. Lists show ready Releases; use `client.releases.get(release_id)`
373
+ to check one still being prepared.
374
+
363
375
  Release item download tokens are persistent Unitlab URLs. For files stored in
364
376
  customer cloud storage, the URL redirects to a signed target URL; only that target
365
377
  URL is temporary.
@@ -421,8 +433,9 @@ unitlab dataset unpublished-changes DATASET_ID
421
433
  unitlab dataset publish DATASET_ID --title "Initial snapshot"
422
434
  unitlab dataset versions DATASET_ID
423
435
 
424
- # Releases
436
+ # Releases (create returns immediately; add --wait to wait until ready)
425
437
  unitlab release create PROJECT_ID --format UUEF --splits train=100
438
+ unitlab release wait RELEASE_ID --timeout 7200
426
439
  unitlab release list
427
440
  unitlab release detail RELEASE_ID
428
441
  unitlab release download RELEASE_ID --split-type train --dest ./release-annotations
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "unitlab"
7
- version = "3.0.3"
7
+ version = "3.0.4"
8
8
  description = "Python SDK for the Unitlab.ai data annotation platform"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -5,6 +5,7 @@ from .client import UnitlabClient
5
5
  from .exceptions import (
6
6
  AmbiguousUploadCompletionError,
7
7
  AuthenticationError,
8
+ ConflictError,
8
9
  NetworkError,
9
10
  NotFoundError,
10
11
  PermissionDeniedError,
@@ -67,6 +68,7 @@ __all__ = [
67
68
  "ChecklistAttribute",
68
69
  "CloudEntry",
69
70
  "CloudStorage",
71
+ "ConflictError",
70
72
  "DataUnit",
71
73
  "Dataset",
72
74
  "DatasetItem",
@@ -7,6 +7,7 @@ import httpx
7
7
 
8
8
  from .exceptions import (
9
9
  AuthenticationError,
10
+ ConflictError,
10
11
  NetworkError,
11
12
  NotFoundError,
12
13
  PermissionDeniedError,
@@ -65,6 +66,10 @@ def raise_for_response(response: httpx.Response) -> None:
65
66
  if code == "permission_denied":
66
67
  raise PermissionDeniedError(message or "Forbidden", error, code)
67
68
  raise SubscriptionError(message or "Forbidden", error, code)
69
+ # Must stay above the message-based NotFound fallback below: a 409 detail
70
+ # can mention "not found" and still has to surface as a conflict.
71
+ if response.status_code == 409:
72
+ raise ConflictError(message, error, code)
68
73
  if response.status_code == 404 or "not found" in message.lower():
69
74
  raise NotFoundError(message, error, code)
70
75
  raise NetworkError(message, error, code)
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import json
5
5
  import uuid
6
+ from collections.abc import Awaitable, Callable
6
7
  from concurrent.futures import ThreadPoolExecutor
7
8
  from contextlib import suppress
8
9
  from pathlib import Path
@@ -198,16 +199,21 @@ async def _put_tiled_part(
198
199
  url: str,
199
200
  start: int,
200
201
  size: int,
202
+ get_url: Callable[[str | None], Awaitable[str]] | None = None,
201
203
  ) -> str:
202
204
  last_error: Exception | None = None
205
+ rejected_url = None
203
206
  for attempt in range(TILED_PART_MAX_ATTEMPTS):
204
207
  try:
205
208
  async with semaphore:
209
+ if get_url is not None:
210
+ url = await get_url(rejected_url)
206
211
  response = await client.put(
207
212
  url,
208
213
  content=_FilePartStream(path, start, size),
209
214
  headers={"Content-Length": str(size)},
210
215
  )
216
+ rejected_url = url if response.status_code == 403 else None
211
217
  response.raise_for_status()
212
218
  etag = response.headers.get("etag")
213
219
  if not etag:
@@ -272,6 +278,55 @@ async def _upload_tiled_file(
272
278
  not part.get("url") for part in part_urls
273
279
  ):
274
280
  raise ValueError("invalid multipart part URLs")
281
+ loop = asyncio.get_running_loop()
282
+ expires_in = float(initiated.get("expires_in", 43200))
283
+ renew_at = loop.time() + expires_in * 0.75
284
+ renewal_lock = asyncio.Lock()
285
+ urls = {int(part["part_number"]): str(part["url"]) for part in part_urls}
286
+
287
+ async def get_url(number: int, rejected_url: str | None = None) -> str:
288
+ nonlocal upload_token, renew_at
289
+ async with renewal_lock:
290
+ if loop.time() >= renew_at or (
291
+ rejected_url is not None and urls[number] == rejected_url
292
+ ):
293
+ try:
294
+ response = await api_client.post(
295
+ f"{endpoint}/refresh/",
296
+ json={
297
+ "upload_token": upload_token,
298
+ "part_numbers": list(urls),
299
+ },
300
+ )
301
+ response.raise_for_status()
302
+ fresh = _response_json(response)
303
+ new_urls = {
304
+ int(part["part_number"]): str(part["url"])
305
+ for part in fresh["part_urls"]
306
+ }
307
+ if new_urls.keys() != urls.keys() or not fresh["upload_token"]:
308
+ raise ValueError("missing refreshed multipart fields")
309
+ urls.update(new_urls)
310
+ upload_token = str(fresh["upload_token"])
311
+ renew_at = loop.time() + float(fresh["expires_in"]) * 0.75
312
+ except (httpx.HTTPError, KeyError, TypeError, ValueError) as exc:
313
+ raise NetworkError("Multipart URL renewal failed", exc) from exc
314
+ return urls[number]
315
+
316
+ async def keep_upload_active():
317
+ # A single slow PUT can outlive the token. Renew independently of
318
+ # part completion; transient failures retry at the next interval.
319
+ event = asyncio.Event()
320
+ while True:
321
+ try:
322
+ await asyncio.wait_for(
323
+ event.wait(), timeout=max(1, min(3600, expires_in / 4))
324
+ )
325
+ except asyncio.TimeoutError:
326
+ with suppress(NetworkError):
327
+ await get_url(1)
328
+
329
+ renewal_task = asyncio.create_task(keep_upload_active())
275
330
  part_tasks = [
276
331
  asyncio.create_task(
277
332
  _put_tiled_part(
@@ -279,6 +334,9 @@ async def _upload_tiled_file(
279
334
  part_semaphore,
280
335
  path,
281
336
  url=str(part["url"]),
337
+ get_url=lambda rejected, number=int(part["part_number"]): get_url(
338
+ number, rejected
339
+ ),
282
340
  start=(int(part["part_number"]) - 1) * part_size,
283
341
  size=min(
284
342
  part_size,
@@ -290,11 +348,16 @@ async def _upload_tiled_file(
290
348
  ]
291
349
  try:
292
350
  parts = await asyncio.gather(*part_tasks)
351
+ await get_url(1)
293
352
  except Exception:
294
353
  for task in part_tasks:
295
354
  task.cancel()
296
355
  await asyncio.gather(*part_tasks, return_exceptions=True)
297
356
  raise
357
+ finally:
358
+ renewal_task.cancel()
359
+ with suppress(asyncio.CancelledError):
360
+ await renewal_task
298
361
  except (KeyError, TypeError, ValueError, NetworkError) as exc:
299
362
  if upload_token:
300
363
  await _abort_tiled_upload(api_client, endpoint, upload_token)
@@ -406,7 +469,7 @@ def _project_files(
406
469
  } or known_extensions()
407
470
  files = collect_files(source, accepted)
408
471
  max_sizes = {
409
- str(key): int(value)
472
+ str(key): None if value is None else int(value)
410
473
  for key, value in (upload_info.get("max_file_sizes") or {}).items()
411
474
  }
412
475
  project_type = upload_info.get("generic_type")
@@ -418,7 +481,7 @@ def _project_files(
418
481
  generic_type or detect_generic_type(path.name) or project_type
419
482
  )
420
483
  max_size = max_sizes.get(file_generic_type)
421
- if max_size is None and project_type and fallback is not None:
484
+ if file_generic_type not in max_sizes and project_type and fallback is not None:
422
485
  max_size = int(fallback)
423
486
  if max_size is None or path.stat().st_size <= max_size:
424
487
  result.append(path)
@@ -195,6 +195,7 @@ SUMMARY_FIELDS = (
195
195
  "title",
196
196
  "file_name",
197
197
  "kind",
198
+ "task_kind",
198
199
  "type",
199
200
  "version",
200
201
  "status",
@@ -1249,6 +1250,17 @@ def release_create(
1249
1250
  ),
1250
1251
  ),
1251
1252
  license_id: UUID | None = typer.Option(None, "--license"),
1253
+ # Unlike the Python API, the CLI does not wait by default: a Release can
1254
+ # take a long time to prepare and a shell command should not hang on it.
1255
+ wait: bool = typer.Option(
1256
+ False,
1257
+ "--wait/--no-wait",
1258
+ help=(
1259
+ "Wait until the Release is ready. Without it the command returns "
1260
+ "the pending Release; follow it with `unitlab release wait`."
1261
+ ),
1262
+ ),
1263
+ timeout: float = typer.Option(7200, help="Seconds to wait with --wait."),
1252
1264
  api_key: API_KEY = None,
1253
1265
  json_output: JSON_OUTPUT = False,
1254
1266
  ):
@@ -1269,10 +1281,23 @@ def release_create(
1269
1281
  data_types=data_type,
1270
1282
  include_download_tokens=include_download_tokens,
1271
1283
  license_id=str(license_id) if license_id else None,
1284
+ wait=wait,
1285
+ timeout=timeout,
1272
1286
  )
1273
1287
  emit(release, json_output)
1274
1288
 
1275
1289
 
1290
+ @release_app.command(name="wait", help="Wait until a Release is ready")
1291
+ def release_wait(
1292
+ release_id: UUID,
1293
+ timeout: float = typer.Option(7200),
1294
+ api_key: API_KEY = None,
1295
+ json_output: JSON_OUTPUT = False,
1296
+ ):
1297
+ release = get_client(api_key).releases.get(str(release_id))
1298
+ emit(release.wait(timeout=timeout), json_output)
1299
+
1300
+
1276
1301
  @ontology_app.command(name="list", help="List Ontologies")
1277
1302
  def ontology_list(
1278
1303
  title_eq: str | None = typer.Option(None, "--title-eq"),
@@ -32,6 +32,16 @@ class NotFoundError(NetworkError):
32
32
  """Raised when the requested resource is not found."""
33
33
 
34
34
 
35
+ class ConflictError(NetworkError):
36
+ """Raised when the API answers 409 because the resource is in a conflicting state.
37
+
38
+ Subclasses NetworkError so callers that already catch NetworkError keep
39
+ working; ``code`` tells the cases apart (``release_in_progress`` for a
40
+ second create while one is being prepared, ``release_not_ready`` for a
41
+ download before the Release is ready).
42
+ """
43
+
44
+
35
45
  class SubscriptionError(NetworkError):
36
46
  """Raised when a subscription error occurs."""
37
47
 
@@ -1,11 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Callable
3
4
  from dataclasses import dataclass, field
4
5
  from pathlib import Path
5
6
  from typing import TYPE_CHECKING, Any
6
7
 
7
8
  from .. import _downloader
8
- from ..types import _data_type_name
9
+ from .._waiter import wait_for_status
10
+ from ..exceptions import UnitlabError
11
+ from ..types import ProcessingStatus, _data_type_name
9
12
  from ._base import Namespace, identifier
10
13
 
11
14
  if TYPE_CHECKING:
@@ -32,9 +35,20 @@ class ReleasesNamespace(Namespace):
32
35
  data_types=None,
33
36
  bundle_formats: dict[str, str] | None = None,
34
37
  license_id: str | None = None,
38
+ wait: bool = True,
39
+ timeout: float = 7200,
35
40
  ) -> Release:
36
41
  """Create an annotation Release from a Project snapshot.
37
42
 
43
+ The backend prepares a Release in the background and answers at once
44
+ with a pending one. Waiting is the default so scripts that create and
45
+ then download keep working unchanged; pass ``wait=False`` to get the
46
+ pending Release back and call ``release.wait()`` later.
47
+
48
+ A second create while one is still being prepared raises
49
+ ``ConflictError`` (code ``release_in_progress``). It is deliberately not
50
+ attached to the in-flight Release, whose format or splits may differ.
51
+
38
52
  Args:
39
53
  project: Project handle or ID.
40
54
  export_type: Primary annotation export format.
@@ -47,9 +61,11 @@ class ReleasesNamespace(Namespace):
47
61
  includes every available concrete type.
48
62
  bundle_formats: Per-concrete-data-type formats for multimodal bundles.
49
63
  license_id: Optional Dataset license ID.
64
+ wait: Block until the Release is ready or has failed.
65
+ timeout: Seconds to wait for preparation when ``wait`` is true.
50
66
 
51
67
  Returns:
52
- The created Release.
68
+ The created Release; ready when ``wait`` is true.
53
69
  """
54
70
  payload: dict[str, Any] = {
55
71
  "export_type": export_type,
@@ -82,12 +98,15 @@ class ReleasesNamespace(Namespace):
82
98
  }
83
99
  if license_id is not None:
84
100
  payload["license"] = identifier(license_id)
101
+ # The long request timeout stays: a backend that predates background
102
+ # preparation still builds the whole Release inside this POST.
85
103
  raw = self._api.post(
86
104
  f"/api/sdk/projects/{identifier(project)}/releases/",
87
105
  json=payload,
88
106
  timeout=600.0,
89
107
  )
90
- return Release._from_raw(self._client, raw)
108
+ release = Release._from_raw(self._client, raw)
109
+ return release.wait(timeout=timeout) if wait else release
91
110
 
92
111
 
93
112
  @dataclass
@@ -100,6 +119,11 @@ class Release:
100
119
  _client: UnitlabClient = field(repr=False, compare=False)
101
120
  data_type: str = ""
102
121
  is_public: bool = False
122
+ # "pending" | "exporting" | "ready" | "failed". None means the backend
123
+ # predates background preparation, where every returned Release is ready.
124
+ status: str | None = None
125
+ progress: int | None = None
126
+ status_error: str | None = None
103
127
 
104
128
  @classmethod
105
129
  def _from_raw(cls, client: UnitlabClient, raw: dict[str, Any]) -> Release:
@@ -107,13 +131,58 @@ class Release:
107
131
  id=str(raw["pk"]),
108
132
  name=str(raw.get("name", "")),
109
133
  version=str(raw.get("version", "")),
134
+ # number_of_data is null until the Release has been frozen.
110
135
  data_item_count=int(raw.get("number_of_data") or 0),
111
136
  raw=raw,
112
137
  _client=client,
113
138
  data_type=_data_type_name(raw.get("generic_type")),
114
139
  is_public=bool(raw.get("is_public", False)),
140
+ status=raw.get("status"),
141
+ progress=raw.get("progress"),
142
+ status_error=raw.get("status_error"),
115
143
  )
116
144
 
145
+ def refresh(self) -> Release:
146
+ """Re-read this Release from the API and update it in place."""
147
+ # Copying every field keeps handles the caller already holds current,
148
+ # including data_item_count, which is only known once frozen.
149
+ vars(self).update(vars(self._client.releases.get(self.id)))
150
+ return self
151
+
152
+ def wait(
153
+ self,
154
+ *,
155
+ timeout: float = 7200,
156
+ on_progress: Callable[[ProcessingStatus], None] | None = None,
157
+ show_progress: bool = True,
158
+ ) -> Release:
159
+ """Block until this Release is ready.
160
+
161
+ Raises:
162
+ UnitlabError: With code ``release_failed`` when preparation failed.
163
+ ProcessingTimeoutError: When it is still running after ``timeout``.
164
+ """
165
+ # Only poll a Release the backend reported as unfinished: a backend
166
+ # without background preparation sends no status and has no /status/
167
+ # route, so polling it would turn a finished Release into a 404.
168
+ if self.status in ("pending", "exporting"):
169
+ wait_for_status(
170
+ self._client._api,
171
+ f"/api/sdk/releases/{self.id}/status/",
172
+ resource_name=f"Release {self.id}",
173
+ timeout=timeout,
174
+ on_progress=on_progress,
175
+ show_progress=show_progress,
176
+ )
177
+ self.refresh()
178
+ # wait_for_status returns on failure too, so failure is judged here.
179
+ if self.status == "failed":
180
+ raise UnitlabError(
181
+ f"Release {self.id} failed: {self.status_error or 'unknown error'}",
182
+ code="release_failed",
183
+ )
184
+ return self
185
+
117
186
  def download(
118
187
  self,
119
188
  split: str | None = None,
@@ -162,6 +162,10 @@ class WorkflowTask:
162
162
  data_type: str = ""
163
163
  available_actions: list[str] = field(default_factory=list)
164
164
  move_targets: list[dict[str, Any]] = field(default_factory=list)
165
+ task_kind: str = "item_state"
166
+ parent_item_id: str | None = None
167
+ assignment_id: str | None = None
168
+ consensus_summary: dict[str, Any] | None = None
165
169
 
166
170
  @classmethod
167
171
  def _from_queue(
@@ -170,8 +174,13 @@ class WorkflowTask:
170
174
  project_id: str,
171
175
  raw: dict[str, Any],
172
176
  ) -> WorkflowTask:
177
+ consensus_summary = (
178
+ raw["consensus_summary"]
179
+ if "consensus_summary" in raw
180
+ else raw.get("consensus")
181
+ )
173
182
  return cls(
174
- id=str(raw["item_id"]),
183
+ id=str(raw.get("task_id") or raw["item_id"]),
175
184
  project_id=project_id,
176
185
  stage_id=str(raw.get("stage_id", "")),
177
186
  stage_type=str(raw.get("stage_type", "")),
@@ -193,6 +202,14 @@ class WorkflowTask:
193
202
  else None
194
203
  ),
195
204
  data_type=_data_type_name(raw.get("generic_type")),
205
+ task_kind=str(raw.get("task_kind") or "item_state"),
206
+ parent_item_id=(
207
+ str(raw["parent_item_id"]) if raw.get("parent_item_id") else None
208
+ ),
209
+ assignment_id=(
210
+ str(raw["assignment_id"]) if raw.get("assignment_id") else None
211
+ ),
212
+ consensus_summary=consensus_summary,
196
213
  )
197
214
 
198
215
  @classmethod
@@ -206,8 +223,15 @@ class WorkflowTask:
206
223
  task = raw.get("task", raw)
207
224
  queue = raw.get("queue") or {}
208
225
  current_stage = task.get("current_stage") or {}
226
+ # Consensus task details retain the parent resource; the queue identifies
227
+ # the private branch. Explicit null also matters when an assignee is cleared.
228
+ datasource_id = queue.get("datasource_id", task.get("datasource_id"))
229
+ assigned_to_id = queue.get("assigned_to_id", task.get("assigned_to_id"))
230
+ consensus_summary = raw.get("consensus_summary")
231
+ if consensus_summary is None:
232
+ consensus_summary = task.get("consensus_summary")
209
233
  return cls(
210
- id=str(task["uuid"]),
234
+ id=str(task.get("task_id") or task["uuid"]),
211
235
  project_id=str(task.get("project_id") or project_id),
212
236
  stage_id=str(current_stage.get("id", "")),
213
237
  stage_type=str(current_stage.get("type", "")),
@@ -217,12 +241,8 @@ class WorkflowTask:
217
241
  raw=raw,
218
242
  _client=client,
219
243
  name=str(queue.get("name", "")),
220
- assigned_to_id=(
221
- str(task["assigned_to_id"]) if task.get("assigned_to_id") else None
222
- ),
223
- datasource_id=(
224
- str(task["datasource_id"]) if task.get("datasource_id") else None
225
- ),
244
+ assigned_to_id=str(assigned_to_id) if assigned_to_id else None,
245
+ datasource_id=str(datasource_id) if datasource_id else None,
226
246
  data_group_id=(
227
247
  str(task.get("project_data_group_id") or task.get("data_group_id"))
228
248
  if task.get("project_data_group_id") or task.get("data_group_id")
@@ -233,6 +253,14 @@ class WorkflowTask:
233
253
  str(value) for value in raw.get("available_actions", [])
234
254
  ],
235
255
  move_targets=list(raw.get("move_targets", [])),
256
+ task_kind=str(task.get("task_kind") or "item_state"),
257
+ parent_item_id=(
258
+ str(task["parent_item_id"]) if task.get("parent_item_id") else None
259
+ ),
260
+ assignment_id=(
261
+ str(task["assignment_id"]) if task.get("assignment_id") else None
262
+ ),
263
+ consensus_summary=consensus_summary,
236
264
  )
237
265
 
238
266
  @classmethod
@@ -272,6 +300,44 @@ class WorkflowTask:
272
300
  )
273
301
  )
274
302
 
303
+ def get_consensus_comparison(self) -> dict[str, Any]:
304
+ return self._client._api.get(
305
+ f"/api/sdk/workflow-tasks/{self.id}/consensus-comparison/"
306
+ )
307
+
308
+ def copy_consensus_component(
309
+ self,
310
+ *,
311
+ source_assignment_id: str,
312
+ component_kind: str,
313
+ component_locator: dict[str, Any],
314
+ operation: str = "add",
315
+ panel_id: str | None = None,
316
+ replacement_locator: dict[str, Any] | None = None,
317
+ ) -> WorkflowTask:
318
+ if operation not in {"add", "replace"}:
319
+ raise ValueError("operation must be 'add' or 'replace'")
320
+
321
+ payload: dict[str, Any] = {
322
+ "source_assignment_id": source_assignment_id,
323
+ "component_kind": component_kind,
324
+ "component_locator": component_locator,
325
+ "operation": operation,
326
+ "idempotency_key": str(uuid4()),
327
+ }
328
+ if panel_id is not None:
329
+ payload["panel_id"] = panel_id
330
+ if replacement_locator is not None:
331
+ payload["replacement_locator"] = replacement_locator
332
+ if self.stage_id:
333
+ payload["expected_stage_id"] = self.stage_id
334
+ return self._replace(
335
+ self._client._api.post(
336
+ f"/api/sdk/workflow-tasks/{self.id}/consensus-selections/",
337
+ json=payload,
338
+ )
339
+ )
340
+
275
341
  def _perform(
276
342
  self,
277
343
  action: str,
@@ -315,6 +381,36 @@ class WorkflowTask:
315
381
  def skip(self) -> WorkflowTask:
316
382
  return self._perform("skip")
317
383
 
384
+ def complete_model_run(
385
+ self,
386
+ run_id: str,
387
+ result: dict[str, Any] | list[dict[str, Any]],
388
+ *,
389
+ model_version: str = "",
390
+ ) -> WorkflowTask:
391
+ """Upload one or more final GeoJSON results for a Model-stage run."""
392
+ result_key = "results" if isinstance(result, list) else "result"
393
+ return self._replace(
394
+ self._client._api.post(
395
+ f"/api/sdk/workflow-tasks/{self.id}/model-result/",
396
+ json={
397
+ "run_id": str(run_id),
398
+ "status": "complete",
399
+ result_key: result,
400
+ "model_version": model_version,
401
+ },
402
+ )
403
+ )
404
+
405
+ def fail_model_run(self, run_id: str, error: str) -> WorkflowTask:
406
+ """Report a failed asynchronous Model-stage run without advancing it."""
407
+ return self._replace(
408
+ self._client._api.post(
409
+ f"/api/sdk/workflow-tasks/{self.id}/model-result/",
410
+ json={"run_id": str(run_id), "status": "failed", "error": error},
411
+ )
412
+ )
413
+
318
414
  def move(self, destination_stage, *, reason: str = "") -> WorkflowTask:
319
415
  """Move this task directly, bypassing normal Workflow edges.
320
416