nominal 1.96.0__py3-none-any.whl → 1.98.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,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.98.0](https://github.com/nominal-io/nominal-client/compare/v1.97.0...v1.98.0) (2025-12-04)
4
+
5
+
6
+ ### Features
7
+
8
+ * as_files_ingested iterator for dataset files ([#533](https://github.com/nominal-io/nominal-client/issues/533)) ([7afc0f6](https://github.com/nominal-io/nominal-client/commit/7afc0f61d24f930d37d55c8e0840ebd18b8711b6))
9
+
10
+ ## [1.97.0](https://github.com/nominal-io/nominal-client/compare/v1.96.0...v1.97.0) (2025-12-04)
11
+
12
+
13
+ ### Features
14
+
15
+ * add create_workbook_template ([#535](https://github.com/nominal-io/nominal-client/issues/535)) ([9c98975](https://github.com/nominal-io/nominal-client/commit/9c989753828c39e417bafed3db2981444132aeac))
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * add per-part retries during multipart uploads ([#537](https://github.com/nominal-io/nominal-client/issues/537)) ([ffdbc4a](https://github.com/nominal-io/nominal-client/commit/ffdbc4ac6f82562fb9eff3fed4b254b784aa89bc))
21
+
3
22
  ## [1.96.0](https://github.com/nominal-io/nominal-client/compare/v1.95.0...v1.96.0) (2025-12-03)
4
23
 
5
24
 
nominal/core/__init__.py CHANGED
@@ -17,7 +17,7 @@ from nominal.core.containerized_extractors import (
17
17
  )
18
18
  from nominal.core.data_review import CheckViolation, DataReview, DataReviewBuilder
19
19
  from nominal.core.dataset import Dataset, poll_until_ingestion_completed
20
- from nominal.core.dataset_file import DatasetFile
20
+ from nominal.core.dataset_file import DatasetFile, IngestWaitType, as_files_ingested, wait_for_files_to_ingest
21
21
  from nominal.core.datasource import DataSource
22
22
  from nominal.core.event import Event, EventType
23
23
  from nominal.core.filetype import FileType, FileTypes
@@ -33,6 +33,7 @@ from nominal.core.workbook_template import WorkbookTemplate
33
33
  from nominal.core.workspace import Workspace
34
34
 
35
35
  __all__ = [
36
+ "as_files_ingested",
36
37
  "Asset",
37
38
  "Attachment",
38
39
  "Bounds",
@@ -53,6 +54,7 @@ __all__ = [
53
54
  "FileExtractionInput",
54
55
  "FileType",
55
56
  "FileTypes",
57
+ "IngestWaitType",
56
58
  "LinkDict",
57
59
  "LogPoint",
58
60
  "NominalClient",
@@ -67,6 +69,7 @@ __all__ = [
67
69
  "UserPassAuth",
68
70
  "Video",
69
71
  "VideoFile",
72
+ "wait_for_files_to_ingest",
70
73
  "Workbook",
71
74
  "WorkbookTemplate",
72
75
  "WorkbookType",
@@ -27,29 +27,53 @@ def _sign_and_upload_part_job(
27
27
  upload_id: str,
28
28
  q: Queue[bytes],
29
29
  part: int,
30
+ num_retries: int = 3,
30
31
  ) -> requests.Response:
31
32
  data = q.get()
33
+
32
34
  try:
33
- response = upload_client.sign_part(auth_header, key, part, upload_id)
34
- logger.debug(
35
- "successfully signed multipart upload part",
36
- extra={"key": key, "part": part, "upload_id": upload_id, "response.url": response.url},
37
- )
38
- put_response = requests.put(
39
- response.url,
40
- data=data,
41
- headers=response.headers,
42
- verify=upload_client._verify,
43
- )
44
- logger.debug(
45
- "put multipart upload part",
46
- extra={"url": response.url, "size": len(data), "status_code": put_response.status_code},
35
+ last_ex: Exception | None = None
36
+ for attempt in range(num_retries):
37
+ try:
38
+ log_extras = {"key": key, "part": part, "upload_id": upload_id, "attempt": attempt + 1}
39
+
40
+ logger.debug("Signing part %d for upload", part, extra=log_extras)
41
+ sign_response = upload_client.sign_part(auth_header, key, part, upload_id)
42
+ logger.debug(
43
+ "Successfully signed part %d for upload",
44
+ part,
45
+ extra={"response.url": sign_response.url, **log_extras},
46
+ )
47
+
48
+ logger.debug("Pushing part %d for multipart upload", extra=log_extras)
49
+ put_response = requests.put(
50
+ sign_response.url,
51
+ data=data,
52
+ headers=sign_response.headers,
53
+ verify=upload_client._verify,
54
+ )
55
+ logger.debug(
56
+ "Finished pushing part %d for multipart upload with status %d",
57
+ part,
58
+ put_response.status_code,
59
+ extra={"response.url": put_response.url, **log_extras},
60
+ )
61
+ put_response.raise_for_status()
62
+ return put_response
63
+ except Exception as ex:
64
+ logger.warning(
65
+ "Failed to upload part %d: %s",
66
+ part,
67
+ ex,
68
+ extra=log_extras,
69
+ )
70
+ last_ex = ex
71
+
72
+ raise (
73
+ last_ex
74
+ if last_ex
75
+ else RuntimeError(f"Unknown error uploading part {part} for upload_id={upload_id} and key={key}")
47
76
  )
48
- put_response.raise_for_status()
49
- return put_response
50
- except Exception as e:
51
- logger.exception("error uploading part", exc_info=e, extra={"key": key, "upload_id": upload_id, "part": part})
52
- raise e
53
77
  finally:
54
78
  q.task_done()
55
79
 
nominal/core/client.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import enum
4
4
  import logging
5
+ import uuid
5
6
  from dataclasses import dataclass, field
6
7
  from datetime import datetime, timedelta
7
8
  from io import TextIOBase
@@ -21,10 +22,12 @@ from nominal_api import (
21
22
  scout_catalog,
22
23
  scout_checks_api,
23
24
  scout_datasource_connection_api,
25
+ scout_layout_api,
24
26
  scout_notebook_api,
25
27
  scout_run_api,
26
28
  scout_template_api,
27
29
  scout_video_api,
30
+ scout_workbookcommon_api,
28
31
  secrets_api,
29
32
  storage_datasource_api,
30
33
  )
@@ -1478,3 +1481,52 @@ class NominalClient:
1478
1481
  "nominal.core.NominalClient.create_workbook_from_template",
1479
1482
  "use 'nominal.core.WorkbookTemplate.create_workbook' instead",
1480
1483
  )
1484
+
1485
+ def create_workbook_template(
1486
+ self,
1487
+ title: str,
1488
+ *,
1489
+ description: str | None = None,
1490
+ labels: list[str] | None = None,
1491
+ properties: dict[str, str] | None = None,
1492
+ commit_message: str | None = None,
1493
+ workspace: Workspace | str | None = None,
1494
+ ) -> WorkbookTemplate:
1495
+ """Create an empty workbook template.
1496
+
1497
+ Args:
1498
+ title: Title of the workbook template
1499
+ description: Description of the workbook template
1500
+ labels: Labels to attach to the workbook template
1501
+ properties: Properties to attach to the workbook template
1502
+ commit_message: An optional message to include with the creation of the template
1503
+ workspace: Workspace to create the workbook template in.
1504
+
1505
+ Returns:
1506
+ The created WorkbookTemplate
1507
+ """
1508
+ request = scout_template_api.CreateTemplateRequest(
1509
+ title=title,
1510
+ description=description if description is not None else "",
1511
+ labels=labels if labels is not None else [],
1512
+ properties=properties if properties is not None else {},
1513
+ is_published=False,
1514
+ layout=scout_layout_api.WorkbookLayout(
1515
+ v1=scout_layout_api.WorkbookLayoutV1(
1516
+ root_panel=scout_layout_api.Panel(
1517
+ tabbed=scout_layout_api.TabbedPanel(
1518
+ v1=scout_layout_api.TabbedPanelV1(
1519
+ id=str(uuid.uuid4()),
1520
+ tabs=[],
1521
+ )
1522
+ )
1523
+ )
1524
+ )
1525
+ ),
1526
+ content=scout_workbookcommon_api.WorkbookContent(channel_variables={}, charts={}),
1527
+ message=commit_message if commit_message is not None else "Initial blank workbook template",
1528
+ workspace=self._workspace_rid_for_search(workspace or WorkspaceSearchType.ALL),
1529
+ )
1530
+
1531
+ template = self._clients.template.create(self._clients.auth_header, request)
1532
+ return WorkbookTemplate._from_conjure(self._clients, template)
@@ -6,7 +6,7 @@ import pathlib
6
6
  import time
7
7
  from dataclasses import dataclass, field
8
8
  from enum import Enum
9
- from typing import Mapping, Protocol, Sequence
9
+ from typing import Iterable, Mapping, Protocol, Sequence
10
10
  from urllib.parse import unquote, urlparse
11
11
 
12
12
  from nominal_api import api, ingest_api, scout_catalog
@@ -287,3 +287,115 @@ class IngestStatus(Enum):
287
287
  elif status.error is not None:
288
288
  return cls.FAILED
289
289
  raise ValueError(f"Unknown ingest status: {status.type}")
290
+
291
+
292
+ class IngestWaitType(Enum):
293
+ FIRST_COMPLETED = "FIRST_COMPLETED"
294
+ FIRST_EXCEPTION = "FIRST_EXCEPTION"
295
+ ALL_COMPLETED = "ALL_COMPLETED"
296
+
297
+
298
+ def wait_for_files_to_ingest(
299
+ files: Sequence[DatasetFile],
300
+ *,
301
+ poll_interval: datetime.timedelta = datetime.timedelta(seconds=1),
302
+ timeout: datetime.timedelta | None = None,
303
+ return_when: IngestWaitType = IngestWaitType.ALL_COMPLETED,
304
+ ) -> tuple[Sequence[DatasetFile], Sequence[DatasetFile]]:
305
+ """Blocks until all of the dataset files have completed their ingestion (or other specified conditions)
306
+ in a similar fashion to `concurrent.futures.wait`.
307
+
308
+ Any files that are already ingested (successfully or with errors) will be returned as "done", whereas any
309
+ files still ingesting by the time of this function's exit will be returned as "not done".
310
+
311
+ Args:
312
+ files: Dataset files to monitor for ingestion completion.
313
+ poll_interval: Interval to sleep between polling the remaining files under watch.
314
+ timeout: If given, the maximum time to wait before returning
315
+ return_when: Condition for this function to exit. By default, this function will block until all files
316
+ have completed their ingestion (successfully or unsuccessfully), but this can be changed to return
317
+ upon the first completed or first failing ingest. This behavior mirrors that of
318
+ `concurrent.futures.wait`.
319
+
320
+ Returns:
321
+ Returns a tuple of (done, not done) dataset files.
322
+ """
323
+ start_time = datetime.datetime.now()
324
+ done: list[DatasetFile] = []
325
+ not_done: list[DatasetFile] = [*files]
326
+ has_failed = False
327
+
328
+ while not_done and (timeout is None or datetime.datetime.now() - start_time < timeout):
329
+ logger.info("Polling for ingestion completion for %d files (%d total)", len(not_done), len(files))
330
+
331
+ next_not_done = []
332
+ for file in not_done:
333
+ latest_api = file._get_latest_api()
334
+ latest_file = file._refresh_from_api(latest_api)
335
+ match file.ingest_status:
336
+ case IngestStatus.SUCCESS:
337
+ done.append(latest_file)
338
+ case IngestStatus.FAILED:
339
+ logger.warning(
340
+ "Dataset file %s from dataset %s failed to ingest! Error message: %s",
341
+ latest_file.id,
342
+ latest_file.dataset_rid,
343
+ latest_api.ingest_status.error.message if latest_api.ingest_status.error else "",
344
+ )
345
+ done.append(latest_file)
346
+ has_failed = True
347
+ case IngestStatus.IN_PROGRESS:
348
+ next_not_done.append(latest_file)
349
+
350
+ not_done = next_not_done
351
+
352
+ if has_failed and return_when is IngestWaitType.FIRST_EXCEPTION:
353
+ break
354
+ elif done and return_when is IngestWaitType.FIRST_COMPLETED:
355
+ break
356
+ elif not not_done:
357
+ break
358
+
359
+ if timeout is not None and datetime.datetime.now() - start_time < timeout:
360
+ logger.info(
361
+ "Sleeping for %f seconds while awaiting ingestion for %d files (%d total)... ",
362
+ len(not_done),
363
+ len(files),
364
+ poll_interval.total_seconds(),
365
+ )
366
+ time.sleep(poll_interval.total_seconds())
367
+
368
+ return done, not_done
369
+
370
+
371
+ def as_files_ingested(
372
+ files: Sequence[DatasetFile],
373
+ *,
374
+ poll_interval: datetime.timedelta = datetime.timedelta(seconds=1),
375
+ ) -> Iterable[DatasetFile]:
376
+ """Iterates over DatasetFiles as they complete their ingestion in a similar fashion to
377
+ `concurrent.futures.as_completed`.
378
+
379
+ Any files that are already ingested (successfully or with errors) will immediately be yielded.
380
+
381
+ Args:
382
+ files: Dataset files to monitor for ingestion completion.
383
+ poll_interval: Interval to sleep between polling the remaining files under watch.
384
+
385
+ Yields:
386
+ Yields DatasetFiles as they are ingested. Due to the polling mechanics, the files are not yielded in
387
+ strictly sorted order based on their ingestion completion time. Ensure to check the `ingest_status` of
388
+ yielded dataset files if important.
389
+ """
390
+ to_poll: Sequence[DatasetFile] = [*files]
391
+ while to_poll:
392
+ logger.info("Awaiting ingestion for %d files (%d total)", len(to_poll), len(files))
393
+ done, not_done = wait_for_files_to_ingest(
394
+ to_poll, poll_interval=poll_interval, return_when=IngestWaitType.FIRST_COMPLETED
395
+ )
396
+ for file in done:
397
+ yield file
398
+
399
+ to_poll = not_done
400
+ if to_poll:
401
+ time.sleep(poll_interval.total_seconds())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nominal
3
- Version: 1.96.0
3
+ Version: 1.98.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
@@ -1,4 +1,4 @@
1
- CHANGELOG.md,sha256=NZ7acl4CWsmucCYmnmZR2SPA6dRL2FekSVJtIWfAkEY,79763
1
+ CHANGELOG.md,sha256=gqkvZQ_HzybFgggDMLGPXEt7eyGDq6jDf26HGFVlKLY,80650
2
2
  LICENSE,sha256=zEGHG9mjDjaIS3I79O8mweQo-yiTbqx8jJvUPppVAwk,1067
3
3
  README.md,sha256=KKe0dxh_pHXCtB7I9G4qWGQYvot_BZU8yW6MJyuyUHM,311
4
4
  nominal/__init__.py,sha256=rbraORnXUrNn1hywLXM0XwSQCd9UmQt20PDYlsBalfE,2167
@@ -27,7 +27,7 @@ nominal/cli/util/global_decorators.py,sha256=SBxhz4KbMlWDcCV08feouftd3HLnBNR-JJt
27
27
  nominal/cli/util/verify_connection.py,sha256=KU17ejaDfKBLmLiZ3MZSVLyfrqNE7c6mFBvskhqQLCo,1902
28
28
  nominal/config/__init__.py,sha256=wV8cq8X3J4NTJ5H_uR5THaMT_NQpWQO5qCUGEb-rPnM,3157
29
29
  nominal/config/_config.py,sha256=yKq_H1iYJDoxRfLz2iXLbbVdoL0MTEY0FS4eVL12w0g,2004
30
- nominal/core/__init__.py,sha256=N2N_kesiFkvfD6eVmGus2gwAfCsZbwR9KzYw4Wtlvc8,2193
30
+ nominal/core/__init__.py,sha256=5eC2J0lzpV7JcuKDUimJCfgXuVL7HNgHrLhqxcy5NCc,2333
31
31
  nominal/core/_clientsbunch.py,sha256=YwciugX7rQ9AOPHyvKuavG7b9SlX1PURRquP37nvLqE,8458
32
32
  nominal/core/_constants.py,sha256=SrxgaSqAEB1MvTSrorgGam3eO29iCmRr6VIdajxX3gI,56
33
33
  nominal/core/asset.py,sha256=Kq3RvdFSdAK-ViACpd_-H30fz1lnOaGU3zgFt13ag20,16674
@@ -35,12 +35,12 @@ nominal/core/attachment.py,sha256=iJaDyF6JXsKxxBLA03I0WMmQF8U0bA-wRwvXMEhfWLU,42
35
35
  nominal/core/bounds.py,sha256=742BWmGL3FBryRAjoiJRg2N6aVinjYkQLxN7kfnJ40Q,581
36
36
  nominal/core/channel.py,sha256=dbe8wpfMiWqHu98x66w6GOmC9Ro33Wv9AhBVx2DvtVk,18970
37
37
  nominal/core/checklist.py,sha256=rO1RPDYV3o2miPKF7DcCiYpj6bUN-sdtZNhJkXzkfYE,7110
38
- nominal/core/client.py,sha256=5raS_n__qbUjpCqqxAovd6LAAxRoSkpL87GhoSCPCj0,65205
38
+ nominal/core/client.py,sha256=L6IQVEPTiKbOjtbn4G0_R90jbVeOOxpHBHmminGQ3FE,67403
39
39
  nominal/core/connection.py,sha256=ySbPN_a2takVa8wIU9mK4fB6vYLyZnN-qSmXVkLUxAY,5157
40
40
  nominal/core/containerized_extractors.py,sha256=HrcMJzdE-hH66AgYIA0LTeFELsBHa0Sm0vlsKMiIzDU,9501
41
41
  nominal/core/data_review.py,sha256=bEnRsd8LI4x9YOBPcF2H3h5-e12A7Gh8gQfsNUAZmPQ,7922
42
42
  nominal/core/dataset.py,sha256=gbQYtAYx-fHaewOZUSC7P9CHlMfmdchtTv1XUuNKg3Y,29933
43
- nominal/core/dataset_file.py,sha256=OhkRsI4F3bz9YLF_lQklFfvi6Crq1r0zMMRGkWQEbeQ,11709
43
+ nominal/core/dataset_file.py,sha256=oENANJ17A4K63cZ8Fr7lUm_kVPyA4fL2rUsZ3oXXk2U,16396
44
44
  nominal/core/datasource.py,sha256=D9jHirAzUZ0pc3nW1XIURpw1UqQoA2E-nUUylZR1jbE,16707
45
45
  nominal/core/event.py,sha256=D8qIX_dTjfSHN7jFW8vV-9htbQTaqk9VvRfK7t-sbbw,5891
46
46
  nominal/core/exceptions.py,sha256=GUpwXRgdYamLl6684FE8ttCRHkBx6WEhOZ3NPE-ybD4,2671
@@ -63,7 +63,7 @@ nominal/core/_stream/write_stream_base.py,sha256=AxK3fAq3IBjNXZkxYFVXu3dGNWLCBhg
63
63
  nominal/core/_utils/README.md,sha256=kWPQDc6kn-PjXFUsIH9u2nOA3RdGSXCOlxqeJSmUsPA,160
64
64
  nominal/core/_utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
65
65
  nominal/core/_utils/api_tools.py,sha256=Z--Et7NjpCH4if72WwGm45EyqyeqK2ambcBrtOSDMrY,2949
66
- nominal/core/_utils/multipart.py,sha256=6bce0kZB510TovD71HnjP-EJr6nas9vfO0VPZB4LVY0,9104
66
+ nominal/core/_utils/multipart.py,sha256=YZb6SDJG9eM9AmSHikMbkEOtxy1DTOi9ggVUVzO0Ur4,9998
67
67
  nominal/core/_utils/multipart_downloader.py,sha256=zS6wxecAZYeWBfiOjtscoUVPaSiUYTI_Ckn4sknSxMM,14275
68
68
  nominal/core/_utils/networking.py,sha256=0cted8IF52WdYAPtiAy9IeosmQkoytXOnazJkdYRkk8,5862
69
69
  nominal/core/_utils/pagination_tools.py,sha256=cEBY1WiA1d3cWJEM0myYF_pX8JdQ_e-5asngVXrUc_Y,12152
@@ -102,8 +102,8 @@ nominal/thirdparty/polars/polars_export_handler.py,sha256=hGCSwXX9dC4MG01CmmjlTb
102
102
  nominal/thirdparty/tdms/__init__.py,sha256=6n2ImFr2Wiil6JM1P5Q7Mpr0VzLcnDkmup_ftNpPq-s,142
103
103
  nominal/thirdparty/tdms/_tdms.py,sha256=eiHFTUviyDPDClckNldjs_jTTSH_sdmboKDq0oIGChQ,8711
104
104
  nominal/ts/__init__.py,sha256=hmd0ENvDhxRnzDKGLxIub6QG8LpcxCgcyAct029CaEs,21442
105
- nominal-1.96.0.dist-info/METADATA,sha256=jq3jplT23Y5wxFaRyMjVL0znHbpFavQSqG6yt00xhyE,1946
106
- nominal-1.96.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
107
- nominal-1.96.0.dist-info/entry_points.txt,sha256=-mCLhxgg9R_lm5efT7vW9wuBH12izvY322R0a3TYxbE,66
108
- nominal-1.96.0.dist-info/licenses/LICENSE,sha256=zEGHG9mjDjaIS3I79O8mweQo-yiTbqx8jJvUPppVAwk,1067
109
- nominal-1.96.0.dist-info/RECORD,,
105
+ nominal-1.98.0.dist-info/METADATA,sha256=av9CrAtGvlkkUC6pdW1oC7TazjZD1tJNIN46C4G4Jng,1946
106
+ nominal-1.98.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
107
+ nominal-1.98.0.dist-info/entry_points.txt,sha256=-mCLhxgg9R_lm5efT7vW9wuBH12izvY322R0a3TYxbE,66
108
+ nominal-1.98.0.dist-info/licenses/LICENSE,sha256=zEGHG9mjDjaIS3I79O8mweQo-yiTbqx8jJvUPppVAwk,1067
109
+ nominal-1.98.0.dist-info/RECORD,,