nominal 1.108.0__py3-none-any.whl → 1.110.0__py3-none-any.whl

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.
CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.110.0](https://github.com/nominal-io/nominal-client/compare/v1.109.0...v1.110.0) (2026-01-23)
4
+
5
+
6
+ ### Features
7
+
8
+ * add data review/checklist methods to assets and runs ([#567](https://github.com/nominal-io/nominal-client/issues/567)) ([4b080d9](https://github.com/nominal-io/nominal-client/commit/4b080d96700fc51555b69a093d592cb03b92e073))
9
+ * add video clone capability in experimental and refactor video methods ([#576](https://github.com/nominal-io/nominal-client/issues/576)) ([f58a89c](https://github.com/nominal-io/nominal-client/commit/f58a89c1fe2220677f439fdf306f0c743e441548))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * properly handle dataset files in the new deletion states ([#590](https://github.com/nominal-io/nominal-client/issues/590)) ([e71bcc9](https://github.com/nominal-io/nominal-client/commit/e71bcc9f8fd752257dc2b33789380f0ffb7c5410))
15
+
16
+ ## [1.109.0](https://github.com/nominal-io/nominal-client/compare/v1.108.0...v1.109.0) (2026-01-23)
17
+
18
+
19
+ ### Features
20
+
21
+ * clone channels when cloning datasets ([#587](https://github.com/nominal-io/nominal-client/issues/587)) ([ab6de09](https://github.com/nominal-io/nominal-client/commit/ab6de0962c55e2c0a6933d6b9f94fb36f5e91273))
22
+
3
23
  ## [1.108.0](https://github.com/nominal-io/nominal-client/compare/v1.107.0...v1.108.0) (2026-01-22)
4
24
 
5
25
 
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import IntEnum
4
+
5
+ from nominal_api import scout_api
6
+
7
+
8
+ class Priority(IntEnum):
9
+ P0 = 0
10
+ P1 = 1
11
+ P2 = 2
12
+ P3 = 3
13
+ P4 = 4
14
+
15
+ @classmethod
16
+ def _from_conjure(cls, priority: scout_api.Priority) -> Priority:
17
+ match priority.name:
18
+ case "P0":
19
+ return cls.P0
20
+ case "P1":
21
+ return cls.P1
22
+ case "P2":
23
+ return cls.P2
24
+ case "P3":
25
+ return cls.P3
26
+ case "P4":
27
+ return cls.P4
28
+ case _:
29
+ raise ValueError(f"unknown priority '{priority}', expected one of {list(cls)}")
30
+
31
+ def _to_conjure(self) -> scout_api.Priority:
32
+ match self:
33
+ case Priority.P0:
34
+ return scout_api.Priority.P0
35
+ case Priority.P1:
36
+ return scout_api.Priority.P1
37
+ case Priority.P2:
38
+ return scout_api.Priority.P2
39
+ case Priority.P3:
40
+ return scout_api.Priority.P3
41
+ case Priority.P4:
42
+ return scout_api.Priority.P4
43
+ case _:
44
+ raise ValueError(f"unknown priority '{self}', expected one of {list(Priority)}")
45
+
46
+
47
+ def _conjure_priority_to_priority(priority: scout_api.Priority) -> Priority:
48
+ return Priority._from_conjure(priority)
@@ -5,13 +5,15 @@ import importlib.metadata
5
5
  import logging
6
6
  import platform
7
7
  import sys
8
- from typing import Any, Generic, Mapping, Protocol, Sequence, TypeAlias, TypedDict, TypeVar, runtime_checkable
8
+ from typing import Any, Generic, Literal, Mapping, Protocol, Sequence, TypeAlias, TypedDict, TypeVar, runtime_checkable
9
9
 
10
- from nominal_api import scout_compute_api, scout_run_api
10
+ from nominal_api import scout_asset_api, scout_compute_api, scout_run_api
11
11
  from typing_extensions import NotRequired, Self
12
12
 
13
13
  from nominal._utils.dataclass_tools import update_dataclass
14
14
 
15
+ ScopeTypeSpecifier: TypeAlias = Literal["connection", "dataset", "video"]
16
+
15
17
  logger = logging.getLogger(__name__)
16
18
 
17
19
  T = TypeVar("T")
@@ -94,3 +96,15 @@ def create_api_tags(tags: Mapping[str, str] | None = None) -> dict[str, scout_co
94
96
  return {}
95
97
 
96
98
  return {key: scout_compute_api.StringConstant(literal=value) for key, value in tags.items()}
99
+
100
+
101
+ def filter_scopes(
102
+ scopes: Sequence[scout_asset_api.DataScope], scope_type: ScopeTypeSpecifier
103
+ ) -> Sequence[scout_asset_api.DataScope]:
104
+ return [scope for scope in scopes if scope.data_source.type.lower() == scope_type]
105
+
106
+
107
+ def filter_scope_rids(scopes: Sequence[scout_asset_api.DataScope], scope_type: ScopeTypeSpecifier) -> Mapping[str, str]:
108
+ return {
109
+ scope.data_scope_name: getattr(scope.data_source, scope_type) for scope in filter_scopes(scopes, scope_type)
110
+ }
@@ -0,0 +1,16 @@
1
+ from dataclasses import dataclass
2
+
3
+ from nominal.ts import IntegralNanosecondsUTC
4
+
5
+
6
+ @dataclass(init=True, repr=False, eq=False, order=False, unsafe_hash=False)
7
+ class McapVideoDetails:
8
+ mcap_channel_locator_topic: str
9
+
10
+
11
+ @dataclass(init=True, repr=False, eq=False, order=False, unsafe_hash=False)
12
+ class TimestampOptions:
13
+ starting_timestamp: IntegralNanosecondsUTC
14
+ ending_timestamp: IntegralNanosecondsUTC
15
+ scaling_factor: float
16
+ true_framerate: float
nominal/core/asset.py CHANGED
@@ -4,7 +4,7 @@ import datetime
4
4
  import logging
5
5
  from dataclasses import dataclass, field
6
6
  from types import MappingProxyType
7
- from typing import Iterable, Literal, Mapping, Protocol, Sequence, TypeAlias
7
+ from typing import Iterable, Mapping, Protocol, Sequence, TypeAlias
8
8
 
9
9
  from nominal_api import (
10
10
  event,
@@ -15,6 +15,7 @@ from nominal_api import (
15
15
  )
16
16
  from typing_extensions import Self
17
17
 
18
+ from nominal.core import data_review, streaming_checklist
18
19
  from nominal.core._clientsbunch import HasScoutParams
19
20
  from nominal.core._event_types import EventType, SearchEventOriginType
20
21
  from nominal.core._utils.api_tools import (
@@ -22,7 +23,10 @@ from nominal.core._utils.api_tools import (
22
23
  Link,
23
24
  LinkDict,
24
25
  RefreshableMixin,
26
+ ScopeTypeSpecifier,
25
27
  create_links,
28
+ filter_scope_rids,
29
+ filter_scopes,
26
30
  rid_from_instance_or_string,
27
31
  )
28
32
  from nominal.core._utils.pagination_tools import search_runs_by_asset_paginated
@@ -35,25 +39,10 @@ from nominal.core.video import Video, _create_video, _get_video
35
39
  from nominal.ts import IntegralNanosecondsDuration, IntegralNanosecondsUTC, _SecondsNanos
36
40
 
37
41
  ScopeType: TypeAlias = Connection | Dataset | Video
38
- ScopeTypeSpecifier: TypeAlias = Literal["connection", "dataset", "video"]
39
42
 
40
43
  logger = logging.getLogger(__name__)
41
44
 
42
45
 
43
- def _filter_scopes(
44
- scopes: Sequence[scout_asset_api.DataScope], scope_type: ScopeTypeSpecifier
45
- ) -> Sequence[scout_asset_api.DataScope]:
46
- return [scope for scope in scopes if scope.data_source.type.lower() == scope_type]
47
-
48
-
49
- def _filter_scope_rids(
50
- scopes: Sequence[scout_asset_api.DataScope], scope_type: ScopeTypeSpecifier
51
- ) -> Mapping[str, str]:
52
- return {
53
- scope.data_scope_name: getattr(scope.data_source, scope_type) for scope in _filter_scopes(scopes, scope_type)
54
- }
55
-
56
-
57
46
  @dataclass(frozen=True)
58
47
  class Asset(_DatasetWrapper, HasRid, RefreshableMixin[scout_asset_api.Asset]):
59
48
  rid: str
@@ -70,6 +59,7 @@ class Asset(_DatasetWrapper, HasRid, RefreshableMixin[scout_asset_api.Asset]):
70
59
  Video._Clients,
71
60
  Attachment._Clients,
72
61
  Event._Clients,
62
+ data_review.DataReview._Clients,
73
63
  HasScoutParams,
74
64
  Protocol,
75
65
  ):
@@ -94,11 +84,11 @@ class Asset(_DatasetWrapper, HasRid, RefreshableMixin[scout_asset_api.Asset]):
94
84
  return response[self.rid]
95
85
 
96
86
  def _list_dataset_scopes(self) -> Sequence[scout_asset_api.DataScope]:
97
- return _filter_scopes(self._get_latest_api().data_scopes, "dataset")
87
+ return filter_scopes(self._get_latest_api().data_scopes, "dataset")
98
88
 
99
89
  def _scope_rids(self, scope_type: ScopeTypeSpecifier) -> Mapping[str, str]:
100
90
  asset = self._get_latest_api()
101
- return _filter_scope_rids(asset.data_scopes, scope_type)
91
+ return filter_scope_rids(asset.data_scopes, scope_type)
102
92
 
103
93
  def update(
104
94
  self,
@@ -540,6 +530,32 @@ class Asset(_DatasetWrapper, HasRid, RefreshableMixin[scout_asset_api.Asset]):
540
530
  origin_types=origin_types,
541
531
  )
542
532
 
533
+ def search_data_reviews(
534
+ self,
535
+ runs: Sequence[Run | str] | None = None,
536
+ ) -> Sequence[data_review.DataReview]:
537
+ """Search for data reviews associated with this Asset. See nominal.core.client.search_data_reviews
538
+ for details.
539
+ """
540
+ return list(
541
+ data_review._iter_search_data_reviews(
542
+ self._clients,
543
+ assets=[self.rid],
544
+ runs=[rid_from_instance_or_string(run) for run in (runs or [])],
545
+ )
546
+ )
547
+
548
+ def list_streaming_checklists(self) -> Sequence[str]:
549
+ """List all Streaming Checklists associated with this Asset. See
550
+ nominal.core.client.list_streaming_checklists for details.
551
+ """
552
+ return list(
553
+ streaming_checklist._iter_list_streaming_checklists(
554
+ self._clients,
555
+ asset_rid=self.rid,
556
+ )
557
+ )
558
+
543
559
  def remove_attachments(self, attachments: Iterable[Attachment] | Iterable[str]) -> None:
544
560
  """Remove attachments from this asset.
545
561
  Does not remove the attachments from Nominal.
nominal/core/checklist.py CHANGED
@@ -2,10 +2,11 @@ from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass, field
4
4
  from datetime import timedelta
5
- from typing import Literal, Mapping, Protocol, Sequence
5
+ from typing import Mapping, Protocol, Sequence
6
6
 
7
7
  from nominal_api import (
8
- scout_api,
8
+ event,
9
+ scout,
9
10
  scout_checklistexecution_api,
10
11
  scout_checks_api,
11
12
  scout_datareview_api,
@@ -13,11 +14,11 @@ from nominal_api import (
13
14
  )
14
15
  from typing_extensions import Self
15
16
 
17
+ from nominal.core import run as core_run
16
18
  from nominal.core._clientsbunch import HasScoutParams
17
19
  from nominal.core._utils.api_tools import HasRid, rid_from_instance_or_string
18
20
  from nominal.core.asset import Asset
19
21
  from nominal.core.data_review import DataReview
20
- from nominal.core.run import Run
21
22
  from nominal.ts import _to_api_duration
22
23
 
23
24
 
@@ -30,13 +31,17 @@ class Checklist(HasRid):
30
31
  labels: Sequence[str]
31
32
  _clients: _Clients = field(repr=False)
32
33
 
33
- class _Clients(DataReview._Clients, HasScoutParams, Protocol):
34
+ class _Clients(HasScoutParams, Protocol):
34
35
  @property
35
36
  def checklist(self) -> scout_checks_api.ChecklistService: ...
36
37
  @property
37
38
  def checklist_execution(self) -> scout_checklistexecution_api.ChecklistExecutionService: ...
38
39
  @property
39
40
  def datareview(self) -> scout_datareview_api.DataReviewService: ...
41
+ @property
42
+ def event(self) -> event.EventService: ...
43
+ @property
44
+ def run(self) -> scout.RunService: ...
40
45
 
41
46
  @classmethod
42
47
  def _from_conjure(cls, clients: _Clients, checklist: scout_checks_api.VersionedChecklist) -> Self:
@@ -53,7 +58,7 @@ class Checklist(HasRid):
53
58
  _clients=clients,
54
59
  )
55
60
 
56
- def execute(self, run: Run | str, commit: str | None = None) -> DataReview:
61
+ def execute(self, run: core_run.Run | str, commit: str | None = None) -> DataReview:
57
62
  """Execute a checklist against a run.
58
63
 
59
64
  Args:
@@ -152,26 +157,7 @@ class Checklist(HasRid):
152
157
  """Returns a link to the page for this checklist in the Nominal app"""
153
158
  return f"{self._clients.app_base_url}/checklists/{self.rid}"
154
159
 
155
- def preview_for_run_url(self, run: Run | str) -> str:
160
+ def preview_for_run_url(self, run: core_run.Run | str) -> str:
156
161
  """Returns a link to the page for previewing this checklist on a given run in the Nominal app"""
157
162
  run_rid = rid_from_instance_or_string(run)
158
163
  return f"{self.nominal_url}?previewRunRid={run_rid}"
159
-
160
-
161
- Priority = Literal[0, 1, 2, 3, 4]
162
-
163
-
164
- _priority_to_conjure_map: dict[Priority, scout_api.Priority] = {
165
- 0: scout_api.Priority.P0,
166
- 1: scout_api.Priority.P1,
167
- 2: scout_api.Priority.P2,
168
- 3: scout_api.Priority.P3,
169
- 4: scout_api.Priority.P4,
170
- }
171
-
172
-
173
- def _conjure_priority_to_priority(priority: scout_api.Priority) -> Priority:
174
- inverted_map = {v: k for k, v in _priority_to_conjure_map.items()}
175
- if priority in inverted_map:
176
- return inverted_map[priority]
177
- raise ValueError(f"unknown priority '{priority}', expected one of {_priority_to_conjure_map.values()}")
nominal/core/client.py CHANGED
@@ -48,11 +48,8 @@ from nominal.core._utils.multipart import (
48
48
  upload_multipart_io,
49
49
  )
50
50
  from nominal.core._utils.pagination_tools import (
51
- list_streaming_checklists_for_asset_paginated,
52
- list_streaming_checklists_paginated,
53
51
  search_assets_paginated,
54
52
  search_checklists_paginated,
55
- search_data_reviews_paginated,
56
53
  search_datasets_paginated,
57
54
  search_runs_by_asset_paginated,
58
55
  search_runs_paginated,
@@ -84,7 +81,7 @@ from nominal.core.containerized_extractors import (
84
81
  FileExtractionInput,
85
82
  FileOutputFormat,
86
83
  )
87
- from nominal.core.data_review import DataReview, DataReviewBuilder
84
+ from nominal.core.data_review import DataReview, DataReviewBuilder, _iter_search_data_reviews
88
85
  from nominal.core.dataset import (
89
86
  Dataset,
90
87
  _create_dataset,
@@ -97,6 +94,7 @@ from nominal.core.exceptions import NominalConfigError, NominalError, NominalMet
97
94
  from nominal.core.filetype import FileType, FileTypes
98
95
  from nominal.core.run import Run, _create_run
99
96
  from nominal.core.secret import Secret
97
+ from nominal.core.streaming_checklist import _iter_list_streaming_checklists
100
98
  from nominal.core.unit import Unit, _available_units
101
99
  from nominal.core.user import User
102
100
  from nominal.core.video import Video, _create_video
@@ -1162,21 +1160,17 @@ class NominalClient:
1162
1160
  )
1163
1161
  return list(self._iter_search_assets(query))
1164
1162
 
1165
- def _iter_list_streaming_checklists(self, asset: str | None) -> Iterable[str]:
1166
- if asset is None:
1167
- return list_streaming_checklists_paginated(self._clients.checklist_execution, self._clients.auth_header)
1168
- return list_streaming_checklists_for_asset_paginated(
1169
- self._clients.checklist_execution, self._clients.auth_header, asset
1170
- )
1171
-
1172
- def list_streaming_checklists(self, asset: Asset | str | None = None) -> Iterable[str]:
1163
+ def list_streaming_checklists(self, asset: Asset | str | None = None) -> Sequence[str]:
1173
1164
  """List all Streaming Checklists.
1174
1165
 
1175
1166
  Args:
1176
1167
  asset: if provided, only return checklists associated with the given asset.
1168
+
1169
+ Returns:
1170
+ All streaming checklist RIDs that match the provided conditions
1177
1171
  """
1178
- asset = None if asset is None else rid_from_instance_or_string(asset)
1179
- return list(self._iter_list_streaming_checklists(asset))
1172
+ asset_rid = None if asset is None else rid_from_instance_or_string(asset)
1173
+ return list(_iter_list_streaming_checklists(self._clients, asset_rid))
1180
1174
 
1181
1175
  def data_review_builder(self) -> DataReviewBuilder:
1182
1176
  return DataReviewBuilder([], [], [], _clients=self._clients)
@@ -1220,27 +1214,29 @@ class NominalClient:
1220
1214
  responses = self._clients.event.batch_get_events(self._clients.auth_header, list(rids))
1221
1215
  return [Event._from_conjure(self._clients, response) for response in responses]
1222
1216
 
1223
- def _iter_search_data_reviews(
1224
- self,
1225
- assets: Sequence[Asset | str] | None = None,
1226
- runs: Sequence[Run | str] | None = None,
1227
- ) -> Iterable[DataReview]:
1228
- for review in search_data_reviews_paginated(
1229
- self._clients.datareview,
1230
- self._clients.auth_header,
1231
- assets=[rid_from_instance_or_string(asset) for asset in assets] if assets else None,
1232
- runs=[rid_from_instance_or_string(run) for run in runs] if runs else None,
1233
- ):
1234
- yield DataReview._from_conjure(self._clients, review)
1235
-
1236
1217
  def search_data_reviews(
1237
1218
  self,
1238
1219
  assets: Sequence[Asset | str] | None = None,
1239
1220
  runs: Sequence[Run | str] | None = None,
1240
1221
  ) -> Sequence[DataReview]:
1241
- """Search for any data reviews present within a collection of runs and assets."""
1222
+ """Search for data reviews meeting the specified filters.
1223
+ Filters are ANDed together, e.g. `(data_review.asset == asset) AND (data_review.run == run)`
1224
+
1225
+ Args:
1226
+ assets: List of assets that must be associated with a data review to be included.
1227
+ runs: List of runs that must be associated with a data review to be included.
1228
+
1229
+ Returns:
1230
+ All data reviews which match all of the provided conditions
1231
+ """
1242
1232
  # TODO (drake-nominal): Expose checklist_refs to users
1243
- return list(self._iter_search_data_reviews(assets, runs))
1233
+ return list(
1234
+ _iter_search_data_reviews(
1235
+ clients=self._clients,
1236
+ assets=[rid_from_instance_or_string(asset) for asset in assets] if assets else None,
1237
+ runs=[rid_from_instance_or_string(run) for run in runs] if runs else None,
1238
+ )
1239
+ )
1244
1240
 
1245
1241
  def search_events(
1246
1242
  self,
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from dataclasses import dataclass, field
4
4
  from datetime import timedelta
5
5
  from time import sleep
6
- from typing import Protocol, Sequence
6
+ from typing import TYPE_CHECKING, Iterable, Protocol, Sequence
7
7
 
8
8
  from nominal_api import (
9
9
  event as event_api,
@@ -18,14 +18,19 @@ from nominal_api import (
18
18
  )
19
19
  from typing_extensions import Self, deprecated
20
20
 
21
- from nominal.core import checklist, event
21
+ from nominal.core._checklist_types import Priority, _conjure_priority_to_priority
22
22
  from nominal.core._clientsbunch import HasScoutParams
23
23
  from nominal.core._utils.api_tools import HasRid, rid_from_instance_or_string
24
- from nominal.core.asset import Asset
24
+ from nominal.core._utils.pagination_tools import search_data_reviews_paginated
25
+ from nominal.core.event import Event
25
26
  from nominal.core.exceptions import NominalMethodRemovedError
26
- from nominal.core.run import Run
27
27
  from nominal.ts import IntegralNanosecondsUTC, _SecondsNanos
28
28
 
29
+ if TYPE_CHECKING:
30
+ from nominal.core.asset import Asset
31
+ from nominal.core.checklist import Checklist
32
+ from nominal.core.run import Run
33
+
29
34
 
30
35
  @dataclass(frozen=True)
31
36
  class DataReview(HasRid):
@@ -66,8 +71,10 @@ class DataReview(HasRid):
66
71
  _clients=clients,
67
72
  )
68
73
 
69
- def get_checklist(self) -> checklist.Checklist:
70
- return checklist.Checklist._from_conjure(
74
+ def get_checklist(self) -> "Checklist":
75
+ from nominal.core.checklist import Checklist
76
+
77
+ return Checklist._from_conjure(
71
78
  self._clients,
72
79
  self._clients.checklist.get(self._clients.auth_header, self.checklist_rid, commit=self.checklist_commit),
73
80
  )
@@ -83,7 +90,7 @@ class DataReview(HasRid):
83
90
  "use 'nominal.core.DataReview.get_events()' instead",
84
91
  )
85
92
 
86
- def get_events(self) -> Sequence[event.Event]:
93
+ def get_events(self) -> Sequence[Event]:
87
94
  """Retrieves the list of events for the data review."""
88
95
  data_review_response = self._clients.datareview.get(self._clients.auth_header, self.rid).check_evaluations
89
96
  all_event_rids = [
@@ -93,7 +100,7 @@ class DataReview(HasRid):
93
100
  for event_rid in check.state._generated_alerts.event_rids
94
101
  ]
95
102
  event_response = self._clients.event.batch_get_events(self._clients.auth_header, all_event_rids)
96
- return [event.Event._from_conjure(self._clients, data_review_event) for data_review_event in event_response]
103
+ return [Event._from_conjure(self._clients, data_review_event) for data_review_event in event_response]
97
104
 
98
105
  def reload(self) -> DataReview:
99
106
  """Reloads the data review from the server."""
@@ -137,7 +144,7 @@ class CheckViolation:
137
144
  name: str
138
145
  start: IntegralNanosecondsUTC
139
146
  end: IntegralNanosecondsUTC | None
140
- priority: checklist.Priority | None
147
+ priority: Priority | None
141
148
 
142
149
  @classmethod
143
150
  def _from_conjure(cls, check_alert: scout_datareview_api.CheckAlert) -> CheckViolation:
@@ -147,7 +154,7 @@ class CheckViolation:
147
154
  name=check_alert.name,
148
155
  start=_SecondsNanos.from_api(check_alert.start).to_nanoseconds(),
149
156
  end=_SecondsNanos.from_api(check_alert.end).to_nanoseconds() if check_alert.end is not None else None,
150
- priority=checklist._conjure_priority_to_priority(check_alert.priority)
157
+ priority=_conjure_priority_to_priority(check_alert.priority)
151
158
  if check_alert.priority is not scout_api.Priority.UNKNOWN
152
159
  else None,
153
160
  )
@@ -192,7 +199,7 @@ class DataReviewBuilder:
192
199
  def execute_checklist(
193
200
  self,
194
201
  run: str | Run,
195
- checklist: str | checklist.Checklist,
202
+ checklist: str | Checklist,
196
203
  *,
197
204
  commit: str | None = None,
198
205
  asset: str | Asset | None = None,
@@ -273,3 +280,17 @@ def poll_until_completed(
273
280
  data_reviews: Sequence[DataReview], interval: timedelta = timedelta(seconds=2)
274
281
  ) -> Sequence[DataReview]:
275
282
  return [review.poll_for_completion(interval) for review in data_reviews]
283
+
284
+
285
+ def _iter_search_data_reviews(
286
+ clients: DataReview._Clients,
287
+ assets: Sequence[str] | None = None,
288
+ runs: Sequence[str] | None = None,
289
+ ) -> Iterable[DataReview]:
290
+ for review in search_data_reviews_paginated(
291
+ clients.datareview,
292
+ clients.auth_header,
293
+ assets=assets,
294
+ runs=runs,
295
+ ):
296
+ yield DataReview._from_conjure(clients, review)
@@ -280,6 +280,8 @@ class IngestStatus(Enum):
280
280
  SUCCESS = "SUCCESS"
281
281
  IN_PROGRESS = "IN_PROGRESS"
282
282
  FAILED = "FAILED"
283
+ DELETION_IN_PROGRESS = "DELETION_IN_PROGRESS"
284
+ DELETED = "DELETED"
283
285
 
284
286
  @classmethod
285
287
  def _from_conjure(cls, status: api.IngestStatusV2) -> IngestStatus:
@@ -289,6 +291,10 @@ class IngestStatus(Enum):
289
291
  return cls.IN_PROGRESS
290
292
  elif status.error is not None:
291
293
  return cls.FAILED
294
+ elif status.deletion_in_progress is not None:
295
+ return cls.DELETION_IN_PROGRESS
296
+ elif status.deleted is not None:
297
+ return cls.DELETED
292
298
  raise ValueError(f"Unknown ingest status: {status.type}")
293
299
 
294
300
 
nominal/core/run.py CHANGED
@@ -3,16 +3,17 @@ from __future__ import annotations
3
3
  from dataclasses import dataclass, field
4
4
  from datetime import datetime, timedelta
5
5
  from types import MappingProxyType
6
- from typing import Iterable, Mapping, Protocol, Sequence, cast
6
+ from typing import TYPE_CHECKING, Iterable, Mapping, Protocol, Sequence, cast
7
7
 
8
8
  from nominal_api import (
9
+ event,
10
+ scout,
9
11
  scout_asset_api,
12
+ scout_assets,
10
13
  scout_run_api,
11
14
  )
12
15
  from typing_extensions import Self
13
16
 
14
- from nominal.core import asset as core_asset
15
- from nominal.core._clientsbunch import HasScoutParams
16
17
  from nominal.core._event_types import EventType
17
18
  from nominal.core._utils.api_tools import (
18
19
  HasRid,
@@ -20,16 +21,20 @@ from nominal.core._utils.api_tools import (
20
21
  LinkDict,
21
22
  RefreshableMixin,
22
23
  create_links,
24
+ filter_scopes,
23
25
  rid_from_instance_or_string,
24
26
  )
25
- from nominal.core.asset import _filter_scopes
26
27
  from nominal.core.attachment import Attachment, _iter_get_attachments
27
28
  from nominal.core.connection import Connection, _get_connections
28
29
  from nominal.core.dataset import Dataset, _DatasetWrapper, _get_datasets
30
+ from nominal.core.datasource import DataSource
29
31
  from nominal.core.event import Event, _create_event
30
32
  from nominal.core.video import Video, _get_video
31
33
  from nominal.ts import IntegralNanosecondsDuration, IntegralNanosecondsUTC, _SecondsNanos, _to_api_duration
32
34
 
35
+ if TYPE_CHECKING:
36
+ from nominal.core.asset import Asset
37
+
33
38
 
34
39
  @dataclass(frozen=True)
35
40
  class Run(HasRid, RefreshableMixin[scout_run_api.Run], _DatasetWrapper):
@@ -48,11 +53,17 @@ class Run(HasRid, RefreshableMixin[scout_run_api.Run], _DatasetWrapper):
48
53
  _clients: _Clients = field(repr=False)
49
54
 
50
55
  class _Clients(
51
- core_asset.Asset._Clients,
52
- HasScoutParams,
56
+ Attachment._Clients,
57
+ DataSource._Clients,
58
+ Video._Clients,
53
59
  Protocol,
54
60
  ):
55
- pass
61
+ @property
62
+ def assets(self) -> scout_assets.AssetService: ...
63
+ @property
64
+ def event(self) -> event.EventService: ...
65
+ @property
66
+ def run(self) -> scout.RunService: ...
56
67
 
57
68
  @property
58
69
  def nominal_url(self) -> str:
@@ -105,7 +116,7 @@ class Run(HasRid, RefreshableMixin[scout_run_api.Run], _DatasetWrapper):
105
116
  if len(api_run.assets) > 1:
106
117
  raise RuntimeError("Can't retrieve dataset scopes on multi-asset runs")
107
118
 
108
- return _filter_scopes(api_run.asset_data_scopes, "dataset")
119
+ return filter_scopes(api_run.asset_data_scopes, "dataset")
109
120
 
110
121
  def _list_datasource_rids(
111
122
  self, datasource_type: str | None = None, property_name: str | None = None
@@ -350,13 +361,16 @@ class Run(HasRid, RefreshableMixin[scout_run_api.Run], _DatasetWrapper):
350
361
  """List a sequence of Attachments associated with this Run."""
351
362
  return list(self._iter_list_attachments())
352
363
 
353
- def _iter_list_assets(self) -> Iterable[core_asset.Asset]:
364
+ def _iter_list_assets(self) -> Iterable["Asset"]:
365
+ from nominal.core.asset import Asset
366
+
367
+ clients = cast(Asset._Clients, self._clients)
354
368
  run = self._get_latest_api()
355
369
  assets = self._clients.assets.get_assets(self._clients.auth_header, run.assets)
356
370
  for a in assets.values():
357
- yield core_asset.Asset._from_conjure(self._clients, a)
371
+ yield Asset._from_conjure(clients, a)
358
372
 
359
- def list_assets(self) -> Sequence[core_asset.Asset]:
373
+ def list_assets(self) -> Sequence["Asset"]:
360
374
  """List assets associated with this run."""
361
375
  return list(self._iter_list_assets())
362
376
 
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Iterable, Protocol
4
+
5
+ from nominal_api import scout_checklistexecution_api
6
+
7
+ from nominal.core._clientsbunch import HasScoutParams
8
+ from nominal.core._utils.pagination_tools import (
9
+ list_streaming_checklists_for_asset_paginated,
10
+ list_streaming_checklists_paginated,
11
+ )
12
+
13
+
14
+ class _Clients(HasScoutParams, Protocol):
15
+ @property
16
+ def checklist_execution(self) -> scout_checklistexecution_api.ChecklistExecutionService: ...
17
+
18
+
19
+ def _iter_list_streaming_checklists(
20
+ clients: _Clients,
21
+ asset_rid: str | None = None,
22
+ ) -> Iterable[str]:
23
+ if asset_rid is None:
24
+ return list_streaming_checklists_paginated(clients.checklist_execution, clients.auth_header)
25
+ return list_streaming_checklists_for_asset_paginated(clients.checklist_execution, clients.auth_header, asset_rid)
nominal/core/video.py CHANGED
@@ -8,9 +8,9 @@ from dataclasses import dataclass, field
8
8
  from datetime import datetime, timedelta
9
9
  from io import BytesIO, TextIOBase, TextIOWrapper
10
10
  from types import MappingProxyType
11
- from typing import BinaryIO, Mapping, Protocol, Sequence
11
+ from typing import BinaryIO, Mapping, Protocol, Sequence, overload
12
12
 
13
- from nominal_api import api, ingest_api, scout_video, scout_video_api, upload_api
13
+ from nominal_api import api, ingest_api, scout_catalog, scout_video, scout_video_api, upload_api
14
14
  from typing_extensions import Self
15
15
 
16
16
  from nominal.core._clientsbunch import HasScoutParams
@@ -44,6 +44,8 @@ class Video(HasRid, RefreshableMixin[scout_video_api.Video]):
44
44
  def ingest(self) -> ingest_api.IngestService: ...
45
45
  @property
46
46
  def video_file(self) -> scout_video.VideoFileService: ...
47
+ @property
48
+ def catalog(self) -> scout_catalog.CatalogService: ...
47
49
 
48
50
  def poll_until_ingestion_completed(self, interval: timedelta = timedelta(seconds=1)) -> None:
49
51
  """Block until video ingestion has completed.
@@ -117,18 +119,38 @@ class Video(HasRid, RefreshableMixin[scout_video_api.Video]):
117
119
  """Unarchives this video, allowing it to show up in the 'All Videos' pane in the UI."""
118
120
  self._clients.video.unarchive(self._clients.auth_header, self.rid)
119
121
 
122
+ @overload
123
+ def add_file(
124
+ self,
125
+ path: PathLike,
126
+ *,
127
+ start: datetime | IntegralNanosecondsUTC,
128
+ description: str | None = None,
129
+ ) -> VideoFile: ...
130
+
131
+ @overload
120
132
  def add_file(
121
133
  self,
122
134
  path: PathLike,
135
+ *,
136
+ frame_timestamps: Sequence[IntegralNanosecondsUTC],
137
+ description: str | None = None,
138
+ ) -> VideoFile: ...
139
+
140
+ def add_file(
141
+ self,
142
+ path: PathLike,
143
+ *,
123
144
  start: datetime | IntegralNanosecondsUTC | None = None,
124
145
  frame_timestamps: Sequence[IntegralNanosecondsUTC] | None = None,
125
146
  description: str | None = None,
126
147
  ) -> VideoFile:
127
- """Append to a video from a file-path to H264-encoded video data.
148
+ """Append to a video from a file-path to H264-encoded video data. Only one of start or frame_timestamps
149
+ is allowed.
128
150
 
129
151
  Args:
130
152
  path: Path to the video file to add to an existing video within Nominal
131
- start: Starting timestamp of the video file in absolute UTC time
153
+ start: Starting timestamp of the video file in absolute UTC time.
132
154
  frame_timestamps: Per-frame absolute nanosecond timestamps. Most usecases should instead use the 'start'
133
155
  parameter, unless precise per-frame metadata is available and desired.
134
156
  description: Description of the video file.
@@ -141,16 +163,46 @@ class Video(HasRid, RefreshableMixin[scout_video_api.Video]):
141
163
  file_type = FileType.from_video(path)
142
164
 
143
165
  with path.open("rb") as video_file:
144
- return self.add_from_io(
145
- video_file,
146
- name=path_upload_name(path, file_type),
147
- start=start,
148
- frame_timestamps=frame_timestamps,
149
- description=description,
150
- file_type=file_type,
151
- )
166
+ if start is not None:
167
+ return self.add_from_io(
168
+ video_file,
169
+ name=path_upload_name(path, file_type),
170
+ start=start,
171
+ description=description,
172
+ file_type=file_type,
173
+ )
174
+ elif frame_timestamps is not None:
175
+ return self.add_from_io(
176
+ video_file,
177
+ name=path_upload_name(path, file_type),
178
+ frame_timestamps=frame_timestamps,
179
+ description=description,
180
+ file_type=file_type,
181
+ )
182
+ else: # This should never be reached due to the validation above
183
+ raise ValueError("Either 'start' or 'frame_timestamps' must be provided")
152
184
 
153
- add_file_to_video = add_file
185
+ @overload
186
+ def add_from_io(
187
+ self,
188
+ video: BinaryIO,
189
+ name: str,
190
+ *,
191
+ start: datetime | IntegralNanosecondsUTC,
192
+ description: str | None = None,
193
+ file_type: tuple[str, str] | FileType = FileTypes.MP4,
194
+ ) -> VideoFile: ...
195
+
196
+ @overload
197
+ def add_from_io(
198
+ self,
199
+ video: BinaryIO,
200
+ name: str,
201
+ *,
202
+ frame_timestamps: Sequence[IntegralNanosecondsUTC],
203
+ description: str | None = None,
204
+ file_type: tuple[str, str] | FileType = FileTypes.MP4,
205
+ ) -> VideoFile: ...
154
206
 
155
207
  def add_from_io(
156
208
  self,
@@ -179,6 +231,12 @@ class Video(HasRid, RefreshableMixin[scout_video_api.Video]):
179
231
  if isinstance(video, TextIOBase):
180
232
  raise TypeError(f"video {video} must be open in binary mode, rather than text mode")
181
233
 
234
+ # Validation: ensure exactly one of start or frame_timestamps is provided
235
+ if start is None and frame_timestamps is None:
236
+ raise ValueError("Either 'start' or 'frame_timestamps' must be provided")
237
+ if start is not None and frame_timestamps is not None:
238
+ raise ValueError("Only one of 'start' or 'frame_timestamps' may be provided")
239
+
182
240
  timestamp_manifest = _build_video_file_timestamp_manifest(
183
241
  self._clients.auth_header, self._clients.workspace_rid, self._clients.upload, start, frame_timestamps
184
242
  )
@@ -4,13 +4,14 @@ import logging
4
4
  import time
5
5
  from dataclasses import dataclass, field
6
6
  from datetime import datetime, timedelta
7
- from typing import Protocol
7
+ from typing import Protocol, Tuple
8
8
 
9
- from nominal_api import scout_video, scout_video_api
9
+ from nominal_api import scout_catalog, scout_video, scout_video_api
10
10
  from typing_extensions import Self
11
11
 
12
12
  from nominal.core._clientsbunch import HasScoutParams
13
13
  from nominal.core._utils.api_tools import HasRid, RefreshableMixin
14
+ from nominal.core._video_types import McapVideoDetails, TimestampOptions
14
15
  from nominal.core.exceptions import NominalIngestError, NominalIngestFailed
15
16
  from nominal.ts import IntegralNanosecondsUTC, _SecondsNanos
16
17
 
@@ -28,6 +29,8 @@ class VideoFile(HasRid, RefreshableMixin[scout_video_api.VideoFile]):
28
29
  class _Clients(HasScoutParams, Protocol):
29
30
  @property
30
31
  def video_file(self) -> scout_video.VideoFileService: ...
32
+ @property
33
+ def catalog(self) -> scout_catalog.CatalogService: ...
31
34
 
32
35
  def archive(self) -> None:
33
36
  """Archive the video file, disallowing it to appear when playing back the video"""
@@ -128,6 +131,63 @@ class VideoFile(HasRid, RefreshableMixin[scout_video_api.VideoFile]):
128
131
 
129
132
  time.sleep(interval.total_seconds())
130
133
 
134
+ def _get_file_ingest_options(self) -> Tuple[McapVideoDetails | None, TimestampOptions | None]:
135
+ """Get ingest options metadata for this video file.
136
+
137
+ Retrieves metadata about the video file (such as timestamps, frame rate, and scale factor)
138
+ that can be used when ingesting this video into a video channel. The returned options
139
+ are either MCAP or MISC metadata depending on the video file type.
140
+
141
+ Returns:
142
+ Video file ingest options (either McapVideoFileMetadata or MiscVideoFileMetadata).
143
+
144
+ Raises:
145
+ ValueError: If the video file has an unexpected timestamp manifest type.
146
+ """
147
+ api_video_file = self._get_latest_api()
148
+ if api_video_file.origin_metadata.timestamp_manifest.type == "mcap":
149
+ mcap_manifest = api_video_file.origin_metadata.timestamp_manifest.mcap
150
+ topic = (
151
+ mcap_manifest.mcap_channel_locator.topic
152
+ if mcap_manifest and mcap_manifest.mcap_channel_locator and mcap_manifest.mcap_channel_locator.topic
153
+ else ""
154
+ )
155
+ mcap_video_details = McapVideoDetails(
156
+ mcap_channel_locator_topic=topic,
157
+ )
158
+ return (mcap_video_details, None)
159
+ else:
160
+ # TODO(sean): We need to add support for if starting timestamp isn't present, aka we have frame timestamps
161
+ # from S3.
162
+ if api_video_file.origin_metadata.timestamp_manifest.no_manifest is None:
163
+ raise NotImplementedError(
164
+ f"Expected no_manifest timestamp manifest for non-MCAP video file, "
165
+ f"but got type: {api_video_file._origin_metadata._timestamp_manifest._type}"
166
+ )
167
+ if api_video_file.segment_metadata is None:
168
+ raise ValueError(
169
+ "Expected segment metadata for non-MCAP video file: %s", api_video_file.segment_metadata
170
+ )
171
+ if (
172
+ api_video_file.segment_metadata.max_absolute_timestamp is None
173
+ or api_video_file.segment_metadata.scale_factor is None
174
+ or api_video_file.segment_metadata.media_frame_rate is None
175
+ ):
176
+ raise ValueError(
177
+ "Not all timestamp metadata is populated in segment metadata: %s", api_video_file.segment_metadata
178
+ )
179
+ video_file_ingest_options = TimestampOptions(
180
+ starting_timestamp=_SecondsNanos.from_api(
181
+ api_video_file.origin_metadata.timestamp_manifest.no_manifest.starting_timestamp
182
+ ).to_nanoseconds(),
183
+ ending_timestamp=_SecondsNanos.from_api(
184
+ api_video_file.segment_metadata.max_absolute_timestamp
185
+ ).to_nanoseconds(),
186
+ scaling_factor=api_video_file.segment_metadata.scale_factor,
187
+ true_framerate=api_video_file.segment_metadata.media_frame_rate,
188
+ )
189
+ return (None, video_file_ingest_options)
190
+
131
191
  @classmethod
132
192
  def _from_conjure(cls, clients: _Clients, video_file: scout_video_api.VideoFile) -> Self:
133
193
  return cls(
@@ -4,7 +4,17 @@ import re
4
4
  import uuid
5
5
  from datetime import datetime, timedelta
6
6
  from pathlib import Path
7
- from typing import Any, BinaryIO, Iterable, Mapping, Sequence, TypeVar, Union, cast, overload
7
+ from typing import (
8
+ Any,
9
+ BinaryIO,
10
+ Iterable,
11
+ Mapping,
12
+ Sequence,
13
+ TypeVar,
14
+ Union,
15
+ cast,
16
+ overload,
17
+ )
8
18
 
9
19
  import requests
10
20
  from conjure_python_client import ConjureBeanType, ConjureEnumType, ConjureUnionType
@@ -25,7 +35,10 @@ from nominal.core import (
25
35
  from nominal.core._event_types import EventType, SearchEventOriginType
26
36
  from nominal.core._utils.api_tools import Link, LinkDict
27
37
  from nominal.core.attachment import Attachment
38
+ from nominal.core.filetype import FileTypes
28
39
  from nominal.core.run import Run
40
+ from nominal.core.video import Video
41
+ from nominal.core.video_file import VideoFile
29
42
  from nominal.experimental.dataset_utils import create_dataset_with_uuid
30
43
  from nominal.experimental.migration.migration_data_config import MigrationDatasetConfig
31
44
  from nominal.experimental.migration.migration_resources import MigrationResources
@@ -189,7 +202,10 @@ def _replace_uuids_in_obj(obj: Any, mapping: dict[str, str]) -> Any:
189
202
  elif isinstance(value, str):
190
203
  parsed_value, was_json = _convert_if_json(value)
191
204
  if was_json:
192
- new_obj[key] = json.dumps(_replace_uuids_in_obj(parsed_value, mapping), separators=(",", ":"))
205
+ new_obj[key] = json.dumps(
206
+ _replace_uuids_in_obj(parsed_value, mapping),
207
+ separators=(",", ":"),
208
+ )
193
209
  else:
194
210
  new_obj[key] = _replace_uuids_in_obj(value, mapping)
195
211
  else:
@@ -212,7 +228,9 @@ def _clone_conjure_objects_with_new_uuids(
212
228
 
213
229
 
214
230
  @overload
215
- def _clone_conjure_objects_with_new_uuids(objs: list[ConjureType]) -> list[ConjureType]: ...
231
+ def _clone_conjure_objects_with_new_uuids(
232
+ objs: list[ConjureType],
233
+ ) -> list[ConjureType]: ...
216
234
 
217
235
 
218
236
  def _clone_conjure_objects_with_new_uuids(
@@ -303,7 +321,10 @@ def copy_workbook_template_from(
303
321
  "destination_client_workspace": destination_client.get_workspace(destination_client._clients.workspace_rid).rid
304
322
  }
305
323
  logger.debug(
306
- "Cloning workbook template: %s (rid: %s)", source_template.title, source_template.rid, extra=log_extras
324
+ "Cloning workbook template: %s (rid: %s)",
325
+ source_template.title,
326
+ source_template.rid,
327
+ extra=log_extras,
307
328
  )
308
329
  raw_source_template = source_template._clients.template.get(
309
330
  source_template._clients.auth_header, source_template.rid
@@ -350,6 +371,140 @@ def copy_workbook_template_from(
350
371
  return new_workbook_template
351
372
 
352
373
 
374
+ def copy_video_file_to_video_dataset(
375
+ source_video_file: VideoFile,
376
+ destination_video_dataset: Video,
377
+ ) -> VideoFile | None:
378
+ """Copy a video dataset file from the source to the destination dataset.
379
+
380
+ This method is specifically designed to handle video files, which may require special handling
381
+ due to their size and streaming nature. It retrieves the video file from the source dataset,
382
+ streams it, and uploads it to the destination dataset while maintaining all associated metadata.
383
+
384
+ Args:
385
+ source_video_file: The source VideoFile to copy. Must be a video file with S3 handle.
386
+ destination_video_dataset: The Video dataset to create the copied file in.
387
+
388
+ Returns:
389
+ The dataset file in the new dataset.
390
+ """
391
+ log_extras = {"destination_client_workspace": destination_video_dataset._clients.workspace_rid}
392
+ logger.debug("Copying video file: %s", source_video_file.name, extra=log_extras)
393
+
394
+ (mcap_video_details, timestamp_options) = source_video_file._get_file_ingest_options()
395
+
396
+ old_file_uri = source_video_file._clients.catalog.get_video_file_uri(
397
+ source_video_file._clients.auth_header, source_video_file.rid
398
+ ).uri
399
+
400
+ response = requests.get(old_file_uri, stream=True)
401
+ response.raise_for_status()
402
+
403
+ file_name = source_video_file.name
404
+ file_stem = Path(file_name).stem
405
+ if timestamp_options is not None:
406
+ new_file = destination_video_dataset.add_from_io(
407
+ video=cast(BinaryIO, response.raw),
408
+ name=file_stem,
409
+ start=timestamp_options.starting_timestamp,
410
+ description=source_video_file.description,
411
+ )
412
+ new_file.update(
413
+ starting_timestamp=timestamp_options.starting_timestamp,
414
+ ending_timestamp=timestamp_options.ending_timestamp,
415
+ )
416
+ elif mcap_video_details is not None:
417
+ new_file = destination_video_dataset.add_mcap_from_io(
418
+ mcap=cast(BinaryIO, response.raw),
419
+ name=file_stem,
420
+ topic=mcap_video_details.mcap_channel_locator_topic,
421
+ description=source_video_file.description,
422
+ file_type=FileTypes.MCAP,
423
+ )
424
+ else:
425
+ raise ValueError(
426
+ "Unsupported video file ingest options for copying video file. "
427
+ "Expected either _mcap_video_details or _timestamp_options to be set."
428
+ )
429
+ logger.debug(
430
+ "New video file created %s in video dataset: %s (rid: %s)",
431
+ new_file.name,
432
+ destination_video_dataset.name,
433
+ destination_video_dataset.rid,
434
+ )
435
+ return new_file
436
+
437
+
438
+ def clone_video(source_video: Video, destination_client: NominalClient) -> Video:
439
+ """Clones a video, maintaining all properties and files.
440
+
441
+ Args:
442
+ source_video (Video): The video to copy from.
443
+ destination_client (NominalClient): The destination client.
444
+
445
+ Returns:
446
+ The cloned video.
447
+ """
448
+ return copy_video_from(
449
+ source_video=source_video,
450
+ destination_client=destination_client,
451
+ include_files=True,
452
+ )
453
+
454
+
455
+ def copy_video_from(
456
+ source_video: Video,
457
+ destination_client: NominalClient,
458
+ *,
459
+ new_video_name: str | None = None,
460
+ new_video_description: str | None = None,
461
+ new_video_properties: dict[str, Any] | None = None,
462
+ new_video_labels: Sequence[str] | None = None,
463
+ include_files: bool = False,
464
+ ) -> Video:
465
+ """Copy a video from the source to the destination client.
466
+
467
+ Args:
468
+ source_video: The source Video to copy.
469
+ destination_client: The NominalClient to create the copied video in.
470
+ new_video_name: Optional new name for the copied video. If not provided, the original name is used.
471
+ new_video_description: Optional new description for the copied video.
472
+ If not provided, the original description is used.
473
+ new_video_properties: Optional new properties for the copied video. If not provided, the original
474
+ properties are used.
475
+ new_video_labels: Optional new labels for the copied video. If not provided, the original labels are used.
476
+ include_files: Whether to include files in the copied video.
477
+
478
+ Returns:
479
+ The newly created Video in the destination client.
480
+ """
481
+ log_extras = {
482
+ "destination_client_workspace": destination_client.get_workspace(destination_client._clients.workspace_rid).rid
483
+ }
484
+ logger.debug(
485
+ "Copying dataset %s (rid: %s)",
486
+ source_video.name,
487
+ source_video.rid,
488
+ extra=log_extras,
489
+ )
490
+ new_video = destination_client.create_video(
491
+ name=new_video_name if new_video_name is not None else source_video.name,
492
+ description=new_video_description if new_video_description is not None else source_video.description,
493
+ properties=new_video_properties if new_video_properties is not None else source_video.properties,
494
+ labels=new_video_labels if new_video_labels is not None else source_video.labels,
495
+ )
496
+ if include_files:
497
+ for source_file in source_video.list_files():
498
+ copy_video_file_to_video_dataset(source_file, new_video)
499
+ logger.debug(
500
+ "New video created: %s (rid: %s)",
501
+ new_video.name,
502
+ new_video.rid,
503
+ extra=log_extras,
504
+ )
505
+ return new_video
506
+
507
+
353
508
  def copy_file_to_dataset(
354
509
  source_file: DatasetFile,
355
510
  destination_dataset: Dataset,
@@ -403,7 +558,7 @@ def copy_file_to_dataset(
403
558
 
404
559
 
405
560
  def clone_dataset(source_dataset: Dataset, destination_client: NominalClient) -> Dataset:
406
- """Clones a dataset, maintaining all properties and files.
561
+ """Clones a dataset, maintaining all properties, files, and channels.
407
562
 
408
563
  Args:
409
564
  source_dataset (Dataset): The dataset to copy from.
@@ -412,7 +567,11 @@ def clone_dataset(source_dataset: Dataset, destination_client: NominalClient) ->
412
567
  Returns:
413
568
  The cloned dataset.
414
569
  """
415
- return copy_dataset_from(source_dataset=source_dataset, destination_client=destination_client, include_files=True)
570
+ return copy_dataset_from(
571
+ source_dataset=source_dataset,
572
+ destination_client=destination_client,
573
+ include_files=True,
574
+ )
416
575
 
417
576
 
418
577
  def copy_dataset_from(
@@ -482,10 +641,29 @@ def copy_dataset_from(
482
641
  labels=dataset_labels,
483
642
  )
484
643
 
644
+ if preserve_uuid:
645
+ channels_copied_count = 0
646
+ for source_channel in source_dataset.search_channels():
647
+ if source_channel.data_type is None:
648
+ logger.warning("Skipping channel %s: unknown data type", source_channel.name, extra=log_extras)
649
+ continue
650
+ new_dataset.add_channel(
651
+ name=source_channel.name,
652
+ data_type=source_channel.data_type,
653
+ description=source_channel.description,
654
+ unit=source_channel.unit,
655
+ )
656
+ channels_copied_count += 1
657
+ logger.info("Copied %d channels from dataset %s", channels_copied_count, source_dataset.name, extra=log_extras)
485
658
  if include_files:
486
659
  for source_file in source_dataset.list_files():
487
660
  copy_file_to_dataset(source_file, new_dataset)
488
- logger.debug("New dataset created: %s (rid: %s)", new_dataset.name, new_dataset.rid, extra=log_extras)
661
+ logger.debug(
662
+ "New dataset created: %s (rid: %s)",
663
+ new_dataset.name,
664
+ new_dataset.rid,
665
+ extra=log_extras,
666
+ )
489
667
  return new_dataset
490
668
 
491
669
 
@@ -551,7 +729,12 @@ def copy_event_from(
551
729
  properties=new_properties or source_event.properties,
552
730
  labels=new_labels or source_event.labels,
553
731
  )
554
- logger.debug("New event created: %s (rid: %s)", new_event.name, new_event.rid, extra=log_extras)
732
+ logger.debug(
733
+ "New event created: %s (rid: %s)",
734
+ new_event.name,
735
+ new_event.rid,
736
+ extra=log_extras,
737
+ )
555
738
  return new_event
556
739
 
557
740
 
@@ -643,6 +826,7 @@ def clone_asset(
643
826
  dataset_config=MigrationDatasetConfig(preserve_dataset_uuid=True, include_dataset_files=True),
644
827
  include_events=True,
645
828
  include_runs=True,
829
+ include_video=True,
646
830
  )
647
831
 
648
832
 
@@ -657,6 +841,7 @@ def copy_asset_from(
657
841
  dataset_config: MigrationDatasetConfig | None = None,
658
842
  include_events: bool = False,
659
843
  include_runs: bool = False,
844
+ include_video: bool = False,
660
845
  ) -> Asset:
661
846
  """Copy an asset from the source to the destination client.
662
847
 
@@ -670,6 +855,7 @@ def copy_asset_from(
670
855
  dataset_config: Configuration for dataset migration.
671
856
  include_events: Whether to include events in the copied dataset.
672
857
  include_runs: Whether to include runs in the copied asset.
858
+ include_video: Whether to include video in the copied asset.
673
859
 
674
860
  Returns:
675
861
  The new asset created.
@@ -677,7 +863,12 @@ def copy_asset_from(
677
863
  log_extras = {
678
864
  "destination_client_workspace": destination_client.get_workspace(destination_client._clients.workspace_rid).rid
679
865
  }
680
- logger.debug("Copying asset %s (rid: %s)", source_asset.name, source_asset.rid, extra=log_extras)
866
+ logger.debug(
867
+ "Copying asset %s (rid: %s)",
868
+ source_asset.name,
869
+ source_asset.rid,
870
+ extra=log_extras,
871
+ )
681
872
  new_asset = destination_client.create_asset(
682
873
  name=new_asset_name if new_asset_name is not None else source_asset.name,
683
874
  description=new_asset_description if new_asset_description is not None else source_asset.description,
@@ -705,6 +896,18 @@ def copy_asset_from(
705
896
  for source_run in source_runs:
706
897
  copy_run_from(source_run, destination_client, new_assets=[new_asset])
707
898
 
899
+ if include_video:
900
+ for data_scope, video_dataset in source_asset.list_videos():
901
+ new_video_dataset = destination_client.create_video(
902
+ name=video_dataset.name,
903
+ description=video_dataset.description,
904
+ properties=video_dataset.properties,
905
+ labels=video_dataset.labels,
906
+ )
907
+ new_asset.add_video(data_scope, new_video_dataset)
908
+ for source_video_file in video_dataset.list_files():
909
+ copy_video_file_to_video_dataset(source_video_file, new_video_dataset)
910
+
708
911
  logger.debug("New asset created: %s (rid: %s)", new_asset, new_asset.rid, extra=log_extras)
709
912
  return new_asset
710
913
 
@@ -741,6 +944,7 @@ def copy_resources_to_destination_client(
741
944
  dataset_config=dataset_config,
742
945
  include_events=True,
743
946
  include_runs=True,
947
+ include_video=True,
744
948
  )
745
949
  new_assets.append(new_asset)
746
950
  new_data_scopes_and_datasets.extend(new_asset.list_datasets())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nominal
3
- Version: 1.108.0
3
+ Version: 1.110.0
4
4
  Summary: Automate Nominal workflows in Python
5
5
  Project-URL: Homepage, https://nominal.io
6
6
  Project-URL: Documentation, https://docs.nominal.io
@@ -20,7 +20,7 @@ Requires-Dist: cachetools>=6.1.0
20
20
  Requires-Dist: click<9,>=8
21
21
  Requires-Dist: conjure-python-client<4,>=3.1.0
22
22
  Requires-Dist: ffmpeg-python>=0.2.0
23
- Requires-Dist: nominal-api==0.1075.0
23
+ Requires-Dist: nominal-api==0.1079.0
24
24
  Requires-Dist: nominal-streaming==0.5.8; platform_python_implementation == 'CPython' and python_version >= '3.10' and ((sys_platform == 'win32' and platform_machine == 'AMD64') or (sys_platform == 'darwin' and platform_machine == 'arm64') or (sys_platform == 'linux' and (platform_machine == 'x86_64' or platform_machine == 'armv7l')))
25
25
  Requires-Dist: openpyxl>=0.0.0
26
26
  Requires-Dist: pandas>=0.0.0
@@ -1,4 +1,4 @@
1
- CHANGELOG.md,sha256=-ls_mO5aZCjyBFUqYkMVzrqOswFIEqKJFz7r0mqSziQ,89542
1
+ CHANGELOG.md,sha256=Om-RWDZX7EQkqisCCqMAftphnIXM3nr7RwpP58_WZXE,90706
2
2
  LICENSE,sha256=zEGHG9mjDjaIS3I79O8mweQo-yiTbqx8jJvUPppVAwk,1067
3
3
  README.md,sha256=KKe0dxh_pHXCtB7I9G4qWGQYvot_BZU8yW6MJyuyUHM,311
4
4
  nominal/__init__.py,sha256=rbraORnXUrNn1hywLXM0XwSQCd9UmQt20PDYlsBalfE,2167
@@ -28,32 +28,35 @@ nominal/cli/util/verify_connection.py,sha256=KU17ejaDfKBLmLiZ3MZSVLyfrqNE7c6mFBv
28
28
  nominal/config/__init__.py,sha256=wV8cq8X3J4NTJ5H_uR5THaMT_NQpWQO5qCUGEb-rPnM,3157
29
29
  nominal/config/_config.py,sha256=yKq_H1iYJDoxRfLz2iXLbbVdoL0MTEY0FS4eVL12w0g,2004
30
30
  nominal/core/__init__.py,sha256=1MiCC44cxHYFofP4hf2fz4EIkepK-OAhDzpPFIzHbWw,2422
31
+ nominal/core/_checklist_types.py,sha256=YcjvuUbQDIOYqqM5H1Eto9ws9Ivm4cPWEaeEF2Uwn1o,1361
31
32
  nominal/core/_clientsbunch.py,sha256=sn-ajYsJ2FuZGLiEYiPwkYCxLKr13w35aqWHE3TX5us,8633
32
33
  nominal/core/_constants.py,sha256=SrxgaSqAEB1MvTSrorgGam3eO29iCmRr6VIdajxX3gI,56
33
34
  nominal/core/_event_types.py,sha256=Cq_8x-zv_5EDvRo9UTbaOpenAy92bTfQxlsEuHPOhtE,3706
34
35
  nominal/core/_types.py,sha256=FktMmcQ5_rD2rbXv8_p-WISzSo8T2NtO-exsLm-iadU,122
35
- nominal/core/asset.py,sha256=-hNMGXiU1dPWfrzmOngbab-Hf6vfq2Rm_j0FP-woJ-s,23120
36
+ nominal/core/_video_types.py,sha256=Cdl0sZxX3cyYtCXzsnnLWjK38hHp3_orMe6oiUU_dyc,465
37
+ nominal/core/asset.py,sha256=S41KS_c14tFcxFLJzU3bnt958KpMSI_U524QcMCiSEE,23609
36
38
  nominal/core/attachment.py,sha256=yOtDUdkLY5MT_Rk9kUlr1yupIJN7a5pt5sJWx4RLQV8,4355
37
39
  nominal/core/bounds.py,sha256=742BWmGL3FBryRAjoiJRg2N6aVinjYkQLxN7kfnJ40Q,581
38
40
  nominal/core/channel.py,sha256=e0RtbjrXYMAqyt8noe610iIFzv30z2P7oqrwq08atD8,19558
39
- nominal/core/checklist.py,sha256=rO1RPDYV3o2miPKF7DcCiYpj6bUN-sdtZNhJkXzkfYE,7110
40
- nominal/core/client.py,sha256=Awt9WPkE-YXBfOwJMTL7Su8AZFJY3UMH7IKp5hI26YQ,68328
41
+ nominal/core/checklist.py,sha256=TXEm9qNYCG6lU5NB5P3RAe-XmXdj1Tcsdbx_c5_spXI,6663
42
+ nominal/core/client.py,sha256=RjMQCU8DmvHcp7lypVCFLY54caoTXu76EwN-oxaFjsw,68091
41
43
  nominal/core/connection.py,sha256=LYllr3a1H2xp8-i4MaX1M7yK8X-HnwuIkciyK9XgLtQ,5175
42
44
  nominal/core/containerized_extractors.py,sha256=fUz3-NHoNWYKqOCD15gLwGXDKVfdsW-x_kpXnkOI3BE,10224
43
- nominal/core/data_review.py,sha256=Z_W1Okp_FSQDiVCk6aKb9gV0EXbE2jtiQaPqc6TaL0g,11038
45
+ nominal/core/data_review.py,sha256=8pyoJiP-6KCSSB4NE_LKjp1JfenEigHTmEVdF1xF1bA,11674
44
46
  nominal/core/dataset.py,sha256=LqofzNAlOd3S_3Aaw6b7DoY50rj6GyMHbUClIA2TmpY,46792
45
- nominal/core/dataset_file.py,sha256=8rCW6MO89MFbQ2NH0WtFWmJfRWeTxhmyuoGojuQQ4Qg,16545
47
+ nominal/core/dataset_file.py,sha256=1cvEsf3IXGCOIr5kWIBBSwfHpZMAY-BEUEtewR6RjNc,16789
46
48
  nominal/core/datasource.py,sha256=i9db5OHNdfJyb3BJmhz2uJ398MfW8zx3Ek9pw5vYn3c,18739
47
49
  nominal/core/event.py,sha256=8trZXyuAqRlKedgcqSgDIimXAAJBmEfDLyHkOOBwUC0,7762
48
50
  nominal/core/exceptions.py,sha256=GUpwXRgdYamLl6684FE8ttCRHkBx6WEhOZ3NPE-ybD4,2671
49
51
  nominal/core/filetype.py,sha256=R8goHGW4SP0iO6AoQiUil2tNVuDgaQoHclftRbw44oc,5558
50
52
  nominal/core/log.py,sha256=z3hI3CIEyMwpUSWjwBsJ6a3JNGzBbsmrVusSU6uI7CY,3885
51
- nominal/core/run.py,sha256=IqXCP24UhdHKkss0LbXU_zAhx-7Pf2MIQI-lic_-quw,17987
53
+ nominal/core/run.py,sha256=1mRMl4bfmhd0MUR-JkvgqqkJYU8_RDNtKX1Qh8xtNtY,18308
52
54
  nominal/core/secret.py,sha256=Ckq48m60i7rktxL9GY-nxHU5v8gHv9F1-JN7_MSf4bM,2863
55
+ nominal/core/streaming_checklist.py,sha256=t7cilpW79hUQ86fJxiAr4Hocy9CdpLLP4azonjOi22o,844
53
56
  nominal/core/unit.py,sha256=Wa-Bvu0hD-nzxVaQJSnn5YqAfnhUd2kWw2SswXnbMHY,3161
54
57
  nominal/core/user.py,sha256=FV333TN4pQzcLh5b2CfxvBnnXyB1TrOP8Ppx1-XdaiE,481
55
- nominal/core/video.py,sha256=xQhDAiKzKuLsS3O6orKMvxejWVwDNK1ik56j4phMobE,16113
56
- nominal/core/video_file.py,sha256=dNAxFNAYwCBUiloCVt62fQymJtI_hK0NGgBXaVWqCXU,5669
58
+ nominal/core/video.py,sha256=p5H46V-esDEEg-j6X0zKrX3_xe5yYA6PSM4MmW6z3a8,18126
59
+ nominal/core/video_file.py,sha256=haq5Gf6V4HCP7iq-wObq5voGEx96ApJ2Ju3FPcTsv4U,8887
57
60
  nominal/core/workbook.py,sha256=lJo9ZaYm0TevAyIs239ZA-_1WUriTkj8i1lxvxH9TJw,8902
58
61
  nominal/core/workbook_template.py,sha256=PBgQjEDVVQdZMlVea99BbhHdAr_bawknSvNKhNtDAq0,7125
59
62
  nominal/core/workspace.py,sha256=_FmMu86xzIcxMt8_82oRSe3N4ss3law-rk0I0s8GMCQ,512
@@ -64,7 +67,7 @@ nominal/core/_stream/write_stream.py,sha256=Xd4VnWU9NANHu7hzknylv_d7qWoIiAOqzVtX
64
67
  nominal/core/_stream/write_stream_base.py,sha256=AxK3fAq3IBjNXZkxYFVXu3dGNWLCBhgknroMEyXqVJo,3787
65
68
  nominal/core/_utils/README.md,sha256=kWPQDc6kn-PjXFUsIH9u2nOA3RdGSXCOlxqeJSmUsPA,160
66
69
  nominal/core/_utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
67
- nominal/core/_utils/api_tools.py,sha256=Z--Et7NjpCH4if72WwGm45EyqyeqK2ambcBrtOSDMrY,2949
70
+ nominal/core/_utils/api_tools.py,sha256=rFL_YsdsEhxHi8v1boDM1NCk1olngRSJjYLUd-9cG-s,3539
68
71
  nominal/core/_utils/multipart.py,sha256=0dA2XcTHuOQIyS0139O8WZiCjwePaD1sYDUmTgmWG9w,10243
69
72
  nominal/core/_utils/multipart_downloader.py,sha256=16OJEPqxCwOnfjptYdrlwQVuSUQYoe9_iiW60ZSjWos,13859
70
73
  nominal/core/_utils/networking.py,sha256=n9ZqYtnpwPCjz9C-4eixsTkrhFh-DW6lknBJlHckHhg,8200
@@ -91,7 +94,7 @@ nominal/experimental/logging/rich_log_handler.py,sha256=8yz_VtxNgJg2oiesnXz2iXoB
91
94
  nominal/experimental/migration/__init__.py,sha256=E2IgWJLwJ5bN6jbl8k5nHECKFx5aT11jKAzVYcyXn3o,460
92
95
  nominal/experimental/migration/migration_data_config.py,sha256=sPwZjyLmL-_pHvDZvQspxrfW6yNZhEsQjDVwKA8IaXM,522
93
96
  nominal/experimental/migration/migration_resources.py,sha256=Tf_7kNBeSaY8z2fTF7DAxk-9q3a7F8xXFVvxI8tTc9c,415
94
- nominal/experimental/migration/migration_utils.py,sha256=isvxBH7pOjvT9PuJRfoMNHGJR17TYsSapDPVSRESCys,31804
97
+ nominal/experimental/migration/migration_utils.py,sha256=w__-709d_CDX1XrASAADYTiXb4T_Vd7lJ7sBC_f8Lto,38965
95
98
  nominal/experimental/rust_streaming/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
96
99
  nominal/experimental/rust_streaming/rust_write_stream.py,sha256=oQ6ixwm8ct8ZDc_qNB7AucDt8o5-_aBVlW2fFCQ_nmA,1541
97
100
  nominal/experimental/stream_v2/__init__.py,sha256=W39vK46pssx5sXvmsImMuJiEPs7iGtwrbYBI0bWnXCY,2313
@@ -110,8 +113,8 @@ nominal/thirdparty/polars/polars_export_handler.py,sha256=hGCSwXX9dC4MG01CmmjlTb
110
113
  nominal/thirdparty/tdms/__init__.py,sha256=6n2ImFr2Wiil6JM1P5Q7Mpr0VzLcnDkmup_ftNpPq-s,142
111
114
  nominal/thirdparty/tdms/_tdms.py,sha256=m4gxbpxB9MTLi2FuYvGlbUGSyDAZKFxbM3ia2x1wIz0,8746
112
115
  nominal/ts/__init__.py,sha256=hmd0ENvDhxRnzDKGLxIub6QG8LpcxCgcyAct029CaEs,21442
113
- nominal-1.108.0.dist-info/METADATA,sha256=BFL54ilX_SW2XErNJm9Q4kEow-SCxGdJcIVvqlJ0b5A,2307
114
- nominal-1.108.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
115
- nominal-1.108.0.dist-info/entry_points.txt,sha256=-mCLhxgg9R_lm5efT7vW9wuBH12izvY322R0a3TYxbE,66
116
- nominal-1.108.0.dist-info/licenses/LICENSE,sha256=zEGHG9mjDjaIS3I79O8mweQo-yiTbqx8jJvUPppVAwk,1067
117
- nominal-1.108.0.dist-info/RECORD,,
116
+ nominal-1.110.0.dist-info/METADATA,sha256=3QUEWDIsDP87VsQbNKDgcBE238-wwKpGJFl4NSKGMi8,2307
117
+ nominal-1.110.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
118
+ nominal-1.110.0.dist-info/entry_points.txt,sha256=-mCLhxgg9R_lm5efT7vW9wuBH12izvY322R0a3TYxbE,66
119
+ nominal-1.110.0.dist-info/licenses/LICENSE,sha256=zEGHG9mjDjaIS3I79O8mweQo-yiTbqx8jJvUPppVAwk,1067
120
+ nominal-1.110.0.dist-info/RECORD,,