unitlab 3.0.2__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.2 → unitlab-3.0.4}/PKG-INFO +15 -2
  2. {unitlab-3.0.2 → unitlab-3.0.4}/README.md +14 -1
  3. {unitlab-3.0.2 → unitlab-3.0.4}/pyproject.toml +1 -1
  4. {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/__init__.py +2 -0
  5. {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/_http.py +5 -0
  6. {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/_uploader.py +110 -8
  7. {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/cli.py +25 -0
  8. {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/exceptions.py +10 -0
  9. {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/ontology.py +2 -0
  10. {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/resources/assets.py +27 -4
  11. {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/resources/projects.py +11 -1
  12. {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/resources/releases.py +72 -3
  13. {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/resources/workflow.py +104 -8
  14. {unitlab-3.0.2 → unitlab-3.0.4}/tests/test_cli.py +69 -1
  15. {unitlab-3.0.2 → unitlab-3.0.4}/tests/test_client.py +451 -0
  16. {unitlab-3.0.2 → unitlab-3.0.4}/tests/test_http.py +27 -1
  17. {unitlab-3.0.2 → unitlab-3.0.4}/tests/test_tiled_uploads.py +197 -2
  18. {unitlab-3.0.2 → unitlab-3.0.4}/tests/test_uploader.py +163 -0
  19. {unitlab-3.0.2 → unitlab-3.0.4}/.gitignore +0 -0
  20. {unitlab-3.0.2 → unitlab-3.0.4}/LICENSE.md +0 -0
  21. {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/__main__.py +0 -0
  22. {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/_config.py +0 -0
  23. {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/_downloader.py +0 -0
  24. {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/_grouping.py +0 -0
  25. {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/_waiter.py +0 -0
  26. {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/client.py +0 -0
  27. {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/py.typed +0 -0
  28. {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/resources/__init__.py +0 -0
  29. {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/resources/_base.py +0 -0
  30. {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/resources/cloud.py +0 -0
  31. {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/resources/datasets.py +0 -0
  32. {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/resources/embeddings.py +0 -0
  33. {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/resources/ontologies.py +0 -0
  34. {unitlab-3.0.2 → unitlab-3.0.4}/src/unitlab/types.py +0 -0
  35. {unitlab-3.0.2 → unitlab-3.0.4}/tests/test_downloader.py +0 -0
  36. {unitlab-3.0.2 → unitlab-3.0.4}/tests/test_embeddings.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: unitlab
3
- Version: 3.0.2
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.2"
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
@@ -56,10 +57,12 @@ TILED_EXTENSIONS = (
56
57
  EXTENSIONS_BY_GENERIC_TYPE: dict[str, set[str]] = {
57
58
  "img": {"jpg", "png", "jpeg", "webp", "gif", "bmp", "ico", "svg"},
58
59
  "text": {"txt"},
60
+ "html": {"html", "htm"},
59
61
  "video": {"mp4", "avi", "mov", "webm", "mkv", "m4v", "wmv", "flv"},
60
62
  "audio": {"mp3", "wav", "ogg", "aac", "flac", "m4a"},
61
63
  "medical": {"dcm", "nii", "nii.gz", "nrrd"},
62
64
  "document": {"pdf"},
65
+ "timeseries": {"csv"},
63
66
  "geospatial": AMBIGUOUS_TIFF_EXTENSIONS | GEOSPATIAL_TILED_EXTENSIONS,
64
67
  "pathology": PATHOLOGY_TILED_EXTENSIONS,
65
68
  }
@@ -196,16 +199,21 @@ async def _put_tiled_part(
196
199
  url: str,
197
200
  start: int,
198
201
  size: int,
202
+ get_url: Callable[[str | None], Awaitable[str]] | None = None,
199
203
  ) -> str:
200
204
  last_error: Exception | None = None
205
+ rejected_url = None
201
206
  for attempt in range(TILED_PART_MAX_ATTEMPTS):
202
207
  try:
203
208
  async with semaphore:
209
+ if get_url is not None:
210
+ url = await get_url(rejected_url)
204
211
  response = await client.put(
205
212
  url,
206
213
  content=_FilePartStream(path, start, size),
207
214
  headers={"Content-Length": str(size)},
208
215
  )
216
+ rejected_url = url if response.status_code == 403 else None
209
217
  response.raise_for_status()
210
218
  etag = response.headers.get("etag")
211
219
  if not etag:
@@ -270,6 +278,55 @@ async def _upload_tiled_file(
270
278
  not part.get("url") for part in part_urls
271
279
  ):
272
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())
273
330
  part_tasks = [
274
331
  asyncio.create_task(
275
332
  _put_tiled_part(
@@ -277,6 +334,9 @@ async def _upload_tiled_file(
277
334
  part_semaphore,
278
335
  path,
279
336
  url=str(part["url"]),
337
+ get_url=lambda rejected, number=int(part["part_number"]): get_url(
338
+ number, rejected
339
+ ),
280
340
  start=(int(part["part_number"]) - 1) * part_size,
281
341
  size=min(
282
342
  part_size,
@@ -288,11 +348,16 @@ async def _upload_tiled_file(
288
348
  ]
289
349
  try:
290
350
  parts = await asyncio.gather(*part_tasks)
351
+ await get_url(1)
291
352
  except Exception:
292
353
  for task in part_tasks:
293
354
  task.cancel()
294
355
  await asyncio.gather(*part_tasks, return_exceptions=True)
295
356
  raise
357
+ finally:
358
+ renewal_task.cancel()
359
+ with suppress(asyncio.CancelledError):
360
+ await renewal_task
296
361
  except (KeyError, TypeError, ValueError, NetworkError) as exc:
297
362
  if upload_token:
298
363
  await _abort_tiled_upload(api_client, endpoint, upload_token)
@@ -396,6 +461,7 @@ async def _finalize_medical_upload(client, endpoint: str, batch_queue_id: str) -
396
461
  def _project_files(
397
462
  source: str | Path,
398
463
  upload_info: dict[str, Any],
464
+ generic_type: str | None = None,
399
465
  ) -> tuple[list[Path], list[UploadFailure]]:
400
466
  accepted = {
401
467
  str(value).lower().lstrip(".")
@@ -403,7 +469,7 @@ def _project_files(
403
469
  } or known_extensions()
404
470
  files = collect_files(source, accepted)
405
471
  max_sizes = {
406
- str(key): int(value)
472
+ str(key): None if value is None else int(value)
407
473
  for key, value in (upload_info.get("max_file_sizes") or {}).items()
408
474
  }
409
475
  project_type = upload_info.get("generic_type")
@@ -411,9 +477,11 @@ def _project_files(
411
477
  result = []
412
478
  failures = []
413
479
  for path in files:
414
- generic_type = detect_generic_type(path.name) or project_type
415
- max_size = max_sizes.get(generic_type)
416
- if max_size is None and project_type and fallback is not None:
480
+ file_generic_type = (
481
+ generic_type or detect_generic_type(path.name) or project_type
482
+ )
483
+ max_size = max_sizes.get(file_generic_type)
484
+ if file_generic_type not in max_sizes and project_type and fallback is not None:
417
485
  max_size = int(fallback)
418
486
  if max_size is None or path.stat().st_size <= max_size:
419
487
  result.append(path)
@@ -435,6 +503,8 @@ async def _upload_project_async(
435
503
  *,
436
504
  session_id: str,
437
505
  fps: float,
506
+ generic_type: str | None,
507
+ primary_column: str | None,
438
508
  batch_size: int,
439
509
  show_progress: bool,
440
510
  ) -> UploadResult:
@@ -455,11 +525,15 @@ async def _upload_project_async(
455
525
  tasks = []
456
526
  generic_types = []
457
527
  for path in chunk:
458
- generic_type = detect_generic_type(path.name)
459
- generic_types.append(generic_type)
528
+ file_generic_type = generic_type or detect_generic_type(path.name)
529
+ generic_types.append(file_generic_type)
460
530
  fields = {"session_id": session_id}
461
- if generic_type == "video":
531
+ if file_generic_type == "video":
462
532
  fields["fps"] = str(fps)
533
+ if generic_type is not None:
534
+ fields["generic_type"] = generic_type
535
+ if primary_column is not None:
536
+ fields["primary_column"] = primary_column
463
537
  tasks.append(
464
538
  _upload_file(
465
539
  client,
@@ -533,13 +607,15 @@ def upload_project(
533
607
  source: str | Path,
534
608
  *,
535
609
  fps: float = 1.0,
610
+ generic_type: str | None = None,
611
+ primary_column: str | None = None,
536
612
  batch_size: int = 100,
537
613
  show_progress: bool = True,
538
614
  ) -> tuple[UploadResult, str]:
539
615
  if batch_size < 1:
540
616
  raise ValueError("batch_size must be at least 1")
541
617
  upload_info = api.get(f"/api/sdk/projects/{project_id}/upload-info/")
542
- files, preflight_failures = _project_files(source, upload_info)
618
+ files, preflight_failures = _project_files(source, upload_info, generic_type)
543
619
  if not files and not preflight_failures:
544
620
  accepted = upload_info.get("accepted_formats") or sorted(known_extensions())
545
621
  raise ValueError(
@@ -560,6 +636,8 @@ def upload_project(
560
636
  files,
561
637
  session_id=session_id,
562
638
  fps=fps,
639
+ generic_type=generic_type,
640
+ primary_column=primary_column,
563
641
  batch_size=batch_size,
564
642
  show_progress=show_progress,
565
643
  )
@@ -576,6 +654,9 @@ def _asset_form_data(
576
654
  path: str | None,
577
655
  tags: list[str] | None,
578
656
  custom_metadata: dict[str, Any] | None,
657
+ defer_timeseries_configuration: bool,
658
+ generic_type: str | None,
659
+ primary_column: str | None,
579
660
  ) -> dict[str, str | list[str]]:
580
661
  fields: dict[str, str | list[str]] = {}
581
662
  if folder:
@@ -592,6 +673,12 @@ def _asset_form_data(
592
673
  ensure_ascii=False,
593
674
  separators=(",", ":"),
594
675
  )
676
+ if defer_timeseries_configuration:
677
+ fields["defer_timeseries_configuration"] = "true"
678
+ if generic_type is not None:
679
+ fields["generic_type"] = generic_type
680
+ if primary_column is not None:
681
+ fields["primary_column"] = primary_column
595
682
  return fields
596
683
 
597
684
 
@@ -640,6 +727,9 @@ async def _upload_assets_async(
640
727
  path: str | None,
641
728
  tags: list[str] | None,
642
729
  custom_metadata: dict[str, Any] | None,
730
+ defer_timeseries_configuration: bool,
731
+ generic_type: str | None,
732
+ primary_column: str | None,
643
733
  show_progress: bool,
644
734
  ) -> tuple[UploadResult, str, str]:
645
735
  failures: list[UploadFailure] = []
@@ -668,6 +758,9 @@ async def _upload_assets_async(
668
758
  path=path,
669
759
  tags=tags,
670
760
  custom_metadata=custom_metadata,
761
+ defer_timeseries_configuration=defer_timeseries_configuration,
762
+ generic_type=generic_type,
763
+ primary_column=primary_column,
671
764
  ),
672
765
  tiled_endpoint="/api/sdk/data-assets/tiled-uploads",
673
766
  complete_fields=_asset_complete_data(
@@ -708,6 +801,9 @@ async def _upload_assets_async(
708
801
  path=path,
709
802
  tags=tags,
710
803
  custom_metadata=custom_metadata,
804
+ defer_timeseries_configuration=defer_timeseries_configuration,
805
+ generic_type=generic_type,
806
+ primary_column=primary_column,
711
807
  ),
712
808
  tiled_endpoint="/api/sdk/data-assets/tiled-uploads",
713
809
  complete_fields=_asset_complete_data(
@@ -756,6 +852,9 @@ def upload_assets(
756
852
  path: str | None = None,
757
853
  tags: list[str] | None = None,
758
854
  custom_metadata: dict[str, Any] | None = None,
855
+ defer_timeseries_configuration: bool = False,
856
+ generic_type: str | None = None,
857
+ primary_column: str | None = None,
759
858
  show_progress: bool = True,
760
859
  ) -> tuple[UploadResult, str, str]:
761
860
  if folder and folder_id:
@@ -774,6 +873,9 @@ def upload_assets(
774
873
  path=path,
775
874
  tags=tags,
776
875
  custom_metadata=custom_metadata,
876
+ defer_timeseries_configuration=defer_timeseries_configuration,
877
+ generic_type=generic_type,
878
+ primary_column=primary_column,
777
879
  show_progress=show_progress,
778
880
  )
779
881
  )
@@ -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
 
@@ -15,6 +15,8 @@ class Shape(str, Enum):
15
15
  CUBOID = "cuboid"
16
16
  TIME_RANGE = "time_range"
17
17
  TEXT = "text"
18
+ INTERVAL = "interval"
19
+ INSTANT = "instant"
18
20
 
19
21
 
20
22
  class OntologyClassificationLevel(str, Enum):
@@ -26,6 +26,9 @@ class AssetsNamespace(Namespace):
26
26
  path: str | None = None,
27
27
  tags: list[str] | None = None,
28
28
  custom_metadata: dict[str, Any] | None = None,
29
+ defer_timeseries_configuration: bool = False,
30
+ generic_type: str | None = None,
31
+ primary_column: str | None = None,
29
32
  ) -> AssetUploadResult:
30
33
  """Upload local files into workspace Assets.
31
34
 
@@ -36,6 +39,12 @@ class AssetsNamespace(Namespace):
36
39
  path: Logical path created below the destination folder.
37
40
  tags: Tags applied to uploaded Assets.
38
41
  custom_metadata: User-owned JSON metadata applied to every upload.
42
+ defer_timeseries_configuration: Keep the legacy deferred chart flow
43
+ when ``generic_type="timeseries"`` is explicitly selected.
44
+ generic_type: Explicit upload family, such as ``tabular`` for CSV
45
+ row mode. Omitted CSV Assets remain family-neutral until they
46
+ are attached to a project.
47
+ primary_column: Preferred row-mode CSV text column.
39
48
 
40
49
  Returns:
41
50
  Successful Assets and per-file failures without discarding partial
@@ -49,6 +58,9 @@ class AssetsNamespace(Namespace):
49
58
  path=path,
50
59
  tags=tags,
51
60
  custom_metadata=custom_metadata,
61
+ defer_timeseries_configuration=defer_timeseries_configuration,
62
+ generic_type=generic_type,
63
+ primary_column=primary_column,
52
64
  show_progress=sys.stderr.isatty(),
53
65
  )
54
66
  assets = [
@@ -269,9 +281,7 @@ class Asset:
269
281
  )
270
282
 
271
283
  def status(self) -> ProcessingStatus:
272
- raw = self._client._api.get(
273
- f"/api/sdk/data-assets/assets/{self.id}/tiled-status/"
274
- )
284
+ raw = self._client._api.get(f"/api/sdk/data-assets/assets/{self.id}/status/")
275
285
  self.upload_status = str(raw.get("upload_status", self.upload_status))
276
286
  return ProcessingStatus._from_raw(raw)
277
287
 
@@ -284,7 +294,7 @@ class Asset:
284
294
  ) -> ProcessingStatus:
285
295
  status = wait_for_status(
286
296
  self._client._api,
287
- f"/api/sdk/data-assets/assets/{self.id}/tiled-status/",
297
+ f"/api/sdk/data-assets/assets/{self.id}/status/",
288
298
  resource_name=f"Asset {self.id}",
289
299
  timeout=timeout,
290
300
  on_progress=on_progress,
@@ -302,6 +312,19 @@ class Asset:
302
312
  self.upload_status = str(raw.get("status", "processing"))
303
313
  return raw
304
314
 
315
+ def configure_timeseries(
316
+ self,
317
+ *,
318
+ x_column: str,
319
+ channels: list[str],
320
+ ) -> dict[str, Any]:
321
+ raw = self._client._api.post(
322
+ f"/api/sdk/data-assets/assets/{self.id}/timeseries-configuration/",
323
+ json={"x_column": x_column, "channels": channels},
324
+ )
325
+ self.upload_status = str(raw.get("upload_status", self.upload_status))
326
+ return raw
327
+
305
328
  def update_custom_metadata(
306
329
  self,
307
330
  custom_metadata: dict[str, Any] | None,
@@ -256,6 +256,8 @@ class Project:
256
256
  source: str | Path,
257
257
  *,
258
258
  fps: float = 1.0,
259
+ generic_type: str | None = None,
260
+ primary_column: str | None = None,
259
261
  batch_size: int = 100,
260
262
  ) -> UploadBatch:
261
263
  """Upload local files into this Project.
@@ -263,17 +265,25 @@ class Project:
263
265
  Args:
264
266
  source: File or directory to upload.
265
267
  fps: Frame rate used for uploaded video.
268
+ generic_type: Explicit upload family, such as ``tabular`` for CSV
269
+ row mode. Omitted CSV uploads remain pending until configured
270
+ in the target project.
271
+ primary_column: Preferred row-mode CSV text column.
266
272
  batch_size: Files scheduled per local upload batch.
267
273
 
268
274
  Returns:
269
275
  Local upload results and the server-side Batch Queue ID. Server
270
- processing may still be running.
276
+ processing may still be running. Declared ``tabular`` uploads are
277
+ accepted asynchronously and their datasource IDs are returned
278
+ immediately.
271
279
  """
272
280
  result, session_id = _uploader.upload_project(
273
281
  self._client._api,
274
282
  self.id,
275
283
  source,
276
284
  fps=fps,
285
+ generic_type=generic_type,
286
+ primary_column=primary_column,
277
287
  batch_size=batch_size,
278
288
  show_progress=sys.stderr.isatty(),
279
289
  )