unitlab 3.0.2__tar.gz → 3.0.3__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.3}/PKG-INFO +1 -1
  2. {unitlab-3.0.2 → unitlab-3.0.3}/pyproject.toml +1 -1
  3. {unitlab-3.0.2 → unitlab-3.0.3}/src/unitlab/_uploader.py +45 -6
  4. {unitlab-3.0.2 → unitlab-3.0.3}/src/unitlab/ontology.py +2 -0
  5. {unitlab-3.0.2 → unitlab-3.0.3}/src/unitlab/resources/assets.py +27 -4
  6. {unitlab-3.0.2 → unitlab-3.0.3}/src/unitlab/resources/projects.py +11 -1
  7. {unitlab-3.0.2 → unitlab-3.0.3}/tests/test_client.py +12 -0
  8. {unitlab-3.0.2 → unitlab-3.0.3}/tests/test_tiled_uploads.py +39 -2
  9. {unitlab-3.0.2 → unitlab-3.0.3}/tests/test_uploader.py +163 -0
  10. {unitlab-3.0.2 → unitlab-3.0.3}/.gitignore +0 -0
  11. {unitlab-3.0.2 → unitlab-3.0.3}/LICENSE.md +0 -0
  12. {unitlab-3.0.2 → unitlab-3.0.3}/README.md +0 -0
  13. {unitlab-3.0.2 → unitlab-3.0.3}/src/unitlab/__init__.py +0 -0
  14. {unitlab-3.0.2 → unitlab-3.0.3}/src/unitlab/__main__.py +0 -0
  15. {unitlab-3.0.2 → unitlab-3.0.3}/src/unitlab/_config.py +0 -0
  16. {unitlab-3.0.2 → unitlab-3.0.3}/src/unitlab/_downloader.py +0 -0
  17. {unitlab-3.0.2 → unitlab-3.0.3}/src/unitlab/_grouping.py +0 -0
  18. {unitlab-3.0.2 → unitlab-3.0.3}/src/unitlab/_http.py +0 -0
  19. {unitlab-3.0.2 → unitlab-3.0.3}/src/unitlab/_waiter.py +0 -0
  20. {unitlab-3.0.2 → unitlab-3.0.3}/src/unitlab/cli.py +0 -0
  21. {unitlab-3.0.2 → unitlab-3.0.3}/src/unitlab/client.py +0 -0
  22. {unitlab-3.0.2 → unitlab-3.0.3}/src/unitlab/exceptions.py +0 -0
  23. {unitlab-3.0.2 → unitlab-3.0.3}/src/unitlab/py.typed +0 -0
  24. {unitlab-3.0.2 → unitlab-3.0.3}/src/unitlab/resources/__init__.py +0 -0
  25. {unitlab-3.0.2 → unitlab-3.0.3}/src/unitlab/resources/_base.py +0 -0
  26. {unitlab-3.0.2 → unitlab-3.0.3}/src/unitlab/resources/cloud.py +0 -0
  27. {unitlab-3.0.2 → unitlab-3.0.3}/src/unitlab/resources/datasets.py +0 -0
  28. {unitlab-3.0.2 → unitlab-3.0.3}/src/unitlab/resources/embeddings.py +0 -0
  29. {unitlab-3.0.2 → unitlab-3.0.3}/src/unitlab/resources/ontologies.py +0 -0
  30. {unitlab-3.0.2 → unitlab-3.0.3}/src/unitlab/resources/releases.py +0 -0
  31. {unitlab-3.0.2 → unitlab-3.0.3}/src/unitlab/resources/workflow.py +0 -0
  32. {unitlab-3.0.2 → unitlab-3.0.3}/src/unitlab/types.py +0 -0
  33. {unitlab-3.0.2 → unitlab-3.0.3}/tests/test_cli.py +0 -0
  34. {unitlab-3.0.2 → unitlab-3.0.3}/tests/test_downloader.py +0 -0
  35. {unitlab-3.0.2 → unitlab-3.0.3}/tests/test_embeddings.py +0 -0
  36. {unitlab-3.0.2 → unitlab-3.0.3}/tests/test_http.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.3
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
@@ -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.3"
8
8
  description = "Python SDK for the Unitlab.ai data annotation platform"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -56,10 +56,12 @@ TILED_EXTENSIONS = (
56
56
  EXTENSIONS_BY_GENERIC_TYPE: dict[str, set[str]] = {
57
57
  "img": {"jpg", "png", "jpeg", "webp", "gif", "bmp", "ico", "svg"},
58
58
  "text": {"txt"},
59
+ "html": {"html", "htm"},
59
60
  "video": {"mp4", "avi", "mov", "webm", "mkv", "m4v", "wmv", "flv"},
60
61
  "audio": {"mp3", "wav", "ogg", "aac", "flac", "m4a"},
61
62
  "medical": {"dcm", "nii", "nii.gz", "nrrd"},
62
63
  "document": {"pdf"},
64
+ "timeseries": {"csv"},
63
65
  "geospatial": AMBIGUOUS_TIFF_EXTENSIONS | GEOSPATIAL_TILED_EXTENSIONS,
64
66
  "pathology": PATHOLOGY_TILED_EXTENSIONS,
65
67
  }
@@ -396,6 +398,7 @@ async def _finalize_medical_upload(client, endpoint: str, batch_queue_id: str) -
396
398
  def _project_files(
397
399
  source: str | Path,
398
400
  upload_info: dict[str, Any],
401
+ generic_type: str | None = None,
399
402
  ) -> tuple[list[Path], list[UploadFailure]]:
400
403
  accepted = {
401
404
  str(value).lower().lstrip(".")
@@ -411,8 +414,10 @@ def _project_files(
411
414
  result = []
412
415
  failures = []
413
416
  for path in files:
414
- generic_type = detect_generic_type(path.name) or project_type
415
- max_size = max_sizes.get(generic_type)
417
+ file_generic_type = (
418
+ generic_type or detect_generic_type(path.name) or project_type
419
+ )
420
+ max_size = max_sizes.get(file_generic_type)
416
421
  if max_size is None and project_type and fallback is not None:
417
422
  max_size = int(fallback)
418
423
  if max_size is None or path.stat().st_size <= max_size:
@@ -435,6 +440,8 @@ async def _upload_project_async(
435
440
  *,
436
441
  session_id: str,
437
442
  fps: float,
443
+ generic_type: str | None,
444
+ primary_column: str | None,
438
445
  batch_size: int,
439
446
  show_progress: bool,
440
447
  ) -> UploadResult:
@@ -455,11 +462,15 @@ async def _upload_project_async(
455
462
  tasks = []
456
463
  generic_types = []
457
464
  for path in chunk:
458
- generic_type = detect_generic_type(path.name)
459
- generic_types.append(generic_type)
465
+ file_generic_type = generic_type or detect_generic_type(path.name)
466
+ generic_types.append(file_generic_type)
460
467
  fields = {"session_id": session_id}
461
- if generic_type == "video":
468
+ if file_generic_type == "video":
462
469
  fields["fps"] = str(fps)
470
+ if generic_type is not None:
471
+ fields["generic_type"] = generic_type
472
+ if primary_column is not None:
473
+ fields["primary_column"] = primary_column
463
474
  tasks.append(
464
475
  _upload_file(
465
476
  client,
@@ -533,13 +544,15 @@ def upload_project(
533
544
  source: str | Path,
534
545
  *,
535
546
  fps: float = 1.0,
547
+ generic_type: str | None = None,
548
+ primary_column: str | None = None,
536
549
  batch_size: int = 100,
537
550
  show_progress: bool = True,
538
551
  ) -> tuple[UploadResult, str]:
539
552
  if batch_size < 1:
540
553
  raise ValueError("batch_size must be at least 1")
541
554
  upload_info = api.get(f"/api/sdk/projects/{project_id}/upload-info/")
542
- files, preflight_failures = _project_files(source, upload_info)
555
+ files, preflight_failures = _project_files(source, upload_info, generic_type)
543
556
  if not files and not preflight_failures:
544
557
  accepted = upload_info.get("accepted_formats") or sorted(known_extensions())
545
558
  raise ValueError(
@@ -560,6 +573,8 @@ def upload_project(
560
573
  files,
561
574
  session_id=session_id,
562
575
  fps=fps,
576
+ generic_type=generic_type,
577
+ primary_column=primary_column,
563
578
  batch_size=batch_size,
564
579
  show_progress=show_progress,
565
580
  )
@@ -576,6 +591,9 @@ def _asset_form_data(
576
591
  path: str | None,
577
592
  tags: list[str] | None,
578
593
  custom_metadata: dict[str, Any] | None,
594
+ defer_timeseries_configuration: bool,
595
+ generic_type: str | None,
596
+ primary_column: str | None,
579
597
  ) -> dict[str, str | list[str]]:
580
598
  fields: dict[str, str | list[str]] = {}
581
599
  if folder:
@@ -592,6 +610,12 @@ def _asset_form_data(
592
610
  ensure_ascii=False,
593
611
  separators=(",", ":"),
594
612
  )
613
+ if defer_timeseries_configuration:
614
+ fields["defer_timeseries_configuration"] = "true"
615
+ if generic_type is not None:
616
+ fields["generic_type"] = generic_type
617
+ if primary_column is not None:
618
+ fields["primary_column"] = primary_column
595
619
  return fields
596
620
 
597
621
 
@@ -640,6 +664,9 @@ async def _upload_assets_async(
640
664
  path: str | None,
641
665
  tags: list[str] | None,
642
666
  custom_metadata: dict[str, Any] | None,
667
+ defer_timeseries_configuration: bool,
668
+ generic_type: str | None,
669
+ primary_column: str | None,
643
670
  show_progress: bool,
644
671
  ) -> tuple[UploadResult, str, str]:
645
672
  failures: list[UploadFailure] = []
@@ -668,6 +695,9 @@ async def _upload_assets_async(
668
695
  path=path,
669
696
  tags=tags,
670
697
  custom_metadata=custom_metadata,
698
+ defer_timeseries_configuration=defer_timeseries_configuration,
699
+ generic_type=generic_type,
700
+ primary_column=primary_column,
671
701
  ),
672
702
  tiled_endpoint="/api/sdk/data-assets/tiled-uploads",
673
703
  complete_fields=_asset_complete_data(
@@ -708,6 +738,9 @@ async def _upload_assets_async(
708
738
  path=path,
709
739
  tags=tags,
710
740
  custom_metadata=custom_metadata,
741
+ defer_timeseries_configuration=defer_timeseries_configuration,
742
+ generic_type=generic_type,
743
+ primary_column=primary_column,
711
744
  ),
712
745
  tiled_endpoint="/api/sdk/data-assets/tiled-uploads",
713
746
  complete_fields=_asset_complete_data(
@@ -756,6 +789,9 @@ def upload_assets(
756
789
  path: str | None = None,
757
790
  tags: list[str] | None = None,
758
791
  custom_metadata: dict[str, Any] | None = None,
792
+ defer_timeseries_configuration: bool = False,
793
+ generic_type: str | None = None,
794
+ primary_column: str | None = None,
759
795
  show_progress: bool = True,
760
796
  ) -> tuple[UploadResult, str, str]:
761
797
  if folder and folder_id:
@@ -774,6 +810,9 @@ def upload_assets(
774
810
  path=path,
775
811
  tags=tags,
776
812
  custom_metadata=custom_metadata,
813
+ defer_timeseries_configuration=defer_timeseries_configuration,
814
+ generic_type=generic_type,
815
+ primary_column=primary_column,
777
816
  show_progress=show_progress,
778
817
  )
779
818
  )
@@ -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
  )
@@ -252,6 +252,18 @@ def test_ontology_builder_and_client_contract():
252
252
  client.close()
253
253
 
254
254
 
255
+ @pytest.mark.parametrize("shape_value", ("interval", "instant"))
256
+ def test_timeseries_ontology_shapes_round_trip(shape_value):
257
+ shape = Shape(shape_value)
258
+ structure = OntologyStructure()
259
+
260
+ created = structure.add_object("Chart event", shape)
261
+ restored = OntologyStructure.from_dict(structure.to_dict()).objects[0]
262
+
263
+ assert created.shape is shape
264
+ assert restored.shape is shape
265
+
266
+
255
267
  def test_release_detail_parses_and_downloads(monkeypatch):
256
268
  def handler(request):
257
269
  assert request.url.path == "/api/sdk/releases/r1/"
@@ -503,8 +503,45 @@ def test_asset_status_wait_and_retry_use_sdk_endpoints():
503
503
  assert asset.retry() == {"task_id": "task-2", "status": "processing"}
504
504
  assert asset.upload_status == "processing"
505
505
  assert requests == [
506
- ("GET", "/api/sdk/data-assets/assets/asset-1/tiled-status/"),
507
- ("GET", "/api/sdk/data-assets/assets/asset-1/tiled-status/"),
506
+ ("GET", "/api/sdk/data-assets/assets/asset-1/status/"),
507
+ ("GET", "/api/sdk/data-assets/assets/asset-1/status/"),
508
508
  ("POST", "/api/sdk/data-assets/assets/asset-1/tiled-retry/"),
509
509
  ]
510
510
  client.close()
511
+
512
+
513
+ def test_html_asset_status_and_wait_use_general_status_endpoint():
514
+ requests = []
515
+
516
+ def handler(request):
517
+ requests.append((request.method, request.url.path))
518
+ return httpx.Response(
519
+ 200,
520
+ json={
521
+ "status": "completed",
522
+ "upload_status": "completed",
523
+ "total": 1,
524
+ "completed": 1,
525
+ "processing": 0,
526
+ "failed": 0,
527
+ },
528
+ )
529
+
530
+ client = configured_client(handler)
531
+ asset = Asset._from_raw(
532
+ client,
533
+ {
534
+ "pk": "asset-html",
535
+ "file_name": "page.html",
536
+ "generic_type": "html",
537
+ "upload_status": "processing",
538
+ },
539
+ )
540
+
541
+ assert asset.status().status == "completed"
542
+ assert asset.wait(show_progress=False).completed == 1
543
+ assert requests == [
544
+ ("GET", "/api/sdk/data-assets/assets/asset-html/status/"),
545
+ ("GET", "/api/sdk/data-assets/assets/asset-html/status/"),
546
+ ]
547
+ client.close()
@@ -8,12 +8,25 @@ from unitlab import (
8
8
  AuthenticationError,
9
9
  ProcessingTimeoutError,
10
10
  UnitlabClient,
11
+ _uploader,
11
12
  tiles_from_template,
12
13
  )
13
14
  from unitlab.resources.assets import Asset
14
15
  from unitlab.resources.projects import BatchQueue, Project
15
16
 
16
17
 
18
+ @pytest.mark.parametrize(
19
+ ("filename", "generic_type"),
20
+ (("page.html", "html"), ("page.htm", "html"), ("series.csv", "timeseries")),
21
+ )
22
+ def test_html_and_timeseries_files_are_uploadable(tmp_path, filename, generic_type):
23
+ path = tmp_path / filename
24
+ path.write_bytes(b"content")
25
+
26
+ assert _uploader.detect_generic_type(filename) == generic_type
27
+ assert _uploader.collect_files(path) == [path]
28
+
29
+
17
30
  def configured_client(handler):
18
31
  transport = httpx.MockTransport(handler)
19
32
  client = UnitlabClient(api_key="key", api_url="http://testserver")
@@ -79,6 +92,49 @@ def test_project_upload_uses_one_batch_queue_and_chunks(tmp_path):
79
92
  client.close()
80
93
 
81
94
 
95
+ def test_project_upload_can_select_tabular_csv_row_mode(tmp_path):
96
+ upload_body = ""
97
+
98
+ def handler(request):
99
+ nonlocal upload_body
100
+ if request.method == "GET":
101
+ return httpx.Response(
102
+ 200,
103
+ json={
104
+ "accepted_formats": ["csv"],
105
+ "max_file_sizes": {"timeseries": 1_000, "tabular": 1_000},
106
+ "generic_type": None,
107
+ },
108
+ )
109
+ upload_body = request.content.decode("latin1")
110
+ return httpx.Response(
111
+ 202,
112
+ json={
113
+ "datasource_id": "row-1",
114
+ "upload_session_id": "queue-1",
115
+ "generic_type": "tabular",
116
+ "upload_status": "processing",
117
+ "task_id": "tabular-task-1",
118
+ },
119
+ )
120
+
121
+ path = tmp_path / "feedback.csv"
122
+ path.write_text("id,comment\n1,Great product\n")
123
+ client = configured_client(handler)
124
+ project = Project("p1", "Project", {"pk": "p1"}, client)
125
+
126
+ batch = project.upload(path, generic_type="tabular", primary_column="comment")
127
+
128
+ assert batch.uploaded == 1
129
+ assert batch.raw[0]["upload_status"] == "processing"
130
+ assert batch.raw[0]["task_id"] == "tabular-task-1"
131
+ assert 'name="generic_type"' in upload_body
132
+ assert "tabular" in upload_body
133
+ assert 'name="primary_column"' in upload_body
134
+ assert "comment" in upload_body
135
+ client.close()
136
+
137
+
82
138
  def test_project_upload_reports_oversized_files(tmp_path):
83
139
  uploads = 0
84
140
 
@@ -291,6 +347,113 @@ def test_asset_upload_resolves_folder_once_then_reuses_id(tmp_path):
291
347
  client.close()
292
348
 
293
349
 
350
+ def test_asset_upload_can_select_tabular_csv_row_mode(tmp_path):
351
+ upload_body = ""
352
+
353
+ def handler(request):
354
+ nonlocal upload_body
355
+ upload_body = request.content.decode("latin1")
356
+ return httpx.Response(
357
+ 201,
358
+ json={
359
+ "folder_id": "folder-1",
360
+ "folder_name": "Feedback",
361
+ "asset": {
362
+ "pk": "asset-1",
363
+ "file_name": "feedback.csv",
364
+ "generic_type": "tabular",
365
+ "folder_id": "folder-1",
366
+ },
367
+ },
368
+ )
369
+
370
+ path = tmp_path / "feedback.csv"
371
+ path.write_text("id,comment\n1,Great product\n")
372
+ client = configured_client(handler)
373
+
374
+ result = client.assets.upload(
375
+ path,
376
+ folder="Feedback",
377
+ generic_type="tabular",
378
+ primary_column="comment",
379
+ )
380
+
381
+ assert result.uploaded == 1
382
+ assert 'name="generic_type"' in upload_body
383
+ assert "tabular" in upload_body
384
+ assert 'name="primary_column"' in upload_body
385
+ assert "comment" in upload_body
386
+ client.close()
387
+
388
+
389
+ def test_deferred_timeseries_upload_can_be_completed(tmp_path):
390
+ upload_body = ""
391
+
392
+ def handler(request):
393
+ nonlocal upload_body
394
+ if request.url.path == "/api/sdk/data-assets/upload/":
395
+ upload_body = request.content.decode("latin1")
396
+ return httpx.Response(
397
+ 202,
398
+ json={
399
+ "folder_id": "folder-1",
400
+ "folder_name": "Charts",
401
+ "timeseries_configuration_required": True,
402
+ "configuration": {
403
+ "columns": ["time", "value"],
404
+ "defaults": {"x_column": "time", "channels": ["value"]},
405
+ },
406
+ "asset": {
407
+ "pk": "asset-1",
408
+ "file_name": "chart.csv",
409
+ "generic_type": "timeseries",
410
+ "folder_id": "folder-1",
411
+ "upload_status": "processing",
412
+ },
413
+ },
414
+ )
415
+ assert request.url.path == (
416
+ "/api/sdk/data-assets/assets/asset-1/timeseries-configuration/"
417
+ )
418
+ assert json.loads(request.content) == {
419
+ "x_column": "time",
420
+ "channels": ["value"],
421
+ }
422
+ return httpx.Response(
423
+ 200,
424
+ json={
425
+ "asset_id": "asset-1",
426
+ "upload_status": "completed",
427
+ "timeseries_configuration_required": False,
428
+ "configuration": None,
429
+ "timeseries": {"x_column": "time", "channels": ["value"]},
430
+ },
431
+ )
432
+
433
+ path = tmp_path / "chart.csv"
434
+ path.write_text("time,value\n0,1\n1,2\n")
435
+ client = configured_client(handler)
436
+
437
+ result = client.assets.upload(
438
+ path,
439
+ folder="Charts",
440
+ defer_timeseries_configuration=True,
441
+ )
442
+ assert 'name="defer_timeseries_configuration"' in upload_body
443
+ assert "true" in upload_body
444
+ assert result.raw[0]["configuration"]["defaults"]["x_column"] == "time"
445
+ asset = result.assets[0]
446
+
447
+ configuration = asset.configure_timeseries(
448
+ x_column="time",
449
+ channels=["value"],
450
+ )
451
+
452
+ assert configuration["timeseries_configuration_required"] is False
453
+ assert asset.upload_status == "completed"
454
+ client.close()
455
+
456
+
294
457
  def test_asset_updates_custom_metadata():
295
458
  def handler(request):
296
459
  assert request.method == "PATCH"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes