diff --git a/poetry.lock b/poetry.lock index b085522..a3b2180 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "alabaster" @@ -1478,7 +1478,7 @@ fqdn = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} idna = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} isoduration = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} jsonpointer = {version = ">1.13", optional = true, markers = "extra == \"format-nongpl\""} -jsonschema-specifications = ">=2023.3.6" +jsonschema-specifications = ">=2023.03.6" referencing = ">=0.28.4" rfc3339-validator = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} rfc3986-validator = {version = ">0.1.0", optional = true, markers = "extra == \"format-nongpl\""} @@ -2582,18 +2582,18 @@ protobuf = ">=4.21" [[package]] name = "ni-protobuf-types" -version = "1.2.0.dev0" +version = "1.2.0.dev1" description = "Protobuf data types for NI gRPC APIs" optional = false python-versions = "<4.0,>=3.10" groups = ["main"] files = [ - {file = "ni_protobuf_types-1.2.0.dev0-py3-none-any.whl", hash = "sha256:9e06049582d8eb0b7412a3fdbb628c45f8adb1d1f26107959ce2eb469c1dc1c8"}, - {file = "ni_protobuf_types-1.2.0.dev0.tar.gz", hash = "sha256:6d9ce29fd577d9d9b6da69fe882b894d8db5303a1f4b5dfb584603234f746463"}, + {file = "ni_protobuf_types-1.2.0.dev1-py3-none-any.whl", hash = "sha256:1c17d52328e4c9123b327dcc3a79f7a62bb35c9b405fe68f6be06f4327343921"}, + {file = "ni_protobuf_types-1.2.0.dev1.tar.gz", hash = "sha256:34193f459b468767caec1fb0df69e0f5795a7e6f1bbda3f0098c68d0302781bf"}, ] [package.dependencies] -nitypes = ">=1.1.0.dev1" +nitypes = ">=1.1.0.dev3" protobuf = ">=4.21" [[package]] @@ -2613,8 +2613,8 @@ better-diff = ">=0.1.3,<0.2.0" black = ">=23.1,<26.0" click = ">=7.1.2" flake8 = [ - {version = ">=6.1,<7.0", markers = "python_version >= \"3.12\" and python_version < \"4.0\""}, {version = ">=5.0,<6.0", markers = "python_version >= \"3.8\" and python_version < \"3.12\""}, + {version = ">=6.1,<7.0", markers = "python_version >= \"3.12\" and python_version < \"4.0\""}, ] flake8-black = ">=0.2.1" flake8-docstrings = ">=1.5.0" @@ -2624,29 +2624,29 @@ isort = ">=5.10" pathspec = ">=0.11.1" pep8-naming = ">=0.11.1" pycodestyle = [ - {version = ">=2.11,<3.0", markers = "python_version >= \"3.12\" and python_version < \"4.0\""}, {version = ">=2.9,<3.0", markers = "python_version >= \"3.8\" and python_version < \"3.12\""}, + {version = ">=2.11,<3.0", markers = "python_version >= \"3.12\" and python_version < \"4.0\""}, ] setuptools = "<82" toml = ">=0.10.1" [[package]] name = "nitypes" -version = "1.1.0.dev1" +version = "1.1.0.dev3" description = "Data types for NI Python APIs" optional = false python-versions = "<4.0,>=3.9" groups = ["main"] files = [ - {file = "nitypes-1.1.0.dev1-py3-none-any.whl", hash = "sha256:d98ad6e3f8b92db76b5c1c584431fa27d3e74cce6e20464e43ee0117b02fe089"}, - {file = "nitypes-1.1.0.dev1.tar.gz", hash = "sha256:50b23e00cc6960996656c4c9ef0ca71dd267fc5c9ca481077b682c29190aa2d3"}, + {file = "nitypes-1.1.0.dev3-py3-none-any.whl", hash = "sha256:b916b60bccb4baf62a18044a58e52117f5d4b4e02ef07385f94e96b5c78f747a"}, + {file = "nitypes-1.1.0.dev3.tar.gz", hash = "sha256:554a4c35dc7295469911b058dff1da8b563c5f8ed093f2e26d18ced251f74dca"}, ] [package.dependencies] hightime = ">=0.2.2" numpy = [ - {version = ">=2.1", markers = "python_version >= \"3.13\" and python_version < \"4.0\""}, {version = ">=1.22", markers = "python_version >= \"3.9\" and python_version < \"3.13\""}, + {version = ">=2.1", markers = "python_version >= \"3.13\" and python_version < \"4.0\""}, ] typing-extensions = ">=4.13.2" @@ -3449,13 +3449,6 @@ optional = false python-versions = ">=3.8" groups = ["dev", "docs", "lint"] files = [ - {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, - {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, - {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, - {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, @@ -4541,4 +4534,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "8d65fe6510964e28aefc1f5b3337ce929cf90ad21f433d6738e64521b0dc8827" +content-hash = "640db6ffef6c32157cb901d1ccf416ec3a7c1de1223044ce73c26cf77feaef6e" diff --git a/pyproject.toml b/pyproject.toml index 6363e56..9238c9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ python = "^3.10" protobuf = {version=">=4.21"} ni-measurements-data-v1-client = { version = ">=1.1.0dev1", allow-prereleases = true } ni-measurements-metadata-v1-client = { version = ">=1.0.0" } -ni-protobuf-types = { version = ">=1.1.0" } +ni-protobuf-types = { version = ">=1.2.0dev1", allow-prereleases = true } hightime = { version = ">=1.0.0" } [tool.poetry.group.dev.dependencies] diff --git a/src/ni/datastore/data/_grpc_conversion.py b/src/ni/datastore/data/_grpc_conversion.py index 5768c17..70bb863 100644 --- a/src/ni/datastore/data/_grpc_conversion.py +++ b/src/ni/datastore/data/_grpc_conversion.py @@ -5,7 +5,7 @@ import datetime as std_datetime import logging from itertools import chain -from typing import Any, Callable, Iterable, Sequence, cast +from typing import Any, Callable, cast, Iterable import hightime as ht import numpy as np @@ -200,18 +200,18 @@ def populate_publish_condition_batch_request_values( if isinstance(values, Vector): publish_request.scalar_values.CopyFrom(vector_to_protobuf(values)) elif isinstance(values, Iterable): - if not values: - raise ValueError("Cannot publish an empty Iterable.") + values_iterator = iter(values) + try: + first_value = next(values_iterator) + except StopIteration as exc: + raise ValueError("Cannot publish an empty Iterable.") from exc - # Vector initialization requires the Iterable to be iterated over multiple times. - # We convert the Iterable to a list if we don't know that the Iterable type - # supports multiple iterations. - condition_values = values if isinstance(values, Sequence) else list(values) + all_values = chain([first_value], values_iterator) try: - vector = Vector(condition_values) + vector = Vector(cast(Iterable[bool | int | float | str], all_values)) except (TypeError, ValueError): raise TypeError( - f"Unsupported iterable: {condition_values}. Subtype must be bool, float, int, or string." + f"Unsupported iterable: {values}. Subtype must be bool, float, int, or string." ) publish_request.scalar_values.CopyFrom(vector_to_protobuf(vector)) @@ -268,10 +268,15 @@ def populate_publish_measurement_request_value( else: raise TypeError(f"Unsupported XYData dtype: {value.dtype}") elif isinstance(value, Iterable): - if not value: - raise ValueError("Cannot publish an empty Iterable.") + value_iterator = iter(value) + try: + first_item = next(value_iterator) + except StopIteration as exc: + raise ValueError("Cannot publish an empty Iterable.") from exc + + all_items = chain([first_item], value_iterator) try: - vector = Vector(value) + vector = Vector(cast(Iterable[bool | int | float | str], all_items)) except (TypeError, ValueError): raise TypeError( f"Unsupported iterable: {value}. Subtype must be bool, float, int, or string." @@ -312,7 +317,7 @@ def populate_publish_measurement_batch_request_values( _populate_xydata_batch_values(publish_request, first_value, all_values) else: try: - vector = Vector(cast(Iterable[bool | int | float | str], list(all_values))) + vector = Vector(cast(Iterable[bool | int | float | str], all_values)) except (TypeError, ValueError): raise TypeError( f"Unsupported iterable. Subtype must be bool, float, int, string, Vector, " diff --git a/tests/acceptance/test_publish_measurement_and_read_data.py b/tests/acceptance/test_publish_measurement_and_read_data.py index f7efb0b..01dcb41 100644 --- a/tests/acceptance/test_publish_measurement_and_read_data.py +++ b/tests/acceptance/test_publish_measurement_and_read_data.py @@ -1,9 +1,17 @@ """Acceptance tests that publish various values then reads the data back.""" +import hightime as ht import numpy as np from nitypes.scalar import Scalar from nitypes.vector import Vector -from nitypes.waveform import AnalogWaveform, ComplexWaveform, DigitalWaveform, Spectrum +from nitypes.waveform import ( + AnalogWaveform, + ComplexWaveform, + DigitalWaveform, + SampleIntervalMode, + Spectrum, + Timing, +) from nitypes.xy_data import XYData from utilities import DataStoreContext @@ -113,6 +121,7 @@ def test___publish_analog_waveform___read_measurement_value_returns_analog_wavef expected_waveform = AnalogWaveform( sample_count=3, raw_data=np.array([1.0, 2.0, 3.0]), + timing=Timing(SampleIntervalMode.NONE, time_offset=ht.timedelta()), ) published_measurement_id = data_store_client.publish_measurement( @@ -133,7 +142,10 @@ def test___publish_digital_waveform___read_measurement_value_returns_digital_wav ) -> None: with DataStoreClient() as data_store_client: step_id = _create_step(data_store_client, "digital waveform") - expected_waveform = DigitalWaveform(10) + expected_waveform = DigitalWaveform( + 10, + timing=Timing(SampleIntervalMode.NONE, time_offset=ht.timedelta()), + ) published_measurement_id = data_store_client.publish_measurement( name="python publish digital waveform", value=expected_waveform, @@ -152,7 +164,10 @@ def test___publish_complex_waveform___read_measurement_value_returns_complex_wav ) -> None: with DataStoreClient() as data_store_client: step_id = _create_step(data_store_client, "complex waveform") - expected_waveform = ComplexWaveform(10) + expected_waveform = ComplexWaveform( + 10, + timing=Timing(SampleIntervalMode.NONE, time_offset=ht.timedelta()), + ) published_measurement_id = data_store_client.publish_measurement( name="python publish complex waveform", value=expected_waveform, diff --git a/tests/acceptance/test_publish_measurement_batch_and_read_data.py b/tests/acceptance/test_publish_measurement_batch_and_read_data.py index 6786a5b..9580485 100644 --- a/tests/acceptance/test_publish_measurement_batch_and_read_data.py +++ b/tests/acceptance/test_publish_measurement_batch_and_read_data.py @@ -1,8 +1,9 @@ """Acceptance tests that publish various batch measurement values then reads the data back.""" +import hightime as ht import numpy as np from nitypes.vector import Vector -from nitypes.waveform import AnalogWaveform +from nitypes.waveform import AnalogWaveform, NoneScaleMode, SampleIntervalMode, Timing from utilities import DataStoreContext from ni.datastore.data import ( @@ -84,8 +85,18 @@ def test___publish_batch_double_analog_waveforms___read_measurement_value_return test_result = TestResult(name=test_result_name) test_result_id = data_store_client.create_test_result(test_result) expected_waveforms = [ - AnalogWaveform(sample_count=3, raw_data=np.array([1.0, 2.0, 3.0], dtype=np.float64)), - AnalogWaveform(sample_count=3, raw_data=np.array([4.0, 5.0, 6.0], dtype=np.float64)), + AnalogWaveform( + sample_count=3, + raw_data=np.array([1.0, 2.0, 3.0]), + scale_mode=NoneScaleMode(), + timing=Timing(SampleIntervalMode.NONE, time_offset=ht.timedelta()), + ), + AnalogWaveform( + sample_count=3, + raw_data=np.array([4.0, 5.0, 6.0]), + scale_mode=NoneScaleMode(), + timing=Timing(SampleIntervalMode.NONE, time_offset=ht.timedelta()), + ), ] step = Step(name="Initial step", test_result_id=test_result_id) step_id = data_store_client.create_step(step) diff --git a/tests/acceptance/test_publish_with_metadata.py b/tests/acceptance/test_publish_with_metadata.py index 0f578bd..fa9c150 100644 --- a/tests/acceptance/test_publish_with_metadata.py +++ b/tests/acceptance/test_publish_with_metadata.py @@ -5,7 +5,7 @@ import hightime as ht import numpy as np -from nitypes.waveform import AnalogWaveform +from nitypes.waveform import AnalogWaveform, NoneScaleMode, Timing, SampleIntervalMode from utilities import DataStoreContext from ni.datastore.data import ( @@ -155,6 +155,8 @@ def test___waveform_with_all_metadata___publish___query_read_returns_correct_dat expected_waveform = AnalogWaveform( sample_count=3, raw_data=np.array([1.0, 2.0, 3.0]), + scale_mode=NoneScaleMode(), + timing=Timing(SampleIntervalMode.NONE, time_offset=ht.timedelta()), ) # Metadata: Test diff --git a/tests/unit/data/test_grpc_conversion.py b/tests/unit/data/test_grpc_conversion.py index 8602293..ffaabc7 100644 --- a/tests/unit/data/test_grpc_conversion.py +++ b/tests/unit/data/test_grpc_conversion.py @@ -1,3 +1,4 @@ +from collections.abc import Generator from typing import Any, Iterable import numpy as np @@ -109,6 +110,16 @@ def test___empty_iterable___populate_condition_batch___raises_error() -> None: populate_publish_condition_batch_request_values(request, []) +def test___empty_generator___populate_condition_batch___raises_error() -> None: + def _values() -> Generator[float, None, None]: + yield from [] + + request = PublishConditionBatchRequest() + + with pytest.raises(ValueError, match="Cannot publish an empty Iterable."): + populate_publish_condition_batch_request_values(request, _values()) + + def test___python_unsupported_iterable___populate_condition_batch___raises_error() -> None: values = [object(), object()] request = PublishConditionBatchRequest() @@ -265,6 +276,37 @@ def test___python_float64_xydata___populate_measurement___measurement_updated_co assert request.x_y_data.attributes["NI_UnitDescription_Y"].string_value == "Seconds" +@pytest.mark.parametrize( + "values, attribute_name", + [ + ([1.5, 2.5, 3.5], "double_array"), + ([1, 2, 3], "sint32_array"), + ([True, False, True], "bool_array"), + (["one", "two", "three"], "string_array"), + ], +) +def test___python_scalar_iterable___populate_measurement___measurement_updated_correctly( + values: list[object], attribute_name: str +) -> None: + request = PublishMeasurementRequest() + populate_publish_measurement_request_value(request, values) + + assert isinstance(request.vector, vector_pb2.Vector) + assert list(getattr(request.vector, attribute_name).values) == values + + +def test___empty_iterable___populate_measurement___raises_value_error() -> None: + request = PublishMeasurementRequest() + with pytest.raises(ValueError, match="Cannot publish an empty Iterable."): + populate_publish_measurement_request_value(request, []) + + +def test___unsupported_iterable___populate_measurement___raises_type_error() -> None: + request = PublishMeasurementRequest() + with pytest.raises(TypeError, match="Unsupported iterable"): + populate_publish_measurement_request_value(request, [object(), object()]) + + # ======================================================== # Populate Measurement Batch # ======================================================== @@ -732,6 +774,16 @@ def test___empty_iterable___populate_measurement_batch___raises_error() -> None: populate_publish_measurement_batch_request_values(request, []) +def test___empty_generator___populate_measurement_batch___raises_error() -> None: + def _values() -> Generator[float, None, None]: + yield from [] + + request = PublishMeasurementBatchRequest() + + with pytest.raises(ValueError, match="Cannot publish an empty Iterable."): + populate_publish_measurement_batch_request_values(request, _values()) + + def test___python_unsupported_iterable___populate_measurement_batch___raises_error() -> None: values = [object(), object()] request = PublishMeasurementBatchRequest()