From 351b335e8ecd716b1cd34e891c12353c2107dd97 Mon Sep 17 00:00:00 2001 From: "Balusamy, Sudharshana" Date: Fri, 23 Oct 2026 19:05:32 +0530 Subject: [PATCH 01/13] add v2 wrapper --- docs/reference/index.rst | 1 + docs/reference/ni-data-store-v2.md | 86 +++ poetry.lock | 69 ++- pyproject.toml | 2 + src/ni/datastore/data/v2/__init__.py | 31 + .../datastore/data/v2/_data_store_client.py | 531 +++++++++++++++++ src/ni/datastore/data/v2/_grpc_conversion.py | 196 +++++++ src/ni/datastore/data/v2/_types/__init__.py | 1 + src/ni/datastore/data/v2/_types/_condition.py | 117 ++++ .../data/v2/_types/_error_information.py | 66 +++ .../datastore/data/v2/_types/_measurement.py | 253 ++++++++ src/ni/datastore/data/v2/_types/_outcome.py | 47 ++ src/ni/datastore/data/v2/_types/_status.py | 41 ++ src/ni/datastore/data/v2/_types/_step.py | 254 ++++++++ .../datastore/data/v2/_types/_test_result.py | 260 ++++++++ test.py | 0 tests/unit/data/test_v2_data_store_client.py | 555 ++++++++++++++++++ tests/unit/data/test_v2_types.py | 199 +++++++ 18 files changed, 2698 insertions(+), 11 deletions(-) create mode 100644 docs/reference/ni-data-store-v2.md create mode 100644 src/ni/datastore/data/v2/__init__.py create mode 100644 src/ni/datastore/data/v2/_data_store_client.py create mode 100644 src/ni/datastore/data/v2/_grpc_conversion.py create mode 100644 src/ni/datastore/data/v2/_types/__init__.py create mode 100644 src/ni/datastore/data/v2/_types/_condition.py create mode 100644 src/ni/datastore/data/v2/_types/_error_information.py create mode 100644 src/ni/datastore/data/v2/_types/_measurement.py create mode 100644 src/ni/datastore/data/v2/_types/_outcome.py create mode 100644 src/ni/datastore/data/v2/_types/_status.py create mode 100644 src/ni/datastore/data/v2/_types/_step.py create mode 100644 src/ni/datastore/data/v2/_types/_test_result.py create mode 100644 test.py create mode 100644 tests/unit/data/test_v2_data_store_client.py create mode 100644 tests/unit/data/test_v2_types.py diff --git a/docs/reference/index.rst b/docs/reference/index.rst index 5f3f0388..c780c41a 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -8,5 +8,6 @@ This section provides comprehensive documentation for the NI Data Store system c :maxdepth: 3 ni-data-store + ni-data-store-v2 ni-metadata-store using-measurement-data-services \ No newline at end of file diff --git a/docs/reference/ni-data-store-v2.md b/docs/reference/ni-data-store-v2.md new file mode 100644 index 00000000..5a13085f --- /dev/null +++ b/docs/reference/ni-data-store-v2.md @@ -0,0 +1,86 @@ +# NI Data Store V2 Python API + +Use the separate V2 client when you need the V2 service model. Like the V1 +client, it uses Python types (`TestResult`, `Step`, `Measurement`, `Condition`, +`ErrorInformation`, `Outcome`, and `Status`) and constructs the gRPC request +messages internally. + +```python +from datetime import timezone + +from hightime import datetime +from ni.datastore.data.v2 import ( + Condition, + DataStoreClient, + Measurement, + Outcome, + Status, + Step, + TestResult, +) + +with DataStoreClient() as client: + test_result_id = client.create_test_result( + TestResult("Power test", start_date_time=datetime.now(tz=timezone.utc)) + ) + step_id = client.create_step( + Step("Measure voltage", test_result_id=test_result_id, status=Status.RUNNING) + ) + client.add_condition( + step_id, Condition("supply-voltage", 5.0, condition_type="Nominal", units="V") + ) + measurement_id = client.create_measurement( + "voltage", + 5.02, + step_id, + units="V", + outcome=Outcome.PASSED, + ) + measurement = client.get_measurement(measurement_id) + voltage = client.read_measurement_value(measurement) +``` + +The client also provides `get_test_result`, `get_step`, `query_test_results`, +`query_steps`, `query_measurements`, and update methods for test results, steps, +and measurements. These return the Python types, with timestamps as +`hightime.datetime`, durations as `hightime.timedelta`, and extensions as +dictionaries. Queries accept an OData expression and optional `read_fields`. +Updates accept mutable fields as keyword arguments and build the protobuf field +mask automatically. Empty updates and fields that the service does not allow to +change raise `ValueError` before an RPC is made. + +## V1 Convenience Differences + +- V1 `publish_condition` and `publish_condition_batch` do not have direct V2 + equivalents. V2 conditions belong to a step; use `add_condition(step_id, ...)` + to add or replace one. Read conditions through the containing step returned + by `get_step` or `query_steps`; there are no standalone V2 condition get/query + methods in this high-level API. +- This high-level V2 API does not include the V1 batch-publish convenience. + Call `create_measurement` once per measurement; these are separate service + operations and are not atomic as a group. +- V2 has its own `TestResult`, `Step`, `Measurement`, `Condition`, and + `ErrorInformation` types in `ni.datastore.data.v2`, because the V2 resource + fields differ from V1. They follow the V1 style: create methods take them, + and get/query/update methods return them instead of protobuf messages. +- V2 create and update methods accept `hightime.datetime` timestamps and + `hightime.timedelta` durations, converted with the NI precision converters. + Timestamps must use `datetime.timezone.utc`; other time zones are rejected. + Round trips are exact to about 1 ns; finer precision is not preserved. +- Test results and steps accept `keywords`, and test results accept `uut_id` + updates. Keywords, duration, and `uut_id` are stored once the service delivers + the Stage 2 New Resource Fields feature. +- Measurement values are converted to scalar, vector, supported NI + waveform-family, or JSON values; `read_measurement_value` converts them back to + Python or nitypes values. Conditions accept scalar or JSON values. JSON values + require the service's Stage 3 Structured JSON Storage And Query Support. +- Software item, hardware item, and test adapter IDs on a measurement must first + be linked to its test result; pass them to `create_test_result`. +- Discovery, channel configuration, and cleanup are delegated to the low-level + V2 client. Its gRPC transport exceptions pass through unchanged; invalid + wrapper values and update fields fail locally with `TypeError` or + `ValueError`. As in V1, calling a method after `close()` raises + `RuntimeError`. +- V1 and V2 hosts both register the metadata store interface. When both run in + the same discovery cluster, `MetadataStoreClient()` cannot choose between + them; pass a `grpc_channel` for the intended host. diff --git a/poetry.lock b/poetry.lock index a6a379b9..f2664f3c 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.5.1 and should not be changed by hand. [[package]] name = "alabaster" @@ -1228,7 +1228,7 @@ comm = ">=0.1.1" debugpy = ">=1.6.5" ipython = ">=7.23.1" jupyter-client = ">=8.9.0" -jupyter-core = ">=5.1,<6.0.dev0 || >=6.1.dev0" +jupyter-core = ">=5.1,<6.0 || >=6.1.dev0" matplotlib-inline = ">=0.1" nest-asyncio2 = ">=1.7.0" packaging = ">=22" @@ -1575,7 +1575,7 @@ files = [ ipykernel = ">=6.14" ipython = "*" jupyter-client = ">=7.0.0" -jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +jupyter-core = ">=4.12,<5.0 || >=5.1.dev0" prompt-toolkit = ">=3.0.30" pygments = "*" pyzmq = ">=17" @@ -1663,7 +1663,7 @@ anyio = ">=3.1.0" argon2-cffi = ">=21.1" jinja2 = ">=3.0.3" jupyter-client = ">=7.4.4" -jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +jupyter-core = ">=4.12,<5.0 || >=5.1.dev0" jupyter-events = ">=0.11.0" jupyter-server-terminals = ">=0.4.4" nbconvert = ">=6.4.4" @@ -1671,7 +1671,7 @@ nbformat = ">=5.3.0" overrides = {version = ">=5.0", markers = "python_version < \"3.12\""} packaging = ">=22.0" prometheus-client = ">=0.9" -pywinpty = {version = ">=2.0.1,<3.0.4 || >3.0.4", markers = "os_name == \"nt\""} +pywinpty = {version = ">=2.0.1,!=3.0.4", markers = "os_name == \"nt\""} pyzmq = ">=24" send2trash = ">=1.8.2" terminado = ">=0.8.3" @@ -1718,7 +1718,7 @@ files = [ [package.dependencies] async-lru = ">=1.0.0" httpx = ">=0.25.0,<1" -ipykernel = ">=6.5.0,<6.30.0 || >6.30.0" +ipykernel = ">=6.5.0,!=6.30.0" jinja2 = ">=3.0.3" jupyter-builder = ">=1.0.2" jupyter-core = "*" @@ -2385,7 +2385,7 @@ files = [ [package.dependencies] fastjsonschema = ">=2.15" jsonschema = ">=2.6" -jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +jupyter-core = ">=4.12,<5.0 || >=5.1.dev0" traitlets = ">=5.1" [package.extras] @@ -2502,6 +2502,53 @@ ni-measurements-metadata-v1-proto = ">=1.0.0" ni-protobuf-types = ">=1.3.0.dev0" protobuf = ">=4.21" +[[package]] +name = "ni-measurements-data-v2-client" +version = "1.0.0.dev0" +description = "gRPC Client for NI Data Store Service V2" +optional = false +python-versions = ">=3.10,<4.0" +groups = ["main"] +files = [] +develop = false + +[package.dependencies] +grpcio = ">=1.49.1,<2.0" +ni-grpc-extensions = ">=1.1.0" +ni-measurementlink-discovery-v1-client = ">=1.1.0" +ni-measurements-data-v2-proto = ">=1.0.0.dev0" + +[package.source] +type = "git" +url = "https://github.com/ni/ni-apis-python.git" +reference = "users/sudharshana/add-v2-proto-and-client-package" +resolved_reference = "1baeee66c9e9b826dcb7a619dcb41c8bd190dbbb" +subdirectory = "packages/ni.measurements.data.v2.client" + +[[package]] +name = "ni-measurements-data-v2-proto" +version = "1.0.0.dev0" +description = "Protobuf data types and service stubs for NI data store V2 gRPC APIs" +optional = false +python-versions = ">=3.10,<4.0" +groups = ["main"] +files = [] +develop = false + +[package.dependencies] +grpcio = ">=1.49.1" +ni-datamonikers-v1-proto = ">=1.0.0" +ni-measurements-metadata-v1-proto = ">=1.0.0" +ni-protobuf-types = ">=1.3.0" +protobuf = ">=4.21" + +[package.source] +type = "git" +url = "https://github.com/ni/ni-apis-python.git" +reference = "users/sudharshana/add-v2-proto-and-client-package" +resolved_reference = "1baeee66c9e9b826dcb7a619dcb41c8bd190dbbb" +subdirectory = "packages/ni.measurements.data.v2.proto" + [[package]] name = "ni-measurements-metadata-v1-client" version = "1.0.0" @@ -2535,14 +2582,14 @@ protobuf = ">=4.21" [[package]] name = "ni-protobuf-types" -version = "1.3.0.dev0" +version = "1.3.0" description = "Protobuf data types for NI gRPC APIs" optional = false python-versions = "<4.0,>=3.10" groups = ["main"] files = [ - {file = "ni_protobuf_types-1.3.0.dev0-py3-none-any.whl", hash = "sha256:a1851f0bccf7da857e1745933e5517fd6d95a79a79141d108fce42af958bde35"}, - {file = "ni_protobuf_types-1.3.0.dev0.tar.gz", hash = "sha256:674b4930dbb6baff6183df9bb4f90f3a3a0723bbe97a4cdeb3962163c220bc23"}, + {file = "ni_protobuf_types-1.3.0-py3-none-any.whl", hash = "sha256:f4c8e1e6c2f229d1d6e2dd45a21d40c61fd576e50fc4a0276a391296e1e9f478"}, + {file = "ni_protobuf_types-1.3.0.tar.gz", hash = "sha256:448c850934f57ee83862cc93fbf33c4b45b9f54d760279874cbfe8879eed412e"}, ] [package.dependencies] @@ -4734,4 +4781,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "1fe2999dd0533f875d6528337b9d72247e3f7e6bebe09f1bb767ed95dda8ef6d" +content-hash = "18dd52b86670431ccadf0e87fe14ba84e3d738be4e73ff761483ff466fc709ed" diff --git a/pyproject.toml b/pyproject.toml index b95a621e..b3d86291 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,8 @@ python = "^3.10" protobuf = {version=">=4.21"} ni-measurements-data-v1-client = { version = ">=1.2.0.dev0", allow-prereleases = true } ni-measurements-metadata-v1-client = { version = ">=1.0.0" } +ni-measurements-data-v2-client = { git = "https://github.com/ni/ni-apis-python.git", branch = "users/sudharshana/add-v2-proto-and-client-package", subdirectory = "packages/ni.measurements.data.v2.client" } +ni-measurements-data-v2-proto = { git = "https://github.com/ni/ni-apis-python.git", branch = "users/sudharshana/add-v2-proto-and-client-package", subdirectory = "packages/ni.measurements.data.v2.proto" } ni-protobuf-types = { version = ">=1.3.0.dev0", allow-prereleases = true } hightime = { version = ">=1.0.0" } diff --git a/src/ni/datastore/data/v2/__init__.py b/src/ni/datastore/data/v2/__init__.py new file mode 100644 index 00000000..67260207 --- /dev/null +++ b/src/ni/datastore/data/v2/__init__.py @@ -0,0 +1,31 @@ +"""Public API for accessing the NI Data Store V2.""" + +from ni.datastore.data.v2._data_store_client import DataStoreClient +from ni.datastore.data.v2._types._condition import Condition +from ni.datastore.data.v2._types._error_information import ErrorInformation +from ni.datastore.data.v2._types._measurement import Measurement +from ni.datastore.data.v2._types._outcome import Outcome +from ni.datastore.data.v2._types._status import Status +from ni.datastore.data.v2._types._step import Step +from ni.datastore.data.v2._types._test_result import TestResult + +__all__ = [ + "Condition", + "DataStoreClient", + "ErrorInformation", + "Measurement", + "Outcome", + "Status", + "Step", + "TestResult", +] + +# Hide that it was not defined in this top-level package +Condition.__module__ = __name__ +DataStoreClient.__module__ = __name__ +ErrorInformation.__module__ = __name__ +Measurement.__module__ = __name__ +Outcome.__module__ = __name__ +Status.__module__ = __name__ +Step.__module__ = __name__ +TestResult.__module__ = __name__ diff --git a/src/ni/datastore/data/v2/_data_store_client.py b/src/ni/datastore/data/v2/_data_store_client.py new file mode 100644 index 00000000..63e7d973 --- /dev/null +++ b/src/ni/datastore/data/v2/_data_store_client.py @@ -0,0 +1,531 @@ +"""Data store client for publishing and reading data with NI Data Store Service V2.""" + +from __future__ import annotations + +import sys +from collections.abc import Iterable, Mapping, Sequence +from types import TracebackType +from typing import Any, cast, overload, Type, TYPE_CHECKING, TypeVar + +import hightime as ht +from google.protobuf import json_format +from google.protobuf.field_mask_pb2 import FieldMask +from grpc import Channel +from ni.measurementlink.discovery.v1.client import DiscoveryClient +from ni.measurements.data.v2.client import DataStoreClient as DataStoreServiceClient +from ni.measurements.data.v2.data_store_pb2 import ( + Measurement as MeasurementProto, + Step as StepProto, + TestResult as TestResultProto, +) +from ni.measurements.data.v2.data_store_service_pb2 import ( + AddConditionRequest, + CreateMeasurementRequest, + CreateStepRequest, + CreateTestResultRequest, + GetMeasurementRequest, + GetStepRequest, + GetTestResultRequest, + QueryMeasurementsRequest, + QueryStepsRequest, + QueryTestResultsRequest, + ReadMeasurementValuesRequest, + UpdateMeasurementRequest, + UpdateStepRequest, + UpdateTestResultRequest, +) +from ni.protobuf.types.precision_duration_conversion import hightime_timedelta_to_protobuf +from ni.protobuf.types.precision_timestamp_conversion import hightime_datetime_to_protobuf +from ni_grpc_extensions.channelpool import GrpcChannelPool + +from ni.datastore.data.v2._grpc_conversion import ( + convert_measurement_value_from_protobuf, + populate_measurement_value, +) +from ni.datastore.data.v2._types._condition import Condition +from ni.datastore.data.v2._types._error_information import ErrorInformation +from ni.datastore.data.v2._types._measurement import Measurement +from ni.datastore.data.v2._types._outcome import Outcome +from ni.datastore.data.v2._types._step import Step +from ni.datastore.data.v2._types._test_result import TestResult + +if TYPE_CHECKING: + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self + +TRead = TypeVar("TRead") + + +class DataStoreClient: + """Data store client for publishing and reading data with the V2 service.""" + + __slots__ = ("_closed", "_data_store_client") + + _DATA_STORE_CLIENT_CLOSED_ERROR = ( + "This DataStoreClient has been closed. Create a new DataStoreClient for further " + "interaction with the data store." + ) + + _closed: bool + _data_store_client: DataStoreServiceClient + + def __init__( + self, + discovery_client: DiscoveryClient | None = None, + grpc_channel: Channel | None = None, + grpc_channel_pool: GrpcChannelPool | None = None, + ) -> None: + """Initialize the DataStoreClient. + + Args: + discovery_client: An optional discovery client (recommended). + + grpc_channel: An optional data store gRPC channel. Providing this channel will bypass + discovery service resolution of the data store. + + grpc_channel_pool: An optional gRPC channel pool (recommended). + """ + self._closed = False + self._data_store_client = DataStoreServiceClient( + discovery_client=discovery_client, + grpc_channel=grpc_channel, + grpc_channel_pool=grpc_channel_pool, + ) + + def __enter__(self) -> Self: + """Enter the runtime context of the data store client.""" + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """Exit the runtime context of the data store client.""" + self.close() + + def close(self) -> None: + """Close the data store client and clean up resources that it owns.""" + self._closed = True + self._data_store_client.close() + + def create_test_result(self, test_result: TestResult) -> str: + """Create a test result. + + Args: + test_result: The metadata of the test result to be created. + + Returns: + str: The test result ID. Generated if not specified. + """ + create_request = CreateTestResultRequest(test_result=test_result.to_protobuf()) + return self._get_data_store_client().create_test_result(create_request).id + + def create_step(self, step: Step) -> str: + """Create a step, including any conditions on the step. + + Args: + step: The metadata of the step to be created. + + Returns: + str: The step ID. Generated if not specified. + """ + create_request = CreateStepRequest(step=step.to_protobuf()) + return self._get_data_store_client().create_step(create_request).id + + def add_condition(self, step_id: str, condition: Condition) -> Step: + """Add a condition to a step, replacing any condition with the same name. + + Args: + step_id: The ID of the step that owns the condition. + + condition: The condition to add. + + Returns: + Step: The updated step. + """ + add_request = AddConditionRequest(step_id=step_id, condition=condition.to_protobuf()) + return Step.from_protobuf(self._get_data_store_client().add_condition(add_request).step) + + def create_measurement( + self, + name: str, + value: object, + step_id: str, + *, + units: str = "", + notes: str = "", + start_date_time: ht.datetime | None = None, + end_date_time: ht.datetime | None = None, + duration: ht.timedelta | None = None, + outcome: Outcome = Outcome.UNSPECIFIED, + error_information: ErrorInformation | None = None, + software_item_ids: Iterable[str] = (), + hardware_item_ids: Iterable[str] = (), + test_adapter_ids: Iterable[str] = (), + ) -> str: + """Create a measurement associated with a test step. + + Args: + name: The name used for grouping conceptually alike measurements. + + value: The measured value. Supported types are bool, int, float, str, + Scalar, Vector, iterables of scalar values, AnalogWaveform, + ComplexWaveform, Spectrum, DigitalWaveform, XYData, and JSON + mappings. + + step_id: The ID of the step that owns the measurement. + + units: Engineering units of the value. + + notes: Any notes to associate with the measurement. + + start_date_time: The start time of the measurement. + + end_date_time: The end time of the measurement. + + duration: The duration of the measurement. + + outcome: The outcome of the measurement. + + error_information: Error details for the measurement. + + software_item_ids: The software item IDs for the measurement. These + must already be linked to the test result. + + hardware_item_ids: The hardware item IDs for the measurement. These + must already be linked to the test result. + + test_adapter_ids: The test adapter IDs for the measurement. These + must already be linked to the test result. + + Returns: + str: The measurement ID. + """ + measurement = Measurement( + name=name, + step_id=step_id, + units=units, + notes=notes, + start_date_time=start_date_time, + end_date_time=end_date_time, + duration=duration, + outcome=outcome, + error_information=error_information, + software_item_ids=software_item_ids, + hardware_item_ids=hardware_item_ids, + test_adapter_ids=test_adapter_ids, + ).to_protobuf() + populate_measurement_value(measurement.value, value) + create_request = CreateMeasurementRequest(measurement=measurement) + return self._get_data_store_client().create_measurement(create_request).id + + @overload + def read_measurement_value( + self, + read_source: Measurement, + expected_type: Type[TRead], + ) -> TRead: ... + + @overload + def read_measurement_value( + self, + read_source: Measurement, + ) -> object: ... + + def read_measurement_value( + self, + read_source: Measurement, + expected_type: Type[TRead] | None = None, + ) -> TRead | object: + """Read the value of a measurement. + + Args: + read_source: The measurement to read. Only its id is used. + + expected_type: Optional type to validate the returned data against. + + Returns: + The value converted to a Python or nitypes value. + + Raises: + TypeError: If expected_type is provided and the actual data type + doesn't match. + """ + read_request = ReadMeasurementValuesRequest( + measurement_id=read_source.id, read_mask=FieldMask(paths=["value"]) + ) + response = self._get_data_store_client().read_measurement_values(read_request) + read_value = convert_measurement_value_from_protobuf(response.value) + if expected_type is not None and not isinstance(read_value, expected_type): + raise TypeError(f"Expected type {expected_type}, got {type(read_value)}") + return read_value + + def get_test_result( + self, test_result_id: str, *, read_fields: Iterable[str] = () + ) -> TestResult: + """Get the test result with the given ID. + + Args: + test_result_id: The ID of the test result. + + read_fields: Optional field names to return. All fields are returned + when empty. + + Returns: + TestResult: The requested test result. + """ + get_request = GetTestResultRequest( + test_result_id=test_result_id, read_mask=_read_mask(read_fields) + ) + return TestResult.from_protobuf(self._get_data_store_client().get_test_result(get_request)) + + def get_step(self, step_id: str, *, read_fields: Iterable[str] = ()) -> Step: + """Get the step with the given ID. + + Args: + step_id: The ID of the step. + + read_fields: Optional field names to return, such as "conditions". + All default fields are returned when empty. + + Returns: + Step: The requested step. + """ + get_request = GetStepRequest(step_id=step_id, read_mask=_read_mask(read_fields)) + return Step.from_protobuf(self._get_data_store_client().get_step(get_request)) + + def get_measurement( + self, measurement_id: str, *, read_fields: Iterable[str] = () + ) -> Measurement: + """Get the measurement with the given ID. + + Args: + measurement_id: The ID of the measurement. + + read_fields: Optional field names to return, such as "conditions". + All default fields are returned when empty. + + Returns: + Measurement: The requested measurement metadata. + """ + get_request = GetMeasurementRequest( + measurement_id=measurement_id, read_mask=_read_mask(read_fields) + ) + return Measurement.from_protobuf(self._get_data_store_client().get_measurement(get_request)) + + def query_test_results( + self, odata_query: str = "", *, read_fields: Iterable[str] = () + ) -> Sequence[TestResult]: + """Query test results using OData query syntax. + + Args: + odata_query: An OData query string. An empty string returns all test + results. $select, $count, $expand, and $apply are not supported. + + read_fields: Optional field names to return for each test result. + + Returns: + Sequence[TestResult]: The matching test results. + """ + query_request = QueryTestResultsRequest( + odata_query=odata_query, read_mask=_read_mask(read_fields) + ) + query_response = self._get_data_store_client().query_test_results(query_request) + return [TestResult.from_protobuf(proto) for proto in query_response.test_results] + + def query_steps( + self, odata_query: str = "", *, read_fields: Iterable[str] = () + ) -> Sequence[Step]: + """Query steps using OData query syntax. + + Args: + odata_query: An OData query string. An empty string returns all + steps. $select, $count, $expand, and $apply are not supported. + + read_fields: Optional field names to return for each step. + + Returns: + Sequence[Step]: The matching steps. + """ + query_request = QueryStepsRequest( + odata_query=odata_query, read_mask=_read_mask(read_fields) + ) + query_response = self._get_data_store_client().query_steps(query_request) + return [Step.from_protobuf(proto) for proto in query_response.steps] + + def query_measurements( + self, odata_query: str = "", *, read_fields: Iterable[str] = () + ) -> Sequence[Measurement]: + """Query measurements using OData query syntax. + + Args: + odata_query: An OData query string. An empty string returns all + measurements. $select, $count, $expand, and $apply are not + supported. + + read_fields: Optional field names to return for each measurement. + + Returns: + Sequence[Measurement]: The matching measurements. + """ + query_request = QueryMeasurementsRequest( + odata_query=odata_query, read_mask=_read_mask(read_fields) + ) + query_response = self._get_data_store_client().query_measurements(query_request) + return [Measurement.from_protobuf(proto) for proto in query_response.measurements] + + def update_test_result(self, test_result_id: str, **fields: object) -> TestResult: + """Update mutable test result fields. + + Args: + test_result_id: The ID of the test result to update. + + **fields: The fields to change, such as link="..." or + outcome=Outcome.FAILED. A value of None clears the field. + + Returns: + TestResult: The updated test result. + + Raises: + ValueError: If no fields are given or a field cannot be updated. + """ + test_result = TestResultProto(id=test_result_id) + mask = _populate_update(test_result, fields, _TEST_RESULT_UPDATE_FIELDS) + update_request = UpdateTestResultRequest(test_result=test_result, update_mask=mask) + return TestResult.from_protobuf( + self._get_data_store_client().update_test_result(update_request) + ) + + def update_step(self, step_id: str, **fields: object) -> Step: + """Update mutable step fields. + + Args: + step_id: The ID of the step to update. + + **fields: The fields to change, such as notes="...". A value of None + clears the field. + + Returns: + Step: The updated step. + + Raises: + ValueError: If no fields are given or a field cannot be updated. + """ + step = StepProto(id=step_id) + mask = _populate_update(step, fields, _STEP_UPDATE_FIELDS) + update_request = UpdateStepRequest(step=step, update_mask=mask) + return Step.from_protobuf(self._get_data_store_client().update_step(update_request)) + + def update_measurement(self, measurement_id: str, **fields: object) -> Measurement: + """Update mutable measurement fields. + + Args: + measurement_id: The ID of the measurement to update. + + **fields: The fields to change, such as notes="...". A value of None + clears the field. + + Returns: + Measurement: The updated measurement metadata. + + Raises: + ValueError: If no fields are given or a field cannot be updated. + """ + measurement = MeasurementProto(id=measurement_id) + mask = _populate_update(measurement, fields, _MEASUREMENT_UPDATE_FIELDS) + update_request = UpdateMeasurementRequest(measurement=measurement, update_mask=mask) + return Measurement.from_protobuf( + self._get_data_store_client().update_measurement(update_request) + ) + + def _get_data_store_client(self) -> DataStoreServiceClient: + if self._closed: + raise RuntimeError(self._DATA_STORE_CLIENT_CLOSED_ERROR) + return self._data_store_client + + +# Mutable update-mask paths from the V2 mutability design, including Stage 2 resource fields. +_TEST_RESULT_UPDATE_FIELDS = frozenset( + { + "uut_instance_id", + "uut_id", + "operator_id", + "test_station_id", + "test_description_id", + "start_date_time", + "end_date_time", + "duration", + "outcome", + "status", + "error_information", + "link", + "keywords", + "extension", + } +) +_STEP_UPDATE_FIELDS = frozenset( + { + "test_id", + "notes", + "start_date_time", + "end_date_time", + "duration", + "outcome", + "status", + "error_information", + "link", + "keywords", + "extension", + } +) +_MEASUREMENT_UPDATE_FIELDS = frozenset( + { + "notes", + "outcome", + "error_information", + "extension", + "start_date_time", + "end_date_time", + "duration", + } +) + + +def _read_mask(fields: Iterable[str]) -> FieldMask | None: + paths = list(fields) + return FieldMask(paths=paths) if paths else None + + +def _populate_update( + resource: TestResultProto | StepProto | MeasurementProto, + fields: Mapping[str, object], + allowed: frozenset[str], +) -> FieldMask: + if not fields: + raise ValueError("At least one field must be supplied for an update.") + unknown_fields = fields.keys() - allowed + if unknown_fields: + raise ValueError(f"Fields cannot be updated: {', '.join(sorted(unknown_fields))}.") + + target = cast(Any, resource) + for name, value in fields.items(): + if value is None: + target.ClearField(name) + elif name in {"start_date_time", "end_date_time"}: + getattr(target, name).CopyFrom(hightime_datetime_to_protobuf(cast(ht.datetime, value))) + elif name == "duration": + target.duration.CopyFrom(hightime_timedelta_to_protobuf(cast(ht.timedelta, value))) + elif name == "keywords": + target.keywords[:] = cast(Iterable[str], value) + elif name == "extension": + json_format.ParseDict(dict(cast(Mapping[str, Any], value)), target.extension) + elif name == "error_information": + target.error_information.CopyFrom(cast(ErrorInformation, value).to_protobuf()) + else: + setattr(target, name, value) + + return FieldMask(paths=list(fields)) diff --git a/src/ni/datastore/data/v2/_grpc_conversion.py b/src/ni/datastore/data/v2/_grpc_conversion.py new file mode 100644 index 00000000..a5463c41 --- /dev/null +++ b/src/ni/datastore/data/v2/_grpc_conversion.py @@ -0,0 +1,196 @@ +"""Conversion helpers for V2 measurement values.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from typing import Any, cast + +import hightime as ht +import numpy as np +from google.protobuf import json_format +from google.protobuf.message import Message +from google.protobuf.struct_pb2 import Struct +from ni.measurements.data.v2.data_store_pb2 import MeasurementValue +from ni.protobuf.types.precision_duration_conversion import ( + hightime_timedelta_from_protobuf, + hightime_timedelta_to_protobuf, +) +from ni.protobuf.types.precision_duration_pb2 import PrecisionDuration +from ni.protobuf.types.precision_timestamp_conversion import ( + hightime_datetime_from_protobuf, + hightime_datetime_to_protobuf, +) +from ni.protobuf.types.precision_timestamp_pb2 import PrecisionTimestamp +from ni.protobuf.types.scalar_conversion import scalar_from_protobuf, scalar_to_protobuf +from ni.protobuf.types.vector_conversion import vector_from_protobuf, vector_to_protobuf +from ni.protobuf.types.waveform_conversion import ( + digital_waveform_from_protobuf, + digital_waveform_to_protobuf, + float32_analog_waveform_from_protobuf, + float32_analog_waveform_to_protobuf, + float32_complex_waveform_from_protobuf, + float32_complex_waveform_to_protobuf, + float32_spectrum_from_protobuf, + float32_spectrum_to_protobuf, + float64_analog_waveform_from_protobuf, + float64_analog_waveform_to_protobuf, + float64_complex_waveform_from_protobuf, + float64_complex_waveform_to_protobuf, + float64_spectrum_from_protobuf, + float64_spectrum_to_protobuf, + int16_analog_waveform_from_protobuf, + int16_analog_waveform_to_protobuf, + int16_complex_waveform_from_protobuf, + int16_complex_waveform_to_protobuf, +) +from ni.protobuf.types.xydata_conversion import ( + float64_xydata_from_protobuf, + float64_xydata_to_protobuf, +) +from nitypes.complex import ComplexInt32DType +from nitypes.scalar import Scalar +from nitypes.vector import Vector +from nitypes.waveform import AnalogWaveform, ComplexWaveform, DigitalWaveform, Spectrum +from nitypes.xy_data import XYData + + +def populate_measurement_value(measurement_value: MeasurementValue, value: object) -> None: + """Convert a Python scalar or vector into a V2 measurement value.""" + if isinstance(value, bool): + measurement_value.scalar.bool_value = value + elif isinstance(value, int): + measurement_value.scalar.sint32_value = value + elif isinstance(value, float): + measurement_value.scalar.double_value = value + elif isinstance(value, str): + measurement_value.scalar.string_value = value + elif isinstance(value, Scalar): + measurement_value.scalar.CopyFrom(scalar_to_protobuf(value)) + elif isinstance(value, Vector): + measurement_value.vector.CopyFrom(vector_to_protobuf(value)) + elif isinstance(value, AnalogWaveform): + if value.dtype == np.float64: + measurement_value.double_analog_waveform.CopyFrom( + float64_analog_waveform_to_protobuf(value) + ) + elif value.dtype == np.float32: + measurement_value.float_analog_waveform.CopyFrom( + float32_analog_waveform_to_protobuf(value) + ) + elif value.dtype == np.int16: + measurement_value.i16_analog_waveform.CopyFrom(int16_analog_waveform_to_protobuf(value)) + else: + raise TypeError(f"Unsupported AnalogWaveform dtype: {value.dtype}") + elif isinstance(value, ComplexWaveform): + if value.dtype == np.complex128: + measurement_value.double_complex_waveform.CopyFrom( + float64_complex_waveform_to_protobuf(value) + ) + elif value.dtype == np.complex64: + measurement_value.float_complex_waveform.CopyFrom( + float32_complex_waveform_to_protobuf(value) + ) + elif value.dtype == ComplexInt32DType: + measurement_value.i16_complex_waveform.CopyFrom( + int16_complex_waveform_to_protobuf(value) + ) + else: + raise TypeError(f"Unsupported ComplexWaveform dtype: {value.dtype}") + elif isinstance(value, Spectrum): + if value.dtype == np.float64: + measurement_value.double_spectrum.CopyFrom(float64_spectrum_to_protobuf(value)) + elif value.dtype == np.float32: + measurement_value.float_spectrum.CopyFrom(float32_spectrum_to_protobuf(value)) + else: + raise TypeError(f"Unsupported Spectrum dtype: {value.dtype}") + elif isinstance(value, DigitalWaveform): + measurement_value.digital_waveform.CopyFrom(digital_waveform_to_protobuf(value)) + elif isinstance(value, XYData): + if value.dtype != np.float64: + raise TypeError(f"Unsupported XYData dtype: {value.dtype}") + measurement_value.x_y_data.CopyFrom(float64_xydata_to_protobuf(value)) + elif isinstance(value, Mapping): + json_format.ParseDict(cast(dict[str, Any], dict(value)), measurement_value.json_value.value) + elif isinstance(value, Iterable): + try: + vector = Vector(cast(Iterable[bool | int | float | str], value)) + except (TypeError, ValueError) as exc: + raise TypeError( + "Iterable measurement values must contain only bool, int, float, or str values." + ) from exc + measurement_value.vector.CopyFrom(vector_to_protobuf(vector)) + else: + raise TypeError( + f"Unsupported measurement value type: {type(value)}. Please consult the documentation." + ) + + +def convert_measurement_value_from_protobuf(measurement_value: MeasurementValue) -> object: + """Convert a V2 measurement value into a useful Python value.""" + value_type = measurement_value.WhichOneof("value") + if value_type == "scalar": + scalar = scalar_from_protobuf(measurement_value.scalar) + return scalar if scalar.units else scalar.value + if value_type == "vector": + return vector_from_protobuf(measurement_value.vector) + if value_type == "double_analog_waveform": + return float64_analog_waveform_from_protobuf(measurement_value.double_analog_waveform) + if value_type == "float_analog_waveform": + return float32_analog_waveform_from_protobuf(measurement_value.float_analog_waveform) + if value_type == "i16_analog_waveform": + return int16_analog_waveform_from_protobuf(measurement_value.i16_analog_waveform) + if value_type == "double_complex_waveform": + return float64_complex_waveform_from_protobuf(measurement_value.double_complex_waveform) + if value_type == "float_complex_waveform": + return float32_complex_waveform_from_protobuf(measurement_value.float_complex_waveform) + if value_type == "i16_complex_waveform": + return int16_complex_waveform_from_protobuf(measurement_value.i16_complex_waveform) + if value_type == "double_spectrum": + return float64_spectrum_from_protobuf(measurement_value.double_spectrum) + if value_type == "float_spectrum": + return float32_spectrum_from_protobuf(measurement_value.float_spectrum) + if value_type == "digital_waveform": + return digital_waveform_from_protobuf(measurement_value.digital_waveform) + if value_type == "x_y_data": + return float64_xydata_from_protobuf(measurement_value.x_y_data) + if value_type == "json_value": + return json_format.MessageToDict(measurement_value.json_value.value) + raise TypeError(f"Unsupported or missing measurement value type: {value_type}.") + + +def timestamp_to_protobuf(value: ht.datetime | None) -> PrecisionTimestamp | None: + """Convert an optional hightime datetime to a PrecisionTimestamp.""" + return hightime_datetime_to_protobuf(value) if value is not None else None + + +def timestamp_from_protobuf(message: Message, field: str) -> ht.datetime | None: + """Convert an optional PrecisionTimestamp field to a hightime datetime.""" + if not message.HasField(field): + return None + return hightime_datetime_from_protobuf(getattr(message, field)) + + +def duration_to_protobuf(value: ht.timedelta | None) -> PrecisionDuration | None: + """Convert an optional hightime timedelta to a PrecisionDuration.""" + return hightime_timedelta_to_protobuf(value) if value is not None else None + + +def duration_from_protobuf(message: Message, field: str) -> ht.timedelta | None: + """Convert an optional PrecisionDuration field to a hightime timedelta.""" + if not message.HasField(field): + return None + return hightime_timedelta_from_protobuf(getattr(message, field)) + + +def extension_to_protobuf(value: Mapping[str, Any]) -> Struct | None: + """Convert an extension mapping to a protobuf Struct, or None when empty.""" + if not value: + return None + struct = Struct() + json_format.ParseDict(dict(value), struct) + return struct + + +def extension_from_protobuf(value: Struct) -> dict[str, Any]: + """Convert a protobuf Struct extension to a Python dictionary.""" + return json_format.MessageToDict(value) diff --git a/src/ni/datastore/data/v2/_types/__init__.py b/src/ni/datastore/data/v2/_types/__init__.py new file mode 100644 index 00000000..78643dc3 --- /dev/null +++ b/src/ni/datastore/data/v2/_types/__init__.py @@ -0,0 +1 @@ +"""Types for use in accessing the NI Data Store V2.""" diff --git a/src/ni/datastore/data/v2/_types/_condition.py b/src/ni/datastore/data/v2/_types/_condition.py new file mode 100644 index 00000000..916781be --- /dev/null +++ b/src/ni/datastore/data/v2/_types/_condition.py @@ -0,0 +1,117 @@ +"""Condition data type for the V2 Data Store Client.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +from google.protobuf import json_format +from ni.measurements.data.v2.data_store_pb2 import Condition as ConditionProto +from ni.protobuf.types.scalar_conversion import scalar_from_protobuf, scalar_to_protobuf +from nitypes.scalar import Scalar + + +class Condition: + """An operational condition, such as a temperature or supply voltage, owned by a step. + + Conditions are immutable. Adding a condition with the same name to a step + replaces the step's association with a new condition value. + """ + + __slots__ = ( + "name", + "value", + "condition_type", + "value_label", + "units", + ) + + def __init__( + self, + name: str, + value: object, + *, + condition_type: str = "", + value_label: str = "", + units: str = "", + ) -> None: + """Initialize a Condition instance. + + Args: + name: The condition name, unique within a step. + value: A bool, int, float, str, or Scalar value, or a JSON mapping. + condition_type (optional): The type of the condition. For example, + "Environment" or "Setup". + value_label (optional): A label for the value. + units (optional): The engineering units of the value. Defaults to the + units of a Scalar value. + """ + self.name = name + self.value = value + self.condition_type = condition_type + self.value_label = value_label + self.units = units + + @staticmethod + def from_protobuf(condition_proto: ConditionProto) -> "Condition": + """Create a Condition instance from a protobuf Condition message.""" + value_kind = condition_proto.value.WhichOneof("value") + value: object = None + if value_kind == "scalar": + value = scalar_from_protobuf(condition_proto.value.scalar).value + elif value_kind == "json_value": + value = json_format.MessageToDict(condition_proto.value.json_value.value) + return Condition( + name=condition_proto.name, + value=value, + condition_type=condition_proto.condition_type, + value_label=condition_proto.value_label, + units=condition_proto.units, + ) + + def to_protobuf(self) -> ConditionProto: + """Convert this Condition instance to a protobuf Condition message.""" + condition_proto = ConditionProto( + name=self.name, + condition_type=self.condition_type, + value_label=self.value_label, + units=self.units, + ) + value = self.value + if isinstance(value, bool): + condition_proto.value.scalar.bool_value = value + elif isinstance(value, int): + condition_proto.value.scalar.sint32_value = value + elif isinstance(value, float): + condition_proto.value.scalar.double_value = value + elif isinstance(value, str): + condition_proto.value.scalar.string_value = value + elif isinstance(value, Scalar): + condition_proto.value.scalar.CopyFrom(scalar_to_protobuf(value)) + if not self.units: + condition_proto.units = value.units + elif isinstance(value, Mapping): + json_format.ParseDict( + cast(dict[str, Any], dict(value)), condition_proto.value.json_value.value + ) + else: + raise TypeError( + "Condition values must be bool, int, float, str, Scalar, or a JSON mapping." + ) + return condition_proto + + def __eq__(self, other: object) -> bool: + """Determine equality.""" + if not isinstance(other, Condition): + return NotImplemented + return ( + self.name == other.name + and self.value == other.value + and self.condition_type == other.condition_type + and self.value_label == other.value_label + and self.units == other.units + ) + + def __str__(self) -> str: + """Return a string representation of the Condition.""" + return str(self.to_protobuf()) diff --git a/src/ni/datastore/data/v2/_types/_error_information.py b/src/ni/datastore/data/v2/_types/_error_information.py new file mode 100644 index 00000000..a7be5442 --- /dev/null +++ b/src/ni/datastore/data/v2/_types/_error_information.py @@ -0,0 +1,66 @@ +"""Error Information data type for the V2 Data Store Client.""" + +from __future__ import annotations + +from ni.measurements.data.v2.data_store_pb2 import ( + ErrorInformation as ErrorInformationProto, +) + + +class ErrorInformation: + """Represents error details reported for a test result, step, or measurement.""" + + __slots__ = ( + "error_code", + "message", + "source", + ) + + def __init__( + self, + *, + error_code: int = 0, + message: str = "", + source: str = "", + ) -> None: + """Initialize an ErrorInformation instance. + + Args: + error_code: The numeric error code associated with the error. + message: A descriptive message explaining the error. + source: The source or location where the error occurred. + """ + self.error_code = error_code + self.message = message + self.source = source + + @staticmethod + def from_protobuf(error_information_proto: ErrorInformationProto) -> "ErrorInformation": + """Create an ErrorInformation instance from a protobuf ErrorInformation message.""" + return ErrorInformation( + error_code=error_information_proto.error_code, + message=error_information_proto.message, + source=error_information_proto.source, + ) + + def to_protobuf(self) -> ErrorInformationProto: + """Convert this ErrorInformation instance to a protobuf ErrorInformation message.""" + return ErrorInformationProto( + error_code=self.error_code, + message=self.message, + source=self.source, + ) + + def __eq__(self, other: object) -> bool: + """Determine equality.""" + if not isinstance(other, ErrorInformation): + return NotImplemented + return ( + self.error_code == other.error_code + and self.message == other.message + and self.source == other.source + ) + + def __str__(self) -> str: + """Return a string representation of the ErrorInformation.""" + return str(self.to_protobuf()) diff --git a/src/ni/datastore/data/v2/_types/_measurement.py b/src/ni/datastore/data/v2/_types/_measurement.py new file mode 100644 index 00000000..9aa2a455 --- /dev/null +++ b/src/ni/datastore/data/v2/_types/_measurement.py @@ -0,0 +1,253 @@ +"""Measurement data type for the V2 Data Store Client.""" + +from __future__ import annotations + +from typing import Any, Iterable, Mapping, MutableMapping, MutableSequence + +import hightime as ht +from ni.measurements.data.v2.data_store_pb2 import Measurement as MeasurementProto + +from ni.datastore.data.v2._grpc_conversion import ( + duration_from_protobuf, + duration_to_protobuf, + extension_from_protobuf, + extension_to_protobuf, + timestamp_from_protobuf, + timestamp_to_protobuf, +) +from ni.datastore.data.v2._types._condition import Condition +from ni.datastore.data.v2._types._error_information import ErrorInformation +from ni.datastore.data.v2._types._outcome import Outcome + + +class Measurement: + """Metadata for a measurement stored in the data store. + + The measured value is not part of this type. Use + DataStoreClient.read_measurement_value to read it. + """ + + __slots__ = ( + "id", + "test_result_id", + "step_id", + "name", + "value_type", + "units", + "notes", + "start_date_time", + "end_date_time", + "duration", + "outcome", + "error_information", + "_extension", + "_software_item_ids", + "_hardware_item_ids", + "_test_adapter_ids", + "comparison_type", + "_conditions", + "created_at", + "updated_at", + "created_by", + "updated_by", + ) + + @property + def extension(self) -> MutableMapping[str, Any]: + """Custom JSON data associated with the measurement.""" + return self._extension + + @property + def software_item_ids(self) -> MutableSequence[str]: + """The software item IDs associated with the measurement.""" + return self._software_item_ids + + @property + def hardware_item_ids(self) -> MutableSequence[str]: + """The hardware item IDs associated with the measurement.""" + return self._hardware_item_ids + + @property + def test_adapter_ids(self) -> MutableSequence[str]: + """The test adapter IDs associated with the measurement.""" + return self._test_adapter_ids + + @property + def conditions(self) -> MutableSequence[Condition]: + """Output only. The conditions copied from the owning step.""" + return self._conditions + + def __init__( + self, + *, + id: str = "", + test_result_id: str = "", + step_id: str = "", + name: str = "", + value_type: str = "", + units: str = "", + notes: str = "", + start_date_time: ht.datetime | None = None, + end_date_time: ht.datetime | None = None, + duration: ht.timedelta | None = None, + outcome: Outcome = Outcome.UNSPECIFIED, + error_information: ErrorInformation | None = None, + extension: Mapping[str, Any] | None = None, + software_item_ids: Iterable[str] | None = None, + hardware_item_ids: Iterable[str] | None = None, + test_adapter_ids: Iterable[str] | None = None, + comparison_type: str = "", + conditions: Iterable[Condition] | None = None, + created_at: ht.datetime | None = None, + updated_at: ht.datetime | None = None, + created_by: str = "", + updated_by: str = "", + ) -> None: + """Initialize a Measurement instance. + + Args: + id (optional): Unique identifier of the measurement. + test_result_id (optional): ID of the test result the measurement belongs to. + step_id (optional): ID of the step the measurement belongs to. + name (optional): The name of the measurement. + value_type (optional): Output only. The service-generated value type. + units (optional): Engineering units of the value. + notes (optional): Additional notes about the measurement. + start_date_time (optional): The start date and time of the measurement. + end_date_time (optional): The end date and time of the measurement. + duration (optional): The duration of the measurement. + outcome (optional): The outcome of the measurement. + error_information (optional): Error details for the measurement. + extension (optional): Custom JSON data for the measurement. + software_item_ids (optional): IDs of software items used for the measurement. + hardware_item_ids (optional): IDs of hardware items used for the measurement. + test_adapter_ids (optional): IDs of test adapters used for the measurement. + comparison_type (optional): The publisher-defined comparison operation. + conditions (optional): Output only. Conditions copied from the owning step. + created_at (optional): Output only. When the service created the measurement. + updated_at (optional): Output only. When the service last updated the measurement. + created_by (optional): Output only. Who created the measurement. + updated_by (optional): Output only. Who last updated the measurement. + """ + self.id = id + self.test_result_id = test_result_id + self.step_id = step_id + self.name = name + self.value_type = value_type + self.units = units + self.notes = notes + self.start_date_time = start_date_time + self.end_date_time = end_date_time + self.duration = duration + self.outcome = outcome + self.error_information = error_information + self._extension: MutableMapping[str, Any] = dict(extension) if extension is not None else {} + self._software_item_ids: MutableSequence[str] = ( + list(software_item_ids) if software_item_ids is not None else [] + ) + self._hardware_item_ids: MutableSequence[str] = ( + list(hardware_item_ids) if hardware_item_ids is not None else [] + ) + self._test_adapter_ids: MutableSequence[str] = ( + list(test_adapter_ids) if test_adapter_ids is not None else [] + ) + self.comparison_type = comparison_type + self._conditions: MutableSequence[Condition] = ( + list(conditions) if conditions is not None else [] + ) + self.created_at = created_at + self.updated_at = updated_at + self.created_by = created_by + self.updated_by = updated_by + + @staticmethod + def from_protobuf(measurement_proto: MeasurementProto) -> "Measurement": + """Create a Measurement instance from a protobuf Measurement message.""" + return Measurement( + id=measurement_proto.id, + test_result_id=measurement_proto.test_result_id, + step_id=measurement_proto.step_id, + name=measurement_proto.name, + value_type=measurement_proto.value_type, + units=measurement_proto.units, + notes=measurement_proto.notes, + start_date_time=timestamp_from_protobuf(measurement_proto, "start_date_time"), + end_date_time=timestamp_from_protobuf(measurement_proto, "end_date_time"), + duration=duration_from_protobuf(measurement_proto, "duration"), + outcome=Outcome.from_protobuf(measurement_proto.outcome), + error_information=( + ErrorInformation.from_protobuf(measurement_proto.error_information) + if measurement_proto.HasField("error_information") + else None + ), + extension=extension_from_protobuf(measurement_proto.extension), + software_item_ids=measurement_proto.software_item_ids, + hardware_item_ids=measurement_proto.hardware_item_ids, + test_adapter_ids=measurement_proto.test_adapter_ids, + comparison_type=measurement_proto.comparison_type, + conditions=[Condition.from_protobuf(c) for c in measurement_proto.conditions], + created_at=timestamp_from_protobuf(measurement_proto, "created_at"), + updated_at=timestamp_from_protobuf(measurement_proto, "updated_at"), + created_by=measurement_proto.created_by, + updated_by=measurement_proto.updated_by, + ) + + def to_protobuf(self) -> MeasurementProto: + """Convert this Measurement to a protobuf Measurement message. + + The value and output-only fields, such as value_type and conditions, are + not included. + """ + return MeasurementProto( + id=self.id, + test_result_id=self.test_result_id, + step_id=self.step_id, + name=self.name, + units=self.units, + notes=self.notes, + start_date_time=timestamp_to_protobuf(self.start_date_time), + end_date_time=timestamp_to_protobuf(self.end_date_time), + duration=duration_to_protobuf(self.duration), + outcome=self.outcome.to_protobuf(), + error_information=( + self.error_information.to_protobuf() if self.error_information is not None else None + ), + extension=extension_to_protobuf(self.extension), + software_item_ids=self.software_item_ids, + hardware_item_ids=self.hardware_item_ids, + test_adapter_ids=self.test_adapter_ids, + comparison_type=self.comparison_type, + ) + + def __eq__(self, other: object) -> bool: + """Determine equality.""" + if not isinstance(other, Measurement): + return NotImplemented + return ( + self.id == other.id + and self.test_result_id == other.test_result_id + and self.step_id == other.step_id + and self.name == other.name + and self.value_type == other.value_type + and self.units == other.units + and self.notes == other.notes + and self.start_date_time == other.start_date_time + and self.end_date_time == other.end_date_time + and self.duration == other.duration + and self.outcome == other.outcome + and self.error_information == other.error_information + and self.extension == other.extension + and self.software_item_ids == other.software_item_ids + and self.hardware_item_ids == other.hardware_item_ids + and self.test_adapter_ids == other.test_adapter_ids + and self.comparison_type == other.comparison_type + and self.conditions == other.conditions + and self.created_at == other.created_at + and self.updated_at == other.updated_at + and self.created_by == other.created_by + and self.updated_by == other.updated_by + ) + + def __str__(self) -> str: + """Return a string representation of the Measurement.""" + return str(self.to_protobuf()) diff --git a/src/ni/datastore/data/v2/_types/_outcome.py b/src/ni/datastore/data/v2/_types/_outcome.py new file mode 100644 index 00000000..e3187c37 --- /dev/null +++ b/src/ni/datastore/data/v2/_types/_outcome.py @@ -0,0 +1,47 @@ +"""Outcome data type for the V2 Data Store Client.""" + +from __future__ import annotations + +from enum import IntEnum + +from ni.measurements.data.v2.data_store_pb2 import Outcome as OutcomeProto + + +class Outcome(IntEnum): + """Represents the terminal result of a test result, step, or measurement.""" + + UNSPECIFIED = OutcomeProto.OUTCOME_UNSPECIFIED + """The outcome is not specified.""" + + PASSED = OutcomeProto.OUTCOME_PASSED + """The operation passed.""" + + FAILED = OutcomeProto.OUTCOME_FAILED + """The operation failed.""" + + SKIPPED = OutcomeProto.OUTCOME_SKIPPED + """The operation was skipped.""" + + TERMINATED = OutcomeProto.OUTCOME_TERMINATED + """The operation was terminated.""" + + ERRORED = OutcomeProto.OUTCOME_ERRORED + """The operation reported an error.""" + + TIMED_OUT = OutcomeProto.OUTCOME_TIMED_OUT + """The operation timed out.""" + + USER_DEFINED = OutcomeProto.OUTCOME_USER_DEFINED + """The outcome is defined by the user.""" + + @classmethod + def from_protobuf(cls, outcome_proto: OutcomeProto.ValueType) -> "Outcome": + """Create an Outcome from a protobuf Outcome value.""" + try: + return cls(outcome_proto) + except ValueError as e: + raise ValueError(f"Unknown outcome value: {outcome_proto}") from e + + def to_protobuf(self) -> OutcomeProto.ValueType: + """Convert this Outcome to a protobuf Outcome value.""" + return OutcomeProto.ValueType(self.value) diff --git a/src/ni/datastore/data/v2/_types/_status.py b/src/ni/datastore/data/v2/_types/_status.py new file mode 100644 index 00000000..b0a4b276 --- /dev/null +++ b/src/ni/datastore/data/v2/_types/_status.py @@ -0,0 +1,41 @@ +"""Status data type for the V2 Data Store Client.""" + +from __future__ import annotations + +from enum import IntEnum + +from ni.measurements.data.v2.data_store_pb2 import Status as StatusProto + + +class Status(IntEnum): + """Represents lifecycle progress, independent of the terminal Outcome.""" + + UNSPECIFIED = StatusProto.STATUS_UNSPECIFIED + """The status is not specified.""" + + NOT_STARTED = StatusProto.STATUS_NOT_STARTED + """The operation has not started.""" + + LOOPING = StatusProto.STATUS_LOOPING + """The operation is looping.""" + + RUNNING = StatusProto.STATUS_RUNNING + """The operation is running.""" + + WAITING = StatusProto.STATUS_WAITING + """The operation is waiting.""" + + DONE = StatusProto.STATUS_DONE + """The operation is done.""" + + @classmethod + def from_protobuf(cls, status_proto: StatusProto.ValueType) -> "Status": + """Create a Status from a protobuf Status value.""" + try: + return cls(status_proto) + except ValueError as e: + raise ValueError(f"Unknown status value: {status_proto}") from e + + def to_protobuf(self) -> StatusProto.ValueType: + """Convert this Status to a protobuf Status value.""" + return StatusProto.ValueType(self.value) diff --git a/src/ni/datastore/data/v2/_types/_step.py b/src/ni/datastore/data/v2/_types/_step.py new file mode 100644 index 00000000..45f3e836 --- /dev/null +++ b/src/ni/datastore/data/v2/_types/_step.py @@ -0,0 +1,254 @@ +"""Step data type for the V2 Data Store Client.""" + +from __future__ import annotations + +from typing import Any, Iterable, Mapping, MutableMapping, MutableSequence + +import hightime as ht +from ni.measurements.data.v2.data_store_pb2 import Step as StepProto + +from ni.datastore.data.v2._grpc_conversion import ( + duration_from_protobuf, + duration_to_protobuf, + extension_from_protobuf, + extension_to_protobuf, + timestamp_from_protobuf, + timestamp_to_protobuf, +) +from ni.datastore.data.v2._types._condition import Condition +from ni.datastore.data.v2._types._error_information import ErrorInformation +from ni.datastore.data.v2._types._outcome import Outcome +from ni.datastore.data.v2._types._status import Status + + +class Step: + """Information about one execution of a test step. + + A step belongs to a test result and owns measurements and conditions. Steps + can be nested by using parent_step_id. + """ + + __slots__ = ( + "name", + "id", + "parent_step_id", + "test_result_id", + "test_id", + "step_type", + "notes", + "start_date_time", + "end_date_time", + "duration", + "outcome", + "status", + "link", + "_keywords", + "_extension", + "error_information", + "_conditions", + "_path", + "_path_ids", + "created_at", + "updated_at", + "created_by", + "updated_by", + ) + + @property + def keywords(self) -> MutableSequence[str]: + """Labels used to classify the step.""" + return self._keywords + + @property + def extension(self) -> MutableMapping[str, Any]: + """Custom JSON data associated with the step.""" + return self._extension + + @property + def conditions(self) -> MutableSequence[Condition]: + """The conditions owned by the step.""" + return self._conditions + + @property + def path(self) -> MutableSequence[str]: + """Output only. The step-name path, including this step.""" + return self._path + + @property + def path_ids(self) -> MutableSequence[str]: + """Output only. The step-identifier path, including this step.""" + return self._path_ids + + def __init__( + self, + name: str, + *, + id: str = "", + parent_step_id: str = "", + test_result_id: str = "", + test_id: str = "", + step_type: str = "", + notes: str = "", + start_date_time: ht.datetime | None = None, + end_date_time: ht.datetime | None = None, + duration: ht.timedelta | None = None, + outcome: Outcome = Outcome.UNSPECIFIED, + status: Status = Status.UNSPECIFIED, + link: str = "", + keywords: Iterable[str] | None = None, + extension: Mapping[str, Any] | None = None, + error_information: ErrorInformation | None = None, + conditions: Iterable[Condition] | None = None, + path: Iterable[str] | None = None, + path_ids: Iterable[str] | None = None, + created_at: ht.datetime | None = None, + updated_at: ht.datetime | None = None, + created_by: str = "", + updated_by: str = "", + ) -> None: + """Initialize a Step instance. + + Args: + name: Human-readable name of the step. + id (optional): Unique identifier for the step. + parent_step_id (optional): ID of the parent step if this is a nested step. + test_result_id (optional): ID of the test result this step belongs to. + test_id (optional): ID or alias of the test associated with this step. + step_type (optional): Free-form classification of the step. + notes (optional): Additional notes about the step. + start_date_time (optional): The start date and time of the step execution. + end_date_time (optional): The end date and time of the step execution. + duration (optional): The duration of the step execution. + outcome (optional): The terminal outcome of the step. + status (optional): The lifecycle status of the step. + link (optional): A URI associated with the step. + keywords (optional): Labels used to classify the step. + extension (optional): Custom JSON data for the step. + error_information (optional): Error details for the step. + conditions (optional): Conditions created with the step. + path (optional): Output only. The step-name path, including this step. + path_ids (optional): Output only. The step-identifier path, including this step. + created_at (optional): Output only. When the service created the step. + updated_at (optional): Output only. When the service last updated the step. + created_by (optional): Output only. Who created the step. + updated_by (optional): Output only. Who last updated the step. + """ + self.name = name + self.id = id + self.parent_step_id = parent_step_id + self.test_result_id = test_result_id + self.test_id = test_id + self.step_type = step_type + self.notes = notes + self.start_date_time = start_date_time + self.end_date_time = end_date_time + self.duration = duration + self.outcome = outcome + self.status = status + self.link = link + self._keywords: MutableSequence[str] = list(keywords) if keywords is not None else [] + self._extension: MutableMapping[str, Any] = dict(extension) if extension is not None else {} + self.error_information = error_information + self._conditions: MutableSequence[Condition] = ( + list(conditions) if conditions is not None else [] + ) + self._path: MutableSequence[str] = list(path) if path is not None else [] + self._path_ids: MutableSequence[str] = list(path_ids) if path_ids is not None else [] + self.created_at = created_at + self.updated_at = updated_at + self.created_by = created_by + self.updated_by = updated_by + + @staticmethod + def from_protobuf(step_proto: StepProto) -> "Step": + """Create a Step instance from a protobuf Step message.""" + return Step( + name=step_proto.name, + id=step_proto.id, + parent_step_id=step_proto.parent_step_id, + test_result_id=step_proto.test_result_id, + test_id=step_proto.test_id, + step_type=step_proto.step_type, + notes=step_proto.notes, + start_date_time=timestamp_from_protobuf(step_proto, "start_date_time"), + end_date_time=timestamp_from_protobuf(step_proto, "end_date_time"), + duration=duration_from_protobuf(step_proto, "duration"), + outcome=Outcome.from_protobuf(step_proto.outcome), + status=Status.from_protobuf(step_proto.status), + link=step_proto.link, + keywords=step_proto.keywords, + extension=extension_from_protobuf(step_proto.extension), + error_information=( + ErrorInformation.from_protobuf(step_proto.error_information) + if step_proto.HasField("error_information") + else None + ), + conditions=[Condition.from_protobuf(c) for c in step_proto.conditions], + path=step_proto.path, + path_ids=step_proto.path_ids, + created_at=timestamp_from_protobuf(step_proto, "created_at"), + updated_at=timestamp_from_protobuf(step_proto, "updated_at"), + created_by=step_proto.created_by, + updated_by=step_proto.updated_by, + ) + + def to_protobuf(self) -> StepProto: + """Convert this Step to a protobuf Step message. + + Output-only fields, such as path and created_at, are not included. + """ + return StepProto( + name=self.name, + id=self.id, + parent_step_id=self.parent_step_id, + test_result_id=self.test_result_id, + test_id=self.test_id, + step_type=self.step_type, + notes=self.notes, + start_date_time=timestamp_to_protobuf(self.start_date_time), + end_date_time=timestamp_to_protobuf(self.end_date_time), + duration=duration_to_protobuf(self.duration), + outcome=self.outcome.to_protobuf(), + status=self.status.to_protobuf(), + link=self.link, + keywords=self.keywords, + extension=extension_to_protobuf(self.extension), + error_information=( + self.error_information.to_protobuf() if self.error_information is not None else None + ), + conditions=[condition.to_protobuf() for condition in self.conditions], + ) + + def __eq__(self, other: object) -> bool: + """Determine equality.""" + if not isinstance(other, Step): + return NotImplemented + return ( + self.name == other.name + and self.id == other.id + and self.parent_step_id == other.parent_step_id + and self.test_result_id == other.test_result_id + and self.test_id == other.test_id + and self.step_type == other.step_type + and self.notes == other.notes + and self.start_date_time == other.start_date_time + and self.end_date_time == other.end_date_time + and self.duration == other.duration + and self.outcome == other.outcome + and self.status == other.status + and self.link == other.link + and self.keywords == other.keywords + and self.extension == other.extension + and self.error_information == other.error_information + and self.conditions == other.conditions + and self.path == other.path + and self.path_ids == other.path_ids + and self.created_at == other.created_at + and self.updated_at == other.updated_at + and self.created_by == other.created_by + and self.updated_by == other.updated_by + ) + + def __str__(self) -> str: + """Return a string representation of the Step.""" + return str(self.to_protobuf()) diff --git a/src/ni/datastore/data/v2/_types/_test_result.py b/src/ni/datastore/data/v2/_types/_test_result.py new file mode 100644 index 00000000..4c7fbb25 --- /dev/null +++ b/src/ni/datastore/data/v2/_types/_test_result.py @@ -0,0 +1,260 @@ +"""Test Result data type for the V2 Data Store Client.""" + +from __future__ import annotations + +from typing import Any, Iterable, Mapping, MutableMapping, MutableSequence + +import hightime as ht +from ni.measurements.data.v2.data_store_pb2 import TestResult as TestResultProto + +from ni.datastore.data.v2._grpc_conversion import ( + duration_from_protobuf, + duration_to_protobuf, + extension_from_protobuf, + extension_to_protobuf, + timestamp_from_protobuf, + timestamp_to_protobuf, +) +from ni.datastore.data.v2._types._error_information import ErrorInformation +from ni.datastore.data.v2._types._outcome import Outcome +from ni.datastore.data.v2._types._status import Status + + +class TestResult: + """Information about one execution of a test process. + + A test result records the UUT, operator, test station, and other metadata + for a test run, along with its timing, status, and outcome. Steps, and the + measurements and conditions they own, belong to a test result. + """ + + __slots__ = ( + "name", + "id", + "uut_instance_id", + "uut_id", + "operator_id", + "test_station_id", + "test_description_id", + "_software_item_ids", + "_hardware_item_ids", + "_test_adapter_ids", + "start_date_time", + "end_date_time", + "duration", + "outcome", + "status", + "link", + "_keywords", + "_extension", + "error_information", + "created_at", + "updated_at", + "created_by", + "updated_by", + ) + + @property + def software_item_ids(self) -> MutableSequence[str]: + """The software item IDs associated with the test result.""" + return self._software_item_ids + + @property + def hardware_item_ids(self) -> MutableSequence[str]: + """The hardware item IDs associated with the test result.""" + return self._hardware_item_ids + + @property + def test_adapter_ids(self) -> MutableSequence[str]: + """The test adapter IDs associated with the test result.""" + return self._test_adapter_ids + + @property + def keywords(self) -> MutableSequence[str]: + """Labels used to classify the test result.""" + return self._keywords + + @property + def extension(self) -> MutableMapping[str, Any]: + """Custom JSON data associated with the test result.""" + return self._extension + + def __init__( + self, + name: str, + *, + id: str = "", + uut_instance_id: str = "", + uut_id: str = "", + operator_id: str = "", + test_station_id: str = "", + test_description_id: str = "", + software_item_ids: Iterable[str] | None = None, + hardware_item_ids: Iterable[str] | None = None, + test_adapter_ids: Iterable[str] | None = None, + start_date_time: ht.datetime | None = None, + end_date_time: ht.datetime | None = None, + duration: ht.timedelta | None = None, + outcome: Outcome = Outcome.UNSPECIFIED, + status: Status = Status.UNSPECIFIED, + link: str = "", + keywords: Iterable[str] | None = None, + extension: Mapping[str, Any] | None = None, + error_information: ErrorInformation | None = None, + created_at: ht.datetime | None = None, + updated_at: ht.datetime | None = None, + created_by: str = "", + updated_by: str = "", + ) -> None: + """Initialize a TestResult instance. + + Args: + name: Human-readable name for the test result. + id (optional): Unique identifier for the test result. + uut_instance_id (optional): ID or alias of the UUT instance that was tested. + uut_id (optional): ID of the UUT product definition. + operator_id (optional): ID or alias of the operator who ran the test. + test_station_id (optional): ID or alias of the test station used. + test_description_id (optional): ID or alias of the test description executed. + software_item_ids (optional): IDs of software items used in the test. + hardware_item_ids (optional): IDs of hardware items used in the test. + test_adapter_ids (optional): IDs of test adapters used in the test. + start_date_time (optional): The start date and time of the test execution. + end_date_time (optional): The end date and time of the test execution. + duration (optional): The duration of the test execution. + outcome (optional): The terminal outcome of the test execution. + status (optional): The lifecycle status of the test execution. + link (optional): A URI associated with the test result. + keywords (optional): Labels used to classify the test result. + extension (optional): Custom JSON data for the test result. + error_information (optional): Error details for the test result. + created_at (optional): Output only. When the service created the test result. + updated_at (optional): Output only. When the service last updated the test result. + created_by (optional): Output only. Who created the test result. + updated_by (optional): Output only. Who last updated the test result. + """ + self.name = name + self.id = id + self.uut_instance_id = uut_instance_id + self.uut_id = uut_id + self.operator_id = operator_id + self.test_station_id = test_station_id + self.test_description_id = test_description_id + self._software_item_ids: MutableSequence[str] = ( + list(software_item_ids) if software_item_ids is not None else [] + ) + self._hardware_item_ids: MutableSequence[str] = ( + list(hardware_item_ids) if hardware_item_ids is not None else [] + ) + self._test_adapter_ids: MutableSequence[str] = ( + list(test_adapter_ids) if test_adapter_ids is not None else [] + ) + self.start_date_time = start_date_time + self.end_date_time = end_date_time + self.duration = duration + self.outcome = outcome + self.status = status + self.link = link + self._keywords: MutableSequence[str] = list(keywords) if keywords is not None else [] + self._extension: MutableMapping[str, Any] = dict(extension) if extension is not None else {} + self.error_information = error_information + self.created_at = created_at + self.updated_at = updated_at + self.created_by = created_by + self.updated_by = updated_by + + @staticmethod + def from_protobuf(test_result_proto: TestResultProto) -> "TestResult": + """Create a TestResult instance from a protobuf TestResult message.""" + return TestResult( + name=test_result_proto.name, + id=test_result_proto.id, + uut_instance_id=test_result_proto.uut_instance_id, + uut_id=test_result_proto.uut_id, + operator_id=test_result_proto.operator_id, + test_station_id=test_result_proto.test_station_id, + test_description_id=test_result_proto.test_description_id, + software_item_ids=test_result_proto.software_item_ids, + hardware_item_ids=test_result_proto.hardware_item_ids, + test_adapter_ids=test_result_proto.test_adapter_ids, + start_date_time=timestamp_from_protobuf(test_result_proto, "start_date_time"), + end_date_time=timestamp_from_protobuf(test_result_proto, "end_date_time"), + duration=duration_from_protobuf(test_result_proto, "duration"), + outcome=Outcome.from_protobuf(test_result_proto.outcome), + status=Status.from_protobuf(test_result_proto.status), + link=test_result_proto.link, + keywords=test_result_proto.keywords, + extension=extension_from_protobuf(test_result_proto.extension), + error_information=( + ErrorInformation.from_protobuf(test_result_proto.error_information) + if test_result_proto.HasField("error_information") + else None + ), + created_at=timestamp_from_protobuf(test_result_proto, "created_at"), + updated_at=timestamp_from_protobuf(test_result_proto, "updated_at"), + created_by=test_result_proto.created_by, + updated_by=test_result_proto.updated_by, + ) + + def to_protobuf(self) -> TestResultProto: + """Convert this TestResult to a protobuf TestResult message. + + Output-only fields, such as created_at, are not included. + """ + return TestResultProto( + name=self.name, + id=self.id, + uut_instance_id=self.uut_instance_id, + uut_id=self.uut_id, + operator_id=self.operator_id, + test_station_id=self.test_station_id, + test_description_id=self.test_description_id, + software_item_ids=self.software_item_ids, + hardware_item_ids=self.hardware_item_ids, + test_adapter_ids=self.test_adapter_ids, + start_date_time=timestamp_to_protobuf(self.start_date_time), + end_date_time=timestamp_to_protobuf(self.end_date_time), + duration=duration_to_protobuf(self.duration), + outcome=self.outcome.to_protobuf(), + status=self.status.to_protobuf(), + link=self.link, + keywords=self.keywords, + extension=extension_to_protobuf(self.extension), + error_information=( + self.error_information.to_protobuf() if self.error_information is not None else None + ), + ) + + def __eq__(self, other: object) -> bool: + """Determine equality.""" + if not isinstance(other, TestResult): + return NotImplemented + return ( + self.name == other.name + and self.id == other.id + and self.uut_instance_id == other.uut_instance_id + and self.uut_id == other.uut_id + and self.operator_id == other.operator_id + and self.test_station_id == other.test_station_id + and self.test_description_id == other.test_description_id + and self.software_item_ids == other.software_item_ids + and self.hardware_item_ids == other.hardware_item_ids + and self.test_adapter_ids == other.test_adapter_ids + and self.start_date_time == other.start_date_time + and self.end_date_time == other.end_date_time + and self.duration == other.duration + and self.outcome == other.outcome + and self.status == other.status + and self.link == other.link + and self.keywords == other.keywords + and self.extension == other.extension + and self.error_information == other.error_information + and self.created_at == other.created_at + and self.updated_at == other.updated_at + and self.created_by == other.created_by + and self.updated_by == other.updated_by + ) + + def __str__(self) -> str: + """Return a string representation of the TestResult.""" + return str(self.to_protobuf()) diff --git a/test.py b/test.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/data/test_v2_data_store_client.py b/tests/unit/data/test_v2_data_store_client.py new file mode 100644 index 00000000..72350ea4 --- /dev/null +++ b/tests/unit/data/test_v2_data_store_client.py @@ -0,0 +1,555 @@ +"""Tests for the V2 data store client.""" + +from __future__ import annotations + +import datetime as std_datetime +from typing import Any +from unittest.mock import Mock + +import numpy as np +import pytest +from hightime import datetime, timedelta +from ni.measurements.data.v2 import data_store_pb2, data_store_service_pb2 +from ni.protobuf.types.precision_duration_conversion import hightime_timedelta_to_protobuf +from ni.protobuf.types.precision_timestamp_conversion import hightime_datetime_to_protobuf +from ni.protobuf.types.vector_conversion import vector_to_protobuf +from ni.protobuf.types.waveform_pb2 import DoubleAnalogWaveform +from nitypes.scalar import Scalar +from nitypes.vector import Vector +from nitypes.waveform import AnalogWaveform, Timing +from pytest_mock import MockerFixture + +from ni.datastore.data.v2 import ( + Condition, + DataStoreClient, + ErrorInformation, + Measurement, + Outcome, + Status, + Step, + TestResult, +) + +UTC = std_datetime.timezone.utc +TIMESTAMP = datetime(2026, 5, 6, 7, 8, 9, tzinfo=UTC) + + +@pytest.fixture +def service_client(mocker: MockerFixture) -> Any: + service_client = Mock() + service_client.update_test_result.return_value = data_store_pb2.TestResult() + service_client.update_step.return_value = data_store_pb2.Step() + service_client.update_measurement.return_value = data_store_pb2.Measurement() + mocker.patch( + "ni.datastore.data.v2._data_store_client.DataStoreServiceClient", + return_value=service_client, + ) + return service_client + + +@pytest.fixture +def client(service_client: Any) -> DataStoreClient: + return DataStoreClient() + + +def _sent(service_method: Mock) -> Any: + return service_method.call_args.args[0] + + +def _read_response(service_client: Any, value: data_store_pb2.MeasurementValue) -> None: + service_client.read_measurement_values.return_value = ( + data_store_service_pb2.ReadMeasurementValuesResponse(value=value) + ) + + +# ---------------------------------------------------------------- create + + +def test___create_test_result___sends_model_and_returns_id( + client: DataStoreClient, service_client: Any +) -> None: + service_client.create_test_result.return_value = data_store_pb2.TestResult(id="tr-id") + test_result = TestResult( + "Power test", + uut_instance_id="uut-instance-id", + software_item_ids=["sw"], + start_date_time=TIMESTAMP, + duration=timedelta(seconds=2), + outcome=Outcome.PASSED, + keywords=["smoke"], + ) + + test_result_id = client.create_test_result(test_result) + + request = _sent(service_client.create_test_result) + assert isinstance(request, data_store_service_pb2.CreateTestResultRequest) + assert request.test_result == test_result.to_protobuf() + assert test_result_id == "tr-id" + + +def test___create_step___sends_model_with_conditions_and_returns_id( + client: DataStoreClient, service_client: Any +) -> None: + service_client.create_step.return_value = data_store_pb2.Step(id="step-id") + step = Step( + "Measure voltage", + test_result_id="tr-id", + status=Status.RUNNING, + conditions=[Condition("temperature", 25.0)], + ) + + step_id = client.create_step(step) + + request = _sent(service_client.create_step) + assert isinstance(request, data_store_service_pb2.CreateStepRequest) + assert request.step == step.to_protobuf() + assert request.step.conditions[0].value.scalar.double_value == 25.0 + assert step_id == "step-id" + + +def test___add_condition___sends_condition_and_returns_step( + client: DataStoreClient, service_client: Any +) -> None: + service_client.add_condition.return_value = data_store_service_pb2.AddConditionResponse( + step=data_store_pb2.Step(id="step-id", name="Measure voltage") + ) + condition = Condition("supply-voltage", Scalar(5.0, "V"), condition_type="Nominal") + + step = client.add_condition("step-id", condition) + + request = _sent(service_client.add_condition) + assert request.step_id == "step-id" + assert request.condition == condition.to_protobuf() + assert request.condition.units == "V" + assert step == Step("Measure voltage", id="step-id") + + +def test___add_condition___rejects_unsupported_value_before_rpc( + client: DataStoreClient, service_client: Any +) -> None: + with pytest.raises(TypeError): + client.add_condition("step-id", Condition("config", [1, 2])) + + service_client.add_condition.assert_not_called() + + +def test___create_measurement___converts_value_and_metadata( + client: DataStoreClient, service_client: Any +) -> None: + service_client.create_measurement.return_value = data_store_pb2.Measurement(id="m-id") + + measurement_id = client.create_measurement( + "voltage", + 5.2, + "step-id", + units="V", + start_date_time=TIMESTAMP, + duration=timedelta(milliseconds=5), + outcome=Outcome.PASSED, + error_information=ErrorInformation(error_code=1, message="m", source="s"), + software_item_ids=["sw"], + ) + + request = _sent(service_client.create_measurement) + assert isinstance(request, data_store_service_pb2.CreateMeasurementRequest) + assert measurement_id == "m-id" + assert (request.measurement.name, request.measurement.step_id) == ("voltage", "step-id") + assert request.measurement.units == "V" + assert request.measurement.value.scalar.double_value == 5.2 + assert request.measurement.start_date_time == hightime_datetime_to_protobuf(TIMESTAMP) + assert request.measurement.duration == hightime_timedelta_to_protobuf(timedelta(milliseconds=5)) + assert request.measurement.outcome == data_store_pb2.OUTCOME_PASSED + assert request.measurement.error_information.message == "m" + assert list(request.measurement.software_item_ids) == ["sw"] + + +def test___create_measurement___converts_mapping_to_json_value( + client: DataStoreClient, service_client: Any +) -> None: + client.create_measurement("configuration", {"enabled": True}, "step-id") + + value = _sent(service_client.create_measurement).measurement.value + assert value.WhichOneof("value") == "json_value" + assert value.json_value.value.fields["enabled"].bool_value is True + + +def test___create_measurement___converts_analog_waveform( + client: DataStoreClient, service_client: Any +) -> None: + waveform = AnalogWaveform( + sample_count=2, + raw_data=np.array([1.0, 2.0], dtype=np.float64), + timing=Timing.create_with_regular_interval(timedelta(seconds=1), TIMESTAMP), + ) + + client.create_measurement("signal", waveform, "step-id") + + value = _sent(service_client.create_measurement).measurement.value + assert value.WhichOneof("value") == "double_analog_waveform" + assert list(value.double_analog_waveform.y_data) == [1.0, 2.0] + + +def test___create_measurement___rejects_unsupported_value_before_rpc( + client: DataStoreClient, service_client: Any +) -> None: + with pytest.raises(TypeError, match="Unsupported measurement value type"): + client.create_measurement("measurement", object(), "step-id") + + service_client.create_measurement.assert_not_called() + + +# ---------------------------------------------------------------- read value + + +def test___read_measurement_value___requests_value_of_measurement_id( + client: DataStoreClient, service_client: Any +) -> None: + value = data_store_pb2.MeasurementValue() + value.scalar.bool_value = True + _read_response(service_client, value) + + read_value = client.read_measurement_value(Measurement(id="m-id")) + + request = _sent(service_client.read_measurement_values) + assert request.measurement_id == "m-id" + assert list(request.read_mask.paths) == ["value"] + assert read_value is True + + +def test___read_measurement_value___preserves_scalar_units( + client: DataStoreClient, service_client: Any +) -> None: + value = data_store_pb2.MeasurementValue() + value.scalar.double_value = 5.2 + value.scalar.attributes["NI_UnitDescription"].string_value = "V" + _read_response(service_client, value) + + read_value = client.read_measurement_value(Measurement(id="m-id"), Scalar) + + assert (read_value.value, read_value.units) == (5.2, "V") + + +def test___read_measurement_value___converts_vector( + client: DataStoreClient, service_client: Any +) -> None: + value = data_store_pb2.MeasurementValue() + value.vector.CopyFrom(vector_to_protobuf(Vector([1.0, 2.0]))) + _read_response(service_client, value) + + read_value = client.read_measurement_value(Measurement(id="m-id"), Vector) + + assert list(read_value) == [1.0, 2.0] + + +def test___read_measurement_value___converts_json( + client: DataStoreClient, service_client: Any +) -> None: + value = data_store_pb2.MeasurementValue() + value.json_value.value.update({"enabled": True}) + _read_response(service_client, value) + + assert client.read_measurement_value(Measurement(id="m-id")) == {"enabled": True} + + +def test___read_measurement_value___converts_analog_waveform( + client: DataStoreClient, service_client: Any +) -> None: + _read_response( + service_client, + data_store_pb2.MeasurementValue(double_analog_waveform=DoubleAnalogWaveform(y_data=[1.0])), + ) + + read_value = client.read_measurement_value(Measurement(id="m-id"), AnalogWaveform) + + assert list(read_value.scaled_data) == [1.0] + + +def test___read_measurement_value___wrong_expected_type___raises( + client: DataStoreClient, service_client: Any +) -> None: + value = data_store_pb2.MeasurementValue() + value.scalar.double_value = 1.0 + _read_response(service_client, value) + + with pytest.raises(TypeError, match="Expected type"): + client.read_measurement_value(Measurement(id="m-id"), Vector) + + +# ---------------------------------------------------------------- get / query + + +def test___get_test_result___returns_model_with_python_values( + client: DataStoreClient, service_client: Any +) -> None: + proto = data_store_pb2.TestResult( + id="tr-id", + name="Power test", + start_date_time=hightime_datetime_to_protobuf(TIMESTAMP), + outcome=data_store_pb2.OUTCOME_PASSED, + ) + proto.extension.update({"lot": "A1"}) + service_client.get_test_result.return_value = proto + + test_result = client.get_test_result("tr-id", read_fields=["name", "extension"]) + + request = _sent(service_client.get_test_result) + assert request.test_result_id == "tr-id" + assert list(request.read_mask.paths) == ["name", "extension"] + assert isinstance(test_result, TestResult) + assert test_result.start_date_time == TIMESTAMP + assert test_result.outcome == Outcome.PASSED + assert test_result.extension == {"lot": "A1"} + + +def test___get_step___omits_empty_read_mask(client: DataStoreClient, service_client: Any) -> None: + service_client.get_step.return_value = data_store_pb2.Step(id="step-id", name="s") + + step = client.get_step("step-id") + + assert not _sent(service_client.get_step).HasField("read_mask") + assert step == Step("s", id="step-id") + + +def test___get_measurement___returns_model(client: DataStoreClient, service_client: Any) -> None: + service_client.get_measurement.return_value = data_store_pb2.Measurement( + id="m-id", name="voltage", units="V" + ) + + measurement = client.get_measurement("m-id") + + assert _sent(service_client.get_measurement).measurement_id == "m-id" + assert measurement == Measurement(id="m-id", name="voltage", units="V") + + +def test___get_measurement___propagates_transport_error( + client: DataStoreClient, service_client: Any +) -> None: + transport_error = RuntimeError("service unavailable") + service_client.get_measurement.side_effect = transport_error + + with pytest.raises(RuntimeError) as error: + client.get_measurement("m-id") + + assert error.value is transport_error + + +def test___query_test_results___returns_models( + client: DataStoreClient, service_client: Any +) -> None: + service_client.query_test_results.return_value = ( + data_store_service_pb2.QueryTestResultsResponse( + test_results=[data_store_pb2.TestResult(id="tr-id", name="Power test")] + ) + ) + + results = client.query_test_results("$filter=Name eq 'Power test'", read_fields=["name"]) + + request = _sent(service_client.query_test_results) + assert request.odata_query == "$filter=Name eq 'Power test'" + assert list(request.read_mask.paths) == ["name"] + assert results == [TestResult("Power test", id="tr-id")] + + +def test___query_steps___returns_models(client: DataStoreClient, service_client: Any) -> None: + service_client.query_steps.return_value = data_store_service_pb2.QueryStepsResponse( + steps=[data_store_pb2.Step(id="step-id", name="s")] + ) + + steps = client.query_steps("$filter=Name eq 's'") + + assert _sent(service_client.query_steps).odata_query == "$filter=Name eq 's'" + assert steps == [Step("s", id="step-id")] + + +def test___query_measurements___returns_models( + client: DataStoreClient, service_client: Any +) -> None: + service_client.query_measurements.return_value = ( + data_store_service_pb2.QueryMeasurementsResponse( + measurements=[data_store_pb2.Measurement(id="m-id", name="voltage")] + ) + ) + + measurements = client.query_measurements("$filter=Name eq 'voltage'") + + assert _sent(service_client.query_measurements).odata_query == "$filter=Name eq 'voltage'" + assert measurements == [Measurement(id="m-id", name="voltage")] + + +# ---------------------------------------------------------------- update + + +def test___update_test_result___builds_mask_and_returns_model( + client: DataStoreClient, service_client: Any +) -> None: + service_client.update_test_result.return_value = data_store_pb2.TestResult( + id="tr-id", name="t", link="https://example.com", outcome=data_store_pb2.OUTCOME_FAILED + ) + + updated = client.update_test_result( + "tr-id", link="https://example.com", outcome=Outcome.FAILED, uut_id="uut-id" + ) + + request = _sent(service_client.update_test_result) + assert request.test_result.id == "tr-id" + assert request.test_result.link == "https://example.com" + assert request.test_result.outcome == data_store_pb2.OUTCOME_FAILED + assert request.test_result.uut_id == "uut-id" + assert list(request.update_mask.paths) == ["link", "outcome", "uut_id"] + assert updated.outcome == Outcome.FAILED + + +def test___update_step___builds_mask_and_returns_model( + client: DataStoreClient, service_client: Any +) -> None: + service_client.update_step.return_value = data_store_pb2.Step(id="step-id", notes="done") + + updated = client.update_step("step-id", notes="done", status=Status.DONE) + + request = _sent(service_client.update_step) + assert request.step.notes == "done" + assert request.step.status == data_store_pb2.STATUS_DONE + assert list(request.update_mask.paths) == ["notes", "status"] + assert updated.notes == "done" + + +def test___update_measurement___converts_error_information_and_extension( + client: DataStoreClient, service_client: Any +) -> None: + service_client.update_measurement.return_value = data_store_pb2.Measurement(id="m-id") + + client.update_measurement( + "m-id", + error_information=ErrorInformation(error_code=2, message="boom"), + extension={"retest": True}, + ) + + request = _sent(service_client.update_measurement) + assert request.measurement.error_information.message == "boom" + assert request.measurement.extension["retest"] is True + assert list(request.update_mask.paths) == ["error_information", "extension"] + + +@pytest.mark.parametrize("field", ["start_date_time", "end_date_time"]) +def test___update_measurement___converts_timestamp( + client: DataStoreClient, service_client: Any, field: str +) -> None: + client.update_measurement("m-id", **{field: TIMESTAMP}) + + request = _sent(service_client.update_measurement) + assert getattr(request.measurement, field) == hightime_datetime_to_protobuf(TIMESTAMP) + assert list(request.update_mask.paths) == [field] + + +@pytest.mark.parametrize( + ("method", "resource_field"), + [("update_test_result", "test_result"), ("update_step", "step")], +) +def test___update___replaces_keywords( + client: DataStoreClient, service_client: Any, method: str, resource_field: str +) -> None: + getattr(client, method)("resource-id", keywords=["b", "c"]) + + request = _sent(getattr(service_client, method)) + assert list(getattr(request, resource_field).keywords) == ["b", "c"] + assert list(request.update_mask.paths) == ["keywords"] + + +@pytest.mark.parametrize( + ("method", "resource_field"), + [ + ("update_test_result", "test_result"), + ("update_step", "step"), + ("update_measurement", "measurement"), + ], +) +def test___update___converts_duration( + client: DataStoreClient, service_client: Any, method: str, resource_field: str +) -> None: + getattr(client, method)("resource-id", duration=timedelta(seconds=3)) + + request = _sent(getattr(service_client, method)) + assert getattr(request, resource_field).duration == hightime_timedelta_to_protobuf( + timedelta(seconds=3) + ) + assert list(request.update_mask.paths) == ["duration"] + + +def test___update___none_clears_field(client: DataStoreClient, service_client: Any) -> None: + client.update_test_result("tr-id", link=None) + + request = _sent(service_client.update_test_result) + assert request.test_result.link == "" + assert list(request.update_mask.paths) == ["link"] + + +def test___update___rejects_empty_update_before_rpc( + client: DataStoreClient, service_client: Any +) -> None: + with pytest.raises(ValueError, match="At least one field"): + client.update_measurement("m-id") + + service_client.update_measurement.assert_not_called() + + +@pytest.mark.parametrize( + ("method", "field"), + [ + ("update_test_result", "name"), + ("update_step", "step_type"), + ("update_measurement", "units"), + ], +) +def test___update___rejects_immutable_field_before_rpc( + client: DataStoreClient, service_client: Any, method: str, field: str +) -> None: + with pytest.raises(ValueError, match=f"Fields cannot be updated: {field}"): + getattr(client, method)("resource-id", **{field: "x"}) + + getattr(service_client, method).assert_not_called() + + +# ---------------------------------------------------------------- lifecycle + + +def test___init___forwards_connection_options(mocker: MockerFixture) -> None: + service_client_class = mocker.patch( + "ni.datastore.data.v2._data_store_client.DataStoreServiceClient" + ) + discovery_client, grpc_channel, grpc_channel_pool = Mock(), Mock(), Mock() + + DataStoreClient( + discovery_client=discovery_client, + grpc_channel=grpc_channel, + grpc_channel_pool=grpc_channel_pool, + ) + + service_client_class.assert_called_once_with( + discovery_client=discovery_client, + grpc_channel=grpc_channel, + grpc_channel_pool=grpc_channel_pool, + ) + + +def test___close___closes_underlying_client(client: DataStoreClient, service_client: Any) -> None: + client.close() + + service_client.close.assert_called_once_with() + + +def test___context_manager___closes_underlying_client(service_client: Any) -> None: + with DataStoreClient(): + pass + + service_client.close.assert_called_once_with() + + +def test___closed_client___raises_without_calling_service( + client: DataStoreClient, service_client: Any +) -> None: + client.close() + + with pytest.raises(RuntimeError, match="closed"): + client.get_measurement("m-id") + + service_client.get_measurement.assert_not_called() diff --git a/tests/unit/data/test_v2_types.py b/tests/unit/data/test_v2_types.py new file mode 100644 index 00000000..02f15f08 --- /dev/null +++ b/tests/unit/data/test_v2_types.py @@ -0,0 +1,199 @@ +"""Tests for the V2 data store Python types.""" + +from __future__ import annotations + +import datetime as std_datetime + +import pytest +from google.protobuf import json_format +from hightime import datetime, timedelta +from ni.measurements.data.v2 import data_store_pb2 +from ni.protobuf.types.precision_duration_conversion import hightime_timedelta_to_protobuf +from ni.protobuf.types.precision_timestamp_conversion import hightime_datetime_to_protobuf +from nitypes.scalar import Scalar + +from ni.datastore.data.v2 import ( + Condition, + ErrorInformation, + Measurement, + Outcome, + Status, + Step, + TestResult, +) + +UTC = std_datetime.timezone.utc +START = datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC) +END = datetime(2026, 1, 2, 3, 4, 15, tzinfo=UTC) +DURATION = timedelta(seconds=10) + + +def test___test_result___round_trips_through_protobuf() -> None: + test_result = TestResult( + "Power test", + id="test-result-id", + uut_instance_id="uut-instance-id", + uut_id="uut-id", + operator_id="operator-id", + test_station_id="station-id", + test_description_id="description-id", + software_item_ids=["sw"], + hardware_item_ids=["hw"], + test_adapter_ids=["ta"], + start_date_time=START, + end_date_time=END, + duration=DURATION, + outcome=Outcome.PASSED, + status=Status.DONE, + link="https://example.com/run", + keywords=["smoke"], + extension={"lot": "A1", "nested": {"ok": True}}, + error_information=ErrorInformation(error_code=-1, message="boom", source="e2e"), + ) + + proto = test_result.to_protobuf() + + assert proto.name == "Power test" + assert proto.uut_id == "uut-id" + assert list(proto.software_item_ids) == ["sw"] + assert proto.start_date_time == hightime_datetime_to_protobuf(START) + assert proto.duration == hightime_timedelta_to_protobuf(DURATION) + assert proto.outcome == data_store_pb2.OUTCOME_PASSED + assert proto.status == data_store_pb2.STATUS_DONE + assert list(proto.keywords) == ["smoke"] + assert json_format.MessageToDict(proto.extension) == {"lot": "A1", "nested": {"ok": True}} + assert proto.error_information.message == "boom" + assert TestResult.from_protobuf(proto) == test_result + + +def test___test_result___unset_optional_fields_read_as_none() -> None: + test_result = TestResult.from_protobuf(data_store_pb2.TestResult(id="id", name="name")) + + assert test_result.start_date_time is None + assert test_result.end_date_time is None + assert test_result.duration is None + assert test_result.error_information is None + assert test_result.extension == {} + assert test_result.outcome == Outcome.UNSPECIFIED + + +def test___step___round_trips_through_protobuf_including_conditions() -> None: + step = Step( + "Measure voltage", + id="step-id", + parent_step_id="parent-id", + test_result_id="test-result-id", + test_id="test-id", + step_type="Measurement", + notes="first", + start_date_time=START, + end_date_time=END, + duration=DURATION, + outcome=Outcome.FAILED, + status=Status.DONE, + link="https://example.com/step", + keywords=["k"], + extension={"inputs": {"v": 1}}, + conditions=[Condition("temperature", 25.0, condition_type="Environment", units="C")], + ) + + proto = step.to_protobuf() + + assert proto.parent_step_id == "parent-id" + assert proto.step_type == "Measurement" + assert proto.conditions[0].value.scalar.double_value == 25.0 + assert Step.from_protobuf(proto) == step + + +def test___measurement___reads_metadata_and_conditions_from_protobuf() -> None: + proto = data_store_pb2.Measurement( + id="measurement-id", + test_result_id="test-result-id", + step_id="step-id", + name="voltage", + value_type="Scalar", + units="V", + notes="note", + outcome=data_store_pb2.OUTCOME_PASSED, + software_item_ids=["sw"], + comparison_type="GELE", + start_date_time=hightime_datetime_to_protobuf(START), + conditions=[ + data_store_pb2.Condition(name="temperature", value=data_store_pb2.ConditionValue()) + ], + ) + proto.conditions[0].value.scalar.double_value = 25.0 + proto.value.scalar.double_value = 5.0 + + measurement = Measurement.from_protobuf(proto) + + assert (measurement.id, measurement.name, measurement.units) == ( + "measurement-id", + "voltage", + "V", + ) + assert (measurement.value_type, measurement.comparison_type) == ("Scalar", "GELE") + assert measurement.outcome == Outcome.PASSED + assert measurement.start_date_time == START + assert list(measurement.software_item_ids) == ["sw"] + assert measurement.conditions == [Condition("temperature", 25.0)] + + +def test___measurement___to_protobuf_does_not_send_value_or_output_fields() -> None: + measurement = Measurement( + id="measurement-id", name="voltage", step_id="step-id", value_type="x" + ) + + proto = measurement.to_protobuf() + + assert proto.id == "measurement-id" + assert not proto.HasField("value") + assert proto.value_type == "" + assert len(proto.conditions) == 0 + + +@pytest.mark.parametrize( + ("value", "field", "expected"), + [ + (5.5, "double_value", 5.5), + (7, "sint32_value", 7), + (True, "bool_value", True), + ("high", "string_value", "high"), + ], +) +def test___condition___converts_scalar_values(value: object, field: str, expected: object) -> None: + proto = Condition("cond", value).to_protobuf() + + assert getattr(proto.value.scalar, field) == expected + assert Condition.from_protobuf(proto).value == expected + + +def test___condition___uses_scalar_units_when_units_not_given() -> None: + proto = Condition("voltage", Scalar(3.3, "V")).to_protobuf() + + assert proto.units == "V" + assert proto.value.scalar.double_value == 3.3 + + +def test___condition___converts_json_mapping() -> None: + proto = Condition("config", {"mode": "fast"}).to_protobuf() + + assert proto.value.WhichOneof("value") == "json_value" + assert Condition.from_protobuf(proto).value == {"mode": "fast"} + + +def test___condition___rejects_unsupported_value() -> None: + with pytest.raises(TypeError): + Condition("bad", [1, 2]).to_protobuf() + + +def test___error_information___round_trips_through_protobuf() -> None: + error = ErrorInformation(error_code=3, message="m", source="s") + + assert ErrorInformation.from_protobuf(error.to_protobuf()) == error + + +@pytest.mark.parametrize("enum_type", [Outcome, Status]) +def test___enums___reject_unknown_protobuf_values(enum_type: type[Outcome] | type[Status]) -> None: + with pytest.raises(ValueError): + enum_type.from_protobuf(999) # type: ignore[arg-type] From a299208d2ce7c36d8d53eb0ddf4df693957ac909 Mon Sep 17 00:00:00 2001 From: "Balusamy, Sudharshana" Date: Mon, 26 Oct 2026 18:18:28 +0530 Subject: [PATCH 02/13] add missing implementations --- docs/reference/ni-data-store-v2.md | 34 +- src/ni/datastore/data/v2/__init__.py | 6 + .../datastore/data/v2/_data_store_client.py | 523 +++++++++++++++++- src/ni/datastore/data/v2/_grpc_conversion.py | 26 +- src/ni/datastore/data/v2/_types/_condition.py | 12 +- .../datastore/data/v2/_types/_json_value.py | 108 ++++ .../datastore/data/v2/_types/_measurement.py | 98 +++- .../data/v2/_types/_query_results.py | 25 + src/ni/datastore/data/v2/_types/_step.py | 46 +- .../datastore/data/v2/_types/_test_result.py | 176 +++++- tests/unit/data/test_v2_data_store_client.py | 432 +++++++++++++++ tests/unit/data/test_v2_types.py | 151 +++++ 12 files changed, 1587 insertions(+), 50 deletions(-) create mode 100644 src/ni/datastore/data/v2/_types/_json_value.py create mode 100644 src/ni/datastore/data/v2/_types/_query_results.py diff --git a/docs/reference/ni-data-store-v2.md b/docs/reference/ni-data-store-v2.md index 5a13085f..fdcd4a05 100644 --- a/docs/reference/ni-data-store-v2.md +++ b/docs/reference/ni-data-store-v2.md @@ -49,6 +49,34 @@ Updates accept mutable fields as keyword arguments and build the protobuf field mask automatically. Empty updates and fields that the service does not allow to change raise `ValueError` before an RPC is made. +`delete_test_result`, `delete_step`, and `delete_measurement` remove resources. +Without `force=True`, deleting a test result with steps, or a step with +measurements, fails with `FAILED_PRECONDITION`; with `force=True`, the owned +children are deleted too. A step that has child steps cannot be deleted. + +The client wraps every method of the V2 low-level client: + +- `remove_condition` removes a condition from a step. +- `add_test_result_metadata`, `remove_test_result_metadata`, + `add_measurement_metadata`, and `remove_measurement_metadata` link or unlink + software items, hardware items, and test adapters after creation. +- `create_measurement` accepts `upper_limit` and `lower_limit` of any supported + value type. `read_measurement_values` returns the value and both limits. +- `batch_create_*`, `batch_update_*`, and `batch_delete_*` handle many test + results, steps, or measurements in one atomic call. Batch updates take a + dictionary of fields keyed by ID, and batch deletes accept one `force` flag or + one flag per ID. +- `TestResult.steps`, `Step.children`, and `Step.measurements` create a whole + tree in one call. Metadata objects such as `Operator` or `SoftwareItem` can be + passed instead of IDs to find or create them. +- `create_measurement` accepts `SoftwareItem`, `HardwareItem`, and + `TestAdapter` objects for inline find-or-create, in addition to their IDs. +- `query_*` methods return `QueryResults`, a list with a `next_link` attribute + that is set when the service has more results. +- JSON values and conditions accept a plain dictionary, or `JsonValue` when the + value needs attributes. Reads return a `JsonValue` only when attributes are + present. + ## V1 Convenience Differences - V1 `publish_condition` and `publish_condition_batch` do not have direct V2 @@ -56,9 +84,9 @@ change raise `ValueError` before an RPC is made. to add or replace one. Read conditions through the containing step returned by `get_step` or `query_steps`; there are no standalone V2 condition get/query methods in this high-level API. -- This high-level V2 API does not include the V1 batch-publish convenience. - Call `create_measurement` once per measurement; these are separate service - operations and are not atomic as a group. +- V1 `publish_measurement_batch` is shaped for parametric sweeps. In V2, use + `batch_create_measurements` with `Measurement` objects; each item can target a + different step, and the whole batch is atomic. - V2 has its own `TestResult`, `Step`, `Measurement`, `Condition`, and `ErrorInformation` types in `ni.datastore.data.v2`, because the V2 resource fields differ from V1. They follow the V1 style: create methods take them, diff --git a/src/ni/datastore/data/v2/__init__.py b/src/ni/datastore/data/v2/__init__.py index 67260207..4111f761 100644 --- a/src/ni/datastore/data/v2/__init__.py +++ b/src/ni/datastore/data/v2/__init__.py @@ -3,8 +3,10 @@ from ni.datastore.data.v2._data_store_client import DataStoreClient from ni.datastore.data.v2._types._condition import Condition from ni.datastore.data.v2._types._error_information import ErrorInformation +from ni.datastore.data.v2._types._json_value import JsonValue from ni.datastore.data.v2._types._measurement import Measurement from ni.datastore.data.v2._types._outcome import Outcome +from ni.datastore.data.v2._types._query_results import QueryResults from ni.datastore.data.v2._types._status import Status from ni.datastore.data.v2._types._step import Step from ni.datastore.data.v2._types._test_result import TestResult @@ -13,8 +15,10 @@ "Condition", "DataStoreClient", "ErrorInformation", + "JsonValue", "Measurement", "Outcome", + "QueryResults", "Status", "Step", "TestResult", @@ -24,8 +28,10 @@ Condition.__module__ = __name__ DataStoreClient.__module__ = __name__ ErrorInformation.__module__ = __name__ +JsonValue.__module__ = __name__ Measurement.__module__ = __name__ Outcome.__module__ = __name__ +QueryResults.__module__ = __name__ Status.__module__ = __name__ Step.__module__ = __name__ TestResult.__module__ = __name__ diff --git a/src/ni/datastore/data/v2/_data_store_client.py b/src/ni/datastore/data/v2/_data_store_client.py index 63e7d973..8628d3ec 100644 --- a/src/ni/datastore/data/v2/_data_store_client.py +++ b/src/ni/datastore/data/v2/_data_store_client.py @@ -20,9 +20,23 @@ ) from ni.measurements.data.v2.data_store_service_pb2 import ( AddConditionRequest, + AddMeasurementMetadataRequest, + AddTestResultMetadataRequest, + BatchCreateMeasurementsRequest, + BatchCreateStepsRequest, + BatchCreateTestResultsRequest, + BatchDeleteMeasurementsRequest, + BatchDeleteStepsRequest, + BatchDeleteTestResultsRequest, + BatchUpdateMeasurementsRequest, + BatchUpdateStepsRequest, + BatchUpdateTestResultsRequest, CreateMeasurementRequest, CreateStepRequest, CreateTestResultRequest, + DeleteMeasurementRequest, + DeleteStepRequest, + DeleteTestResultRequest, GetMeasurementRequest, GetStepRequest, GetTestResultRequest, @@ -30,6 +44,9 @@ QueryStepsRequest, QueryTestResultsRequest, ReadMeasurementValuesRequest, + RemoveConditionRequest, + RemoveMeasurementMetadataRequest, + RemoveTestResultMetadataRequest, UpdateMeasurementRequest, UpdateStepRequest, UpdateTestResultRequest, @@ -40,14 +57,16 @@ from ni.datastore.data.v2._grpc_conversion import ( convert_measurement_value_from_protobuf, - populate_measurement_value, + measurement_value_from_protobuf, ) from ni.datastore.data.v2._types._condition import Condition from ni.datastore.data.v2._types._error_information import ErrorInformation from ni.datastore.data.v2._types._measurement import Measurement from ni.datastore.data.v2._types._outcome import Outcome +from ni.datastore.data.v2._types._query_results import QueryResults from ni.datastore.data.v2._types._step import Step from ni.datastore.data.v2._types._test_result import TestResult +from ni.datastore.metadata import HardwareItem, SoftwareItem, TestAdapter if TYPE_CHECKING: if sys.version_info >= (3, 11): @@ -150,6 +169,22 @@ def add_condition(self, step_id: str, condition: Condition) -> Step: add_request = AddConditionRequest(step_id=step_id, condition=condition.to_protobuf()) return Step.from_protobuf(self._get_data_store_client().add_condition(add_request).step) + def remove_condition(self, step_id: str, condition_name: str) -> Step: + """Remove a condition from a step. + + Args: + step_id: The ID of the step that owns the condition. + + condition_name: The name of the condition to remove. + + Returns: + Step: The updated step. + """ + remove_request = RemoveConditionRequest(step_id=step_id, condition_name=condition_name) + return Step.from_protobuf( + self._get_data_store_client().remove_condition(remove_request).step + ) + def create_measurement( self, name: str, @@ -157,15 +192,23 @@ def create_measurement( step_id: str, *, units: str = "", + upper_limit: object = None, + lower_limit: object = None, + comparison_type: str = "", notes: str = "", start_date_time: ht.datetime | None = None, end_date_time: ht.datetime | None = None, duration: ht.timedelta | None = None, outcome: Outcome = Outcome.UNSPECIFIED, error_information: ErrorInformation | None = None, + extension: Mapping[str, Any] | None = None, + schema_id: str = "", software_item_ids: Iterable[str] = (), hardware_item_ids: Iterable[str] = (), test_adapter_ids: Iterable[str] = (), + software_items: Iterable[SoftwareItem] = (), + hardware_items: Iterable[HardwareItem] = (), + test_adapters: Iterable[TestAdapter] = (), ) -> str: """Create a measurement associated with a test step. @@ -181,6 +224,15 @@ def create_measurement( units: Engineering units of the value. + upper_limit: The publisher-supplied upper limit. Any supported value + type is accepted. + + lower_limit: The publisher-supplied lower limit. Any supported value + type is accepted. + + comparison_type: The publisher-defined comparison operation. The + service stores it but does not derive the outcome from it. + notes: Any notes to associate with the measurement. start_date_time: The start time of the measurement. @@ -193,6 +245,11 @@ def create_measurement( error_information: Error details for the measurement. + extension: Custom JSON data for the measurement. + + schema_id: Schema that governs the extension. Reserved by the service + for future support. + software_item_ids: The software item IDs for the measurement. These must already be linked to the test result. @@ -202,27 +259,96 @@ def create_measurement( test_adapter_ids: The test adapter IDs for the measurement. These must already be linked to the test result. + software_items: Software items to find or create as part of this + request. + + hardware_items: Hardware items to find or create as part of this + request. + + test_adapters: Test adapters to find or create as part of this + request. + Returns: str: The measurement ID. """ measurement = Measurement( name=name, + value=value, step_id=step_id, units=units, + upper_limit=upper_limit, + lower_limit=lower_limit, + comparison_type=comparison_type, notes=notes, start_date_time=start_date_time, end_date_time=end_date_time, duration=duration, outcome=outcome, error_information=error_information, + extension=extension, + schema_id=schema_id, software_item_ids=software_item_ids, hardware_item_ids=hardware_item_ids, test_adapter_ids=test_adapter_ids, - ).to_protobuf() - populate_measurement_value(measurement.value, value) - create_request = CreateMeasurementRequest(measurement=measurement) + software_items=software_items, + hardware_items=hardware_items, + test_adapters=test_adapters, + ) + create_request = CreateMeasurementRequest(measurement=measurement.to_protobuf()) return self._get_data_store_client().create_measurement(create_request).id + def batch_create_test_results(self, test_results: Iterable[TestResult]) -> Sequence[str]: + """Create several test results, including any nested steps, in one atomic call. + + Args: + test_results: The test results to create. + + Returns: + Sequence[str]: The test result IDs, in request order. + """ + batch_request = BatchCreateTestResultsRequest( + requests=[ + CreateTestResultRequest(test_result=test_result.to_protobuf()) + for test_result in test_results + ] + ) + batch_response = self._get_data_store_client().batch_create_test_results(batch_request) + return [test_result.id for test_result in batch_response.test_results] + + def batch_create_steps(self, steps: Iterable[Step]) -> Sequence[str]: + """Create several steps, including any nested children, in one atomic call. + + Args: + steps: The steps to create. + + Returns: + Sequence[str]: The step IDs, in request order. + """ + batch_request = BatchCreateStepsRequest( + requests=[CreateStepRequest(step=step.to_protobuf()) for step in steps] + ) + batch_response = self._get_data_store_client().batch_create_steps(batch_request) + return [step.id for step in batch_response.steps] + + def batch_create_measurements(self, measurements: Iterable[Measurement]) -> Sequence[str]: + """Create several measurements in one atomic call. + + Args: + measurements: The measurements to create. Each must set step_id and + value, and may target a different step or test result. + + Returns: + Sequence[str]: The measurement IDs, in request order. + """ + batch_request = BatchCreateMeasurementsRequest( + requests=[ + CreateMeasurementRequest(measurement=measurement.to_protobuf()) + for measurement in measurements + ] + ) + batch_response = self._get_data_store_client().batch_create_measurements(batch_request) + return [measurement.id for measurement in batch_response.measurements] + @overload def read_measurement_value( self, @@ -264,6 +390,32 @@ def read_measurement_value( raise TypeError(f"Expected type {expected_type}, got {type(read_value)}") return read_value + def read_measurement_values( + self, read_source: Measurement, *, read_fields: Iterable[str] = () + ) -> Measurement: + """Read the value and limits of a measurement. + + Args: + read_source: The measurement to read. Only its id is used. + + read_fields: Optional fields to read: "value", "upper_limit", and + "lower_limit". All three are read when empty. + + Returns: + Measurement: A measurement with id, value, upper_limit, and + lower_limit set. A limit that was not published is None. + """ + read_request = ReadMeasurementValuesRequest( + measurement_id=read_source.id, read_mask=_read_mask(read_fields) + ) + response = self._get_data_store_client().read_measurement_values(read_request) + return Measurement( + id=read_source.id, + value=measurement_value_from_protobuf(response, "value"), + upper_limit=measurement_value_from_protobuf(response, "upper_limit"), + lower_limit=measurement_value_from_protobuf(response, "lower_limit"), + ) + def get_test_result( self, test_result_id: str, *, read_fields: Iterable[str] = () ) -> TestResult: @@ -319,7 +471,7 @@ def get_measurement( def query_test_results( self, odata_query: str = "", *, read_fields: Iterable[str] = () - ) -> Sequence[TestResult]: + ) -> QueryResults[TestResult]: """Query test results using OData query syntax. Args: @@ -329,17 +481,21 @@ def query_test_results( read_fields: Optional field names to return for each test result. Returns: - Sequence[TestResult]: The matching test results. + QueryResults[TestResult]: The matching test results, with next_link + set when more results are available. """ query_request = QueryTestResultsRequest( odata_query=odata_query, read_mask=_read_mask(read_fields) ) query_response = self._get_data_store_client().query_test_results(query_request) - return [TestResult.from_protobuf(proto) for proto in query_response.test_results] + return QueryResults( + (TestResult.from_protobuf(proto) for proto in query_response.test_results), + query_response.next_link, + ) def query_steps( self, odata_query: str = "", *, read_fields: Iterable[str] = () - ) -> Sequence[Step]: + ) -> QueryResults[Step]: """Query steps using OData query syntax. Args: @@ -349,17 +505,21 @@ def query_steps( read_fields: Optional field names to return for each step. Returns: - Sequence[Step]: The matching steps. + QueryResults[Step]: The matching steps, with next_link set when more + results are available. """ query_request = QueryStepsRequest( odata_query=odata_query, read_mask=_read_mask(read_fields) ) query_response = self._get_data_store_client().query_steps(query_request) - return [Step.from_protobuf(proto) for proto in query_response.steps] + return QueryResults( + (Step.from_protobuf(proto) for proto in query_response.steps), + query_response.next_link, + ) def query_measurements( self, odata_query: str = "", *, read_fields: Iterable[str] = () - ) -> Sequence[Measurement]: + ) -> QueryResults[Measurement]: """Query measurements using OData query syntax. Args: @@ -370,13 +530,17 @@ def query_measurements( read_fields: Optional field names to return for each measurement. Returns: - Sequence[Measurement]: The matching measurements. + QueryResults[Measurement]: The matching measurements, with next_link + set when more results are available. """ query_request = QueryMeasurementsRequest( odata_query=odata_query, read_mask=_read_mask(read_fields) ) query_response = self._get_data_store_client().query_measurements(query_request) - return [Measurement.from_protobuf(proto) for proto in query_response.measurements] + return QueryResults( + (Measurement.from_protobuf(proto) for proto in query_response.measurements), + query_response.next_link, + ) def update_test_result(self, test_result_id: str, **fields: object) -> TestResult: """Update mutable test result fields. @@ -442,6 +606,306 @@ def update_measurement(self, measurement_id: str, **fields: object) -> Measureme self._get_data_store_client().update_measurement(update_request) ) + def batch_update_test_results( + self, updates: Mapping[str, Mapping[str, object]] + ) -> Sequence[TestResult]: + """Update several test results in one atomic call. + + Args: + updates: The fields to change, keyed by test result ID. Each value + follows the update_test_result field rules. + + Returns: + Sequence[TestResult]: The updated test results, in request order. + + Raises: + ValueError: If no updates are given, or an update has no fields or + a field that cannot be updated. + """ + requests = [] + for test_result_id, fields in _require_items(updates).items(): + test_result = TestResultProto(id=test_result_id) + mask = _populate_update(test_result, fields, _TEST_RESULT_UPDATE_FIELDS) + requests.append(UpdateTestResultRequest(test_result=test_result, update_mask=mask)) + batch_request = BatchUpdateTestResultsRequest(requests=requests) + batch_response = self._get_data_store_client().batch_update_test_results(batch_request) + return [TestResult.from_protobuf(proto) for proto in batch_response.test_results] + + def batch_update_steps(self, updates: Mapping[str, Mapping[str, object]]) -> Sequence[Step]: + """Update several steps in one atomic call. + + Args: + updates: The fields to change, keyed by step ID. Each value follows + the update_step field rules. + + Returns: + Sequence[Step]: The updated steps, in request order. + + Raises: + ValueError: If no updates are given, or an update has no fields or + a field that cannot be updated. + """ + requests = [] + for step_id, fields in _require_items(updates).items(): + step = StepProto(id=step_id) + mask = _populate_update(step, fields, _STEP_UPDATE_FIELDS) + requests.append(UpdateStepRequest(step=step, update_mask=mask)) + batch_request = BatchUpdateStepsRequest(requests=requests) + batch_response = self._get_data_store_client().batch_update_steps(batch_request) + return [Step.from_protobuf(proto) for proto in batch_response.steps] + + def batch_update_measurements( + self, updates: Mapping[str, Mapping[str, object]] + ) -> Sequence[Measurement]: + """Update several measurements in one atomic call. + + Args: + updates: The fields to change, keyed by measurement ID. Each value + follows the update_measurement field rules. + + Returns: + Sequence[Measurement]: The updated measurements, in request order. + + Raises: + ValueError: If no updates are given, or an update has no fields or + a field that cannot be updated. + """ + requests = [] + for measurement_id, fields in _require_items(updates).items(): + measurement = MeasurementProto(id=measurement_id) + mask = _populate_update(measurement, fields, _MEASUREMENT_UPDATE_FIELDS) + requests.append(UpdateMeasurementRequest(measurement=measurement, update_mask=mask)) + batch_request = BatchUpdateMeasurementsRequest(requests=requests) + batch_response = self._get_data_store_client().batch_update_measurements(batch_request) + return [Measurement.from_protobuf(proto) for proto in batch_response.measurements] + + def add_test_result_metadata( + self, + test_result_id: str, + *, + software_item_ids: Iterable[str] = (), + hardware_item_ids: Iterable[str] = (), + test_adapter_ids: Iterable[str] = (), + ) -> TestResult: + """Link existing software items, hardware items, or test adapters to a test result. + + Args: + test_result_id: The ID of the test result. + + software_item_ids: The software item IDs to link. + + hardware_item_ids: The hardware item IDs to link. + + test_adapter_ids: The test adapter IDs to link. + + Returns: + TestResult: The updated test result. + """ + add_request = AddTestResultMetadataRequest( + test_result_id=test_result_id, + software_item_ids=software_item_ids, + hardware_item_ids=hardware_item_ids, + test_adapter_ids=test_adapter_ids, + ) + return TestResult.from_protobuf( + self._get_data_store_client().add_test_result_metadata(add_request).test_result + ) + + def remove_test_result_metadata( + self, + test_result_id: str, + *, + software_item_ids: Iterable[str] = (), + hardware_item_ids: Iterable[str] = (), + test_adapter_ids: Iterable[str] = (), + ) -> TestResult: + """Unlink software items, hardware items, or test adapters from a test result. + + The service rejects the request if a measurement of the test result + still references a removed item. + + Args: + test_result_id: The ID of the test result. + + software_item_ids: The software item IDs to unlink. + + hardware_item_ids: The hardware item IDs to unlink. + + test_adapter_ids: The test adapter IDs to unlink. + + Returns: + TestResult: The updated test result. + """ + remove_request = RemoveTestResultMetadataRequest( + test_result_id=test_result_id, + software_item_ids=software_item_ids, + hardware_item_ids=hardware_item_ids, + test_adapter_ids=test_adapter_ids, + ) + return TestResult.from_protobuf( + self._get_data_store_client().remove_test_result_metadata(remove_request).test_result + ) + + def add_measurement_metadata( + self, + measurement_id: str, + *, + software_item_ids: Iterable[str] = (), + hardware_item_ids: Iterable[str] = (), + test_adapter_ids: Iterable[str] = (), + ) -> Measurement: + """Link software items, hardware items, or test adapters to a measurement. + + Each item must already be linked to the measurement's test result. + + Args: + measurement_id: The ID of the measurement. + + software_item_ids: The software item IDs to link. + + hardware_item_ids: The hardware item IDs to link. + + test_adapter_ids: The test adapter IDs to link. + + Returns: + Measurement: The updated measurement. + """ + add_request = AddMeasurementMetadataRequest( + measurement_id=measurement_id, + software_item_ids=software_item_ids, + hardware_item_ids=hardware_item_ids, + test_adapter_ids=test_adapter_ids, + ) + return Measurement.from_protobuf( + self._get_data_store_client().add_measurement_metadata(add_request).measurement + ) + + def remove_measurement_metadata( + self, + measurement_id: str, + *, + software_item_ids: Iterable[str] = (), + hardware_item_ids: Iterable[str] = (), + test_adapter_ids: Iterable[str] = (), + ) -> Measurement: + """Unlink software items, hardware items, or test adapters from a measurement. + + Args: + measurement_id: The ID of the measurement. + + software_item_ids: The software item IDs to unlink. + + hardware_item_ids: The hardware item IDs to unlink. + + test_adapter_ids: The test adapter IDs to unlink. + + Returns: + Measurement: The updated measurement. + """ + remove_request = RemoveMeasurementMetadataRequest( + measurement_id=measurement_id, + software_item_ids=software_item_ids, + hardware_item_ids=hardware_item_ids, + test_adapter_ids=test_adapter_ids, + ) + return Measurement.from_protobuf( + self._get_data_store_client().remove_measurement_metadata(remove_request).measurement + ) + + def delete_test_result(self, test_result_id: str, *, force: bool = False) -> None: + """Delete a test result. + + Args: + test_result_id: The ID of the test result to delete. + + force: If True, also delete the steps, measurements, and conditions that the + test result owns. If False, the service rejects the delete with + FAILED_PRECONDITION when owned children exist. + """ + delete_request = DeleteTestResultRequest(test_result_id=test_result_id, force=force) + self._get_data_store_client().delete_test_result(delete_request) + + def delete_step(self, step_id: str, *, force: bool = False) -> None: + """Delete a step. + + A step that has child steps cannot be deleted. + + Args: + step_id: The ID of the step to delete. + + force: If True, also delete the measurements of the step. If False, the + service rejects the delete with FAILED_PRECONDITION when measurements exist. + """ + delete_request = DeleteStepRequest(step_id=step_id, force=force) + self._get_data_store_client().delete_step(delete_request) + + def delete_measurement(self, measurement_id: str) -> None: + """Delete a measurement. + + Args: + measurement_id: The ID of the measurement to delete. + """ + delete_request = DeleteMeasurementRequest(measurement_id=measurement_id) + self._get_data_store_client().delete_measurement(delete_request) + + def batch_delete_test_results( + self, test_result_ids: Iterable[str], *, force: bool | Iterable[bool] = False + ) -> None: + """Delete several test results in one atomic call. + + Args: + test_result_ids: The IDs of the test results to delete. + + force: One flag for every test result, or one flag per test result. + See delete_test_result. + + Raises: + ValueError: If one flag per item is given and the counts differ. + """ + batch_request = BatchDeleteTestResultsRequest( + requests=[ + DeleteTestResultRequest(test_result_id=test_result_id, force=item_force) + for test_result_id, item_force in _with_force(test_result_ids, force) + ] + ) + self._get_data_store_client().batch_delete_test_results(batch_request) + + def batch_delete_steps( + self, step_ids: Iterable[str], *, force: bool | Iterable[bool] = False + ) -> None: + """Delete several steps in one atomic call. + + Args: + step_ids: The IDs of the steps to delete. A step and its ancestor or + descendant cannot be deleted in the same call. + + force: One flag for every step, or one flag per step. See delete_step. + + Raises: + ValueError: If one flag per item is given and the counts differ. + """ + batch_request = BatchDeleteStepsRequest( + requests=[ + DeleteStepRequest(step_id=step_id, force=item_force) + for step_id, item_force in _with_force(step_ids, force) + ] + ) + self._get_data_store_client().batch_delete_steps(batch_request) + + def batch_delete_measurements(self, measurement_ids: Iterable[str]) -> None: + """Delete several measurements in one atomic call. + + Args: + measurement_ids: The IDs of the measurements to delete. + """ + batch_request = BatchDeleteMeasurementsRequest( + requests=[ + DeleteMeasurementRequest(measurement_id=measurement_id) + for measurement_id in measurement_ids + ] + ) + self._get_data_store_client().batch_delete_measurements(batch_request) + def _get_data_store_client(self) -> DataStoreServiceClient: if self._closed: raise RuntimeError(self._DATA_STORE_CLIENT_CLOSED_ERROR) @@ -456,6 +920,10 @@ def _get_data_store_client(self) -> DataStoreServiceClient: "operator_id", "test_station_id", "test_description_id", + "uut_instance", + "operator", + "test_station", + "test_description", "start_date_time", "end_date_time", "duration", @@ -470,6 +938,7 @@ def _get_data_store_client(self) -> DataStoreServiceClient: _STEP_UPDATE_FIELDS = frozenset( { "test_id", + "test", "notes", "start_date_time", "end_date_time", @@ -495,11 +964,37 @@ def _get_data_store_client(self) -> DataStoreServiceClient: ) +# Update fields that take a metadata model, such as Operator, and are sent as its protobuf. +_METADATA_UPDATE_FIELDS = frozenset( + {"uut_instance", "operator", "test_station", "test_description", "test"} +) + + def _read_mask(fields: Iterable[str]) -> FieldMask | None: paths = list(fields) return FieldMask(paths=paths) if paths else None +def _require_items( + updates: Mapping[str, Mapping[str, object]], +) -> Mapping[str, Mapping[str, object]]: + if not updates: + raise ValueError("At least one update must be supplied for a batch update.") + return updates + + +def _with_force(ids: Iterable[str], force: bool | Iterable[bool]) -> list[tuple[str, bool]]: + id_list = list(ids) + if isinstance(force, bool): + return [(resource_id, force) for resource_id in id_list] + force_list = list(force) + if len(force_list) != len(id_list): + raise ValueError( + f"Expected {len(id_list)} force flags, one per ID, but got {len(force_list)}." + ) + return list(zip(id_list, force_list)) + + def _populate_update( resource: TestResultProto | StepProto | MeasurementProto, fields: Mapping[str, object], @@ -525,6 +1020,8 @@ def _populate_update( json_format.ParseDict(dict(cast(Mapping[str, Any], value)), target.extension) elif name == "error_information": target.error_information.CopyFrom(cast(ErrorInformation, value).to_protobuf()) + elif name in _METADATA_UPDATE_FIELDS: + getattr(target, name).CopyFrom(cast(Any, value).to_protobuf()) else: setattr(target, name, value) diff --git a/src/ni/datastore/data/v2/_grpc_conversion.py b/src/ni/datastore/data/v2/_grpc_conversion.py index a5463c41..0ca174d5 100644 --- a/src/ni/datastore/data/v2/_grpc_conversion.py +++ b/src/ni/datastore/data/v2/_grpc_conversion.py @@ -53,6 +53,8 @@ from nitypes.waveform import AnalogWaveform, ComplexWaveform, DigitalWaveform, Spectrum from nitypes.xy_data import XYData +from ni.datastore.data.v2._types._json_value import JsonValue, json_value_from_protobuf + def populate_measurement_value(measurement_value: MeasurementValue, value: object) -> None: """Convert a Python scalar or vector into a V2 measurement value.""" @@ -109,6 +111,8 @@ def populate_measurement_value(measurement_value: MeasurementValue, value: objec if value.dtype != np.float64: raise TypeError(f"Unsupported XYData dtype: {value.dtype}") measurement_value.x_y_data.CopyFrom(float64_xydata_to_protobuf(value)) + elif isinstance(value, JsonValue): + measurement_value.json_value.CopyFrom(value.to_protobuf()) elif isinstance(value, Mapping): json_format.ParseDict(cast(dict[str, Any], dict(value)), measurement_value.json_value.value) elif isinstance(value, Iterable): @@ -154,10 +158,30 @@ def convert_measurement_value_from_protobuf(measurement_value: MeasurementValue) if value_type == "x_y_data": return float64_xydata_from_protobuf(measurement_value.x_y_data) if value_type == "json_value": - return json_format.MessageToDict(measurement_value.json_value.value) + return json_value_from_protobuf(measurement_value.json_value) raise TypeError(f"Unsupported or missing measurement value type: {value_type}.") +def measurement_value_to_protobuf(value: object) -> MeasurementValue | None: + """Convert an optional measurement value or limit to a MeasurementValue.""" + if value is None: + return None + measurement_value = MeasurementValue() + populate_measurement_value(measurement_value, value) + return measurement_value + + +def measurement_value_from_protobuf(message: Message, field: str) -> object: + """Convert an optional MeasurementValue field, or return None when it has no inline value.""" + if not message.HasField(field): + return None + measurement_value = cast(MeasurementValue, getattr(message, field)) + # Non-scalar payloads may be returned only as a data_moniker reference. + if measurement_value.WhichOneof("value") in (None, "data_moniker"): + return None + return convert_measurement_value_from_protobuf(measurement_value) + + def timestamp_to_protobuf(value: ht.datetime | None) -> PrecisionTimestamp | None: """Convert an optional hightime datetime to a PrecisionTimestamp.""" return hightime_datetime_to_protobuf(value) if value is not None else None diff --git a/src/ni/datastore/data/v2/_types/_condition.py b/src/ni/datastore/data/v2/_types/_condition.py index 916781be..037d6d2a 100644 --- a/src/ni/datastore/data/v2/_types/_condition.py +++ b/src/ni/datastore/data/v2/_types/_condition.py @@ -10,6 +10,8 @@ from ni.protobuf.types.scalar_conversion import scalar_from_protobuf, scalar_to_protobuf from nitypes.scalar import Scalar +from ni.datastore.data.v2._types._json_value import JsonValue, json_value_from_protobuf + class Condition: """An operational condition, such as a temperature or supply voltage, owned by a step. @@ -39,7 +41,8 @@ def __init__( Args: name: The condition name, unique within a step. - value: A bool, int, float, str, or Scalar value, or a JSON mapping. + value: A bool, int, float, str, or Scalar value, a JSON mapping, or a + JsonValue. condition_type (optional): The type of the condition. For example, "Environment" or "Setup". value_label (optional): A label for the value. @@ -60,7 +63,7 @@ def from_protobuf(condition_proto: ConditionProto) -> "Condition": if value_kind == "scalar": value = scalar_from_protobuf(condition_proto.value.scalar).value elif value_kind == "json_value": - value = json_format.MessageToDict(condition_proto.value.json_value.value) + value = json_value_from_protobuf(condition_proto.value.json_value) return Condition( name=condition_proto.name, value=value, @@ -90,13 +93,16 @@ def to_protobuf(self) -> ConditionProto: condition_proto.value.scalar.CopyFrom(scalar_to_protobuf(value)) if not self.units: condition_proto.units = value.units + elif isinstance(value, JsonValue): + condition_proto.value.json_value.CopyFrom(value.to_protobuf()) elif isinstance(value, Mapping): json_format.ParseDict( cast(dict[str, Any], dict(value)), condition_proto.value.json_value.value ) else: raise TypeError( - "Condition values must be bool, int, float, str, Scalar, or a JSON mapping." + "Condition values must be bool, int, float, str, Scalar, a JSON mapping, " + "or a JsonValue." ) return condition_proto diff --git a/src/ni/datastore/data/v2/_types/_json_value.py b/src/ni/datastore/data/v2/_types/_json_value.py new file mode 100644 index 00000000..6b15aa62 --- /dev/null +++ b/src/ni/datastore/data/v2/_types/_json_value.py @@ -0,0 +1,108 @@ +"""JSON value data type for the V2 Data Store Client.""" + +from __future__ import annotations + +from collections.abc import Mapping, MutableMapping +from typing import Any, cast + +from google.protobuf import json_format +from ni.measurements.data.v2.data_store_pb2 import JsonValue as JsonValueProto +from ni.protobuf.types.attribute_value_pb2 import AttributeValue + +AttributeValueType = bool | int | float | str + + +class JsonValue: + """Structured JSON used as a measurement value, limit, or condition value. + + A plain dictionary can be used instead when no attributes or schema are needed. + """ + + __slots__ = ("_value", "_attributes", "schema_id") + + @property + def value(self) -> MutableMapping[str, Any]: + """The JSON object.""" + return self._value + + @property + def attributes(self) -> MutableMapping[str, AttributeValueType]: + """Attributes that describe the JSON value.""" + return self._attributes + + def __init__( + self, + value: Mapping[str, Any], + *, + attributes: Mapping[str, AttributeValueType] | None = None, + schema_id: str = "", + ) -> None: + """Initialize a JsonValue instance. + + Args: + value: The JSON object. + attributes (optional): Attributes that describe the JSON value. + schema_id (optional): Schema for the JSON content. Reserved by the + service for future support. + """ + self._value: MutableMapping[str, Any] = dict(value) + self._attributes: MutableMapping[str, AttributeValueType] = ( + dict(attributes) if attributes is not None else {} + ) + self.schema_id = schema_id + + @staticmethod + def from_protobuf(json_value_proto: JsonValueProto) -> "JsonValue": + """Create a JsonValue instance from a protobuf JsonValue message.""" + return JsonValue( + json_format.MessageToDict(json_value_proto.value), + attributes={ + key: getattr(attribute, cast(str, attribute.WhichOneof("attribute"))) + for key, attribute in json_value_proto.attributes.items() + }, + schema_id=json_value_proto.schema_id, + ) + + def to_protobuf(self) -> JsonValueProto: + """Convert this JsonValue to a protobuf JsonValue message.""" + json_value_proto = JsonValueProto(schema_id=self.schema_id) + json_format.ParseDict(dict(self.value), json_value_proto.value) + for key, attribute in self.attributes.items(): + json_value_proto.attributes[key].CopyFrom(_attribute_to_protobuf(attribute)) + return json_value_proto + + def __eq__(self, other: object) -> bool: + """Determine equality.""" + if not isinstance(other, JsonValue): + return NotImplemented + return ( + self.value == other.value + and self.attributes == other.attributes + and self.schema_id == other.schema_id + ) + + def __repr__(self) -> str: + """Return a representation of the JsonValue.""" + return ( + f"JsonValue({dict(self.value)!r}, attributes={dict(self.attributes)!r}, " + f"schema_id={self.schema_id!r})" + ) + + +def json_value_from_protobuf(json_value_proto: JsonValueProto) -> dict[str, Any] | JsonValue: + """Return a plain dictionary, or a JsonValue when attributes or a schema are present.""" + if json_value_proto.attributes or json_value_proto.schema_id: + return JsonValue.from_protobuf(json_value_proto) + return json_format.MessageToDict(json_value_proto.value) + + +def _attribute_to_protobuf(value: AttributeValueType) -> AttributeValue: + if isinstance(value, bool): + return AttributeValue(bool_value=value) + if isinstance(value, int): + return AttributeValue(integer_value=value) + if isinstance(value, float): + return AttributeValue(double_value=value) + if isinstance(value, str): + return AttributeValue(string_value=value) + raise TypeError(f"JsonValue attributes must be bool, int, float, or str, not {type(value)}.") diff --git a/src/ni/datastore/data/v2/_types/_measurement.py b/src/ni/datastore/data/v2/_types/_measurement.py index 9aa2a455..fa305025 100644 --- a/src/ni/datastore/data/v2/_types/_measurement.py +++ b/src/ni/datastore/data/v2/_types/_measurement.py @@ -12,19 +12,23 @@ duration_to_protobuf, extension_from_protobuf, extension_to_protobuf, + measurement_value_from_protobuf, + measurement_value_to_protobuf, timestamp_from_protobuf, timestamp_to_protobuf, ) from ni.datastore.data.v2._types._condition import Condition from ni.datastore.data.v2._types._error_information import ErrorInformation from ni.datastore.data.v2._types._outcome import Outcome +from ni.datastore.metadata import HardwareItem, SoftwareItem, TestAdapter class Measurement: - """Metadata for a measurement stored in the data store. + """A measurement stored in the data store. - The measured value is not part of this type. Use - DataStoreClient.read_measurement_value to read it. + The value and limits are sent on create. Reads return them only when the + service includes an inline value; use DataStoreClient.read_measurement_value + or DataStoreClient.read_measurement_values to read them reliably. """ __slots__ = ( @@ -32,6 +36,9 @@ class Measurement: "test_result_id", "step_id", "name", + "value", + "upper_limit", + "lower_limit", "value_type", "units", "notes", @@ -41,9 +48,13 @@ class Measurement: "outcome", "error_information", "_extension", + "schema_id", "_software_item_ids", "_hardware_item_ids", "_test_adapter_ids", + "_software_items", + "_hardware_items", + "_test_adapters", "comparison_type", "_conditions", "created_at", @@ -72,6 +83,21 @@ def test_adapter_ids(self) -> MutableSequence[str]: """The test adapter IDs associated with the measurement.""" return self._test_adapter_ids + @property + def software_items(self) -> MutableSequence[SoftwareItem]: + """Software items to find or create on create, or expanded on read.""" + return self._software_items + + @property + def hardware_items(self) -> MutableSequence[HardwareItem]: + """Hardware items to find or create on create, or expanded on read.""" + return self._hardware_items + + @property + def test_adapters(self) -> MutableSequence[TestAdapter]: + """Test adapters to find or create on create, or expanded on read.""" + return self._test_adapters + @property def conditions(self) -> MutableSequence[Condition]: """Output only. The conditions copied from the owning step.""" @@ -84,6 +110,9 @@ def __init__( test_result_id: str = "", step_id: str = "", name: str = "", + value: object = None, + upper_limit: object = None, + lower_limit: object = None, value_type: str = "", units: str = "", notes: str = "", @@ -93,9 +122,13 @@ def __init__( outcome: Outcome = Outcome.UNSPECIFIED, error_information: ErrorInformation | None = None, extension: Mapping[str, Any] | None = None, + schema_id: str = "", software_item_ids: Iterable[str] | None = None, hardware_item_ids: Iterable[str] | None = None, test_adapter_ids: Iterable[str] | None = None, + software_items: Iterable[SoftwareItem] | None = None, + hardware_items: Iterable[HardwareItem] | None = None, + test_adapters: Iterable[TestAdapter] | None = None, comparison_type: str = "", conditions: Iterable[Condition] | None = None, created_at: ht.datetime | None = None, @@ -110,6 +143,12 @@ def __init__( test_result_id (optional): ID of the test result the measurement belongs to. step_id (optional): ID of the step the measurement belongs to. name (optional): The name of the measurement. + value (optional): The measured value. See DataStoreClient.create_measurement + for the supported types. + upper_limit (optional): The publisher-supplied upper limit. Any value type + supported for value is accepted. + lower_limit (optional): The publisher-supplied lower limit. Any value type + supported for value is accepted. value_type (optional): Output only. The service-generated value type. units (optional): Engineering units of the value. notes (optional): Additional notes about the measurement. @@ -119,9 +158,14 @@ def __init__( outcome (optional): The outcome of the measurement. error_information (optional): Error details for the measurement. extension (optional): Custom JSON data for the measurement. + schema_id (optional): Schema that governs the extension. Reserved by the + service for future support. software_item_ids (optional): IDs of software items used for the measurement. hardware_item_ids (optional): IDs of hardware items used for the measurement. test_adapter_ids (optional): IDs of test adapters used for the measurement. + software_items (optional): Software items to find or create on create. + hardware_items (optional): Hardware items to find or create on create. + test_adapters (optional): Test adapters to find or create on create. comparison_type (optional): The publisher-defined comparison operation. conditions (optional): Output only. Conditions copied from the owning step. created_at (optional): Output only. When the service created the measurement. @@ -133,6 +177,9 @@ def __init__( self.test_result_id = test_result_id self.step_id = step_id self.name = name + self.value = value + self.upper_limit = upper_limit + self.lower_limit = lower_limit self.value_type = value_type self.units = units self.notes = notes @@ -142,6 +189,7 @@ def __init__( self.outcome = outcome self.error_information = error_information self._extension: MutableMapping[str, Any] = dict(extension) if extension is not None else {} + self.schema_id = schema_id self._software_item_ids: MutableSequence[str] = ( list(software_item_ids) if software_item_ids is not None else [] ) @@ -151,6 +199,15 @@ def __init__( self._test_adapter_ids: MutableSequence[str] = ( list(test_adapter_ids) if test_adapter_ids is not None else [] ) + self._software_items: MutableSequence[SoftwareItem] = ( + list(software_items) if software_items is not None else [] + ) + self._hardware_items: MutableSequence[HardwareItem] = ( + list(hardware_items) if hardware_items is not None else [] + ) + self._test_adapters: MutableSequence[TestAdapter] = ( + list(test_adapters) if test_adapters is not None else [] + ) self.comparison_type = comparison_type self._conditions: MutableSequence[Condition] = ( list(conditions) if conditions is not None else [] @@ -168,6 +225,9 @@ def from_protobuf(measurement_proto: MeasurementProto) -> "Measurement": test_result_id=measurement_proto.test_result_id, step_id=measurement_proto.step_id, name=measurement_proto.name, + value=measurement_value_from_protobuf(measurement_proto, "value"), + upper_limit=measurement_value_from_protobuf(measurement_proto, "upper_limit"), + lower_limit=measurement_value_from_protobuf(measurement_proto, "lower_limit"), value_type=measurement_proto.value_type, units=measurement_proto.units, notes=measurement_proto.notes, @@ -181,9 +241,17 @@ def from_protobuf(measurement_proto: MeasurementProto) -> "Measurement": else None ), extension=extension_from_protobuf(measurement_proto.extension), + schema_id=measurement_proto.schema_id, software_item_ids=measurement_proto.software_item_ids, hardware_item_ids=measurement_proto.hardware_item_ids, test_adapter_ids=measurement_proto.test_adapter_ids, + software_items=[ + SoftwareItem.from_protobuf(i) for i in measurement_proto.software_items + ], + hardware_items=[ + HardwareItem.from_protobuf(i) for i in measurement_proto.hardware_items + ], + test_adapters=[TestAdapter.from_protobuf(i) for i in measurement_proto.test_adapters], comparison_type=measurement_proto.comparison_type, conditions=[Condition.from_protobuf(c) for c in measurement_proto.conditions], created_at=timestamp_from_protobuf(measurement_proto, "created_at"), @@ -195,14 +263,16 @@ def from_protobuf(measurement_proto: MeasurementProto) -> "Measurement": def to_protobuf(self) -> MeasurementProto: """Convert this Measurement to a protobuf Measurement message. - The value and output-only fields, such as value_type and conditions, are - not included. + Output-only fields, such as value_type and conditions, are not included. """ return MeasurementProto( id=self.id, test_result_id=self.test_result_id, step_id=self.step_id, name=self.name, + value=measurement_value_to_protobuf(self.value), + upper_limit=measurement_value_to_protobuf(self.upper_limit), + lower_limit=measurement_value_to_protobuf(self.lower_limit), units=self.units, notes=self.notes, start_date_time=timestamp_to_protobuf(self.start_date_time), @@ -213,9 +283,13 @@ def to_protobuf(self) -> MeasurementProto: self.error_information.to_protobuf() if self.error_information is not None else None ), extension=extension_to_protobuf(self.extension), + schema_id=self.schema_id, software_item_ids=self.software_item_ids, hardware_item_ids=self.hardware_item_ids, test_adapter_ids=self.test_adapter_ids, + software_items=[item.to_protobuf() for item in self.software_items], + hardware_items=[item.to_protobuf() for item in self.hardware_items], + test_adapters=[item.to_protobuf() for item in self.test_adapters], comparison_type=self.comparison_type, ) @@ -228,6 +302,9 @@ def __eq__(self, other: object) -> bool: and self.test_result_id == other.test_result_id and self.step_id == other.step_id and self.name == other.name + and _values_equal(self.value, other.value) + and _values_equal(self.upper_limit, other.upper_limit) + and _values_equal(self.lower_limit, other.lower_limit) and self.value_type == other.value_type and self.units == other.units and self.notes == other.notes @@ -237,9 +314,13 @@ def __eq__(self, other: object) -> bool: and self.outcome == other.outcome and self.error_information == other.error_information and self.extension == other.extension + and self.schema_id == other.schema_id and self.software_item_ids == other.software_item_ids and self.hardware_item_ids == other.hardware_item_ids and self.test_adapter_ids == other.test_adapter_ids + and self.software_items == other.software_items + and self.hardware_items == other.hardware_items + and self.test_adapters == other.test_adapters and self.comparison_type == other.comparison_type and self.conditions == other.conditions and self.created_at == other.created_at @@ -251,3 +332,10 @@ def __eq__(self, other: object) -> bool: def __str__(self) -> str: """Return a string representation of the Measurement.""" return str(self.to_protobuf()) + + +def _values_equal(left: object, right: object) -> bool: + # Waveform equality returns arrays, so compare through the protobuf form. + if left is None or right is None: + return left is right + return measurement_value_to_protobuf(left) == measurement_value_to_protobuf(right) diff --git a/src/ni/datastore/data/v2/_types/_query_results.py b/src/ni/datastore/data/v2/_types/_query_results.py new file mode 100644 index 00000000..0c141766 --- /dev/null +++ b/src/ni/datastore/data/v2/_types/_query_results.py @@ -0,0 +1,25 @@ +"""Query results data type for the V2 Data Store Client.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import TypeVar + +T = TypeVar("T") + + +class QueryResults(list[T]): + """A list of query results with the service's next-page link.""" + + next_link: str + """The OData link to the next page of results, or an empty string on the last page.""" + + def __init__(self, items: Iterable[T] = (), next_link: str = "") -> None: + """Initialize a QueryResults instance. + + Args: + items: The results on this page. + next_link: The OData link to the next page, or an empty string. + """ + super().__init__(items) + self.next_link = next_link diff --git a/src/ni/datastore/data/v2/_types/_step.py b/src/ni/datastore/data/v2/_types/_step.py index 45f3e836..c97a5f2e 100644 --- a/src/ni/datastore/data/v2/_types/_step.py +++ b/src/ni/datastore/data/v2/_types/_step.py @@ -17,15 +17,18 @@ ) from ni.datastore.data.v2._types._condition import Condition from ni.datastore.data.v2._types._error_information import ErrorInformation +from ni.datastore.data.v2._types._measurement import Measurement from ni.datastore.data.v2._types._outcome import Outcome from ni.datastore.data.v2._types._status import Status +from ni.datastore.metadata import Test class Step: """Information about one execution of a test step. A step belongs to a test result and owns measurements and conditions. Steps - can be nested by using parent_step_id. + can be nested by using parent_step_id, or created together by using + children and measurements. """ __slots__ = ( @@ -34,6 +37,7 @@ class Step: "parent_step_id", "test_result_id", "test_id", + "test", "step_type", "notes", "start_date_time", @@ -44,8 +48,11 @@ class Step: "link", "_keywords", "_extension", + "schema_id", "error_information", "_conditions", + "_measurements", + "_children", "_path", "_path_ids", "created_at", @@ -69,6 +76,16 @@ def conditions(self) -> MutableSequence[Condition]: """The conditions owned by the step.""" return self._conditions + @property + def measurements(self) -> MutableSequence[Measurement]: + """Measurements created with the step, or expanded on read.""" + return self._measurements + + @property + def children(self) -> MutableSequence[Step]: + """Child steps created with the step, or expanded on read.""" + return self._children + @property def path(self) -> MutableSequence[str]: """Output only. The step-name path, including this step.""" @@ -87,6 +104,7 @@ def __init__( parent_step_id: str = "", test_result_id: str = "", test_id: str = "", + test: Test | None = None, step_type: str = "", notes: str = "", start_date_time: ht.datetime | None = None, @@ -97,8 +115,11 @@ def __init__( link: str = "", keywords: Iterable[str] | None = None, extension: Mapping[str, Any] | None = None, + schema_id: str = "", error_information: ErrorInformation | None = None, conditions: Iterable[Condition] | None = None, + measurements: Iterable[Measurement] | None = None, + children: Iterable[Step] | None = None, path: Iterable[str] | None = None, path_ids: Iterable[str] | None = None, created_at: ht.datetime | None = None, @@ -114,6 +135,7 @@ def __init__( parent_step_id (optional): ID of the parent step if this is a nested step. test_result_id (optional): ID of the test result this step belongs to. test_id (optional): ID or alias of the test associated with this step. + test (optional): Test to find or create on create, or expanded on read. step_type (optional): Free-form classification of the step. notes (optional): Additional notes about the step. start_date_time (optional): The start date and time of the step execution. @@ -124,8 +146,12 @@ def __init__( link (optional): A URI associated with the step. keywords (optional): Labels used to classify the step. extension (optional): Custom JSON data for the step. + schema_id (optional): Schema that governs the extension. Reserved by the + service for future support. error_information (optional): Error details for the step. conditions (optional): Conditions created with the step. + measurements (optional): Measurements created with the step. + children (optional): Child steps created with the step. path (optional): Output only. The step-name path, including this step. path_ids (optional): Output only. The step-identifier path, including this step. created_at (optional): Output only. When the service created the step. @@ -138,6 +164,7 @@ def __init__( self.parent_step_id = parent_step_id self.test_result_id = test_result_id self.test_id = test_id + self.test = test self.step_type = step_type self.notes = notes self.start_date_time = start_date_time @@ -148,10 +175,15 @@ def __init__( self.link = link self._keywords: MutableSequence[str] = list(keywords) if keywords is not None else [] self._extension: MutableMapping[str, Any] = dict(extension) if extension is not None else {} + self.schema_id = schema_id self.error_information = error_information self._conditions: MutableSequence[Condition] = ( list(conditions) if conditions is not None else [] ) + self._measurements: MutableSequence[Measurement] = ( + list(measurements) if measurements is not None else [] + ) + self._children: MutableSequence[Step] = list(children) if children is not None else [] self._path: MutableSequence[str] = list(path) if path is not None else [] self._path_ids: MutableSequence[str] = list(path_ids) if path_ids is not None else [] self.created_at = created_at @@ -168,6 +200,7 @@ def from_protobuf(step_proto: StepProto) -> "Step": parent_step_id=step_proto.parent_step_id, test_result_id=step_proto.test_result_id, test_id=step_proto.test_id, + test=Test.from_protobuf(step_proto.test) if step_proto.HasField("test") else None, step_type=step_proto.step_type, notes=step_proto.notes, start_date_time=timestamp_from_protobuf(step_proto, "start_date_time"), @@ -178,12 +211,15 @@ def from_protobuf(step_proto: StepProto) -> "Step": link=step_proto.link, keywords=step_proto.keywords, extension=extension_from_protobuf(step_proto.extension), + schema_id=step_proto.schema_id, error_information=( ErrorInformation.from_protobuf(step_proto.error_information) if step_proto.HasField("error_information") else None ), conditions=[Condition.from_protobuf(c) for c in step_proto.conditions], + measurements=[Measurement.from_protobuf(m) for m in step_proto.measurements], + children=[Step.from_protobuf(child) for child in step_proto.children], path=step_proto.path, path_ids=step_proto.path_ids, created_at=timestamp_from_protobuf(step_proto, "created_at"), @@ -203,6 +239,7 @@ def to_protobuf(self) -> StepProto: parent_step_id=self.parent_step_id, test_result_id=self.test_result_id, test_id=self.test_id, + test=self.test.to_protobuf() if self.test is not None else None, step_type=self.step_type, notes=self.notes, start_date_time=timestamp_to_protobuf(self.start_date_time), @@ -213,10 +250,13 @@ def to_protobuf(self) -> StepProto: link=self.link, keywords=self.keywords, extension=extension_to_protobuf(self.extension), + schema_id=self.schema_id, error_information=( self.error_information.to_protobuf() if self.error_information is not None else None ), conditions=[condition.to_protobuf() for condition in self.conditions], + measurements=[measurement.to_protobuf() for measurement in self.measurements], + children=[child.to_protobuf() for child in self.children], ) def __eq__(self, other: object) -> bool: @@ -229,6 +269,7 @@ def __eq__(self, other: object) -> bool: and self.parent_step_id == other.parent_step_id and self.test_result_id == other.test_result_id and self.test_id == other.test_id + and self.test == other.test and self.step_type == other.step_type and self.notes == other.notes and self.start_date_time == other.start_date_time @@ -239,8 +280,11 @@ def __eq__(self, other: object) -> bool: and self.link == other.link and self.keywords == other.keywords and self.extension == other.extension + and self.schema_id == other.schema_id and self.error_information == other.error_information and self.conditions == other.conditions + and self.measurements == other.measurements + and self.children == other.children and self.path == other.path and self.path_ids == other.path_ids and self.created_at == other.created_at diff --git a/src/ni/datastore/data/v2/_types/_test_result.py b/src/ni/datastore/data/v2/_types/_test_result.py index 4c7fbb25..0c334e8f 100644 --- a/src/ni/datastore/data/v2/_types/_test_result.py +++ b/src/ni/datastore/data/v2/_types/_test_result.py @@ -18,6 +18,17 @@ from ni.datastore.data.v2._types._error_information import ErrorInformation from ni.datastore.data.v2._types._outcome import Outcome from ni.datastore.data.v2._types._status import Status +from ni.datastore.data.v2._types._step import Step +from ni.datastore.metadata import ( + HardwareItem, + Operator, + SoftwareItem, + TestAdapter, + TestDescription, + TestStation, + Uut, + UutInstance, +) class TestResult: @@ -36,9 +47,17 @@ class TestResult: "operator_id", "test_station_id", "test_description_id", + "uut_instance", + "uut", + "operator", + "test_station", + "test_description", "_software_item_ids", "_hardware_item_ids", "_test_adapter_ids", + "_software_items", + "_hardware_items", + "_test_adapters", "start_date_time", "end_date_time", "duration", @@ -47,7 +66,9 @@ class TestResult: "link", "_keywords", "_extension", + "schema_id", "error_information", + "_steps", "created_at", "updated_at", "created_by", @@ -69,6 +90,26 @@ def test_adapter_ids(self) -> MutableSequence[str]: """The test adapter IDs associated with the test result.""" return self._test_adapter_ids + @property + def software_items(self) -> MutableSequence[SoftwareItem]: + """Software items to find or create on create, or expanded on read.""" + return self._software_items + + @property + def hardware_items(self) -> MutableSequence[HardwareItem]: + """Hardware items to find or create on create, or expanded on read.""" + return self._hardware_items + + @property + def test_adapters(self) -> MutableSequence[TestAdapter]: + """Test adapters to find or create on create, or expanded on read.""" + return self._test_adapters + + @property + def steps(self) -> MutableSequence[Step]: + """Steps created with the test result, or expanded on read.""" + return self._steps + @property def keywords(self) -> MutableSequence[str]: """Labels used to classify the test result.""" @@ -89,9 +130,17 @@ def __init__( operator_id: str = "", test_station_id: str = "", test_description_id: str = "", + uut_instance: UutInstance | None = None, + uut: Uut | None = None, + operator: Operator | None = None, + test_station: TestStation | None = None, + test_description: TestDescription | None = None, software_item_ids: Iterable[str] | None = None, hardware_item_ids: Iterable[str] | None = None, test_adapter_ids: Iterable[str] | None = None, + software_items: Iterable[SoftwareItem] | None = None, + hardware_items: Iterable[HardwareItem] | None = None, + test_adapters: Iterable[TestAdapter] | None = None, start_date_time: ht.datetime | None = None, end_date_time: ht.datetime | None = None, duration: ht.timedelta | None = None, @@ -100,7 +149,9 @@ def __init__( link: str = "", keywords: Iterable[str] | None = None, extension: Mapping[str, Any] | None = None, + schema_id: str = "", error_information: ErrorInformation | None = None, + steps: Iterable[Step] | None = None, created_at: ht.datetime | None = None, updated_at: ht.datetime | None = None, created_by: str = "", @@ -116,9 +167,20 @@ def __init__( operator_id (optional): ID or alias of the operator who ran the test. test_station_id (optional): ID or alias of the test station used. test_description_id (optional): ID or alias of the test description executed. + uut_instance (optional): UUT instance to find or create on create, or expanded + on read. + uut (optional): UUT to find or create on create, or expanded on read. + operator (optional): Operator to find or create on create, or expanded on read. + test_station (optional): Test station to find or create on create, or expanded + on read. + test_description (optional): Test description to find or create on create, or + expanded on read. software_item_ids (optional): IDs of software items used in the test. hardware_item_ids (optional): IDs of hardware items used in the test. test_adapter_ids (optional): IDs of test adapters used in the test. + software_items (optional): Software items to find or create on create. + hardware_items (optional): Hardware items to find or create on create. + test_adapters (optional): Test adapters to find or create on create. start_date_time (optional): The start date and time of the test execution. end_date_time (optional): The end date and time of the test execution. duration (optional): The duration of the test execution. @@ -127,7 +189,10 @@ def __init__( link (optional): A URI associated with the test result. keywords (optional): Labels used to classify the test result. extension (optional): Custom JSON data for the test result. + schema_id (optional): Schema that governs the extension. Reserved by the + service for future support. error_information (optional): Error details for the test result. + steps (optional): Steps created with the test result. created_at (optional): Output only. When the service created the test result. updated_at (optional): Output only. When the service last updated the test result. created_by (optional): Output only. Who created the test result. @@ -140,6 +205,11 @@ def __init__( self.operator_id = operator_id self.test_station_id = test_station_id self.test_description_id = test_description_id + self.uut_instance = uut_instance + self.uut = uut + self.operator = operator + self.test_station = test_station + self.test_description = test_description self._software_item_ids: MutableSequence[str] = ( list(software_item_ids) if software_item_ids is not None else [] ) @@ -149,6 +219,15 @@ def __init__( self._test_adapter_ids: MutableSequence[str] = ( list(test_adapter_ids) if test_adapter_ids is not None else [] ) + self._software_items: MutableSequence[SoftwareItem] = ( + list(software_items) if software_items is not None else [] + ) + self._hardware_items: MutableSequence[HardwareItem] = ( + list(hardware_items) if hardware_items is not None else [] + ) + self._test_adapters: MutableSequence[TestAdapter] = ( + list(test_adapters) if test_adapters is not None else [] + ) self.start_date_time = start_date_time self.end_date_time = end_date_time self.duration = duration @@ -157,7 +236,9 @@ def __init__( self.link = link self._keywords: MutableSequence[str] = list(keywords) if keywords is not None else [] self._extension: MutableMapping[str, Any] = dict(extension) if extension is not None else {} + self.schema_id = schema_id self.error_information = error_information + self._steps: MutableSequence[Step] = list(steps) if steps is not None else [] self.created_at = created_at self.updated_at = updated_at self.created_by = created_by @@ -166,34 +247,57 @@ def __init__( @staticmethod def from_protobuf(test_result_proto: TestResultProto) -> "TestResult": """Create a TestResult instance from a protobuf TestResult message.""" + proto = test_result_proto return TestResult( - name=test_result_proto.name, - id=test_result_proto.id, - uut_instance_id=test_result_proto.uut_instance_id, - uut_id=test_result_proto.uut_id, - operator_id=test_result_proto.operator_id, - test_station_id=test_result_proto.test_station_id, - test_description_id=test_result_proto.test_description_id, - software_item_ids=test_result_proto.software_item_ids, - hardware_item_ids=test_result_proto.hardware_item_ids, - test_adapter_ids=test_result_proto.test_adapter_ids, - start_date_time=timestamp_from_protobuf(test_result_proto, "start_date_time"), - end_date_time=timestamp_from_protobuf(test_result_proto, "end_date_time"), - duration=duration_from_protobuf(test_result_proto, "duration"), - outcome=Outcome.from_protobuf(test_result_proto.outcome), - status=Status.from_protobuf(test_result_proto.status), - link=test_result_proto.link, - keywords=test_result_proto.keywords, - extension=extension_from_protobuf(test_result_proto.extension), + name=proto.name, + id=proto.id, + uut_instance_id=proto.uut_instance_id, + uut_id=proto.uut_id, + operator_id=proto.operator_id, + test_station_id=proto.test_station_id, + test_description_id=proto.test_description_id, + uut_instance=( + UutInstance.from_protobuf(proto.uut_instance) + if proto.HasField("uut_instance") + else None + ), + uut=Uut.from_protobuf(proto.uut) if proto.HasField("uut") else None, + operator=Operator.from_protobuf(proto.operator) if proto.HasField("operator") else None, + test_station=( + TestStation.from_protobuf(proto.test_station) + if proto.HasField("test_station") + else None + ), + test_description=( + TestDescription.from_protobuf(proto.test_description) + if proto.HasField("test_description") + else None + ), + software_item_ids=proto.software_item_ids, + hardware_item_ids=proto.hardware_item_ids, + test_adapter_ids=proto.test_adapter_ids, + software_items=[SoftwareItem.from_protobuf(i) for i in proto.software_items], + hardware_items=[HardwareItem.from_protobuf(i) for i in proto.hardware_items], + test_adapters=[TestAdapter.from_protobuf(i) for i in proto.test_adapters], + start_date_time=timestamp_from_protobuf(proto, "start_date_time"), + end_date_time=timestamp_from_protobuf(proto, "end_date_time"), + duration=duration_from_protobuf(proto, "duration"), + outcome=Outcome.from_protobuf(proto.outcome), + status=Status.from_protobuf(proto.status), + link=proto.link, + keywords=proto.keywords, + extension=extension_from_protobuf(proto.extension), + schema_id=proto.schema_id, error_information=( - ErrorInformation.from_protobuf(test_result_proto.error_information) - if test_result_proto.HasField("error_information") + ErrorInformation.from_protobuf(proto.error_information) + if proto.HasField("error_information") else None ), - created_at=timestamp_from_protobuf(test_result_proto, "created_at"), - updated_at=timestamp_from_protobuf(test_result_proto, "updated_at"), - created_by=test_result_proto.created_by, - updated_by=test_result_proto.updated_by, + steps=[Step.from_protobuf(step) for step in proto.steps], + created_at=timestamp_from_protobuf(proto, "created_at"), + updated_at=timestamp_from_protobuf(proto, "updated_at"), + created_by=proto.created_by, + updated_by=proto.updated_by, ) def to_protobuf(self) -> TestResultProto: @@ -209,9 +313,17 @@ def to_protobuf(self) -> TestResultProto: operator_id=self.operator_id, test_station_id=self.test_station_id, test_description_id=self.test_description_id, + uut_instance=_optional_to_protobuf(self.uut_instance), + uut=_optional_to_protobuf(self.uut), + operator=_optional_to_protobuf(self.operator), + test_station=_optional_to_protobuf(self.test_station), + test_description=_optional_to_protobuf(self.test_description), software_item_ids=self.software_item_ids, hardware_item_ids=self.hardware_item_ids, test_adapter_ids=self.test_adapter_ids, + software_items=[item.to_protobuf() for item in self.software_items], + hardware_items=[item.to_protobuf() for item in self.hardware_items], + test_adapters=[item.to_protobuf() for item in self.test_adapters], start_date_time=timestamp_to_protobuf(self.start_date_time), end_date_time=timestamp_to_protobuf(self.end_date_time), duration=duration_to_protobuf(self.duration), @@ -220,9 +332,11 @@ def to_protobuf(self) -> TestResultProto: link=self.link, keywords=self.keywords, extension=extension_to_protobuf(self.extension), + schema_id=self.schema_id, error_information=( self.error_information.to_protobuf() if self.error_information is not None else None ), + steps=[step.to_protobuf() for step in self.steps], ) def __eq__(self, other: object) -> bool: @@ -237,9 +351,17 @@ def __eq__(self, other: object) -> bool: and self.operator_id == other.operator_id and self.test_station_id == other.test_station_id and self.test_description_id == other.test_description_id + and self.uut_instance == other.uut_instance + and self.uut == other.uut + and self.operator == other.operator + and self.test_station == other.test_station + and self.test_description == other.test_description and self.software_item_ids == other.software_item_ids and self.hardware_item_ids == other.hardware_item_ids and self.test_adapter_ids == other.test_adapter_ids + and self.software_items == other.software_items + and self.hardware_items == other.hardware_items + and self.test_adapters == other.test_adapters and self.start_date_time == other.start_date_time and self.end_date_time == other.end_date_time and self.duration == other.duration @@ -248,7 +370,9 @@ def __eq__(self, other: object) -> bool: and self.link == other.link and self.keywords == other.keywords and self.extension == other.extension + and self.schema_id == other.schema_id and self.error_information == other.error_information + and self.steps == other.steps and self.created_at == other.created_at and self.updated_at == other.updated_at and self.created_by == other.created_by @@ -258,3 +382,7 @@ def __eq__(self, other: object) -> bool: def __str__(self) -> str: """Return a string representation of the TestResult.""" return str(self.to_protobuf()) + + +def _optional_to_protobuf(item: Any) -> Any: + return item.to_protobuf() if item is not None else None diff --git a/tests/unit/data/test_v2_data_store_client.py b/tests/unit/data/test_v2_data_store_client.py index 72350ea4..e19c843a 100644 --- a/tests/unit/data/test_v2_data_store_client.py +++ b/tests/unit/data/test_v2_data_store_client.py @@ -23,8 +23,10 @@ Condition, DataStoreClient, ErrorInformation, + JsonValue, Measurement, Outcome, + QueryResults, Status, Step, TestResult, @@ -163,6 +165,30 @@ def test___create_measurement___converts_value_and_metadata( assert list(request.measurement.software_item_ids) == ["sw"] +def test___create_measurement___accepts_inline_metadata_objects( + client: DataStoreClient, service_client: Any +) -> None: + from ni.datastore.metadata import HardwareItem, SoftwareItem, TestAdapter + + software = SoftwareItem(product="p", version="1") + hardware = HardwareItem(manufacturer="m", model="x", serial_number="1") + adapter = TestAdapter(name="adapter") + + client.create_measurement( + "voltage", + 5.0, + "step-id", + software_items=[software], + hardware_items=[hardware], + test_adapters=[adapter], + ) + + measurement = _sent(service_client.create_measurement).measurement + assert list(measurement.software_items) == [software.to_protobuf()] + assert list(measurement.hardware_items) == [hardware.to_protobuf()] + assert list(measurement.test_adapters) == [adapter.to_protobuf()] + + def test___create_measurement___converts_mapping_to_json_value( client: DataStoreClient, service_client: Any ) -> None: @@ -376,6 +402,52 @@ def test___query_measurements___returns_models( assert measurements == [Measurement(id="m-id", name="voltage")] +@pytest.mark.parametrize( + ("method", "response_type", "items_field"), + [ + ("query_test_results", "QueryTestResultsResponse", "test_results"), + ("query_steps", "QueryStepsResponse", "steps"), + ("query_measurements", "QueryMeasurementsResponse", "measurements"), + ], +) +def test___query___returns_next_link( + client: DataStoreClient, + service_client: Any, + method: str, + response_type: str, + items_field: str, +) -> None: + getattr(service_client, method).return_value = getattr(data_store_service_pb2, response_type)( + next_link="https://next" + ) + + results = getattr(client, method)() + + assert isinstance(results, QueryResults) + assert (list(results), results.next_link) == ([], "https://next") + + +def test___query___next_link_is_empty_on_last_page( + client: DataStoreClient, service_client: Any +) -> None: + service_client.query_steps.return_value = data_store_service_pb2.QueryStepsResponse() + + assert client.query_steps().next_link == "" + + +def test___json_value_measurement___sends_and_reads_attributes( + client: DataStoreClient, service_client: Any +) -> None: + service_client.create_measurement.return_value = data_store_pb2.Measurement(id="m-id") + value = JsonValue({"serial": "SN-1"}, attributes={"source": "dmm", "rev": 2}) + + client.create_measurement("config", value, "step-id") + + sent = _sent(service_client.create_measurement).measurement.value + _read_response(service_client, sent) + assert client.read_measurement_value(Measurement(id="m-id")) == value + + # ---------------------------------------------------------------- update @@ -509,6 +581,366 @@ def test___update___rejects_immutable_field_before_rpc( getattr(service_client, method).assert_not_called() +# ---------------------------------------------------------------- delete + + +@pytest.mark.parametrize("force", [False, True]) +def test___delete_test_result___sends_id_and_force( + client: DataStoreClient, service_client: Any, force: bool +) -> None: + client.delete_test_result("tr-id", force=force) + + assert _sent(service_client.delete_test_result) == ( + data_store_service_pb2.DeleteTestResultRequest(test_result_id="tr-id", force=force) + ) + + +@pytest.mark.parametrize("force", [False, True]) +def test___delete_step___sends_id_and_force( + client: DataStoreClient, service_client: Any, force: bool +) -> None: + client.delete_step("step-id", force=force) + + assert _sent(service_client.delete_step) == data_store_service_pb2.DeleteStepRequest( + step_id="step-id", force=force + ) + + +def test___delete_measurement___sends_id(client: DataStoreClient, service_client: Any) -> None: + client.delete_measurement("m-id") + + assert _sent(service_client.delete_measurement) == ( + data_store_service_pb2.DeleteMeasurementRequest(measurement_id="m-id") + ) + + +def test___delete___defaults_to_no_force(client: DataStoreClient, service_client: Any) -> None: + client.delete_test_result("tr-id") + client.delete_step("step-id") + + assert _sent(service_client.delete_test_result).force is False + assert _sent(service_client.delete_step).force is False + + +# ---------------------------------------------------------------- conditions and metadata links + + +def test___remove_condition___sends_name_and_returns_step( + client: DataStoreClient, service_client: Any +) -> None: + service_client.remove_condition.return_value = data_store_service_pb2.RemoveConditionResponse( + step=data_store_pb2.Step(id="step-id") + ) + + step = client.remove_condition("step-id", "temperature") + + assert _sent(service_client.remove_condition) == data_store_service_pb2.RemoveConditionRequest( + step_id="step-id", condition_name="temperature" + ) + assert step.id == "step-id" + + +@pytest.mark.parametrize( + ("method", "id_field", "response_type", "response_field"), + [ + ( + "add_test_result_metadata", + "test_result_id", + "AddTestResultMetadataResponse", + "test_result", + ), + ( + "remove_test_result_metadata", + "test_result_id", + "RemoveTestResultMetadataResponse", + "test_result", + ), + ( + "add_measurement_metadata", + "measurement_id", + "AddMeasurementMetadataResponse", + "measurement", + ), + ( + "remove_measurement_metadata", + "measurement_id", + "RemoveMeasurementMetadataResponse", + "measurement", + ), + ], +) +def test___metadata_link_methods___send_ids_and_return_model( + client: DataStoreClient, + service_client: Any, + method: str, + id_field: str, + response_type: str, + response_field: str, +) -> None: + response = getattr(data_store_service_pb2, response_type)() + getattr(response, response_field).id = "resource-id" + getattr(service_client, method).return_value = response + + result = getattr(client, method)( + "resource-id", software_item_ids=["sw"], hardware_item_ids=["hw"], test_adapter_ids=["ta"] + ) + + request = _sent(getattr(service_client, method)) + assert getattr(request, id_field) == "resource-id" + assert ( + list(request.software_item_ids), + list(request.hardware_item_ids), + list(request.test_adapter_ids), + ) == (["sw"], ["hw"], ["ta"]) + assert result.id == "resource-id" + + +# ---------------------------------------------------------------- limits and value reads + + +def test___create_measurement___sends_limits_and_extra_fields( + client: DataStoreClient, service_client: Any +) -> None: + service_client.create_measurement.return_value = data_store_pb2.Measurement(id="m-id") + + client.create_measurement( + "voltage", + 5.0, + "step-id", + upper_limit=5.5, + lower_limit=Vector([4.5, 4.6]), + comparison_type="GELE", + extension={"lot": "A1"}, + schema_id="schema", + ) + + sent = _sent(service_client.create_measurement).measurement + assert sent.value.scalar.double_value == 5.0 + assert sent.upper_limit.scalar.double_value == 5.5 + assert sent.lower_limit.vector == vector_to_protobuf(Vector([4.5, 4.6])) + assert (sent.comparison_type, sent.schema_id) == ("GELE", "schema") + assert sent.extension["lot"] == "A1" + + +def test___create_measurement___omits_limits_when_not_given( + client: DataStoreClient, service_client: Any +) -> None: + service_client.create_measurement.return_value = data_store_pb2.Measurement(id="m-id") + + client.create_measurement("voltage", 5.0, "step-id") + + sent = _sent(service_client.create_measurement).measurement + assert not sent.HasField("upper_limit") + assert not sent.HasField("lower_limit") + + +def test___read_measurement_values___returns_value_and_limits( + client: DataStoreClient, service_client: Any +) -> None: + response = data_store_service_pb2.ReadMeasurementValuesResponse() + response.value.scalar.double_value = 5.0 + response.upper_limit.scalar.double_value = 5.5 + service_client.read_measurement_values.return_value = response + + result = client.read_measurement_values(Measurement(id="m-id")) + + assert _sent(service_client.read_measurement_values) == ( + data_store_service_pb2.ReadMeasurementValuesRequest(measurement_id="m-id") + ) + assert (result.id, result.value, result.upper_limit, result.lower_limit) == ( + "m-id", + 5.0, + 5.5, + None, + ) + + +def test___read_measurement_values___sends_read_fields( + client: DataStoreClient, service_client: Any +) -> None: + service_client.read_measurement_values.return_value = ( + data_store_service_pb2.ReadMeasurementValuesResponse() + ) + + client.read_measurement_values(Measurement(id="m-id"), read_fields=["upper_limit"]) + + assert list(_sent(service_client.read_measurement_values).read_mask.paths) == ["upper_limit"] + + +# ---------------------------------------------------------------- batch create + + +def test___batch_create_test_results___sends_models_and_returns_ids( + client: DataStoreClient, service_client: Any +) -> None: + service_client.batch_create_test_results.return_value = ( + data_store_service_pb2.BatchCreateTestResultsResponse( + test_results=[data_store_pb2.TestResult(id="a"), data_store_pb2.TestResult(id="b")] + ) + ) + nested = TestResult("tr-1", steps=[Step("s", measurements=[Measurement(name="m", value=1.0)])]) + + ids = client.batch_create_test_results([nested, TestResult("tr-2")]) + + requests = _sent(service_client.batch_create_test_results).requests + assert [r.test_result.name for r in requests] == ["tr-1", "tr-2"] + assert requests[0].test_result.steps[0].measurements[0].value.scalar.double_value == 1.0 + assert ids == ["a", "b"] + + +def test___batch_create_steps___sends_models_and_returns_ids( + client: DataStoreClient, service_client: Any +) -> None: + service_client.batch_create_steps.return_value = ( + data_store_service_pb2.BatchCreateStepsResponse(steps=[data_store_pb2.Step(id="a")]) + ) + + ids = client.batch_create_steps([Step("parent", children=[Step("child")])]) + + [request] = _sent(service_client.batch_create_steps).requests + assert request.step.children[0].name == "child" + assert ids == ["a"] + + +def test___batch_create_measurements___sends_values_and_returns_ids( + client: DataStoreClient, service_client: Any +) -> None: + service_client.batch_create_measurements.return_value = ( + data_store_service_pb2.BatchCreateMeasurementsResponse( + measurements=[data_store_pb2.Measurement(id="a"), data_store_pb2.Measurement(id="b")] + ) + ) + + ids = client.batch_create_measurements( + [ + Measurement(name="m1", step_id="s1", value=1.0, upper_limit=2.0), + Measurement(name="m2", step_id="s2", value="PASS"), + ] + ) + + requests = _sent(service_client.batch_create_measurements).requests + assert requests[0].measurement.upper_limit.scalar.double_value == 2.0 + assert requests[1].measurement.value.scalar.string_value == "PASS" + assert ids == ["a", "b"] + + +# ---------------------------------------------------------------- batch update + + +@pytest.mark.parametrize( + ("method", "response_type", "response_field", "resource_field", "proto_type"), + [ + ( + "batch_update_test_results", + "BatchUpdateTestResultsResponse", + "test_results", + "test_result", + data_store_pb2.TestResult, + ), + ("batch_update_steps", "BatchUpdateStepsResponse", "steps", "step", data_store_pb2.Step), + ( + "batch_update_measurements", + "BatchUpdateMeasurementsResponse", + "measurements", + "measurement", + data_store_pb2.Measurement, + ), + ], +) +def test___batch_update___sends_per_item_masks_and_returns_models( + client: DataStoreClient, + service_client: Any, + method: str, + response_type: str, + response_field: str, + resource_field: str, + proto_type: Any, +) -> None: + response = getattr(data_store_service_pb2, response_type)( + **{response_field: [proto_type(id="a"), proto_type(id="b")]} + ) + getattr(service_client, method).return_value = response + + results = getattr(client, method)( + {"a": {"outcome": Outcome.PASSED}, "b": {"extension": {"x": 1}, "outcome": None}} + ) + + first, second = _sent(getattr(service_client, method)).requests + assert getattr(first, resource_field).id == "a" + assert list(first.update_mask.paths) == ["outcome"] + assert getattr(first, resource_field).outcome == data_store_pb2.OUTCOME_PASSED + assert getattr(second, resource_field).id == "b" + assert list(second.update_mask.paths) == ["extension", "outcome"] + assert [r.id for r in results] == ["a", "b"] + + +@pytest.mark.parametrize( + "method", ["batch_update_test_results", "batch_update_steps", "batch_update_measurements"] +) +def test___batch_update___rejects_empty_or_invalid_before_rpc( + client: DataStoreClient, service_client: Any, method: str +) -> None: + with pytest.raises(ValueError, match="At least one update"): + getattr(client, method)({}) + with pytest.raises(ValueError, match="At least one field"): + getattr(client, method)({"a": {}}) + with pytest.raises(ValueError, match="Fields cannot be updated: name"): + getattr(client, method)({"a": {"name": "x"}}) + + getattr(service_client, method).assert_not_called() + + +def test___update_test_result___accepts_metadata_models( + client: DataStoreClient, service_client: Any +) -> None: + from ni.datastore.metadata import Operator + + client.update_test_result("tr-id", operator=Operator(name="Ada", role="Engineer")) + + sent = _sent(service_client.update_test_result) + assert sent.test_result.operator.name == "Ada" + assert list(sent.update_mask.paths) == ["operator"] + + +# ---------------------------------------------------------------- batch delete + + +def test___batch_delete_test_results___applies_single_force_to_all( + client: DataStoreClient, service_client: Any +) -> None: + client.batch_delete_test_results(["a", "b"], force=True) + + requests = _sent(service_client.batch_delete_test_results).requests + assert [(r.test_result_id, r.force) for r in requests] == [("a", True), ("b", True)] + + +def test___batch_delete_steps___accepts_per_item_force( + client: DataStoreClient, service_client: Any +) -> None: + client.batch_delete_steps(["a", "b"], force=[True, False]) + + requests = _sent(service_client.batch_delete_steps).requests + assert [(r.step_id, r.force) for r in requests] == [("a", True), ("b", False)] + + +def test___batch_delete___rejects_mismatched_force_count( + client: DataStoreClient, service_client: Any +) -> None: + with pytest.raises(ValueError, match="Expected 2 force flags"): + client.batch_delete_steps(["a", "b"], force=[True]) + + service_client.batch_delete_steps.assert_not_called() + + +def test___batch_delete_measurements___sends_ids( + client: DataStoreClient, service_client: Any +) -> None: + client.batch_delete_measurements(["a", "b"]) + + requests = _sent(service_client.batch_delete_measurements).requests + assert [r.measurement_id for r in requests] == ["a", "b"] + + # ---------------------------------------------------------------- lifecycle diff --git a/tests/unit/data/test_v2_types.py b/tests/unit/data/test_v2_types.py index 02f15f08..d1f89a2c 100644 --- a/tests/unit/data/test_v2_types.py +++ b/tests/unit/data/test_v2_types.py @@ -3,6 +3,7 @@ from __future__ import annotations import datetime as std_datetime +from typing import Any import pytest from google.protobuf import json_format @@ -11,16 +12,29 @@ from ni.protobuf.types.precision_duration_conversion import hightime_timedelta_to_protobuf from ni.protobuf.types.precision_timestamp_conversion import hightime_datetime_to_protobuf from nitypes.scalar import Scalar +from nitypes.vector import Vector from ni.datastore.data.v2 import ( Condition, ErrorInformation, + JsonValue, Measurement, Outcome, Status, Step, TestResult, ) +from ni.datastore.metadata import ( + HardwareItem, + Operator, + SoftwareItem, + Test, + TestAdapter, + TestDescription, + TestStation, + Uut, + UutInstance, +) UTC = std_datetime.timezone.utc START = datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC) @@ -197,3 +211,140 @@ def test___error_information___round_trips_through_protobuf() -> None: def test___enums___reject_unknown_protobuf_values(enum_type: type[Outcome] | type[Status]) -> None: with pytest.raises(ValueError): enum_type.from_protobuf(999) # type: ignore[arg-type] + + +def test___measurement___round_trips_value_limits_and_metadata() -> None: + measurement = Measurement( + name="voltage", + step_id="step-id", + value=Scalar(5.0, "V"), + upper_limit=5.5, + lower_limit=Vector([4.5, 4.6]), + schema_id="schema", + software_items=[SoftwareItem(product="p", version="1")], + hardware_items=[HardwareItem(manufacturer="m", model="x", serial_number="1")], + test_adapters=[TestAdapter(name="adapter")], + ) + + proto = measurement.to_protobuf() + + assert proto.value.scalar.double_value == 5.0 + assert proto.upper_limit.scalar.double_value == 5.5 + assert proto.schema_id == "schema" + assert proto.software_items[0].product == "p" + assert Measurement.from_protobuf(proto) == measurement + + +def test___measurement___moniker_or_missing_value_reads_as_none() -> None: + proto = data_store_pb2.Measurement(id="m") + proto.value.data_moniker.data_instance = 1 + + measurement = Measurement.from_protobuf(proto) + + assert (measurement.value, measurement.upper_limit, measurement.lower_limit) == ( + None, + None, + None, + ) + + +def test___test_result___round_trips_metadata_objects_and_nested_steps() -> None: + test_result = TestResult( + "Power test", + uut_instance=UutInstance(uut_id="uut", serial_number="sn"), + uut=Uut(model_name="model"), + operator=Operator(name="Ada", role="Engineer"), + test_station=TestStation(name="station"), + test_description=TestDescription(uut_id="uut", name="desc"), + software_items=[SoftwareItem(product="p", version="1")], + schema_id="schema", + steps=[ + Step( + "parent", + test=Test(name="t"), + schema_id="step-schema", + measurements=[Measurement(name="m", value=1.0)], + children=[Step("child")], + ) + ], + ) + + proto = test_result.to_protobuf() + + assert proto.operator.name == "Ada" + assert proto.steps[0].children[0].name == "child" + assert proto.steps[0].measurements[0].value.scalar.double_value == 1.0 + assert proto.steps[0].test.name == "t" + assert TestResult.from_protobuf(proto) == test_result + + +def test___test_result___unset_metadata_objects_read_as_none() -> None: + test_result = TestResult.from_protobuf(data_store_pb2.TestResult(name="n")) + + assert (test_result.operator, test_result.uut_instance, test_result.uut) == (None, None, None) + assert test_result.steps == [] + + +def test___json_value___round_trips_attributes_and_schema() -> None: + value = JsonValue( + {"a": 1, "nested": {"ok": True}}, + attributes={"flag": True, "count": 3, "ratio": 0.5, "name": "x"}, + schema_id="schema", + ) + + proto = value.to_protobuf() + + assert proto.attributes["count"].integer_value == 3 + assert proto.attributes["flag"].bool_value is True + assert proto.schema_id == "schema" + assert JsonValue.from_protobuf(proto) == value + + +def test___json_value___rejects_unsupported_attribute() -> None: + with pytest.raises(TypeError): + JsonValue({}, attributes={"bad": [1]}).to_protobuf() # type: ignore[dict-item] + + +def test___condition___json_value_with_attributes_round_trips() -> None: + condition = Condition("config", JsonValue({"mode": "fast"}, attributes={"source": "fixture"})) + + assert Condition.from_protobuf(condition.to_protobuf()) == condition + + +def test___condition___plain_json_reads_as_dict() -> None: + proto = Condition("config", {"mode": "fast"}).to_protobuf() + + assert Condition.from_protobuf(proto).value == {"mode": "fast"} + + +@pytest.mark.parametrize( + ("proto_type", "model_type"), + [ + (data_store_pb2.TestResult, TestResult), + (data_store_pb2.Step, Step), + (data_store_pb2.Measurement, Measurement), + (data_store_pb2.Condition, Condition), + (data_store_pb2.ErrorInformation, ErrorInformation), + (data_store_pb2.JsonValue, JsonValue), + ], +) +def test___models___expose_every_protobuf_field(proto_type: Any, model_type: Any) -> None: + model_fields = {slot.lstrip("_") for slot in model_type.__slots__} + + missing = [f.name for f in proto_type.DESCRIPTOR.fields if f.name not in model_fields] + + assert missing == [] + + +def test___client___wraps_every_low_level_method() -> None: + from ni.measurements.data.v2.client import DataStoreClient as LowLevelClient + + from ni.datastore.data.v2 import DataStoreClient + + low_level_methods = { + name + for name in dir(LowLevelClient) + if not name.startswith("_") and callable(getattr(LowLevelClient, name)) + } + + assert sorted(low_level_methods - set(dir(DataStoreClient))) == [] From 5dcf5ffc7725aa7f737f87de5e149bc6505c0ffe Mon Sep 17 00:00:00 2001 From: "Balusamy, Sudharshana" Date: Tue, 27 Oct 2026 10:34:11 +0530 Subject: [PATCH 03/13] add v2 examples --- examples/notebooks/v2/README.md | 43 + .../measurement_values.ipynb | 289 +++ .../v2/overview/publish_measurement.ipynb | 309 +++ .../notebooks/v2/publish/batch_create.ipynb | 282 +++ .../v2/query/publish_sample_data.ipynb | 453 +++++ .../v2/query/query_measurements.ipynb | 302 +++ .../structured_extensions.ipynb | 195 ++ .../v2/update-delete/update_delete.ipynb | 370 ++++ examples/overview-v2/README.md | 27 + examples/overview-v2/poetry.lock | 1799 +++++++++++++++++ examples/overview-v2/poetry.toml | 2 + examples/overview-v2/pyproject.toml | 76 + examples/overview-v2/src/__init__.py | 1 + examples/overview-v2/src/overview.py | 162 ++ 14 files changed, 4310 insertions(+) create mode 100644 examples/notebooks/v2/README.md create mode 100644 examples/notebooks/v2/measurement-values/measurement_values.ipynb create mode 100644 examples/notebooks/v2/overview/publish_measurement.ipynb create mode 100644 examples/notebooks/v2/publish/batch_create.ipynb create mode 100644 examples/notebooks/v2/query/publish_sample_data.ipynb create mode 100644 examples/notebooks/v2/query/query_measurements.ipynb create mode 100644 examples/notebooks/v2/structured-extensions/structured_extensions.ipynb create mode 100644 examples/notebooks/v2/update-delete/update_delete.ipynb create mode 100644 examples/overview-v2/README.md create mode 100644 examples/overview-v2/poetry.lock create mode 100644 examples/overview-v2/poetry.toml create mode 100644 examples/overview-v2/pyproject.toml create mode 100644 examples/overview-v2/src/__init__.py create mode 100644 examples/overview-v2/src/overview.py diff --git a/examples/notebooks/v2/README.md b/examples/notebooks/v2/README.md new file mode 100644 index 00000000..1e41c1a8 --- /dev/null +++ b/examples/notebooks/v2/README.md @@ -0,0 +1,43 @@ +# V2 Example Notebooks + +The Jupyter Notebooks in the sub-directories of this directory demonstrate the +V2 Measurement Data Store Python API, `ni.datastore.data.v2`. They are the V2 +companions of the notebooks in the parent directory. + +V2 reuses the V1 Metadata API, so these notebooks create and read metadata with +`ni.datastore.metadata`, as the V1 notebooks do. + +## Setup + +These notebooks use the main Python environment / kernel of `ni.datastore`. To +execute the code in these notebooks, first execute the following command from +the repo's root directory: + +```sh +poetry install +``` + +After executing this command, select the environment that it creates as the +kernel to use for executing any of the provided notebooks. + +The notebooks need NI Measurement Data Services running in V2 mode. By +default, the clients use discovery to find the service. To connect to a specific +service instance, set the `MDS_V2_GRPC_TARGET` environment variable to its gRPC +target, such as `[::1]:59606`, before starting Jupyter. + +## Notebooks + +- `overview/publish_measurement.ipynb`: Publish a waveform measurement with a + condition, then query and read it back. +- `query/publish_sample_data.ipynb`: Create sample data for the query notebook. + Run this first. +- `query/query_measurements.ipynb`: Query test results, steps, measurements, and + conditions with OData. +- `publish/batch_create.ipynb`: Create test results, steps, and measurements in + atomic batches, including nested steps and measurements. +- `measurement-values/measurement_values.ipynb`: Scalar and non-scalar + measurement values, and limits. +- `update-delete/update_delete.ipynb`: Update fields, change conditions, link + metadata, and delete data. +- `structured-extensions/structured_extensions.ipynb`: Store nested JSON data in + extensions. diff --git a/examples/notebooks/v2/measurement-values/measurement_values.ipynb b/examples/notebooks/v2/measurement-values/measurement_values.ipynb new file mode 100644 index 00000000..f54bc5bc --- /dev/null +++ b/examples/notebooks/v2/measurement-values/measurement_values.ipynb @@ -0,0 +1,289 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "98e8cdcf", + "metadata": {}, + "source": [ + "# Measurement Values (V2)\n", + "\n", + "This notebook shows the kinds of values that a V2 measurement can hold, how to add limits, and how to read the values back.\n", + "\n", + "- **Scalar values**: `float`, `int`, `str`, `bool`, and `Scalar` (a value with units).\n", + "- **Non-scalar values**: `Vector`, a list of numbers, `AnalogWaveform`, `ComplexWaveform`, `DigitalWaveform`, `Spectrum`, and `XYData`.\n", + "- **Limits**: an upper and lower limit stored with the measurement.\n", + "\n", + "`get_measurement` and `query_measurements` include the value only for scalar values. For any value, use `read_measurement_value`." + ] + }, + { + "cell_type": "markdown", + "id": "f29e3749", + "metadata": {}, + "source": [ + "## Prerequisites\n", + "\n", + "- NI Measurement Data Services running in V2 mode\n", + "- Python environment with the `ni.datastore` package (see `../README.md`)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e42feae1", + "metadata": {}, + "outputs": [], + "source": [ + "# Perform example-specific setup. This is not needed when writing production code.\n", + "# Set MDS_V2_GRPC_TARGET (for example, \"[::1]:59606\") to connect to a specific V2 service.\n", + "# When it is not set, the clients use discovery to find the service.\n", + "import os\n", + "\n", + "import grpc\n", + "\n", + "grpc_target = os.environ.get(\"MDS_V2_GRPC_TARGET\", \"\")\n", + "grpc_channel = grpc.insecure_channel(grpc_target) if grpc_target else None\n", + "\n", + "from ni.datastore.data.v2 import DataStoreClient\n", + "\n", + "data_store_client = DataStoreClient(grpc_channel=grpc_channel)\n", + "\n", + "from ni.datastore.data.v2 import Step, TestResult\n", + "\n", + "test_result_id = data_store_client.create_test_result(TestResult(name=\"Measurement Values Example\"))\n", + "step_id = data_store_client.create_step(Step(name=\"Example Step\", test_result_id=test_result_id))\n", + "print(f\"Created Test Result: {test_result_id}\")\n", + "print(f\"Created Step: {step_id}\")" + ] + }, + { + "cell_type": "markdown", + "id": "4bfcc9dc", + "metadata": {}, + "source": [ + "## Scalar Values\n", + "\n", + "A `Scalar` carries its own units. For a plain value, pass `units` to `create_measurement`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cae596d1", + "metadata": {}, + "outputs": [], + "source": [ + "from nitypes.scalar import Scalar\n", + "\n", + "scalar_values = {\n", + " \"Example Float Measurement\": 0.125,\n", + " \"Example Integer Measurement\": 42,\n", + " \"Example String Measurement\": \"nominal\",\n", + " \"Example Bool Measurement\": True,\n", + " \"Example Scalar Measurement\": Scalar(4.98, \"V\"),\n", + "}\n", + "\n", + "scalar_ids = {\n", + " name: data_store_client.create_measurement(name, value, step_id)\n", + " for name, value in scalar_values.items()\n", + "}\n", + "\n", + "for name, measurement_id in scalar_ids.items():\n", + " measurement = data_store_client.get_measurement(measurement_id)\n", + " print(f\"{name}: {measurement.value!r}\")\n", + " assert measurement.value == scalar_values[name]" + ] + }, + { + "cell_type": "markdown", + "id": "e2411323", + "metadata": {}, + "source": [ + "## Limits\n", + "\n", + "Limits are stored with the measurement. Use `read_measurement_values` to read the value and both limits in one call. A limit that was not set is `None`.\n", + "\n", + "The service stores the outcome that you supply; it does not compute it from the limits." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bfc0c49a", + "metadata": {}, + "outputs": [], + "source": [ + "from ni.datastore.data.v2 import Outcome\n", + "\n", + "limited_measurement_id = data_store_client.create_measurement(\n", + " \"Example Limited Measurement\",\n", + " Scalar(5.01, \"V\"),\n", + " step_id,\n", + " upper_limit=5.1,\n", + " lower_limit=4.9,\n", + " outcome=Outcome.PASSED,\n", + ")\n", + "\n", + "values = data_store_client.read_measurement_values(\n", + " data_store_client.get_measurement(limited_measurement_id)\n", + ")\n", + "print(f\"value: {values.value}\")\n", + "print(f\"upper limit: {values.upper_limit}\")\n", + "print(f\"lower limit: {values.lower_limit}\")\n", + "assert values.upper_limit == 5.1 and values.lower_limit == 4.9\n", + "\n", + "value_only = data_store_client.read_measurement_values(\n", + " data_store_client.get_measurement(limited_measurement_id), read_fields=[\"value\"]\n", + ")\n", + "print(f\"value only: {value_only.value}, upper limit: {value_only.upper_limit}\")" + ] + }, + { + "cell_type": "markdown", + "id": "2a3a752c", + "metadata": {}, + "source": [ + "## Non-Scalar Values\n", + "\n", + "Create non-scalar values the same way. Read them back with `read_measurement_value`, optionally passing `expected_type` to check the type." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7a3e4bef", + "metadata": {}, + "outputs": [], + "source": [ + "from datetime import timezone\n", + "\n", + "import hightime as ht\n", + "import numpy as np\n", + "from nitypes.vector import Vector\n", + "from nitypes.waveform import AnalogWaveform, ComplexWaveform, DigitalWaveform, Spectrum, Timing\n", + "from nitypes.xy_data import XYData\n", + "\n", + "non_scalar_values = {\n", + " \"Example Vector Measurement\": Vector([1.0, 1.25, 1.5], units=\"V\"),\n", + " \"Example List Measurement\": [1.0, 2.0, 3.0],\n", + " \"Example AnalogWaveform Measurement\": AnalogWaveform(\n", + " sample_count=3,\n", + " raw_data=np.array([1.0, 2.0, 3.0]),\n", + " timing=Timing.create_with_regular_interval(\n", + " ht.timedelta(seconds=1e-3), ht.datetime.now(timezone.utc)\n", + " ),\n", + " ),\n", + " \"Example ComplexWaveform Measurement\": ComplexWaveform.from_array_1d(\n", + " np.array([1 + 2j, 3 + 4j]), dtype=np.complex128\n", + " ),\n", + " \"Example DigitalWaveform Measurement\": DigitalWaveform.from_lines([[0, 1], [1, 0]], signal_count=2),\n", + " \"Example Spectrum Measurement\": Spectrum.from_array_1d(\n", + " np.array([1.0, 2.0]), dtype=np.float64, start_frequency=10.0, frequency_increment=5.0\n", + " ),\n", + " \"Example XYData Measurement\": XYData.from_arrays_1d(\n", + " [1.0, 2.0], [3.0, 4.0], np.float64, x_units=\"s\", y_units=\"V\"\n", + " ),\n", + "}\n", + "\n", + "non_scalar_ids = {\n", + " name: data_store_client.create_measurement(name, value, step_id)\n", + " for name, value in non_scalar_values.items()\n", + "}\n", + "\n", + "for name, measurement_id in non_scalar_ids.items():\n", + " measurement = data_store_client.get_measurement(measurement_id)\n", + " read_back = data_store_client.read_measurement_value(measurement)\n", + " print(f\"{name}: {type(read_back).__name__}\")\n", + " # get_measurement does not include non-scalar values.\n", + " assert measurement.value is None\n", + "\n", + "read_back_waveform = data_store_client.read_measurement_value(\n", + " data_store_client.get_measurement(non_scalar_ids[\"Example AnalogWaveform Measurement\"]),\n", + " expected_type=AnalogWaveform,\n", + ")\n", + "print(f\"waveform data: {read_back_waveform.raw_data}\")\n", + "assert read_back_waveform.raw_data.tolist() == [1.0, 2.0, 3.0]\n", + "\n", + "# A list of numbers is read back as a Vector.\n", + "read_back_list = data_store_client.read_measurement_value(\n", + " data_store_client.get_measurement(non_scalar_ids[\"Example List Measurement\"]),\n", + " expected_type=Vector,\n", + ")\n", + "print(f\"list read back as: {read_back_list}\")" + ] + }, + { + "cell_type": "markdown", + "id": "03e41bf9", + "metadata": {}, + "source": [ + "Limits can also be non-scalar, such as a `Vector` for per-point limits:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5b52a28e", + "metadata": {}, + "outputs": [], + "source": [ + "vector_limit_id = data_store_client.create_measurement(\n", + " \"Example Vector Limit Measurement\",\n", + " Vector([1.0, 2.0], units=\"V\"),\n", + " step_id,\n", + " upper_limit=Vector([3.0, 3.0], units=\"V\"),\n", + " lower_limit=Vector([0.0, 0.0], units=\"V\"),\n", + ")\n", + "vector_values = data_store_client.read_measurement_values(\n", + " data_store_client.get_measurement(vector_limit_id)\n", + ")\n", + "print(f\"value: {vector_values.value}\")\n", + "print(f\"upper limit: {vector_values.upper_limit}\")\n", + "print(f\"lower limit: {vector_values.lower_limit}\")" + ] + }, + { + "cell_type": "markdown", + "id": "ed51ece1", + "metadata": {}, + "source": [ + "## Clean up" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "125c1596", + "metadata": {}, + "outputs": [], + "source": [ + "data_store_client.close()\n", + "\n", + "# Perform example-specific cleanup. This is not needed when writing production code.\n", + "if grpc_channel is not None:\n", + " grpc_channel.close()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/notebooks/v2/overview/publish_measurement.ipynb b/examples/notebooks/v2/overview/publish_measurement.ipynb new file mode 100644 index 00000000..98eb447b --- /dev/null +++ b/examples/notebooks/v2/overview/publish_measurement.ipynb @@ -0,0 +1,309 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "2110fdd9", + "metadata": {}, + "source": [ + "# Publish a Measurement (V2)\n", + "\n", + "This notebook is the V2 companion of `../../overview/publish_measurement.ipynb`. It publishes a waveform measurement and reads it back when NI Measurement Data Services runs in V2 mode.\n", + "\n", + "The main V2 differences are:\n", + "- Data uses `ni.datastore.data.v2`. Metadata still uses `ni.datastore.metadata`, because V2 reuses the V1 Metadata API.\n", + "- Conditions belong to a step. You add them to the step instead of publishing them separately.\n", + "- Measurements are created with `create_measurement`.\n", + "- Test results and steps have a `status` (how far the work has progressed) that is separate from the `outcome` (whether it passed)." + ] + }, + { + "cell_type": "markdown", + "id": "bb5d191d", + "metadata": {}, + "source": [ + "## Create a client\n", + "\n", + "To get started creating metadata and publishing measurements, the client code must first instantiate a `MetadataStoreClient` and a V2 `DataStoreClient`. The `MetadataStoreClient` is used for creating and querying metadata, and the `DataStoreClient` is used for creating and querying test results, steps, and measurements." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bca88a15", + "metadata": {}, + "outputs": [], + "source": [ + "# Perform example-specific setup. This is not needed when writing production code.\n", + "# Set MDS_V2_GRPC_TARGET (for example, \"[::1]:59606\") to connect to a specific V2 service.\n", + "# When it is not set, the clients use discovery to find the service.\n", + "import os\n", + "\n", + "import grpc\n", + "\n", + "grpc_target = os.environ.get(\"MDS_V2_GRPC_TARGET\", \"\")\n", + "grpc_channel = grpc.insecure_channel(grpc_target) if grpc_target else None\n", + "\n", + "from ni.datastore.data.v2 import DataStoreClient\n", + "from ni.datastore.metadata import MetadataStoreClient\n", + "\n", + "metadata_store_client = MetadataStoreClient(grpc_channel=grpc_channel)\n", + "data_store_client = DataStoreClient(grpc_channel=grpc_channel)" + ] + }, + { + "cell_type": "markdown", + "id": "984c7a43", + "metadata": {}, + "source": [ + "__Recommendation:__ A client can be used within the context of a `with` statement in order to have its lifetime managed for it. Upon exiting the `with` statement, any gRPC channels owned by the client will be closed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5ea43e3c", + "metadata": {}, + "outputs": [], + "source": [ + "with DataStoreClient(grpc_channel=grpc_channel) as data_store_client_one:\n", + " query_results = data_store_client_one.query_test_results()\n", + " # ... Perform any further operations with data_store_client_one ...\n", + "\n", + "# data_store_client_one will automatically close upon exiting the 'with' block" + ] + }, + { + "cell_type": "markdown", + "id": "16c2dfe2", + "metadata": {}, + "source": [ + "Alternatively, the `close()` method of the client may be called to perform the same cleanup that occurs when exiting the `with` block above." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "91de5983", + "metadata": {}, + "outputs": [], + "source": [ + "data_store_client_two = DataStoreClient(grpc_channel=grpc_channel)\n", + "\n", + "query_results = data_store_client_two.query_test_results()\n", + "# ... Perform any further operations with data_store_client_two ...\n", + "\n", + "# Manually close the client when done using it\n", + "data_store_client_two.close()" + ] + }, + { + "cell_type": "markdown", + "id": "55dc458a", + "metadata": {}, + "source": [ + "Because notebooks such as the current example do not support splitting a single `with` statement across multiple code snippets, this example opts to call `close()` manually in the last code snippet." + ] + }, + { + "cell_type": "markdown", + "id": "c304f516", + "metadata": {}, + "source": [ + "## Create the test result metadata\n", + "\n", + "Create the metadata that describes who ran the test, where it ran, and which software was used. Then describe the test result.\n", + "\n", + "The V1 notebook also registers a metadata schema here. V2 does not accept a `schema_id` on test results, steps, or measurements yet, so this notebook stores its custom data in `extension` without a schema." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "63bd3dd0", + "metadata": {}, + "outputs": [], + "source": [ + "from ni.datastore.data.v2 import Status, TestResult\n", + "from ni.datastore.metadata import Operator, SoftwareItem, TestStation\n", + "\n", + "operator_id = metadata_store_client.create_operator(\n", + " Operator(name=\"James Bowery\", role=\"Test Operator\")\n", + ")\n", + "test_station_id = metadata_store_client.create_test_station(TestStation(name=\"TestStation_12\"))\n", + "software_item_id = metadata_store_client.create_software_item(\n", + " SoftwareItem(product=\"Windows\", version=\"10.0.19044\")\n", + ")\n", + "\n", + "# The test is starting, so its status is RUNNING. The outcome is set when it finishes.\n", + "test_result = TestResult(\n", + " name=\"sample publish test result\",\n", + " operator_id=operator_id,\n", + " test_station_id=test_station_id,\n", + " software_item_ids=[software_item_id],\n", + " status=Status.RUNNING,\n", + " extension={\"session_file_path\": \"C:\\\\my_test_description.xlsx\"},\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "74dc1422", + "metadata": {}, + "source": [ + "## Create the test result object\n", + "\n", + "Now that we have the test result metadata, we can create the test result object." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ad313793", + "metadata": {}, + "outputs": [], + "source": [ + "test_result_id = data_store_client.create_test_result(test_result)\n", + "print(f\"created test result id: {test_result_id}\")" + ] + }, + { + "cell_type": "markdown", + "id": "bd91ffe2", + "metadata": {}, + "source": [ + "## Publish the data\n", + "\n", + "In V2, a step owns its conditions. Add them when you create the step, or later with `add_condition`. Then create the measurement on the step." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "da0e69ef", + "metadata": {}, + "outputs": [], + "source": [ + "from datetime import timezone\n", + "\n", + "import hightime as ht\n", + "import numpy as np\n", + "from nitypes.waveform import AnalogWaveform, Timing\n", + "\n", + "from ni.datastore.data.v2 import Condition, Outcome, Step\n", + "\n", + "name = \"data publish sample\"\n", + "waveform = AnalogWaveform(\n", + " sample_count=3,\n", + " raw_data=np.array([1.0, 2.0, 3.0]),\n", + " timing=Timing.create_with_regular_interval(\n", + " ht.timedelta(seconds=1e-3),\n", + " ht.datetime.now(timezone.utc)\n", + " )\n", + ")\n", + "step = Step(\n", + " name=\"Initial step\",\n", + " test_result_id=test_result_id,\n", + " conditions=[\n", + " Condition(\"Ambient Temperature\", 23.0, condition_type=\"Environment\", units=\"degC\"),\n", + " ],\n", + ")\n", + "step_id = data_store_client.create_step(step)\n", + "published_measurement_id = data_store_client.create_measurement(\n", + " name=name,\n", + " value=waveform,\n", + " step_id=step_id,\n", + " units=\"V\",\n", + " outcome=Outcome.PASSED,\n", + ")\n", + "print(f\"The published measurement id is {published_measurement_id}\")\n", + "\n", + "# The test is finished, so mark it as done and record the outcome.\n", + "data_store_client.update_test_result(test_result_id, status=Status.DONE, outcome=Outcome.PASSED)" + ] + }, + { + "cell_type": "markdown", + "id": "2f89ce0b", + "metadata": {}, + "source": [ + "## Query the data\n", + "\n", + "Now that we've got data in the database, we can query for it using our OData query API.\n", + "\n", + "For non-scalar values, such as waveforms, `get_measurement` and `query_measurements` return the measurement details without the value. Use `read_measurement_value` to read the value." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17e06ee2", + "metadata": {}, + "outputs": [], + "source": [ + "published_measurements = data_store_client.query_measurements(\n", + " odata_query=f\"$filter=Id eq {published_measurement_id}\"\n", + ")\n", + "found_measurement = next(iter(published_measurements), None)\n", + "assert found_measurement is not None\n", + "\n", + "test_result = data_store_client.get_test_result(found_measurement.test_result_id)\n", + "operator = metadata_store_client.get_operator(test_result.operator_id)\n", + "print(f\"test result '{test_result.name}' is {test_result.status.name}: {test_result.outcome.name}\")\n", + "print(f\"operator: {operator.name}\")\n", + "print(f\"session file: {test_result.extension['session_file_path']}\")\n", + "\n", + "step = data_store_client.get_step(found_measurement.step_id)\n", + "for condition in step.conditions:\n", + " print(f\"condition: {condition.name} = {condition.value} {condition.units}\")\n", + "\n", + "waveform = data_store_client.read_measurement_value(found_measurement, expected_type=AnalogWaveform)\n", + "print(f\"published data is: {waveform.raw_data}\")\n", + "assert waveform.raw_data.tolist() == [1.0, 2.0, 3.0]" + ] + }, + { + "cell_type": "markdown", + "id": "4e76a5b5", + "metadata": {}, + "source": [ + "## Clean up" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5c8b351f", + "metadata": {}, + "outputs": [], + "source": [ + "metadata_store_client.close()\n", + "data_store_client.close()\n", + "\n", + "# Perform example-specific cleanup. This is not needed when writing production code.\n", + "if grpc_channel is not None:\n", + " grpc_channel.close()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/notebooks/v2/publish/batch_create.ipynb b/examples/notebooks/v2/publish/batch_create.ipynb new file mode 100644 index 00000000..6116a2bb --- /dev/null +++ b/examples/notebooks/v2/publish/batch_create.ipynb @@ -0,0 +1,282 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "706c1b5b", + "metadata": {}, + "source": [ + "# Batch Creating\n", + "\n", + "This notebook is the V2 companion of `../../publish/publish_batch.ipynb`. It shows how to create many test results, steps, and measurements with one call.\n", + "\n", + "V2 does not have `publish_condition_batch` or `publish_measurement_batch`. Instead, it has:\n", + "- `batch_create_test_results`: creates test results, and any steps, conditions, and measurements nested inside them.\n", + "- `batch_create_steps`: creates steps, and any child steps, conditions, and measurements nested inside them.\n", + "- `batch_create_measurements`: creates measurements. Each measurement may target a different step.\n", + "\n", + "Each batch call is **atomic**: either every item is created, or nothing is created." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "95b3abe1", + "metadata": {}, + "outputs": [], + "source": [ + "# Perform example-specific setup. This is not needed when writing production code.\n", + "# Set MDS_V2_GRPC_TARGET (for example, \"[::1]:59606\") to connect to a specific V2 service.\n", + "# When it is not set, the clients use discovery to find the service.\n", + "import os\n", + "\n", + "import grpc\n", + "\n", + "grpc_target = os.environ.get(\"MDS_V2_GRPC_TARGET\", \"\")\n", + "grpc_channel = grpc.insecure_channel(grpc_target) if grpc_target else None\n", + "\n", + "from ni.datastore.data.v2 import DataStoreClient\n", + "\n", + "data_store_client = DataStoreClient(grpc_channel=grpc_channel)" + ] + }, + { + "cell_type": "markdown", + "id": "6e3908b8", + "metadata": {}, + "source": [ + "## Create a Test Result with Nested Steps and Measurements\n", + "\n", + "A test result can include its steps, and a step can include its conditions, measurements, and child steps. One `batch_create_test_results` call creates the whole tree." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e31d9cdf", + "metadata": {}, + "outputs": [], + "source": [ + "from ni.datastore.data.v2 import Condition, Measurement, Step, TestResult\n", + "\n", + "test_result = TestResult(\n", + " name=\"Batch Create Example\",\n", + " steps=[\n", + " Step(\n", + " name=\"Example Parent Step\",\n", + " conditions=[Condition(\"Ambient Temperature\", 25.0, condition_type=\"Environment\", units=\"degC\")],\n", + " measurements=[Measurement(name=\"Example Parent Measurement\", value=1.0, units=\"V\")],\n", + " children=[\n", + " Step(\n", + " name=\"Example Child Step\",\n", + " measurements=[Measurement(name=\"Example Child Measurement\", value=2.0, units=\"V\")],\n", + " ),\n", + " ],\n", + " ),\n", + " ],\n", + ")\n", + "\n", + "[test_result_id] = data_store_client.batch_create_test_results([test_result])\n", + "print(f\"Created Test Result: {test_result_id}\")\n", + "\n", + "created_steps = data_store_client.query_steps(f\"$filter=TestResultId eq {test_result_id}\")\n", + "created_measurements = data_store_client.query_measurements(f\"$filter=TestResultId eq {test_result_id}\")\n", + "for step in created_steps:\n", + " kind = \"child\" if step.parent_step_id else \"parent\"\n", + " print(f\"Created {kind} step: {step.name}\")\n", + "for measurement in created_measurements:\n", + " print(f\"Created measurement: {measurement.name} = {measurement.value}\")\n", + "\n", + "assert len(created_steps) == 2\n", + "assert len(created_measurements) == 2" + ] + }, + { + "cell_type": "markdown", + "id": "2d4458a2", + "metadata": {}, + "source": [ + "## Batch Creating Steps\n", + "\n", + "`batch_create_steps` creates several steps at once. Each step names the test result that owns it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6486e64a", + "metadata": {}, + "outputs": [], + "source": [ + "step_ids = data_store_client.batch_create_steps(\n", + " [\n", + " Step(\n", + " name=\"Example Step 1\",\n", + " test_result_id=test_result_id,\n", + " conditions=[Condition(\"Supply Voltage\", 5.0, condition_type=\"Setup\", units=\"V\")],\n", + " ),\n", + " Step(\n", + " name=\"Example Step 2\",\n", + " test_result_id=test_result_id,\n", + " conditions=[Condition(\"Supply Voltage\", 12.0, condition_type=\"Setup\", units=\"V\")],\n", + " ),\n", + " ]\n", + ")\n", + "print(f\"Created Steps: {step_ids}\")" + ] + }, + { + "cell_type": "markdown", + "id": "bd4458eb", + "metadata": {}, + "source": [ + "## Batch Creating Measurements\n", + "\n", + "`batch_create_measurements` creates several measurements at once. The measurements can have different names, different value types, and different steps." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "de7c6787", + "metadata": {}, + "outputs": [], + "source": [ + "from datetime import timezone\n", + "\n", + "import hightime as ht\n", + "import numpy as np\n", + "from nitypes.scalar import Scalar\n", + "from nitypes.vector import Vector\n", + "from nitypes.waveform import AnalogWaveform, Timing\n", + "\n", + "waveform = AnalogWaveform(\n", + " sample_count=4,\n", + " raw_data=np.array([0.0, 0.25, 0.5, 0.75], dtype=np.float64),\n", + " timing=Timing.create_with_regular_interval(\n", + " ht.timedelta(seconds=1e-3),\n", + " ht.datetime.now(timezone.utc),\n", + " ),\n", + ")\n", + "\n", + "measurement_ids = data_store_client.batch_create_measurements(\n", + " [\n", + " Measurement(name=\"Example Float Measurement\", value=Scalar(4.98, \"V\"), step_id=step_ids[0]),\n", + " Measurement(name=\"Example String Measurement\", value=\"nominal\", step_id=step_ids[0]),\n", + " Measurement(name=\"Example Vector Measurement\", value=Vector([1.0, 1.25, 1.5], units=\"V\"), step_id=step_ids[1]),\n", + " Measurement(name=\"Example AnalogWaveform Measurement\", value=waveform, step_id=step_ids[1]),\n", + " ]\n", + ")\n", + "print(f\"Created Measurements: {measurement_ids}\")" + ] + }, + { + "cell_type": "markdown", + "id": "14e7544a", + "metadata": {}, + "source": [ + "The returned IDs are in the same order as the request. Read the values back with `read_measurement_value`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "67a450e7", + "metadata": {}, + "outputs": [], + "source": [ + "for measurement_id in measurement_ids:\n", + " measurement = data_store_client.get_measurement(measurement_id)\n", + " value = data_store_client.read_measurement_value(measurement)\n", + " print(f\"{measurement.name}: {value}\")\n", + "\n", + "read_back_waveform = data_store_client.read_measurement_value(\n", + " data_store_client.get_measurement(measurement_ids[3]), expected_type=AnalogWaveform\n", + ")\n", + "assert read_back_waveform.raw_data.tolist() == [0.0, 0.25, 0.5, 0.75]" + ] + }, + { + "cell_type": "markdown", + "id": "677d93be", + "metadata": {}, + "source": [ + "## Batches Are Atomic\n", + "\n", + "If any item in a batch fails, nothing in the batch is created. Below, the second measurement names a step that does not exist, so the first measurement is not created either." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e6f7a5de", + "metadata": {}, + "outputs": [], + "source": [ + "import grpc\n", + "\n", + "try:\n", + " data_store_client.batch_create_measurements(\n", + " [\n", + " Measurement(name=\"Example Rollback Measurement\", value=1.0, step_id=step_ids[0]),\n", + " Measurement(\n", + " name=\"Example Rollback Measurement\",\n", + " value=2.0,\n", + " step_id=\"00000000-0000-0000-0000-000000000000\",\n", + " ),\n", + " ]\n", + " )\n", + "except grpc.RpcError as e:\n", + " print(f\"Batch failed: {e.details()}\")\n", + "\n", + "rolled_back = data_store_client.query_measurements(\"$filter=Name eq 'Example Rollback Measurement'\")\n", + "print(f\"Measurements created by the failed batch: {len(rolled_back)}\")\n", + "assert len(rolled_back) == 0" + ] + }, + { + "cell_type": "markdown", + "id": "a74cc0d1", + "metadata": {}, + "source": [ + "## Clean-Up\n", + "\n", + "Close the `DataStoreClient` and tear down the example-specific setup when done." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "62162947", + "metadata": {}, + "outputs": [], + "source": [ + "data_store_client.close()\n", + "\n", + "# Perform example-specific cleanup. This is not needed when writing production code.\n", + "if grpc_channel is not None:\n", + " grpc_channel.close()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/notebooks/v2/query/publish_sample_data.ipynb b/examples/notebooks/v2/query/publish_sample_data.ipynb new file mode 100644 index 00000000..0413b9fc --- /dev/null +++ b/examples/notebooks/v2/query/publish_sample_data.ipynb @@ -0,0 +1,453 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "28b7ae47", + "metadata": {}, + "source": [ + "# Publish Sample Data for V2 OData Query Examples\n", + "\n", + "This notebook is the V2 companion of `../../query/publish_sample_data.ipynb`. It creates structured sample data in NI Measurement Data Services (V2 mode) that the V2 query notebook uses.\n", + "\n", + "**Run this notebook first** before using `query_measurements.ipynb`.\n", + "\n", + "## What This Creates\n", + "\n", + "This notebook sets up a realistic test scenario with:\n", + "- **3 TestResults**: 2 Power Supply tests and 1 Audio Amplifier test\n", + "- **12 Test Steps**: Various measurement procedures across all tests\n", + "- **38 Measurements**: Scalar measurements (voltages, currents, power, THD, etc.)\n", + "- **Conditions on every step**: Environmental and setup parameters. In V2, conditions belong to the step, and text values are supported.\n", + "- **Metadata**: 3 operators, 3 test stations, 2 hardware items, 2 UUTs, 3 UUT instances, 3 software items\n", + "- **Aliases**: Human-readable references for all metadata entities" + ] + }, + { + "cell_type": "markdown", + "id": "e8c184e8", + "metadata": {}, + "source": [ + "## Prerequisites\n", + "\n", + "- NI Measurement Data Services running in V2 mode\n", + "- Python environment with the `ni.datastore` package (see `../README.md`)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4644f4c2", + "metadata": {}, + "outputs": [], + "source": [ + "# Perform example-specific setup. This is not needed when writing production code.\n", + "# Set MDS_V2_GRPC_TARGET (for example, \"[::1]:59606\") to connect to a specific V2 service.\n", + "# When it is not set, the clients use discovery to find the service.\n", + "import os\n", + "\n", + "import grpc\n", + "\n", + "grpc_target = os.environ.get(\"MDS_V2_GRPC_TARGET\", \"\")\n", + "grpc_channel = grpc.insecure_channel(grpc_target) if grpc_target else None\n", + "\n", + "from ni.datastore.metadata import (\n", + " MetadataStoreClient,\n", + " HardwareItem,\n", + " Operator,\n", + " SoftwareItem,\n", + " TestStation,\n", + " Uut,\n", + " UutInstance,\n", + ")\n", + "from ni.datastore.data.v2 import DataStoreClient\n", + "\n", + "# Create clients\n", + "metadata_store_client = MetadataStoreClient(grpc_channel=grpc_channel)\n", + "data_store_client = DataStoreClient(grpc_channel=grpc_channel)" + ] + }, + { + "cell_type": "markdown", + "id": "a23853a9", + "metadata": {}, + "source": [ + "## Create Metadata Entities\n", + "\n", + "Setting up the metadata for operators, test stations, hardware, etc. This is the same as in V1, because V2 reuses the V1 Metadata API. This is typically done once and establishes 'aliases' which are human-readable strings that can be used to refer to metadata later." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "73eb10ac", + "metadata": {}, + "outputs": [], + "source": [ + "print(\"🚀 Starting sample data creation...\")\n", + "\n", + "# Create sample operators\n", + "print(\"\\n✅ Creating operators...\")\n", + "operator1 = Operator(name=\"Alex Smith\", role=\"Test Engineer\")\n", + "metadata_store_client.create_operator(operator1)\n", + "ALIAS_OPERATOR_ALEX = \"V2_Operator_Alex_Smith\"\n", + "metadata_store_client.create_alias(ALIAS_OPERATOR_ALEX, operator1)\n", + "\n", + "operator2 = Operator(name=\"Jordan Chen\", role=\"Senior Test Engineer\")\n", + "metadata_store_client.create_operator(operator2)\n", + "ALIAS_OPERATOR_JORDAN = \"V2_Operator_Jordan_Chen\"\n", + "metadata_store_client.create_alias(ALIAS_OPERATOR_JORDAN, operator2)\n", + "\n", + "operator3 = Operator(name=\"Taylor Johnson\", role=\"Lab Technician\")\n", + "metadata_store_client.create_operator(operator3)\n", + "ALIAS_OPERATOR_TAYLOR = \"V2_Operator_Taylor_Johnson\"\n", + "metadata_store_client.create_alias(ALIAS_OPERATOR_TAYLOR, operator3)\n", + "\n", + "# Create sample test stations\n", + "print(\"\\n✅ Creating test stations...\")\n", + "station1 = TestStation(name=\"TestStation_A1\")\n", + "metadata_store_client.create_test_station(station1)\n", + "ALIAS_STATION_A1 = \"V2_Station_A1\"\n", + "metadata_store_client.create_alias(ALIAS_STATION_A1, station1)\n", + "\n", + "station2 = TestStation(name=\"TestStation_B2\")\n", + "metadata_store_client.create_test_station(station2)\n", + "ALIAS_STATION_B2 = \"V2_Station_B2\"\n", + "metadata_store_client.create_alias(ALIAS_STATION_B2, station2)\n", + "\n", + "station3 = TestStation(name=\"TestStation_C3\")\n", + "metadata_store_client.create_test_station(station3)\n", + "ALIAS_STATION_C3 = \"V2_Station_C3\"\n", + "metadata_store_client.create_alias(ALIAS_STATION_C3, station3)\n", + "\n", + "# Create sample hardware items (test equipment)\n", + "print(\"\\n✅ Creating hardware items...\")\n", + "dmm = HardwareItem(manufacturer=\"NI\", model=\"PXIe-4081\", serial_number=\"DMM001\")\n", + "metadata_store_client.create_hardware_item(dmm)\n", + "ALIAS_DMM = \"V2_DMM_PXIe4081\"\n", + "metadata_store_client.create_alias(ALIAS_DMM, dmm)\n", + "\n", + "scope = HardwareItem(manufacturer=\"NI\", model=\"PXIe-5171\", serial_number=\"SCOPE001\")\n", + "metadata_store_client.create_hardware_item(scope)\n", + "ALIAS_SCOPE = \"V2_Scope_PXIe5171\"\n", + "metadata_store_client.create_alias(ALIAS_SCOPE, scope)\n", + "\n", + "# Create sample UUTs (Units Under Test - product definitions)\n", + "print(\"\\n✅ Creating UUTs...\")\n", + "power_supply_uut = Uut(model_name=\"PowerSupply v2.1\", family=\"Power\")\n", + "metadata_store_client.create_uut(power_supply_uut)\n", + "ALIAS_UUT_POWER_SUPPLY = \"V2_UUT_PowerSupply_v2_1\"\n", + "metadata_store_client.create_alias(ALIAS_UUT_POWER_SUPPLY, power_supply_uut)\n", + "\n", + "amplifier_uut = Uut(model_name=\"Audio Amplifier v1.3\", family=\"Audio\")\n", + "metadata_store_client.create_uut(amplifier_uut)\n", + "ALIAS_UUT_AMPLIFIER = \"V2_UUT_AudioAmplifier_v1_3\"\n", + "metadata_store_client.create_alias(ALIAS_UUT_AMPLIFIER, amplifier_uut)\n", + "\n", + "# Create sample software items\n", + "print(\"\\n✅ Creating software items...\")\n", + "python_env = SoftwareItem(product=\"Python\", version=\"3.11.5\")\n", + "metadata_store_client.create_software_item(python_env)\n", + "ALIAS_SOFTWARE_PYTHON = \"V2_Software_Python_3_11_5\"\n", + "metadata_store_client.create_alias(ALIAS_SOFTWARE_PYTHON, python_env)\n", + "\n", + "pytest_env = SoftwareItem(product=\"pytest\", version=\"7.4.0\")\n", + "metadata_store_client.create_software_item(pytest_env)\n", + "ALIAS_SOFTWARE_PYTEST = \"V2_Software_pytest_7_4_0\"\n", + "metadata_store_client.create_alias(ALIAS_SOFTWARE_PYTEST, pytest_env)\n", + "\n", + "nidaq_env = SoftwareItem(product=\"NI-DAQmx\", version=\"23.3.0\")\n", + "metadata_store_client.create_software_item(nidaq_env)\n", + "ALIAS_SOFTWARE_NIDAQ = \"V2_Software_NI_DAQmx_23_3_0\"\n", + "metadata_store_client.create_alias(ALIAS_SOFTWARE_NIDAQ, nidaq_env)" + ] + }, + { + "cell_type": "markdown", + "id": "fea2d252", + "metadata": {}, + "source": [ + "## Create UUT Instances\n", + "\n", + "This setup is done for each test run. Each time we're going to run a test, we create instances of the specific UUT we are testing." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3d02ceda", + "metadata": {}, + "outputs": [], + "source": [ + "print(\"\\n✅ Creating UUT instances...\")\n", + "ps_instance1 = UutInstance(uut_id=ALIAS_UUT_POWER_SUPPLY, serial_number=\"PS-2024-001\")\n", + "metadata_store_client.create_uut_instance(ps_instance1)\n", + "ALIAS_UUT_INSTANCE_PS1 = \"V2_UUT_Instance_PS_001\"\n", + "metadata_store_client.create_alias(ALIAS_UUT_INSTANCE_PS1, ps_instance1)\n", + "\n", + "ps_instance2 = UutInstance(uut_id=ALIAS_UUT_POWER_SUPPLY, serial_number=\"PS-2024-002\")\n", + "metadata_store_client.create_uut_instance(ps_instance2)\n", + "ALIAS_UUT_INSTANCE_PS2 = \"V2_UUT_Instance_PS_002\"\n", + "metadata_store_client.create_alias(ALIAS_UUT_INSTANCE_PS2, ps_instance2)\n", + "\n", + "amp_instance1 = UutInstance(uut_id=ALIAS_UUT_AMPLIFIER, serial_number=\"AMP-2024-001\")\n", + "metadata_store_client.create_uut_instance(amp_instance1)\n", + "ALIAS_UUT_INSTANCE_AMP1 = \"V2_UUT_Instance_AMP_001\"\n", + "metadata_store_client.create_alias(ALIAS_UUT_INSTANCE_AMP1, amp_instance1)" + ] + }, + { + "cell_type": "markdown", + "id": "8461bd00", + "metadata": {}, + "source": [ + "## Create Test Results, Steps, and Measurements\n", + "\n", + "The helper below creates one test result. For each step, it creates the step with its conditions, then creates a scalar measurement for each measurement name.\n", + "\n", + "In V2, a test result starts as `RUNNING`. When all of its measurements are created, the helper marks it `DONE` and sets its outcome to `FAILED` if any measurement failed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "76b4e351", + "metadata": {}, + "outputs": [], + "source": [ + "import random\n", + "\n", + "from nitypes.scalar import Scalar\n", + "\n", + "from ni.datastore.data.v2 import Condition, Outcome, Status, Step, TestResult\n", + "\n", + "\n", + "def sample_value(measurement_name: str) -> tuple[float, str]:\n", + " \"\"\"Return a realistic value and units for a measurement name.\"\"\"\n", + " if \"-12V\" in measurement_name:\n", + " return random.uniform(-12.2, -11.8), \"V\"\n", + " if \"5V\" in measurement_name:\n", + " return random.uniform(4.95, 5.05), \"V\"\n", + " if \"12V\" in measurement_name:\n", + " return random.uniform(11.8, 12.2), \"V\"\n", + " if \"Input Voltage\" in measurement_name:\n", + " return random.uniform(115, 125), \"V\"\n", + " if \"Supply Voltage\" in measurement_name:\n", + " return random.uniform(14.5, 15.5), \"V\"\n", + " if \"Standby Current\" in measurement_name:\n", + " return random.uniform(0.01, 0.05), \"A\"\n", + " if \"Current\" in measurement_name:\n", + " return random.uniform(0.5, 1.5), \"A\"\n", + " if \"DC Offset\" in measurement_name:\n", + " return random.uniform(-5, 5), \"mV\"\n", + " if \"Response\" in measurement_name:\n", + " return random.uniform(-1, 1), \"dB\"\n", + " if \"THD\" in measurement_name and \"Power\" not in measurement_name:\n", + " return random.uniform(0.001, 0.01), \"%\"\n", + " if \"Power\" in measurement_name:\n", + " return random.uniform(40, 150), \"W\"\n", + " if \"Ripple\" in measurement_name:\n", + " return random.uniform(10, 50), \"mV\"\n", + " if \"SNR\" in measurement_name:\n", + " return random.uniform(95, 105), \"dB\"\n", + " return random.uniform(0, 100), \"\"\n", + "\n", + "\n", + "def create_sample_test(\n", + " test_result: TestResult,\n", + " steps: list[tuple[str, list[str]]],\n", + " conditions: list[tuple[str, object, str, str]],\n", + ") -> tuple[str, int, int]:\n", + " \"\"\"Create a test result with steps, conditions, and measurements.\n", + "\n", + " Returns the test result ID, the number of measurements, and the number of conditions.\n", + " \"\"\"\n", + " test_result_id = data_store_client.create_test_result(test_result)\n", + " measurement_count = 0\n", + " condition_count = 0\n", + " any_failed = False\n", + " for step_name, measurement_names in steps:\n", + " step = Step(\n", + " name=step_name,\n", + " test_result_id=test_result_id,\n", + " conditions=[\n", + " Condition(name, value, condition_type=condition_type, units=units)\n", + " for name, value, units, condition_type in conditions\n", + " ],\n", + " )\n", + " step_id = data_store_client.create_step(step)\n", + " condition_count += len(conditions)\n", + " for measurement_name in measurement_names:\n", + " value, units = sample_value(measurement_name)\n", + " # Insert some random failures\n", + " outcome = Outcome.PASSED if random.random() > 0.4 else Outcome.FAILED\n", + " any_failed = any_failed or outcome == Outcome.FAILED\n", + " data_store_client.create_measurement(\n", + " measurement_name, Scalar(value, units), step_id, outcome=outcome\n", + " )\n", + " measurement_count += 1\n", + "\n", + " data_store_client.update_test_result(\n", + " test_result_id,\n", + " status=Status.DONE,\n", + " outcome=Outcome.FAILED if any_failed else Outcome.PASSED,\n", + " )\n", + " return test_result_id, measurement_count, condition_count" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "973ce332", + "metadata": {}, + "outputs": [], + "source": [ + "print(\"\\n🔬 Creating sample test results with measurements...\")\n", + "\n", + "# Scenario 1: Power Supply Test. Use aliases instead of IDs for better readability.\n", + "print(\"📋 Creating Power Supply test...\")\n", + "ps_test_result_id, ps_measurements, ps_conditions = create_sample_test(\n", + " TestResult(\n", + " name=\"Power Supply Test v2.1\",\n", + " uut_instance_id=ALIAS_UUT_INSTANCE_PS1,\n", + " operator_id=ALIAS_OPERATOR_ALEX,\n", + " test_station_id=ALIAS_STATION_A1,\n", + " software_item_ids=[ALIAS_SOFTWARE_PYTHON],\n", + " hardware_item_ids=[ALIAS_DMM, ALIAS_SCOPE],\n", + " status=Status.RUNNING,\n", + " ),\n", + " steps=[\n", + " (\"Initialize\", [\"Configure DMM\", \"Set Load Conditions\", \"Warm-up Period\"]),\n", + " (\"Input Voltage Test\", [\"Measure Input Voltage\", \"Measure Input Current\", \"Calculate Input Power\"]),\n", + " (\"Output Voltage Test\", [\"Measure 5V Rail\", \"Measure 12V Rail\", \"Measure -12V Rail\"]),\n", + " (\"Load Regulation\", [\"No Load Test\", \"25% Load Test\", \"50% Load Test\", \"75% Load Test\", \"Full Load Test\"]),\n", + " (\"Ripple Measurement\", [\"5V Ripple\", \"12V Ripple\", \"-12V Ripple\"]),\n", + " ],\n", + " conditions=[\n", + " (\"Ambient Temperature\", random.uniform(21, 26), \"°C\", \"Environment\"),\n", + " (\"Barometric Pressure\", random.uniform(995, 1015), \"hPa\", \"Environment\"),\n", + " (\"Line Frequency\", random.uniform(59.9, 60.1), \"Hz\", \"Environment\"),\n", + " # V2 conditions also support text values.\n", + " (\"Calibration Date\", \"2024-03-15\", \"\", \"Setup\"),\n", + " (\"Test Fixture\", \"PS-TEST-001\", \"\", \"Setup\"),\n", + " ],\n", + ")\n", + "print(f\" 📊 Created {ps_measurements} measurements and {ps_conditions} conditions\")\n", + "\n", + "# Scenario 2: Audio Amplifier Test\n", + "print(\"\\n📋 Creating Audio Amplifier test...\")\n", + "amp_test_result_id, amp_measurements, amp_conditions = create_sample_test(\n", + " TestResult(\n", + " name=\"Audio Amplifier Test v1.3\",\n", + " uut_instance_id=ALIAS_UUT_INSTANCE_AMP1,\n", + " operator_id=ALIAS_OPERATOR_JORDAN,\n", + " test_station_id=ALIAS_STATION_B2,\n", + " software_item_ids=[ALIAS_SOFTWARE_PYTHON],\n", + " hardware_item_ids=[ALIAS_DMM, ALIAS_SCOPE],\n", + " status=Status.RUNNING,\n", + " ),\n", + " steps=[\n", + " (\"Power-On Test\", [\"Supply Voltage\", \"Standby Current\", \"Enable Signal\"]),\n", + " (\"DC Offset\", [\"Left Channel DC Offset\", \"Right Channel DC Offset\"]),\n", + " (\"Frequency Response\", [\"20Hz Response\", \"1kHz Response\", \"10kHz Response\", \"20kHz Response\"]),\n", + " (\"THD+N Test\", [\"1kHz THD+N Left\", \"1kHz THD+N Right\", \"10kHz THD+N Left\", \"10kHz THD+N Right\"]),\n", + " (\"Power Output\", [\"Max Power Left\", \"Max Power Right\", \"Power at 1% THD\"]),\n", + " (\"Signal-to-Noise\", [\"SNR Left Channel\", \"SNR Right Channel\"]),\n", + " ],\n", + " conditions=[\n", + " (\"Ambient Temperature\", random.uniform(22, 25), \"°C\", \"Environment\"),\n", + " (\"Relative Humidity\", random.uniform(45, 55), \"%\", \"Environment\"),\n", + " (\"Test Load\", random.uniform(7.8, 8.2), \"Ω\", \"Setup\"),\n", + " ],\n", + ")\n", + "print(f\" 📊 Created {amp_measurements} measurements and {amp_conditions} conditions\")\n", + "\n", + "# Scenario 3: Additional Power Supply Test (second unit, quick verification)\n", + "print(\"\\n📋 Creating second Power Supply test...\")\n", + "ps2_test_result_id, ps2_measurements, ps2_conditions = create_sample_test(\n", + " TestResult(\n", + " name=\"Power Supply Test v2.1\",\n", + " uut_instance_id=ALIAS_UUT_INSTANCE_PS2,\n", + " operator_id=ALIAS_OPERATOR_TAYLOR,\n", + " test_station_id=ALIAS_STATION_C3,\n", + " software_item_ids=[ALIAS_SOFTWARE_PYTHON],\n", + " hardware_item_ids=[ALIAS_DMM, ALIAS_SCOPE],\n", + " status=Status.RUNNING,\n", + " ),\n", + " steps=[(\"Quick Verification\", [\"Output 5V\", \"Output 12V\", \"Load Current\"])],\n", + " conditions=[(\"Room Temperature\", random.uniform(20, 24), \"°C\", \"Environment\")],\n", + ")\n", + "print(f\" 📊 Created {ps2_measurements} measurements and {ps2_conditions} conditions\")\n", + "\n", + "total_measurements = ps_measurements + amp_measurements + ps2_measurements\n", + "total_conditions = ps_conditions + amp_conditions + ps2_conditions\n", + "assert total_measurements == 38\n", + "\n", + "print(\"\\n🎯 Sample data creation complete!\")\n", + "print(\"📊 Summary:\")\n", + "print(\" - Test Results: 3\")\n", + "print(\" - Steps: 12\")\n", + "print(f\" - Measurements: {total_measurements}\")\n", + "print(f\" - Conditions: {total_conditions}\")\n", + "print(\"\\n✅ Ready to run query_measurements.ipynb!\")" + ] + }, + { + "cell_type": "markdown", + "id": "8e6ea1a5", + "metadata": {}, + "source": [ + "## Clean up" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e6e5f10d", + "metadata": {}, + "outputs": [], + "source": [ + "data_store_client.close()\n", + "metadata_store_client.close()\n", + "\n", + "# Perform example-specific cleanup. This is not needed when writing production code.\n", + "if grpc_channel is not None:\n", + " grpc_channel.close()" + ] + }, + { + "cell_type": "markdown", + "id": "9ea63443", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "✅ **Sample data has been successfully created!**\n", + "\n", + "You can now run **`query_measurements.ipynb`** to learn how to query test results, steps, measurements, and conditions with the V2 API.\n", + "\n", + "To query metadata, such as operators and hardware, use `../../query/query_metadata.ipynb`. The Metadata API is the same in V1 and V2." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/notebooks/v2/query/query_measurements.ipynb b/examples/notebooks/v2/query/query_measurements.ipynb new file mode 100644 index 00000000..0f8e393f --- /dev/null +++ b/examples/notebooks/v2/query/query_measurements.ipynb @@ -0,0 +1,302 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "4e8596e4", + "metadata": {}, + "source": [ + "# OData Measurement and Test Result Queries (V2)\n", + "\n", + "This notebook is the V2 companion of `../../query/query_measurements.ipynb`. It shows how to use OData queries to find test results, steps, measurements, and conditions in NI Measurement Data Services (V2 mode).\n", + "\n", + "## Prerequisites\n", + "\n", + "**⚠️ Important:** Run the `publish_sample_data.ipynb` notebook first to create the sample test data that this notebook queries.\n", + "\n", + "- NI Measurement Data Services running in V2 mode\n", + "- Python environment with the `ni.datastore` package (see `../README.md`)\n", + "- Sample test data created by running `publish_sample_data.ipynb`" + ] + }, + { + "cell_type": "markdown", + "id": "c09f60e1", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "Import the required libraries and create both metadata and data store clients." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "801c9619", + "metadata": {}, + "outputs": [], + "source": [ + "# Perform example-specific setup. This is not needed when writing production code.\n", + "# Set MDS_V2_GRPC_TARGET (for example, \"[::1]:59606\") to connect to a specific V2 service.\n", + "# When it is not set, the clients use discovery to find the service.\n", + "import os\n", + "\n", + "import grpc\n", + "\n", + "grpc_target = os.environ.get(\"MDS_V2_GRPC_TARGET\", \"\")\n", + "grpc_channel = grpc.insecure_channel(grpc_target) if grpc_target else None\n", + "\n", + "from ni.datastore.data.v2 import DataStoreClient\n", + "from ni.datastore.metadata import MetadataStoreClient\n", + "\n", + "metadata_store_client = MetadataStoreClient(grpc_channel=grpc_channel)\n", + "data_store_client = DataStoreClient(grpc_channel=grpc_channel)" + ] + }, + { + "cell_type": "markdown", + "id": "4431330c", + "metadata": {}, + "source": [ + "## Query Test Results\n", + "\n", + "V2 can query test results directly. Test results also have a `status` that you can filter on.\n", + "\n", + "**Additional test result query examples:**\n", + "\n", + "```python\n", + "# Filter by status\n", + "done_test_results = data_store_client.query_test_results(\"$filter=Status eq 'Done'\")\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d9b7ed5d", + "metadata": {}, + "outputs": [], + "source": [ + "print(\"=== Power Supply Test Results ===\")\n", + "power_test_results = data_store_client.query_test_results(\"$filter=contains(Name,'Power Supply')\")\n", + "for test_result in power_test_results:\n", + " print(f\" 📋 {test_result.name} - {test_result.status.name}, {test_result.outcome.name}\")\n", + "assert len(power_test_results) >= 2\n", + "\n", + "print(\"\\n=== Failed Test Results ===\")\n", + "failed_test_results = data_store_client.query_test_results(\"$filter=Outcome eq 'Failed'\")\n", + "for test_result in failed_test_results:\n", + " print(f\" ❌ {test_result.name}\")\n", + "if not failed_test_results:\n", + " print(\" ✅ No failed test results found!\")" + ] + }, + { + "cell_type": "markdown", + "id": "d2a45bf8", + "metadata": {}, + "source": [ + "## Query Measurements\n", + "\n", + "Measurements are the individual data points collected during test steps. In V2, you can filter on the outcome in the query itself.\n", + "\n", + "**Additional measurement query examples:**\n", + "\n", + "```python\n", + "# Filter by measurement name\n", + "voltage_measurements = data_store_client.query_measurements(\"$filter=contains(Name,'Voltage')\")\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8f0a9279", + "metadata": {}, + "outputs": [], + "source": [ + "from ni.datastore.data.v2 import Outcome\n", + "\n", + "def print_measurement_with_outcome(measurement) -> str:\n", + " name = measurement.name or \"Unnamed Measurement\"\n", + " passed = measurement.outcome == Outcome.PASSED\n", + " failed = measurement.outcome == Outcome.FAILED\n", + " return f\"{name} - {'✅ (Passed)' if passed else '❌ (Failed)' if failed else '❓ (Unknown)'}\"\n", + "\n", + "print(\"=== Failed Measurements ===\")\n", + "failed_measurements = data_store_client.query_measurements(\"$filter=Outcome eq 'Failed'\")\n", + "for measurement in failed_measurements[:15]:\n", + " print(f\" ❌ {measurement.name} = {measurement.value}\")\n", + "if not failed_measurements:\n", + " print(\" ✅ No failed measurements found!\")\n", + "if len(failed_measurements) > 15:\n", + " print(f\" ... and {len(failed_measurements) - 15} more measurements\")\n", + "assert all(measurement.outcome == Outcome.FAILED for measurement in failed_measurements)\n", + "\n", + "print(\"\\n=== Voltage Measurements ===\")\n", + "voltage_measurements = data_store_client.query_measurements(\"$filter=contains(Name,'Voltage')\")\n", + "for measurement in voltage_measurements:\n", + " print(f\" ⚡ {print_measurement_with_outcome(measurement)}\")\n", + "\n", + "print(\"\\n=== Current Measurements ===\")\n", + "current_measurements = data_store_client.query_measurements(\"$filter=contains(Name,'Current')\")\n", + "for measurement in current_measurements:\n", + " print(f\" 🔌 {print_measurement_with_outcome(measurement)}\")\n", + "\n", + "print(\"\\n=== Failed Measurements by Operator Alex Smith ===\")\n", + "test_results_by_id = {}\n", + "for measurement in failed_measurements:\n", + " if measurement.test_result_id not in test_results_by_id:\n", + " test_results_by_id[measurement.test_result_id] = data_store_client.get_test_result(\n", + " measurement.test_result_id\n", + " )\n", + " test_result = test_results_by_id[measurement.test_result_id]\n", + " if test_result.operator_id:\n", + " operator = metadata_store_client.get_operator(test_result.operator_id)\n", + " if operator.name == \"Alex Smith\":\n", + " print(f\" 📋 {print_measurement_with_outcome(measurement)} - Operator: {operator.name}\")" + ] + }, + { + "cell_type": "markdown", + "id": "7cfeedef", + "metadata": {}, + "source": [ + "## Query Steps\n", + "\n", + "Steps are the individual test phases within a test result. Each step can contain multiple measurements.\n", + "\n", + "**Additional step query examples:**\n", + "\n", + "```python\n", + "# Filter by step name\n", + "initialization_steps = data_store_client.query_steps(\"$filter=contains(Name,'Initialize')\")\n", + "measurement_steps = data_store_client.query_steps(\"$filter=contains(Name,'Measure')\")\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b50d0dcf", + "metadata": {}, + "outputs": [], + "source": [ + "all_steps = data_store_client.query_steps(\"\")\n", + "\n", + "print(f\"Found {len(all_steps)} steps total\")\n", + "print(\"\\nStep summary:\")\n", + "for step in all_steps[:10]: # Show first 10 steps\n", + " print(f\" 🔧 {step.name}\")\n", + "\n", + "if len(all_steps) > 10:\n", + " print(f\" ... and {len(all_steps) - 10} more steps\")\n", + "\n", + "print(\"\\n=== Steps containing 'Voltage' ===\")\n", + "voltage_steps = data_store_client.query_steps(\"$filter=contains(Name,'Voltage')\")\n", + "for step in voltage_steps:\n", + " print(f\" 🔧 {step.name}\")" + ] + }, + { + "cell_type": "markdown", + "id": "7c92759e", + "metadata": {}, + "source": [ + "## Query Conditions\n", + "\n", + "In V2, conditions belong to steps. To find conditions, query the steps that have them with `Conditions/any(...)`, then read each step's `conditions`.\n", + "\n", + "`read_fields` limits which fields the service returns. When you use it, list every field that you need, such as `name` and `conditions`.\n", + "\n", + "**Additional condition query examples:**\n", + "\n", + "```python\n", + "# Find the measurements whose step has a humidity condition\n", + "humidity_measurements = data_store_client.query_measurements(\n", + " \"$filter=Conditions/any(c: c/Name eq 'Relative Humidity')\"\n", + ")\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1b1635f2", + "metadata": {}, + "outputs": [], + "source": [ + "temp_and_pressure_steps = data_store_client.query_steps(\n", + " \"$filter=Conditions/any(c: contains(c/Name,'Temperature') or contains(c/Name,'Pressure'))\",\n", + " read_fields=[\"name\", \"conditions\"],\n", + ")\n", + "\n", + "unique_condition_names = sorted(\n", + " {\n", + " condition.name\n", + " for step in temp_and_pressure_steps\n", + " for condition in step.conditions\n", + " if \"Temperature\" in condition.name or \"Pressure\" in condition.name\n", + " }\n", + ")\n", + "\n", + "if unique_condition_names:\n", + " print(f\"Found {len(temp_and_pressure_steps)} steps with temperature or pressure conditions\")\n", + " print(\"\\nConditions related to temp and pressure:\")\n", + " for condition_name in unique_condition_names:\n", + " print(f\" 🌡️ {condition_name}\")\n", + "else:\n", + " print(\"No conditions found in the sample data\")\n", + "\n", + "print(\"\\n=== Measurements whose step has a humidity condition ===\")\n", + "humidity_measurements = data_store_client.query_measurements(\n", + " \"$filter=Conditions/any(c: c/Name eq 'Relative Humidity')\"\n", + ")\n", + "print(f\" 💧 {len(humidity_measurements)} measurements\")" + ] + }, + { + "cell_type": "markdown", + "id": "06cd0667", + "metadata": {}, + "source": [ + "## Clean up" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "91f398e4", + "metadata": {}, + "outputs": [], + "source": [ + "metadata_store_client.close()\n", + "data_store_client.close()\n", + "\n", + "# Perform example-specific cleanup. This is not needed when writing production code.\n", + "if grpc_channel is not None:\n", + " grpc_channel.close()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/notebooks/v2/structured-extensions/structured_extensions.ipynb b/examples/notebooks/v2/structured-extensions/structured_extensions.ipynb new file mode 100644 index 00000000..3e5cb057 --- /dev/null +++ b/examples/notebooks/v2/structured-extensions/structured_extensions.ipynb @@ -0,0 +1,195 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "de3c93e3", + "metadata": {}, + "source": [ + "# Structured Extensions (V2)\n", + "\n", + "This notebook is the V2 companion of `../../extension-attributes/extension_attributes.ipynb`. It shows how to store your own data in the `extension` of test results, steps, and measurements.\n", + "\n", + "In V2, an extension is a JSON object. It can hold nested objects and lists, not just text.\n", + "\n", + "**Note:** V2 does not accept a `schema_id` on test results, steps, or measurements yet, so the service does not check extensions against a schema. Numbers are stored as JSON numbers, so whole numbers are read back as `float` values (for example, `3` is read back as `3.0`)." + ] + }, + { + "cell_type": "markdown", + "id": "b67c1e9c", + "metadata": {}, + "source": [ + "## Prerequisites\n", + "\n", + "- NI Measurement Data Services running in V2 mode\n", + "- Python environment with the `ni.datastore` package (see `../README.md`)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f216c9a8", + "metadata": {}, + "outputs": [], + "source": [ + "# Perform example-specific setup. This is not needed when writing production code.\n", + "# Set MDS_V2_GRPC_TARGET (for example, \"[::1]:59606\") to connect to a specific V2 service.\n", + "# When it is not set, the clients use discovery to find the service.\n", + "import os\n", + "\n", + "import grpc\n", + "\n", + "grpc_target = os.environ.get(\"MDS_V2_GRPC_TARGET\", \"\")\n", + "grpc_channel = grpc.insecure_channel(grpc_target) if grpc_target else None\n", + "\n", + "from ni.datastore.data.v2 import DataStoreClient\n", + "\n", + "data_store_client = DataStoreClient(grpc_channel=grpc_channel)" + ] + }, + { + "cell_type": "markdown", + "id": "3ea98e28", + "metadata": {}, + "source": [ + "## Create Data with Extensions" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "31230c46", + "metadata": {}, + "outputs": [], + "source": [ + "from ni.datastore.data.v2 import Step, TestResult\n", + "\n", + "test_result_id = data_store_client.create_test_result(\n", + " TestResult(\n", + " name=\"Structured Extensions Example\",\n", + " extension={\n", + " \"fixture\": {\"id\": \"FIX-12\", \"slots\": [1, 2, 3]},\n", + " \"retest\": False,\n", + " \"operator_notes\": \"Second run after recalibration.\",\n", + " },\n", + " )\n", + ")\n", + "step_id = data_store_client.create_step(\n", + " Step(\n", + " name=\"Warm-up\",\n", + " test_result_id=test_result_id,\n", + " extension={\"phase\": {\"name\": \"warmup\", \"seconds\": 30}},\n", + " )\n", + ")\n", + "measurement_id = data_store_client.create_measurement(\n", + " \"Output Voltage\",\n", + " 5.01,\n", + " step_id,\n", + " units=\"V\",\n", + " extension={\"probe\": {\"channel\": 3, \"attenuation\": \"10x\"}},\n", + ")\n", + "print(f\"Created Test Result: {test_result_id}\")" + ] + }, + { + "cell_type": "markdown", + "id": "0b0b891d", + "metadata": {}, + "source": [ + "## Read Extensions" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "35887f6d", + "metadata": {}, + "outputs": [], + "source": [ + "test_result = data_store_client.get_test_result(test_result_id)\n", + "step = data_store_client.get_step(step_id)\n", + "measurement = data_store_client.get_measurement(measurement_id)\n", + "\n", + "print(f\"test result fixture: {test_result.extension['fixture']}\")\n", + "print(f\"test result retest: {test_result.extension['retest']}\")\n", + "print(f\"step phase: {step.extension['phase']}\")\n", + "print(f\"measurement probe: {measurement.extension['probe']}\")\n", + "\n", + "assert test_result.extension[\"fixture\"][\"id\"] == \"FIX-12\"\n", + "assert test_result.extension[\"fixture\"][\"slots\"] == [1, 2, 3]\n", + "assert measurement.extension[\"probe\"][\"channel\"] == 3" + ] + }, + { + "cell_type": "markdown", + "id": "cabcb73d", + "metadata": {}, + "source": [ + "## Update Extensions\n", + "\n", + "Updating `extension` replaces the whole extension, so include every key that you want to keep." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ad08c394", + "metadata": {}, + "outputs": [], + "source": [ + "new_extension = dict(test_result.extension)\n", + "new_extension[\"retest\"] = True\n", + "new_extension[\"fixture\"] = {\"id\": \"FIX-13\", \"slots\": [1, 2]}\n", + "\n", + "updated_test_result = data_store_client.update_test_result(test_result_id, extension=new_extension)\n", + "print(f\"updated extension: {updated_test_result.extension}\")\n", + "assert updated_test_result.extension[\"retest\"] is True\n", + "assert updated_test_result.extension[\"operator_notes\"] == \"Second run after recalibration.\"" + ] + }, + { + "cell_type": "markdown", + "id": "dfced6a4", + "metadata": {}, + "source": [ + "## Clean up" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "009034a8", + "metadata": {}, + "outputs": [], + "source": [ + "data_store_client.delete_test_result(test_result_id, force=True)\n", + "data_store_client.close()\n", + "\n", + "# Perform example-specific cleanup. This is not needed when writing production code.\n", + "if grpc_channel is not None:\n", + " grpc_channel.close()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/notebooks/v2/update-delete/update_delete.ipynb b/examples/notebooks/v2/update-delete/update_delete.ipynb new file mode 100644 index 00000000..b39cca0c --- /dev/null +++ b/examples/notebooks/v2/update-delete/update_delete.ipynb @@ -0,0 +1,370 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "7d407736", + "metadata": {}, + "source": [ + "# Update and Delete (V2)\n", + "\n", + "This notebook shows how to change and remove data that was already created in V2:\n", + "- Update fields of test results, steps, and measurements, one at a time or in a batch.\n", + "- Add, replace, and remove the conditions of a step.\n", + "- Link and unlink software items, hardware items, and test adapters.\n", + "- Delete measurements, steps, and test results, with and without `force`.\n", + "\n", + "Not every field can be changed. For example, a measurement's name and value cannot be changed after it is created." + ] + }, + { + "cell_type": "markdown", + "id": "df014e04", + "metadata": {}, + "source": [ + "## Prerequisites\n", + "\n", + "- NI Measurement Data Services running in V2 mode\n", + "- Python environment with the `ni.datastore` package (see `../README.md`)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c839bb4a", + "metadata": {}, + "outputs": [], + "source": [ + "# Perform example-specific setup. This is not needed when writing production code.\n", + "# Set MDS_V2_GRPC_TARGET (for example, \"[::1]:59606\") to connect to a specific V2 service.\n", + "# When it is not set, the clients use discovery to find the service.\n", + "import os\n", + "\n", + "import grpc\n", + "\n", + "grpc_target = os.environ.get(\"MDS_V2_GRPC_TARGET\", \"\")\n", + "grpc_channel = grpc.insecure_channel(grpc_target) if grpc_target else None\n", + "\n", + "from ni.datastore.data.v2 import DataStoreClient\n", + "from ni.datastore.metadata import MetadataStoreClient\n", + "\n", + "metadata_store_client = MetadataStoreClient(grpc_channel=grpc_channel)\n", + "data_store_client = DataStoreClient(grpc_channel=grpc_channel)" + ] + }, + { + "cell_type": "markdown", + "id": "18cc3ab7", + "metadata": {}, + "source": [ + "## Create the Example Data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1465905c", + "metadata": {}, + "outputs": [], + "source": [ + "from ni.datastore.data.v2 import Condition, Outcome, Status, Step, TestResult\n", + "from ni.datastore.metadata import SoftwareItem\n", + "\n", + "software_item_id = metadata_store_client.create_software_item(\n", + " SoftwareItem(product=\"Example Driver\", version=\"1.0.0\")\n", + ")\n", + "\n", + "test_result_id = data_store_client.create_test_result(\n", + " TestResult(name=\"Update and Delete Example\", status=Status.RUNNING)\n", + ")\n", + "step_id = data_store_client.create_step(\n", + " Step(\n", + " name=\"Example Step\",\n", + " test_result_id=test_result_id,\n", + " status=Status.RUNNING,\n", + " conditions=[\n", + " Condition(\"Ambient Temperature\", 23.0, condition_type=\"Environment\", units=\"degC\"),\n", + " Condition(\"Test Fixture\", \"FIX-001\", condition_type=\"Setup\"),\n", + " ],\n", + " )\n", + ")\n", + "measurement_ids = [\n", + " data_store_client.create_measurement(f\"Example Measurement {index}\", float(index), step_id)\n", + " for index in range(3)\n", + "]\n", + "print(f\"Created Test Result: {test_result_id}\")\n", + "print(f\"Created Step: {step_id}\")\n", + "print(f\"Created Measurements: {measurement_ids}\")" + ] + }, + { + "cell_type": "markdown", + "id": "2077b8ef", + "metadata": {}, + "source": [ + "## Update Fields\n", + "\n", + "Pass the fields to change as keyword arguments. The update returns the changed object.\n", + "\n", + "`status` describes how far the work has progressed, and `outcome` describes the result. They are separate, so a test can be `DONE` and `FAILED`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "244dfe7a", + "metadata": {}, + "outputs": [], + "source": [ + "updated_step = data_store_client.update_step(\n", + " step_id, status=Status.DONE, outcome=Outcome.FAILED, notes=\"Output was out of range.\"\n", + ")\n", + "print(f\"step: {updated_step.status.name}, {updated_step.outcome.name}, notes='{updated_step.notes}'\")\n", + "\n", + "updated_test_result = data_store_client.update_test_result(\n", + " test_result_id,\n", + " status=Status.DONE,\n", + " outcome=Outcome.FAILED,\n", + " link=\"https://example.com/runs/1\",\n", + ")\n", + "print(\n", + " f\"test result: {updated_test_result.status.name}, {updated_test_result.outcome.name}, \"\n", + " f\"link={updated_test_result.link}\"\n", + ")\n", + "\n", + "updated_measurement = data_store_client.update_measurement(\n", + " measurement_ids[0], notes=\"Rechecked by operator.\"\n", + ")\n", + "print(f\"measurement notes: '{updated_measurement.notes}'\")\n", + "\n", + "assert updated_test_result.status == Status.DONE\n", + "assert updated_test_result.outcome == Outcome.FAILED" + ] + }, + { + "cell_type": "markdown", + "id": "f093a4c0", + "metadata": {}, + "source": [ + "Update several items in one atomic call by passing the fields for each ID:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "33bdc44a", + "metadata": {}, + "outputs": [], + "source": [ + "updated_measurements = data_store_client.batch_update_measurements(\n", + " {\n", + " measurement_ids[0]: {\"outcome\": Outcome.PASSED},\n", + " measurement_ids[1]: {\"outcome\": Outcome.FAILED},\n", + " measurement_ids[2]: {\"outcome\": Outcome.PASSED, \"notes\": \"Borderline.\"},\n", + " }\n", + ")\n", + "for measurement in updated_measurements:\n", + " print(f\"{measurement.name}: {measurement.outcome.name}\")" + ] + }, + { + "cell_type": "markdown", + "id": "4a089811", + "metadata": {}, + "source": [ + "## Add, Replace, and Remove Conditions\n", + "\n", + "Conditions cannot be changed in place. Adding a condition with an existing name replaces it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9a2fd632", + "metadata": {}, + "outputs": [], + "source": [ + "step = data_store_client.add_condition(\n", + " step_id, Condition(\"Relative Humidity\", 45.0, condition_type=\"Environment\", units=\"%\")\n", + ")\n", + "step = data_store_client.add_condition(\n", + " step_id, Condition(\"Ambient Temperature\", 24.5, condition_type=\"Environment\", units=\"degC\")\n", + ")\n", + "step = data_store_client.remove_condition(step_id, \"Test Fixture\")\n", + "\n", + "for condition in step.conditions:\n", + " print(f\"{condition.name} = {condition.value} {condition.units}\")\n", + "\n", + "condition_names = {condition.name for condition in step.conditions}\n", + "assert condition_names == {\"Ambient Temperature\", \"Relative Humidity\"}" + ] + }, + { + "cell_type": "markdown", + "id": "b2e4befe", + "metadata": {}, + "source": [ + "## Link and Unlink Metadata\n", + "\n", + "Link a software item to the test result first. Then a measurement of that test result can also link to it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e1853600", + "metadata": {}, + "outputs": [], + "source": [ + "test_result = data_store_client.add_test_result_metadata(\n", + " test_result_id, software_item_ids=[software_item_id]\n", + ")\n", + "print(f\"test result software items: {list(test_result.software_item_ids)}\")\n", + "\n", + "measurement = data_store_client.add_measurement_metadata(\n", + " measurement_ids[0], software_item_ids=[software_item_id]\n", + ")\n", + "print(f\"measurement software items: {list(measurement.software_item_ids)}\")\n", + "\n", + "measurement = data_store_client.remove_measurement_metadata(\n", + " measurement_ids[0], software_item_ids=[software_item_id]\n", + ")\n", + "test_result = data_store_client.remove_test_result_metadata(\n", + " test_result_id, software_item_ids=[software_item_id]\n", + ")\n", + "print(f\"after unlinking: {list(measurement.software_item_ids)}, {list(test_result.software_item_ids)}\")" + ] + }, + { + "cell_type": "markdown", + "id": "4106a6af", + "metadata": {}, + "source": [ + "## Delete\n", + "\n", + "Deleting a measurement removes it. Getting it afterward fails with `NOT_FOUND`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "04c5e62d", + "metadata": {}, + "outputs": [], + "source": [ + "import grpc\n", + "\n", + "data_store_client.delete_measurement(measurement_ids[2])\n", + "\n", + "try:\n", + " data_store_client.get_measurement(measurement_ids[2])\n", + "except grpc.RpcError as e:\n", + " print(f\"{e.code().name}: {e.details()}\")" + ] + }, + { + "cell_type": "markdown", + "id": "35cbc7c2", + "metadata": {}, + "source": [ + "A step or test result that still owns data cannot be deleted unless you pass `force=True`. This protects you from deleting data by accident." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17e505c6", + "metadata": {}, + "outputs": [], + "source": [ + "try:\n", + " data_store_client.delete_step(step_id)\n", + "except grpc.RpcError as e:\n", + " print(f\"{e.code().name}: {e.details()}\")\n", + "\n", + "try:\n", + " data_store_client.delete_test_result(test_result_id)\n", + "except grpc.RpcError as e:\n", + " print(f\"{e.code().name}: {e.details()}\")" + ] + }, + { + "cell_type": "markdown", + "id": "6e90f2e8", + "metadata": {}, + "source": [ + "Delete several items in one atomic call, or use `force=True` to also delete everything the item owns:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f696c296", + "metadata": {}, + "outputs": [], + "source": [ + "data_store_client.batch_delete_measurements(measurement_ids[:2])\n", + "data_store_client.delete_step(step_id) # No measurements are left, so force is not needed.\n", + "data_store_client.delete_test_result(test_result_id)\n", + "\n", + "# Create two more test results, each with a step, and delete them together.\n", + "extra_test_result_ids = data_store_client.batch_create_test_results(\n", + " [\n", + " TestResult(name=\"Delete Example A\", steps=[Step(name=\"Step A\")]),\n", + " TestResult(name=\"Delete Example B\", steps=[Step(name=\"Step B\")]),\n", + " ]\n", + ")\n", + "data_store_client.batch_delete_test_results(extra_test_result_ids, force=True)\n", + "\n", + "remaining = data_store_client.query_test_results(\n", + " \"$filter=Name eq 'Update and Delete Example' or startswith(Name,'Delete Example')\"\n", + ")\n", + "print(f\"Remaining example test results: {len(remaining)}\")\n", + "assert len(remaining) == 0" + ] + }, + { + "cell_type": "markdown", + "id": "8ea3657c", + "metadata": {}, + "source": [ + "## Clean up" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "46a5e41c", + "metadata": {}, + "outputs": [], + "source": [ + "metadata_store_client.close()\n", + "data_store_client.close()\n", + "\n", + "# Perform example-specific cleanup. This is not needed when writing production code.\n", + "if grpc_channel is not None:\n", + " grpc_channel.close()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/overview-v2/README.md b/examples/overview-v2/README.md new file mode 100644 index 00000000..506efbfe --- /dev/null +++ b/examples/overview-v2/README.md @@ -0,0 +1,27 @@ +# Overview V2 Example + +`overview-v2` contains sample Python code for writing to and reading from +the NI Measurement Data Store when the service runs in V2 mode. + +It is the V2 companion of the [`overview`](../overview/) example. Data is +published with `ni.datastore.data.v2`, and metadata is created with +`ni.datastore.metadata`, because V2 reuses the V1 Metadata API. + +## Required Software + +`overview-v2` requires Python 3.10 or greater, and a Measurement Data +Services instance started in V2 mode. + +## Usage + +```python +poetry install +poetry run overview-v2 +``` + +By default, the clients use discovery to find the service. To connect to a +specific service instance, pass its gRPC target: + +```python +poetry run overview-v2 --grpc-target "[::1]:59606" +``` diff --git a/examples/overview-v2/poetry.lock b/examples/overview-v2/poetry.lock new file mode 100644 index 00000000..eccd1a68 --- /dev/null +++ b/examples/overview-v2/poetry.lock @@ -0,0 +1,1799 @@ +# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand. + +[[package]] +name = "ast-serialize" +version = "0.13.1" +description = "Python bindings for mypy AST serialization" +optional = false +python-versions = ">=3.7" +groups = ["lint"] +files = [ + {file = "ast_serialize-0.13.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3e129d44b0cffbea24a7f40117ce49e30993c3d119e584d4817ebd6f87246456"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:523d45bab16ddaf1399c04c4fe81bbf180f81e33c2f7bcbe76be56b0f7311ab4"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:638333fb8ae72c62753f9e794fda20290883ddc65b0d85e26e08f8bb227567ad"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc34c670747d85d4232834665e247009413eaebe47c69eaf60a5c439aaecaed9"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8c8ebd419811c435812d242fb2a173e57dfede198c9ac9a0a584666ebc9b9331"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8899c038b1c15ae8b98e379057fb3ef398ceb0b21d1737a8156dd5a0923b7e51"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e14c4de98f965647a74e6eaf37e20207124e45451ccd36e84a6f6ebab45e0372"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f3e8a3eff69af640db985360fef35073fb46956dfc3e5c614cf00920d2746dd"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:75d7750fe98f6f35a96c43aba8d98a2e623e22c778a0c17ab9ede92eb10f5d82"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43e8ddd63e80fc280c2f790157386f27f9b9f6ccb9c4f8e803b863cbfb085769"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9221e09496fb942901993c1812ee87950cc0ecbb514c7c0f1f5fa6d78f701ed8"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7a5c38bdb93669c596f94fb7204dd6ebe4fadb19058ac9a2faf2948c2a175f96"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:acf4e9f055d75058ab01536da330648746f2f82b7697a85230547f2d5ebe895c"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87427913c325adc811e81e7c9a757b061f47ef1e29114ac479aa0953a2687a98"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:ad88921f95b92bc144d9ad059a0e16f8c1ae69f5239f2d3c6536fd2774f73dfe"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f8b17e2921e56621bcb46792bd3e0b82ca3b85de78d5936e7b8174ba1758b0aa"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-win32.whl", hash = "sha256:2f9a9ce8b0994f757b941f7f7fe384a6666b4162b941ebc5c248081baea18e47"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5d99910e6b9a9b9360bcca2ae99aee36cc912ce058df2a57d1c33099b6cb2593"}, + {file = "ast_serialize-0.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:a789cbe14d0a0e9ec9b32a456e5abc81ad8799479c3cfd88a29e3442262aebe6"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:23d768369b89d551077dc173da6892fad00443338417523b6732f4e8ba4a7ed5"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:68f8829d114dab6c9db4a8bbb8d0e342958faee1b5e15a29022678df639960f8"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e385b17d5bde892a6184003a9aff3518387908b73f8f5ea539313c07c8c0ede"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:02686b281ec9b8e9614f6f0db27e12e4cac515f336c5d05b678bc9eb73ff2af5"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8ab2c2585b2e22e48cd4aff2136fce4eab4ced569b85f150ef9a5a5d6f34d49"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c652d9f5d52d8b81eff3ff2c95c9dd0f281de45f6ad5d3fa95527550c396454a"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:121e1d08d58b75c15491cc3003609cbad71ac2b0016043c36d30a6b2032b6605"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:30cf89b6a6844542d116c758725d13de5b97b9410a51ee577715f761270d0c1c"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:732aaae9e498c973fc63fd7f308009b57c4afa6ff59d0b674d894e57d0bca01a"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:d9c1a920b2aa0c1e016bed7721689717034ca0dba64ac3a3ff243b003d4e8dd8"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:847cad4c22953823f5e76d0b126c6f9da670cfa45b1df41b9324e22189d77611"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:aca07de3ca8e3d9f2b716c3c31f4f384fe4bd2d03e09912417cff7a3e08036f7"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:5db12d843d931b9d1f1519ce66b9cbfe72d8563b75c1204d255322254d1964dd"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:fb8021f4dc3296de18737e3b8b34994411da725374d74a0b9eb32a738f2a23d0"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:0d764913374ba21df8cd618045b371ed104ce55c6276de7c59e554a4f5ba26fd"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:2bad302ae2427454ef9dab71bd5c3ad67188860c39d36daee38f72031edb39c4"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:032b09d86c785c3984e975378b7861f2a68ac44e22ba7b07b93a536b2524eeb8"}, + {file = "ast_serialize-0.13.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:929eda0c419e622ab49df3a4c45f81c57dcc10874bbad55d0470a4a03324dac3"}, + {file = "ast_serialize-0.13.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bf53a3ba1cbe45399fc1e4ccd684b8f2a11cca450f89706b79122f478534c6f5"}, + {file = "ast_serialize-0.13.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4bcf1d5f4eefecaa325a34ecf079b2741189e7d0821523f92cb343a389a67489"}, + {file = "ast_serialize-0.13.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1b158f1c9e3a0d386913924c57995d6df78224868a4d0f215bdd41587c97b34a"}, + {file = "ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a6201b69ce42e919f2fd9a559138d5410b5d8afff7c3a6b1f500b0481ff7d8"}, + {file = "ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eb0be64c9b8f1ed26637eef345abe75ea90351c92c85a8aec7838b3af971011"}, + {file = "ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f2427bddfca208e84de700044fadf9aef2769a70bae740a1bdabb89ee9e1117"}, + {file = "ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88df960e67184a71874e5b2ee014f87d1254a0dbc1f903c7bd7b1ab7733ecaef"}, + {file = "ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f99786e0d62082a5b121b3437f3167562133711df22128de1e9886f57f0b226"}, + {file = "ast_serialize-0.13.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:a01b515b25a03184eff4acef25b981ae007916379682e908fc8785b342a0bf3e"}, + {file = "ast_serialize-0.13.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:29422d7bcd9e7f39411571c36cec45ce8a7a9b3ad4b2e7aa64497c15a9e397f6"}, + {file = "ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ffb0f548a7dc126067fdfb7b2325ab5dfcdeecc29faf5e0f0461c96d6ebfa3d"}, + {file = "ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2876444a5f87a7c76f371af3c07c2fda563a08bc8af52568df812e8aba4bf568"}, + {file = "ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:885a62ec6481ce58ab24c6357360d7a82023b62dd7e5e7ceccf59e9662a3272b"}, + {file = "ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2015fd0165e463aac3b1aadc6a1fa1e5820c14ca950821f4fe02f8a3f6a388d6"}, + {file = "ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:eb41e1906502ac80d24ff343f45008552a3f5b5158607959fe6a07dbfc6eb037"}, + {file = "ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ca9acc67f796aa2554fa3037dccc022a4b08b38b2ad153edfac9c9bb7cb1f3c6"}, + {file = "ast_serialize-0.13.1-cp39-abi3-win32.whl", hash = "sha256:4f98da562e5901d69c7dd30885594c5438f859edf354e9ac7517d188fb026c3a"}, + {file = "ast_serialize-0.13.1-cp39-abi3-win_amd64.whl", hash = "sha256:ddf7cbc9c8abf70102c4a088efe154bb86bfb825b0a10e556d2b15972cf99bd7"}, + {file = "ast_serialize-0.13.1-cp39-abi3-win_arm64.whl", hash = "sha256:48f11d96c9ec71119612f8e79a8c55c12f2b136716d68347433c561d27a25f43"}, + {file = "ast_serialize-0.13.1.tar.gz", hash = "sha256:401cc84e8888d4d76f2c8d296fa006ed1a5ff993b6aff90dd777c03973efd025"}, +] + +[[package]] +name = "better-diff" +version = "0.1.4" +description = "A simple library for printing better diffs based on the stdlib unified_diff format." +optional = false +python-versions = "<4.0,>=3.8" +groups = ["lint"] +files = [ + {file = "better_diff-0.1.4-py3-none-any.whl", hash = "sha256:06e63358b2047ae2695abd96316f47c6d3c38b9e641f53012279878d66d8792e"}, + {file = "better_diff-0.1.4.tar.gz", hash = "sha256:920ca76bdbcd2f0c361fa5d9a2d4727624a3545d6cb467b1b6616cad8a634de7"}, +] + +[[package]] +name = "black" +version = "25.12.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.10" +groups = ["lint"] +files = [ + {file = "black-25.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f85ba1ad15d446756b4ab5f3044731bf68b777f8f9ac9cdabd2425b97cd9c4e8"}, + {file = "black-25.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:546eecfe9a3a6b46f9d69d8a642585a6eaf348bcbbc4d87a19635570e02d9f4a"}, + {file = "black-25.12.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17dcc893da8d73d8f74a596f64b7c98ef5239c2cd2b053c0f25912c4494bf9ea"}, + {file = "black-25.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:09524b0e6af8ba7a3ffabdfc7a9922fb9adef60fed008c7cd2fc01f3048e6e6f"}, + {file = "black-25.12.0-cp310-cp310-win_arm64.whl", hash = "sha256:b162653ed89eb942758efeb29d5e333ca5bb90e5130216f8369857db5955a7da"}, + {file = "black-25.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0cfa263e85caea2cff57d8f917f9f51adae8e20b610e2b23de35b5b11ce691a"}, + {file = "black-25.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a2f578ae20c19c50a382286ba78bfbeafdf788579b053d8e4980afb079ab9be"}, + {file = "black-25.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e1b65634b0e471d07ff86ec338819e2ef860689859ef4501ab7ac290431f9b"}, + {file = "black-25.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a3fa71e3b8dd9f7c6ac4d818345237dfb4175ed3bf37cd5a581dbc4c034f1ec5"}, + {file = "black-25.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:51e267458f7e650afed8445dc7edb3187143003d52a1b710c7321aef22aa9655"}, + {file = "black-25.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31f96b7c98c1ddaeb07dc0f56c652e25bdedaac76d5b68a059d998b57c55594a"}, + {file = "black-25.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:05dd459a19e218078a1f98178c13f861fe6a9a5f88fc969ca4d9b49eb1809783"}, + {file = "black-25.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1f68c5eff61f226934be6b5b80296cf6939e5d2f0c2f7d543ea08b204bfaf59"}, + {file = "black-25.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:274f940c147ddab4442d316b27f9e332ca586d39c85ecf59ebdea82cc9ee8892"}, + {file = "black-25.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:169506ba91ef21e2e0591563deda7f00030cb466e747c4b09cb0a9dae5db2f43"}, + {file = "black-25.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a05ddeb656534c3e27a05a29196c962877c83fa5503db89e68857d1161ad08a5"}, + {file = "black-25.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ec77439ef3e34896995503865a85732c94396edcc739f302c5673a2315e1e7f"}, + {file = "black-25.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e509c858adf63aa61d908061b52e580c40eae0dfa72415fa47ac01b12e29baf"}, + {file = "black-25.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:252678f07f5bac4ff0d0e9b261fbb029fa530cfa206d0a636a34ab445ef8ca9d"}, + {file = "black-25.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc5b1c09fe3c931ddd20ee548511c64ebf964ada7e6f0763d443947fd1c603ce"}, + {file = "black-25.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a0953b134f9335c2434864a643c842c44fba562155c738a2a37a4d61f00cad5"}, + {file = "black-25.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2355bbb6c3b76062870942d8cc450d4f8ac71f9c93c40122762c8784df49543f"}, + {file = "black-25.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9678bd991cc793e81d19aeeae57966ee02909877cb65838ccffef24c3ebac08f"}, + {file = "black-25.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:97596189949a8aad13ad12fcbb4ae89330039b96ad6742e6f6b45e75ad5cfd83"}, + {file = "black-25.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:778285d9ea197f34704e3791ea9404cd6d07595745907dd2ce3da7a13627b29b"}, + {file = "black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828"}, + {file = "black-25.12.0.tar.gz", hash = "sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" +pytokens = ">=0.3.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.10)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "click" +version = "8.5.0" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["lint"] +files = [ + {file = "click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360"}, + {file = "click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34"}, +] + +[[package]] +name = "flake8" +version = "5.0.4" +description = "the modular source code checker: pep8 pyflakes and co" +optional = false +python-versions = ">=3.6.1" +groups = ["lint"] +markers = "python_version < \"3.12\"" +files = [ + {file = "flake8-5.0.4-py2.py3-none-any.whl", hash = "sha256:7a1cf6b73744f5806ab95e526f6f0d8c01c66d7bbe349562d22dfca20610b248"}, + {file = "flake8-5.0.4.tar.gz", hash = "sha256:6fbe320aad8d6b95cec8b8e47bc933004678dc63095be98528b7bdd2a9f510db"}, +] + +[package.dependencies] +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.9.0,<2.10.0" +pyflakes = ">=2.5.0,<2.6.0" + +[[package]] +name = "flake8" +version = "6.1.0" +description = "the modular source code checker: pep8 pyflakes and co" +optional = false +python-versions = ">=3.8.1" +groups = ["lint"] +markers = "python_version >= \"3.12\"" +files = [ + {file = "flake8-6.1.0-py2.py3-none-any.whl", hash = "sha256:ffdfce58ea94c6580c77888a86506937f9a1a227dfcd15f245d694ae20a6b6e5"}, + {file = "flake8-6.1.0.tar.gz", hash = "sha256:d5b3857f07c030bdb5bf41c7f53799571d75c4491748a3adcd47de929e34cd23"}, +] + +[package.dependencies] +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.11.0,<2.12.0" +pyflakes = ">=3.1.0,<3.2.0" + +[[package]] +name = "flake8-black" +version = "0.4.0" +description = "flake8 plugin to call black as a code style validator" +optional = false +python-versions = ">=3.9" +groups = ["lint"] +files = [ + {file = "flake8_black-0.4.0-py3-none-any.whl", hash = "sha256:288762d0c9ea065782d87eeecbcc20c69079d17fe1d0f0445f0eb0b0ffb80c39"}, + {file = "flake8_black-0.4.0.tar.gz", hash = "sha256:bf226868f695dee48d55ff6d7747e900709bfd6f605b7a378c70e711e3fc26cb"}, +] + +[package.dependencies] +black = ">=22.1.0" +flake8 = ">=3" +tomli = {version = "*", markers = "python_version < \"3.11\""} + +[package.extras] +develop = ["build", "twine"] + +[[package]] +name = "flake8-docstrings" +version = "1.7.0" +description = "Extension for flake8 which uses pydocstyle to check docstrings" +optional = false +python-versions = ">=3.7" +groups = ["lint"] +files = [ + {file = "flake8_docstrings-1.7.0-py2.py3-none-any.whl", hash = "sha256:51f2344026da083fc084166a9353f5082b01f72901df422f74b4d953ae88ac75"}, + {file = "flake8_docstrings-1.7.0.tar.gz", hash = "sha256:4c8cc748dc16e6869728699e5d0d685da9a10b0ea718e090b1ba088e67a941af"}, +] + +[package.dependencies] +flake8 = ">=3" +pydocstyle = ">=2.1" + +[[package]] +name = "flake8-import-order" +version = "0.19.2" +description = "Flake8 and pylama plugin that checks the ordering of import statements." +optional = false +python-versions = "*" +groups = ["lint"] +files = [ + {file = "flake8_import_order-0.19.2-py3-none-any.whl", hash = "sha256:2dfe60175e7195cf36d4c573861fd2e3258cd6650cbd7616da3c6b8193b29b7c"}, + {file = "flake8_import_order-0.19.2.tar.gz", hash = "sha256:133b3c55497631e4235074fc98a95078bba817832379f22a31f0ad2455bcb0b2"}, +] + +[package.dependencies] +pycodestyle = "*" +setuptools = "*" + +[[package]] +name = "flake8-tidy-imports" +version = "4.12.0" +description = "A flake8 plugin that helps you write tidier imports." +optional = false +python-versions = ">=3.9" +groups = ["lint"] +files = [ + {file = "flake8_tidy_imports-4.12.0-py3-none-any.whl", hash = "sha256:ab1e31a5ce07518a31c0a34cd92551f4c27639ae2c35a21364680a0318da312e"}, + {file = "flake8_tidy_imports-4.12.0.tar.gz", hash = "sha256:9254788c3b6862c2fcec0250d2dc9af089afebff9c5b8a8ac8b9525b059b06db"}, +] + +[package.dependencies] +flake8 = ">=3.8" + +[[package]] +name = "grpcio" +version = "1.85.0" +description = "HTTP/2-based RPC framework" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "grpcio-1.85.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:d617b017395ff7a84fe0aa682890a366afcdd502f2610029052165cd3778b561"}, + {file = "grpcio-1.85.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:ed8ca34cc644a82602097249bdd9af2526f2fe93c641439a19351f3ed2e91de3"}, + {file = "grpcio-1.85.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc1a478ce75e66ab9cbf7077bb12b2a31f75de091122d48845f281f8fd47a0d7"}, + {file = "grpcio-1.85.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3ec59a8175c0bcf51ac767c7baa9083ec369284e1055614bff246257349d4e19"}, + {file = "grpcio-1.85.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9eb6e4149d2e8ea7fc56052eabd36f1bd52a3c0a53f1eeec74528654a895456a"}, + {file = "grpcio-1.85.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5427356333810631bfc3541da05fa57bcd4a0bf846728d8512c9e4bb1f97b261"}, + {file = "grpcio-1.85.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:55a1df9e50009d85fe674fbfe31710b6b3e83702c857114832449c498e78593f"}, + {file = "grpcio-1.85.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:de5b49ab15a350170905b03c03f88f277f83e1910fa87e6f12c63f2da7480a4b"}, + {file = "grpcio-1.85.0-cp310-cp310-win32.whl", hash = "sha256:b016a4663ee8ec37b0e555a65fcc129598404bd5c14d8c00d03f8da551ce0e30"}, + {file = "grpcio-1.85.0-cp310-cp310-win_amd64.whl", hash = "sha256:dbd651df4cf258f2073f8a10f878cee9b8723986ca69206a0da67a2f1651ae86"}, + {file = "grpcio-1.85.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2c6a55a4cb43fe9b14d4aae123ee655cdeb288d8810bec8f8c1adce0d4359d8e"}, + {file = "grpcio-1.85.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:299b13839203e82be15216ae4c3379801a3ad216feff05b165f14962f0939b0c"}, + {file = "grpcio-1.85.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:312939c9afac5d9d5e3b9e50c40a9ebfbe444ca35b28a99ce34903cc9279a5b5"}, + {file = "grpcio-1.85.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:6127bab580cc247c6c62f460485afeba10ec7b9e1b338eef8bde1344acfd9ab2"}, + {file = "grpcio-1.85.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ba535a7b78d5049101a896efbb4ef9edd471c322ea4fc0cc8245545b184ff687"}, + {file = "grpcio-1.85.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d115d1d4e31fa964a348e54445154cce1649da4204f07425028608982d056a2b"}, + {file = "grpcio-1.85.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:864607775e0166a4d3d6bd8276334d96f0b6e9e0a84f3964dc6005db07e9627b"}, + {file = "grpcio-1.85.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58c0ffd1eb2a0d7d216a0c8faefde999586fc017e735dfe9d1142a8481f7d0b6"}, + {file = "grpcio-1.85.0-cp311-cp311-win32.whl", hash = "sha256:0633c4ff112d2237d09ded3044b5acee4fb12de7b39ff4dcfb09b0f029c33992"}, + {file = "grpcio-1.85.0-cp311-cp311-win_amd64.whl", hash = "sha256:2dd69ec2998c2285c323bfa2e3acbd7f475881db97aa9359f2edcbf9ae284169"}, + {file = "grpcio-1.85.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c02d956990c55730efcca487a4eac64f8e0fcb3af7e53c9e89cd02a985669709"}, + {file = "grpcio-1.85.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b10f0f0a34ce6b122dbf5dcf8226cbe41509c602af926b1e55113ad9e49cc5f7"}, + {file = "grpcio-1.85.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b1ea46f63a7a9e19ce5cc09be0a6f9b347e0dcc14c4ab47b48488f6c093b8171"}, + {file = "grpcio-1.85.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e2f1c3089ed6ae75e24529a498a6b65941fdb6856c3416d62c88da944947fc88"}, + {file = "grpcio-1.85.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ddfe8b667889b9be5990ae674b13f0db0a9eb61972306038d0140a1da0f0b87"}, + {file = "grpcio-1.85.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17641f3a6ff3512c34e5c367805d2632a43a773d7990f1c4c55243df6bee8fbb"}, + {file = "grpcio-1.85.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:1a94f32df519cb7efe3822f7f3c2daee0b0e2f01a281e1db5dbfb0efd1635b6e"}, + {file = "grpcio-1.85.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8572f9d7282cded6b018fb0147d09bbe7e59177ba6c452f1903682a80dc0857e"}, + {file = "grpcio-1.85.0-cp312-cp312-win32.whl", hash = "sha256:25007d0e4657dcdafa9a80677895b71de8c1d6335afb7fa6a4b35330e2d3127a"}, + {file = "grpcio-1.85.0-cp312-cp312-win_amd64.whl", hash = "sha256:d6c43a06a059a95f03013aa5fce9106a6a2b6b78cfff4003a53e0eb410c0e1d7"}, + {file = "grpcio-1.85.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:8f1c7cb18d7beb81aa65666b739f8a43261df7f9529628e639b578b6d344cdf9"}, + {file = "grpcio-1.85.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:874af68625a2ff2585cb112e7583c19a7cb463ab638a7fe5b8a58f76e6d4e7c2"}, + {file = "grpcio-1.85.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc2865f523feeac453f35c762a2e4155df58565229d77069b211f2e21de2e79a"}, + {file = "grpcio-1.85.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4c6c39eee05f6b98266315486a93f36d73a59adc4d34f8b63f1b5725f897d8ea"}, + {file = "grpcio-1.85.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:55cee7cfe71b29f6b377681936f9a5f35e59f5c941a4d84e2712cb309753e7a9"}, + {file = "grpcio-1.85.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1a8ee918b5b18c82be2e5f30978258269b858d7e16a36e8729c09d9654bac2f1"}, + {file = "grpcio-1.85.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3c49f03d8d74196ca6d1351b749aab3fb3362c850286f28aafcb7cd773134b04"}, + {file = "grpcio-1.85.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:372e9cbf91f74127652138b020cf15c6a44541a91aee89c50a5943d1996b44aa"}, + {file = "grpcio-1.85.0-cp313-cp313-win32.whl", hash = "sha256:087f297442a62e267f37df0cd03681159c225b8e73741b94d55c515e7a4df81e"}, + {file = "grpcio-1.85.0-cp313-cp313-win_amd64.whl", hash = "sha256:09ab4af4c4c74baa9b17f9a6fcdcef5be98357774417d624191ec41690df4bb3"}, + {file = "grpcio-1.85.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:86bd1d2e197d3e4cde1c04136db48bf22a6fe873b7f55c83e9747d5b36f0cbb4"}, + {file = "grpcio-1.85.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7a24e53259c2005c77b03c48272eec4b374eba027d077e84d0e99f9e30747439"}, + {file = "grpcio-1.85.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6248ed9a86c4191a0d8d7fe54eaea0511aadcadc2ec128daa995eaac91e42880"}, + {file = "grpcio-1.85.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:c5f993fa6b3bf0af33ed7c2bc42f58d22f10964607cf614c2c08b67db80ced60"}, + {file = "grpcio-1.85.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d2ba969495b45ff5e0d10451898387427906b82e28c3c1cfae2dda006e25e18d"}, + {file = "grpcio-1.85.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9c91ad19d92ff81600c087db4829c26b47f9dafd04ed0a26c35e21a05d20f671"}, + {file = "grpcio-1.85.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:26147a7a3c9d1c865cc16b3f98e5c38e5606932aba236b7627ca7eb4378c5c7e"}, + {file = "grpcio-1.85.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:beb6ffcf5253f4247efa47837d876e42157969127657fad4a68be181830ddadd"}, + {file = "grpcio-1.85.0-cp314-cp314-win32.whl", hash = "sha256:a03fee5e413938ddb71aa2fd7c52520e2fc3aec2e9602d50464272c75b274a54"}, + {file = "grpcio-1.85.0-cp314-cp314-win_amd64.whl", hash = "sha256:a11c8c322a19d6d3cd6fb645127e8f1ca4e8041d05e41e9b24c7678ec7391021"}, + {file = "grpcio-1.85.0-cp315-cp315-linux_armv7l.whl", hash = "sha256:e0b87fde3b80955343b37cf9b9deac52432e9e7a4b3e0f92abbd61fee0f7b184"}, + {file = "grpcio-1.85.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:27b024f8420c9d63f7df9f51d4c2d2e54fcf3b26c9963041f5fc75b83f35e87b"}, + {file = "grpcio-1.85.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:dc726ae4f2f4e2215cb1b987b8ddd7f4f2f253d6ceb48cfbb9b88555d325e10a"}, + {file = "grpcio-1.85.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3b463b3b97f35149a3f69234de92eddfd7c94c9c19794e3b264ccba7b6b03fac"}, + {file = "grpcio-1.85.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:426e41a32d10f841f41fc67e4e6d150af7e7c7c3433fe61fa3a0ca19a8dc9885"}, + {file = "grpcio-1.85.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:a8dd5c3823194811c5e6932b454bcaa7b8e474a7690ed4ce6246bd502849a82c"}, + {file = "grpcio-1.85.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c8accda49358f99875057ff1ec6a79fead12dac41ac63c1c1e1757a2aab14c1d"}, + {file = "grpcio-1.85.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a04b67a146f1a95e2bd281614be89b662973535a94eac944135ea86b2e5a7ef"}, + {file = "grpcio-1.85.0-cp315-cp315-win32.whl", hash = "sha256:b2da79c303f23e66d08baaa0c78d8b8630c700e1562babb1f326ada0a97ee5d0"}, + {file = "grpcio-1.85.0-cp315-cp315-win_amd64.whl", hash = "sha256:dd88a98073ebc0e6771ff8c2fd9451fbf887f575b0da9d30246ee1b2813f6b57"}, + {file = "grpcio-1.85.0.tar.gz", hash = "sha256:8aa2647afcbe5a87783e9626a2ece6277db33c6604c21690d19b5bd15609e36d"}, +] + +[package.dependencies] +typing-extensions = ">=4.12,<5.0" + +[package.extras] +protobuf = ["grpcio-tools (>=1.85.0)"] + +[[package]] +name = "grpcio-tools" +version = "1.49.1" +description = "Protobuf code generator for gRPC" +optional = false +python-versions = ">=3.7" +groups = ["main"] +markers = "python_version < \"3.12\"" +files = [ + {file = "grpcio-tools-1.49.1.tar.gz", hash = "sha256:84cc64e5b46bad43d5d7bd2fd772b656eba0366961187a847e908e2cb735db91"}, + {file = "grpcio_tools-1.49.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:2dfb6c7ece84d46bd690b23d3e060d18115c8bc5047d2e8a33e6747ed323a348"}, + {file = "grpcio_tools-1.49.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:8f452a107c054a04db2570f7851a07f060313c6e841b0d394ce6030d598290e6"}, + {file = "grpcio_tools-1.49.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:6a198871b582287213c4d70792bf275e1d7cf34eed1d019f534ddf4cd15ab039"}, + {file = "grpcio_tools-1.49.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a0cca67a7d0287bdc855d81fdd38dc949c4273273a74f832f9e520abe4f20bc6"}, + {file = "grpcio_tools-1.49.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaff4c89eecb37c247b93025410db68114d97fa093cbb028e9bd7cda5912473"}, + {file = "grpcio_tools-1.49.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bb8773118ad315db317d7b22b5ff75d649ca20931733281209e7cbd8c0fad53e"}, + {file = "grpcio_tools-1.49.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7cc5534023735b8a8f56760b7c533918f874ce5a9064d7c5456d2709ae2b31f9"}, + {file = "grpcio_tools-1.49.1-cp310-cp310-win32.whl", hash = "sha256:d277642acbe305f5586f9597b78fb9970d6633eb9f89c61e429c92c296c37129"}, + {file = "grpcio_tools-1.49.1-cp310-cp310-win_amd64.whl", hash = "sha256:eed599cf08fc1a06c72492d3c5750c32f58de3750eddd984af1f257c14326701"}, + {file = "grpcio_tools-1.49.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:9e5c13809ab2f245398e8446c4c3b399a62d591db651e46806cccf52a700452e"}, + {file = "grpcio_tools-1.49.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:ab3d0ee9623720ee585fdf3753b3755d3144a4a8ae35bca8e3655fa2f41056be"}, + {file = "grpcio_tools-1.49.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ba87e3512bc91d78bf9febcfb522eadda171d2d4ddaf886066b0f01aa4929ad"}, + {file = "grpcio_tools-1.49.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e13b3643e7577a3ec13b79689eb4d7548890b1e104c04b9ed6557a3c3dd452"}, + {file = "grpcio_tools-1.49.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:324f67d9cb4b7058b6ce45352fb64c20cc1fa04c34d97ad44772cfe6a4ae0cf5"}, + {file = "grpcio_tools-1.49.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a64bab81b220c50033f584f57978ebbea575f09c1ccee765cd5c462177988098"}, + {file = "grpcio_tools-1.49.1-cp311-cp311-win32.whl", hash = "sha256:f632d376f92f23e5931697a3acf1b38df7eb719774213d93c52e02acd2d529ac"}, + {file = "grpcio_tools-1.49.1-cp311-cp311-win_amd64.whl", hash = "sha256:28ff2b978d9509474928b9c096a0cce4eaa9c8f7046136aee1545f6211ed8126"}, + {file = "grpcio_tools-1.49.1-cp37-cp37m-linux_armv7l.whl", hash = "sha256:46afd3cb7e555187451a5d283f108cdef397952a662cb48680afc615b158864a"}, + {file = "grpcio_tools-1.49.1-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:9284568b728e41fa8f7e9c2e7399545d605f75d8072ef0e9aa2a05655cb679eb"}, + {file = "grpcio_tools-1.49.1-cp37-cp37m-manylinux_2_17_aarch64.whl", hash = "sha256:aa34442cf787732cb41f2aa6172007e24f480b8b9d3dc5166de80d63e9072ea4"}, + {file = "grpcio_tools-1.49.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b8c9eb5a4250905414cd53a68caea3eb8f0c515aadb689e6e81b71ebe9ab5c6"}, + {file = "grpcio_tools-1.49.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab15db024051bf21feb21c29cb2c3ea0a2e4f5cf341d46ef76e17fcf6aaef164"}, + {file = "grpcio_tools-1.49.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:502084b622f758bef620a9107c2db9fcdf66d26c7e0e481d6bb87db4dc917d70"}, + {file = "grpcio_tools-1.49.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:4085890b77c640085f82bf1e90a0ea166ce48000bc2f5180914b974783c9c0a8"}, + {file = "grpcio_tools-1.49.1-cp37-cp37m-win32.whl", hash = "sha256:da0edb984699769ce02e18e3392d54b59a7a3f93acd285a68043f5bde4fc028e"}, + {file = "grpcio_tools-1.49.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9887cd622770271101a7dd1832845d64744c3f88fd11ccb2620394079197a42e"}, + {file = "grpcio_tools-1.49.1-cp38-cp38-linux_armv7l.whl", hash = "sha256:8440fe7dae6a40c279e3a24b82793735babd38ecbb0d07bb712ff9c8963185d9"}, + {file = "grpcio_tools-1.49.1-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:b5de2bb7dd6b6231da9b1556ade981513330b740e767f1d902c71ceee0a7d196"}, + {file = "grpcio_tools-1.49.1-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:1e6f06a763aea7836b63d9c117347f2bf7038008ceef72758815c9e09c5fb1fc"}, + {file = "grpcio_tools-1.49.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e31562f90120318c5395aabec0f2f69ad8c14b6676996b7730d9d2eaf9415d57"}, + {file = "grpcio_tools-1.49.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49ef9a4e389a618157a9daa9fafdfeeaef1ece9adda7f50f85db928f24d4b3e8"}, + {file = "grpcio_tools-1.49.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:b384cb8e8d9bcb55ee8f9b064374561c7a1a05d848249581403d36fc7060032f"}, + {file = "grpcio_tools-1.49.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:73732f77943ac3e898879cbb29c27253aa3c47566b8a59780fd24c6a54de1b66"}, + {file = "grpcio_tools-1.49.1-cp38-cp38-win32.whl", hash = "sha256:b594b2745a5ba9e7a76ce561bc5ab40bc65bb44743c505529b1e4f12af29104d"}, + {file = "grpcio_tools-1.49.1-cp38-cp38-win_amd64.whl", hash = "sha256:680fbc88f8709ddcabb88f86749f2d8e429160890cff2c70680880a6970d4eef"}, + {file = "grpcio_tools-1.49.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:e8c3869121860f6767eedb7d24fc54dfd71e737fdfbb26e1334684606f3274fd"}, + {file = "grpcio_tools-1.49.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:73e9d7c886ba10e20c97d1dab0ff961ba5800757ae5e31be21b1cda8130c52f8"}, + {file = "grpcio_tools-1.49.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:1760de2dd2c4f08de87b039043a4797f3c17193656e7e3eb84e92f0517083c0c"}, + {file = "grpcio_tools-1.49.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cd4b1e216dd04d9245ee8f4e601a1f98c25e6e417ea5cf8d825c50589a8b447e"}, + {file = "grpcio_tools-1.49.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1c28751ab5955cae563d07677e799233f0fe1c0fc49d9cbd61ff1957e83617f"}, + {file = "grpcio_tools-1.49.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c24239c3ee9ed16314c14b4e24437b5079ebc344f343f33629a582f8699f583b"}, + {file = "grpcio_tools-1.49.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:892d3dacf1942820f0b7a868a30e6fbcdf5bec08543b682c7274b0101cee632d"}, + {file = "grpcio_tools-1.49.1-cp39-cp39-win32.whl", hash = "sha256:704d21509ec06efc9d034dbe70e7152715aac004941f4f0f553cf3a0aff15bd5"}, + {file = "grpcio_tools-1.49.1-cp39-cp39-win_amd64.whl", hash = "sha256:1efa0c221c719433f441ac0e026fc3c4dbc9a1a08a552ecdc707775e2f2fbbae"}, +] + +[package.dependencies] +grpcio = ">=1.49.1" +protobuf = ">=4.21.3,<5.0" +setuptools = "*" + +[[package]] +name = "grpcio-tools" +version = "1.59.0" +description = "Protobuf code generator for gRPC" +optional = false +python-versions = ">=3.7" +groups = ["main"] +markers = "python_version == \"3.12\"" +files = [ + {file = "grpcio-tools-1.59.0.tar.gz", hash = "sha256:aa4018f2d8662ac4d9830445d3d253a11b3e096e8afe20865547137aa1160e93"}, + {file = "grpcio_tools-1.59.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:882b809b42b5464bee55288f4e60837297f9618e53e69ae3eea6d61b05ce48fa"}, + {file = "grpcio_tools-1.59.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:4499d4bc5aa9c7b645018d8b0db4bebd663d427aabcd7bee7777046cb1bcbca7"}, + {file = "grpcio_tools-1.59.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:f381ae3ad6a5eb27aad8d810438937d8228977067c54e0bd456fce7e11fdbf3d"}, + {file = "grpcio_tools-1.59.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1c684c0d9226d04cadafced620a46ab38c346d0780eaac7448da96bf12066a3"}, + {file = "grpcio_tools-1.59.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40cbf712769242c2ba237745285ef789114d7fcfe8865fc4817d87f20015e99a"}, + {file = "grpcio_tools-1.59.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:1df755951f204e65bf9232a9cac5afe7d6b8e4c87ac084d3ecd738fdc7aa4174"}, + {file = "grpcio_tools-1.59.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:de156c18b0c638aaee3be6ad650c8ba7dec94ed4bac26403aec3dce95ffe9407"}, + {file = "grpcio_tools-1.59.0-cp310-cp310-win32.whl", hash = "sha256:9af7e138baa9b2895cf1f3eb718ac96fc5ae2f8e31fca405e21e0e5cd1643c52"}, + {file = "grpcio_tools-1.59.0-cp310-cp310-win_amd64.whl", hash = "sha256:f14a6e4f700dfd30ff8f0e6695f944affc16ae5a1e738666b3fae4e44b65637e"}, + {file = "grpcio_tools-1.59.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:db030140d0da2368319e2f23655df3baec278c7e0078ecbe051eaf609a69382c"}, + {file = "grpcio_tools-1.59.0-cp311-cp311-macosx_10_10_universal2.whl", hash = "sha256:eeed386971bb8afc3ec45593df6a1154d680d87be1209ef8e782e44f85f47e64"}, + {file = "grpcio_tools-1.59.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:962d1a3067129152cee3e172213486cb218a6bad703836991f46f216caefcf00"}, + {file = "grpcio_tools-1.59.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:26eb2eebf150a33ebf088e67c1acf37eb2ac4133d9bfccbaa011ad2148c08b42"}, + {file = "grpcio_tools-1.59.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5b2d6da553980c590487f2e7fd3ec9c1ad8805ff2ec77977b92faa7e3ca14e1f"}, + {file = "grpcio_tools-1.59.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:335e2f355a0c544a88854e2c053aff8a3f398b84a263a96fa19d063ca1fe513a"}, + {file = "grpcio_tools-1.59.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:204e08f807b1d83f5f0efea30c4e680afe26a43dec8ba614a45fa698a7ef0a19"}, + {file = "grpcio_tools-1.59.0-cp311-cp311-win32.whl", hash = "sha256:05bf7b3ed01c8a562bb7e840f864c58acedbd6924eb616367c0bd0a760bdf483"}, + {file = "grpcio_tools-1.59.0-cp311-cp311-win_amd64.whl", hash = "sha256:df85096fcac7cea8aa5bd84b7a39c4cdbf556b93669bb4772eb96aacd3222a4e"}, + {file = "grpcio_tools-1.59.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:240a7a3c2c54f77f1f66085a635bca72003d02f56a670e7db19aec531eda8f78"}, + {file = "grpcio_tools-1.59.0-cp312-cp312-macosx_10_10_universal2.whl", hash = "sha256:6119f62c462d119c63227b9534210f0f13506a888151b9bf586f71e7edf5088b"}, + {file = "grpcio_tools-1.59.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:387662bee8e4c0b52cc0f61eaaca0ca583f5b227103f685b76083a3590a71a3e"}, + {file = "grpcio_tools-1.59.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8f0da5861ee276ca68493b217daef358960e8527cc63c7cb292ca1c9c54939af"}, + {file = "grpcio_tools-1.59.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0f0806de1161c7f248e4c183633ee7a58dfe45c2b77ddf0136e2e7ad0650b1b"}, + {file = "grpcio_tools-1.59.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:c683be38a9bf4024c223929b4cd2f0a0858c94e9dc8b36d7eaa5a48ce9323a6f"}, + {file = "grpcio_tools-1.59.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f965707da2b48a33128615bcfebedd215a3a30e346447e885bb3da37a143177a"}, + {file = "grpcio_tools-1.59.0-cp312-cp312-win32.whl", hash = "sha256:2ee960904dde12a7fa48e1591a5b3eeae054bdce57bacf9fd26685a98138f5bf"}, + {file = "grpcio_tools-1.59.0-cp312-cp312-win_amd64.whl", hash = "sha256:71cc6db1d66da3bc3730d9937bddc320f7b1f1dfdff6342bcb5741515fe4110b"}, + {file = "grpcio_tools-1.59.0-cp37-cp37m-linux_armv7l.whl", hash = "sha256:f6263b85261b62471cb97b7505df72d72b8b62e5e22d8184924871a6155b4dbf"}, + {file = "grpcio_tools-1.59.0-cp37-cp37m-macosx_10_10_universal2.whl", hash = "sha256:b8e95d921cc2a1521d4750eedefec9f16031457920a6677edebe9d1b2ad6ae60"}, + {file = "grpcio_tools-1.59.0-cp37-cp37m-manylinux_2_17_aarch64.whl", hash = "sha256:cb63055739808144b541986291679d643bae58755d0eb082157c4d4c04443905"}, + {file = "grpcio_tools-1.59.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c4634b3589efa156a8d5860c0a2547315bd5c9e52d14c960d716fe86e0927be"}, + {file = "grpcio_tools-1.59.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d970aa26854f535ffb94ea098aa8b43de020d9a14682e4a15dcdaeac7801b27"}, + {file = "grpcio_tools-1.59.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:821dba464d84ebbcffd9d420302404db2fa7a40c7ff4c4c4c93726f72bfa2769"}, + {file = "grpcio_tools-1.59.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0548e901894399886ff4a4cd808cb850b60c021feb4a8977a0751f14dd7e55d9"}, + {file = "grpcio_tools-1.59.0-cp37-cp37m-win_amd64.whl", hash = "sha256:bb87158dbbb9e5a79effe78d54837599caa16df52d8d35366e06a91723b587ae"}, + {file = "grpcio_tools-1.59.0-cp38-cp38-linux_armv7l.whl", hash = "sha256:1d551ff42962c7c333c3da5c70d5e617a87dee581fa2e2c5ae2d5137c8886779"}, + {file = "grpcio_tools-1.59.0-cp38-cp38-macosx_10_10_universal2.whl", hash = "sha256:4ee443abcd241a5befb05629013fbf2eac637faa94aaa3056351aded8a31c1bc"}, + {file = "grpcio_tools-1.59.0-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:520c0c83ea79d14b0679ba43e19c64ca31d30926b26ad2ca7db37cbd89c167e2"}, + {file = "grpcio_tools-1.59.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9fc02a6e517c34dcf885ff3b57260b646551083903e3d2c780b4971ce7d4ab7c"}, + {file = "grpcio_tools-1.59.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6aec8a4ed3808b7dfc1276fe51e3e24bec0eeaf610d395bcd42934647cf902a3"}, + {file = "grpcio_tools-1.59.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:99b3bde646720bbfb77f263f5ba3e1a0de50632d43c38d405a0ef9c7e94373cd"}, + {file = "grpcio_tools-1.59.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:51d9595629998d8b519126c5a610f15deb0327cd6325ed10796b47d1d292e70b"}, + {file = "grpcio_tools-1.59.0-cp38-cp38-win32.whl", hash = "sha256:bfa4b2b7d21c5634b62e5f03462243bd705adc1a21806b5356b8ce06d902e160"}, + {file = "grpcio_tools-1.59.0-cp38-cp38-win_amd64.whl", hash = "sha256:9ed05197c5ab071e91bcef28901e97ca168c4ae94510cb67a14cb4931b94255a"}, + {file = "grpcio_tools-1.59.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:498e7be0b14385980efa681444ba481349c131fc5ec88003819f5d929646947c"}, + {file = "grpcio_tools-1.59.0-cp39-cp39-macosx_10_10_universal2.whl", hash = "sha256:b519f2ecde9a579cad2f4a7057d5bb4e040ad17caab8b5e691ed7a13b9db0be9"}, + {file = "grpcio_tools-1.59.0-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:ef3e8aca2261f7f07436d4e2111556c1fb9bf1f9cfcdf35262743ccdee1b6ce9"}, + {file = "grpcio_tools-1.59.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27a7f226b741b2ebf7e2d0779d2c9b17f446d1b839d59886c1619e62cc2ae472"}, + {file = "grpcio_tools-1.59.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:784aa52965916fec5afa1a28eeee6f0073bb43a2a1d7fedf963393898843077a"}, + {file = "grpcio_tools-1.59.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:e312ddc2d8bec1a23306a661ad52734f984c9aad5d8f126ebb222a778d95407d"}, + {file = "grpcio_tools-1.59.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:868892ad9e00651a38dace3e4924bae82fc4fd4df2c65d37b74381570ee8deb1"}, + {file = "grpcio_tools-1.59.0-cp39-cp39-win32.whl", hash = "sha256:a4f6cae381f21fee1ef0a5cbbbb146680164311157ae618edf3061742d844383"}, + {file = "grpcio_tools-1.59.0-cp39-cp39-win_amd64.whl", hash = "sha256:4a10e59cca462208b489478340b52a96d64e8b8b6f1ac097f3e8cb211d3f66c0"}, +] + +[package.dependencies] +grpcio = ">=1.59.0" +protobuf = ">=4.21.6,<5.0" +setuptools = "*" + +[[package]] +name = "grpcio-tools" +version = "1.67.0" +description = "Protobuf code generator for gRPC" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version == \"3.13\"" +files = [ + {file = "grpcio_tools-1.67.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:12aa38af76b5ef00a55808c7c374ed18d5dc7cc8081b717e56da3c50df1776e2"}, + {file = "grpcio_tools-1.67.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:b0b03d055127bbc7c629454804b53b5cad2cedfcf904576d159a8a04c22b8e66"}, + {file = "grpcio_tools-1.67.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:02b0b50c59a8f7428326197027a2f586d216c46138c547f861533c46bff78bfe"}, + {file = "grpcio_tools-1.67.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2afdfe151ed9edbd4a3fd646716f83b58010769c57f9c0aa1cf4c3bfb1240a8"}, + {file = "grpcio_tools-1.67.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc3eeb87575b2b360c5ef5aef22eb76cfdd6a255d2f628a48ab0e5a61a0039fb"}, + {file = "grpcio_tools-1.67.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ead78089c4771605a1ff8894e47f2267440693f1beeee06fd5a788aede83370f"}, + {file = "grpcio_tools-1.67.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0671dcdccef09ca4eb415c1d6f470a857c6486733c146676f6810a3ade1d42cb"}, + {file = "grpcio_tools-1.67.0-cp310-cp310-win32.whl", hash = "sha256:a7398d90b8c7da479aec8f853d3664d5a93c209f8ac3bd41cb7ae4e8677a45c6"}, + {file = "grpcio_tools-1.67.0-cp310-cp310-win_amd64.whl", hash = "sha256:f7e7d70a74df7e07be7cceaa694b7e8e5f3bef8e0299906f60885ecf7a40adb4"}, + {file = "grpcio_tools-1.67.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:655716bf931a22a090134d87953710033640996d31e36f5f9b0106ff5f552d8e"}, + {file = "grpcio_tools-1.67.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:484ae782f9d3ff58e0bbb2f4cad14d5f5d9132fc701835b1dffd2c2a06f73ba6"}, + {file = "grpcio_tools-1.67.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:f3e34de876efe1273f91e25ef241e449ed7f9411472dd9ff56d2039618017c30"}, + {file = "grpcio_tools-1.67.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8301719edde2c3d388995703cdd962f558b76e9750405f772dce61402e4c3d0"}, + {file = "grpcio_tools-1.67.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1629ea246044ccd473d9ac4c9f137a440d830b5e08d35225e1b354dbbb15b75d"}, + {file = "grpcio_tools-1.67.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d77a3c5cec0065267ca1a0b2589ececd1277ce25aa67f13ec50c816ee6f26f7f"}, + {file = "grpcio_tools-1.67.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9bf992bcc7d9e6eaa20705056e1b955593092a38cec1746fef389d873ab2056"}, + {file = "grpcio_tools-1.67.0-cp311-cp311-win32.whl", hash = "sha256:7e6e3db119c38629e0767cdb2ee18726ecc87e2249117d4c9e7ce06ea8c894ea"}, + {file = "grpcio_tools-1.67.0-cp311-cp311-win_amd64.whl", hash = "sha256:c6c27aec301a0e6cf231f9ee1c467c64002af51170fa7c0f3bb10bbfcd03fee7"}, + {file = "grpcio_tools-1.67.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:dca7f053cbdb26a587d4410ddb893877c585fb60a31f22fdd128e4f7c4dab27c"}, + {file = "grpcio_tools-1.67.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:de8c4f68ffa690769d84329c17c7fdd5fbe4c61b8f8a0de03f1ad8ef8bb06963"}, + {file = "grpcio_tools-1.67.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:6e4ecb24c27a78f09fead45d4ed873805d6026124ccb6793b6fb93a490b78ddf"}, + {file = "grpcio_tools-1.67.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:004d6ef1b5f724480f05c0bdc904bf8c78c43d633c537d99abe51b52ce0cadeb"}, + {file = "grpcio_tools-1.67.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dd257072c86eb9b36791b3674a513a215ba76bbdd38fc228f0e8c6dc5ce3524"}, + {file = "grpcio_tools-1.67.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a8cca551317ed26e17d13b6ee27b2bd62f5fe9b3842b4e88389deb984f995848"}, + {file = "grpcio_tools-1.67.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a7ac3b4f837c693142f6688b629d1f6408f6ab250d927159b572555f5339fe25"}, + {file = "grpcio_tools-1.67.0-cp312-cp312-win32.whl", hash = "sha256:95feec33388e2a8f72c360a68efe6f0bfed9c771e94d21b7f2359d0010f60219"}, + {file = "grpcio_tools-1.67.0-cp312-cp312-win_amd64.whl", hash = "sha256:50a31d035193ebe7154181eac84734e25bdcdb36adba849d3b2adf1c3b0c382b"}, + {file = "grpcio_tools-1.67.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:9ecb7c2e5da052a3feaeaa83d8f2a946a8feec8a50751b0e6175da982b49ebb1"}, + {file = "grpcio_tools-1.67.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3c52164f2b9d41c6d75464bb45f45737dcb421e92e98d85d94fda100c67a24d8"}, + {file = "grpcio_tools-1.67.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:471f58b919767290260d427dd9b760796e6208ee5fcda2f76bb8bd585ff842ec"}, + {file = "grpcio_tools-1.67.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c6bcdf38f672721c093c92b1fb1f9a02a365acc5bd42e1c69fe6e904b26081"}, + {file = "grpcio_tools-1.67.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:833b1eb9c03d28a798294523f75294055eff78fa897adf797876337b901afeb9"}, + {file = "grpcio_tools-1.67.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:1db92ad6ade1946fc5705eb04956fcfdb3a0a4682de8dc3fce31cb97b6e4fcb8"}, + {file = "grpcio_tools-1.67.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:38128310ded818e1044c0cd0979d76f7c0d3c3946a526a8aa39cd258624c3bf3"}, + {file = "grpcio_tools-1.67.0-cp313-cp313-win32.whl", hash = "sha256:db57930dc20ab678311727883bdb9f122daf06c14f3fd3067c9ccedb7eb056c3"}, + {file = "grpcio_tools-1.67.0-cp313-cp313-win_amd64.whl", hash = "sha256:7de44d8d3bb920a4973a559f2950d03382fa4aed4880306416ffa73d24838477"}, + {file = "grpcio_tools-1.67.0-cp38-cp38-linux_armv7l.whl", hash = "sha256:793896648734aad3ad8f26795dcdd6040aecd35efef43fcbb67d221373e6379a"}, + {file = "grpcio_tools-1.67.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:941418cba6a8adfcac3ff7ff3bdef00b55a44d673634c15bddcfa7778e49239e"}, + {file = "grpcio_tools-1.67.0-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:0d63ff6be6f3d0294249fc7a21f26f06c9cc209130c5328907cd678406d7d232"}, + {file = "grpcio_tools-1.67.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:af80ced3ba49377ef7bec93e9ccbfa357875460e9a624ed12d9a7d5348741a76"}, + {file = "grpcio_tools-1.67.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3c3fbb4a6d10764295540579492397bc7a3334e1a92dd17a4bc7b69159cf70a"}, + {file = "grpcio_tools-1.67.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a05c10fb783f609d16e1f754ebad9bb432a1adbfc46139d154e8fd6b15f59988"}, + {file = "grpcio_tools-1.67.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ea8af001f08c678ab59e2bf2614d8b09d62210e540f7af1129c172fe4fd330c7"}, + {file = "grpcio_tools-1.67.0-cp38-cp38-win32.whl", hash = "sha256:004d329aee385fa874979196e5359a967c370d31813f61eba88043ccaa2e06d8"}, + {file = "grpcio_tools-1.67.0-cp38-cp38-win_amd64.whl", hash = "sha256:fc43593bd051abb73a5d130fc041923144089ac459fb01165960106ebb686fd0"}, + {file = "grpcio_tools-1.67.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:d285c036ddfc2618c4db60b584409dd8313d41473bd46177c763ea22ed9aeb1f"}, + {file = "grpcio_tools-1.67.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:623fdad489447e1565d0ba5a818d54b4e74cd73800b6a32c4c009601c7f7a36c"}, + {file = "grpcio_tools-1.67.0-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:24536af8a5f8e532fbd996c1763eff51526d1d1563f9499ff5ffffb9a08811f3"}, + {file = "grpcio_tools-1.67.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd64a9a8eb675dd2aa59cb4b2ab025a3b02ae1bb9e483c7fb518ffa0f0755cda"}, + {file = "grpcio_tools-1.67.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f94378bc90fb008b0a56237748aa42c787fd86c392e7df3d65f0fe7fcd93844a"}, + {file = "grpcio_tools-1.67.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d0f39a9d860a6768574cd77b5d9ad81513fa1c575d3a050d4e72e6d79dcd62f3"}, + {file = "grpcio_tools-1.67.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c578f1306bfd0dd0668e24f8c04d61529928de2660217022a947a56be177bc2d"}, + {file = "grpcio_tools-1.67.0-cp39-cp39-win32.whl", hash = "sha256:0c2cf8d09bdc05e0550ad413e0bc0d552500bb7f98d36079b7b9d38e064e02f7"}, + {file = "grpcio_tools-1.67.0-cp39-cp39-win_amd64.whl", hash = "sha256:cc570bcd9c9681bb011f746ea90cc50559be629227aaaaae9fde8549525f0287"}, + {file = "grpcio_tools-1.67.0.tar.gz", hash = "sha256:181b3d4e61b83142c182ec366f3079b0023509743986e54c9465ca38cac255f8"}, +] + +[package.dependencies] +grpcio = ">=1.67.0" +protobuf = ">=5.26.1,<6.0" +setuptools = "*" + +[[package]] +name = "grpcio-tools" +version = "1.75.1" +description = "Protobuf code generator for gRPC" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "grpcio_tools-1.75.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:ae0f04d5ec8b8e13476bf516a08fc1de4e58c6bf79f99123a6b964ca7d02c790"}, + {file = "grpcio_tools-1.75.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:24a881ad7292e904fc256892b647da17d9137ef2e72faf8b7c8e515314ad1377"}, + {file = "grpcio_tools-1.75.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1b5810ace274dba12ecfac69ac32c8047c6ee0200a23274cb4885ed4187271f8"}, + {file = "grpcio_tools-1.75.1-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ab33993288b97b1180e092fa447a8ce00fbc8c59d67b23553245b88d14fe36bb"}, + {file = "grpcio_tools-1.75.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4cac693621043ef11d3ab2318e811d919779f8cd5011ba8e37f44c178c831d94"}, + {file = "grpcio_tools-1.75.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a09cd5d267b296af67116fe098633ad770bc8c19831a5f3c896f65fad90c1064"}, + {file = "grpcio_tools-1.75.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dff4bcb4d16cf9ef745c1984394ed15187e6c23d73d71377377deaf443d11358"}, + {file = "grpcio_tools-1.75.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:16d5986b37e2a9203f85e456c7ff8705b932718021d408adfe4a79e0f4d95949"}, + {file = "grpcio_tools-1.75.1-cp310-cp310-win32.whl", hash = "sha256:3fbac14998bfadc6b9140b6339dbc5f673700ebb4d45ba0c4d4fbe0ffb8559a9"}, + {file = "grpcio_tools-1.75.1-cp310-cp310-win_amd64.whl", hash = "sha256:b56e495844eb899de721eb77d9e077192bdeb40842f598481d32a8f6de3db124"}, + {file = "grpcio_tools-1.75.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:f0635231feb70a9d551452829943a1a5fa651283e7a300aadc22df5ea5da696f"}, + {file = "grpcio_tools-1.75.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:626293296ef7e2d87ab1a80b81a55eef91883c65b59a97576099a28b9535100b"}, + {file = "grpcio_tools-1.75.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:071339d90f1faab332ce4919c815a10b9c3ed2c09473f550f686bf9cc148579f"}, + {file = "grpcio_tools-1.75.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:44195f58c052fa935b78c7438c85cbcd4b273dd685028e4f6d4d7b30d47daad1"}, + {file = "grpcio_tools-1.75.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:860fafdb85726029d646c99859ff7bdca5aae61b5ff038c3bd355fc1ec6b2764"}, + {file = "grpcio_tools-1.75.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4559547a0cb3d3db1b982eea87d4656036339b400f48127fef932210672fb59e"}, + {file = "grpcio_tools-1.75.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9af65a310807d7f36a8f7cddea142fe97d6dffba74444f38870272f2e5a3a06b"}, + {file = "grpcio_tools-1.75.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c1de31aefc0585d2f915a7cd0994d153547495b8d79c44c58048a3ede0b65be"}, + {file = "grpcio_tools-1.75.1-cp311-cp311-win32.whl", hash = "sha256:efaf95fcaa5d3ac1bcfe44ceed9e2512eb95b5c8c476569bdbbe2bee4b59c8a9"}, + {file = "grpcio_tools-1.75.1-cp311-cp311-win_amd64.whl", hash = "sha256:7cefe76fc35c825f0148d60d2294a527053d0f5dd6a60352419214a8c53223c9"}, + {file = "grpcio_tools-1.75.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:49b68936cf212052eeafa50b824e17731b78d15016b235d36e0d32199000b14c"}, + {file = "grpcio_tools-1.75.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:08cb6e568e58b76a2178ad3b453845ff057131fff00f634d7e15dcd015cd455b"}, + {file = "grpcio_tools-1.75.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:168402ad29a249092673079cf46266936ec2fb18d4f854d96e9c5fa5708efa39"}, + {file = "grpcio_tools-1.75.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:bbae11c29fcf450730f021bfc14b12279f2f985e2e493ccc2f133108728261db"}, + {file = "grpcio_tools-1.75.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38c6c7d5d4800f636ee691cd073db1606d1a6a76424ca75c9b709436c9c20439"}, + {file = "grpcio_tools-1.75.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:626f6a61a8f141dde9a657775854d1c0d99509f9a2762b82aa401a635f6ec73d"}, + {file = "grpcio_tools-1.75.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f61a8334ae38d4f98c744a732b89527e5af339d17180e25fff0676060f8709b7"}, + {file = "grpcio_tools-1.75.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd0c3fb40d89a1e24a41974e77c7331e80396ab7cde39bc396a13d6b5e2a750b"}, + {file = "grpcio_tools-1.75.1-cp312-cp312-win32.whl", hash = "sha256:004bc5327593eea48abd03be3188e757c3ca0039079587a6aac24275127cac20"}, + {file = "grpcio_tools-1.75.1-cp312-cp312-win_amd64.whl", hash = "sha256:23952692160b5fe7900653dfdc9858dc78c2c42e15c27e19ee780c8917ba6028"}, + {file = "grpcio_tools-1.75.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:ca9e116aab0ecf4365fc2980f2e8ae1b22273c3847328b9a8e05cbd14345b397"}, + {file = "grpcio_tools-1.75.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:9fe87a926b65eb7f41f8738b6d03677cc43185ff77a9d9b201bdb2f673f3fa1e"}, + {file = "grpcio_tools-1.75.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:45503a6094f91b3fd31c3d9adef26ac514f102086e2a37de797e220a6791ee87"}, + {file = "grpcio_tools-1.75.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b01b60b3de67be531a39fd869d7613fa8f178aff38c05e4d8bc2fc530fa58cb5"}, + {file = "grpcio_tools-1.75.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09e2b9b9488735514777d44c1e4eda813122d2c87aad219f98d5d49b359a8eab"}, + {file = "grpcio_tools-1.75.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:55e60300e62b220fabe6f062fe69f143abaeff3335f79b22b56d86254f3c3c80"}, + {file = "grpcio_tools-1.75.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:49ce00fcc6facbbf52bf376e55b8e08810cecd03dab0b3a2986d73117c6f6ee4"}, + {file = "grpcio_tools-1.75.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:71e95479aea868f8c8014d9dc4267f26ee75388a0d8a552e1648cfa0b53d24b4"}, + {file = "grpcio_tools-1.75.1-cp313-cp313-win32.whl", hash = "sha256:fff9d2297416eae8861e53154ccf70a19994e5935e6c8f58ebf431f81cbd8d12"}, + {file = "grpcio_tools-1.75.1-cp313-cp313-win_amd64.whl", hash = "sha256:1849ddd508143eb48791e81d42ddc924c554d1b4900e06775a927573a8d4267f"}, + {file = "grpcio_tools-1.75.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:f281b594489184b1f9a337cdfed1fc1ddb8428f41c4b4023de81527e90b38e1e"}, + {file = "grpcio_tools-1.75.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:becf8332f391abc62bf4eea488b63be063d76a7cf2ef00b2e36c617d9ee9216b"}, + {file = "grpcio_tools-1.75.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a08330f24e5cd7b39541882a95a8ba04ffb4df79e2984aa0cd01ed26dcdccf49"}, + {file = "grpcio_tools-1.75.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:6bf3742bd8f102630072ed317d1496f31c454cd85ad19d37a68bd85bf9d5f8b9"}, + {file = "grpcio_tools-1.75.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f26028949474feb380460ce52d9d090d00023940c65236294a66c42ac5850e8b"}, + {file = "grpcio_tools-1.75.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1bd68fb98bf08f11b6c3210834a14eefe585bad959bdba38e78b4ae3b04ba5bd"}, + {file = "grpcio_tools-1.75.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f1496e21586193da62c3a73cd16f9c63c5b3efd68ff06dab96dbdfefa90d40bf"}, + {file = "grpcio_tools-1.75.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:14a78b1e36310cdb3516cdf9ee2726107875e0b247e2439d62fc8dc38cf793c1"}, + {file = "grpcio_tools-1.75.1-cp314-cp314-win32.whl", hash = "sha256:0e6f916daf222002fb98f9a6f22de0751959e7e76a24941985cc8e43cea77b50"}, + {file = "grpcio_tools-1.75.1-cp314-cp314-win_amd64.whl", hash = "sha256:878c3b362264588c45eba57ce088755f8b2b54893d41cc4a68cdeea62996da5c"}, + {file = "grpcio_tools-1.75.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:eca28a90020fc1596f48cf51b02e56bc3d285f7f9ebaf0493144160d69c2cae7"}, + {file = "grpcio_tools-1.75.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:6744a14983f82e04cfd799ed779d06ee92035bb497f2d0fa84e81921a6c9c985"}, + {file = "grpcio_tools-1.75.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2a59120f17d36de6e16a058d88f2fcd255bafaccb487fea0613860a5287f77c6"}, + {file = "grpcio_tools-1.75.1-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:02b0c237882e45247570afdc34717ce80831184882186ef47afca9f8cac2f71c"}, + {file = "grpcio_tools-1.75.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4999ada9721ce2a0eae66bf7f2793bc6fe7a473eef4e38bb542d1e5c6d9f7d91"}, + {file = "grpcio_tools-1.75.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dd13f0d87605eb34f8b8868e3ad9202b90e9e58417276db79c3298538d0d60e3"}, + {file = "grpcio_tools-1.75.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9555db0d2eb22850b7e9a27c0476627d483c114fcdf40d29b03aef446f5e4c43"}, + {file = "grpcio_tools-1.75.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a35800ce3ecea4aaad511bc18daccd37b1560132694f30b606f2044f1242c9a0"}, + {file = "grpcio_tools-1.75.1-cp39-cp39-win32.whl", hash = "sha256:8e7f2c1a37a5c8db92c5cba4034c370598f7458b275606f7a2a114f8c25c0326"}, + {file = "grpcio_tools-1.75.1-cp39-cp39-win_amd64.whl", hash = "sha256:0de3a82ee33d960f117ab66da51254cccd8bda9118d11ec3379f954cfbf6bc39"}, + {file = "grpcio_tools-1.75.1.tar.gz", hash = "sha256:bb78960cf3d58941e1fec70cbdaccf255918beed13c34112a6915a6d8facebd1"}, +] + +[package.dependencies] +grpcio = ">=1.75.1" +protobuf = ">=6.31.1,<7.0.0" +setuptools = "*" + +[[package]] +name = "hightime" +version = "1.0.0" +description = "Hightime Python API" +optional = false +python-versions = "<4.0,>=3.9" +groups = ["main"] +files = [ + {file = "hightime-1.0.0-py3-none-any.whl", hash = "sha256:ba86d42976c36451b14e11c736e61f296f9f00dbb79c8488e18d70c6b2dbb395"}, + {file = "hightime-1.0.0.tar.gz", hash = "sha256:480d2a03e2c3ed44916d2406d40ab6d10a276ed7f101619fc3fcc1e00c46aacf"}, +] + +[[package]] +name = "isort" +version = "9.0.2" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.10.0" +groups = ["lint"] +files = [ + {file = "isort-9.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b3e81cae981a52f94d5b31a474e1cbb033ea9cc850bc4c922117c0534a1864dd"}, + {file = "isort-9.0.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cd67d39c3501d7227e8b229476da1d8679c03e0af97bd295876cf7070e5b709"}, + {file = "isort-9.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:288a320e6d52ba2d3447345390c8a8400591e4033ffbe4ce6bc3e50e5b4818e1"}, + {file = "isort-9.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:67b12d9504e5bc6359bb3bb4493f36cf1093d15477c61c349f52f7d04209fb5d"}, + {file = "isort-9.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4a54aed1bb731d7cf80ef5dfbae5b960f777cea70523b751ee6049bcb604371"}, + {file = "isort-9.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b34a165cd4e25726930ed2eed8cf2fe46fb1a5ebacd9b28eaf566b343a6457ca"}, + {file = "isort-9.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5c929e8ec9d9fb83f034d5f50895503f40c624605f552b97ad090a37e62407ca"}, + {file = "isort-9.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:5f448510ef0a92fa626a975759d76bdbe3b721c3d615da6d1010cc451de5610d"}, + {file = "isort-9.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a05dc63cb6ae2a8e62ec4184153f424b1650593e00a24e6138184c46193891e9"}, + {file = "isort-9.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af8be0b5cac101202c8255360e5de832ebbb84b2e863dc0f65dbb1a3d63dd40a"}, + {file = "isort-9.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3fe693c1e56781de387a6c206306e9e5e560cfeb4acdfd85f0c46122afd48792"}, + {file = "isort-9.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:f7a9efeb3689c7327a0d637eb4e12691e8d5ab1297caee997b144dc595ccb93f"}, + {file = "isort-9.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f65ff614632ddc3306c40f619717b3b3ca69938ffee21d97110056d52472c79a"}, + {file = "isort-9.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e5f11c7ccd5f079ac0431fe52c7b38ea5d9f4e31a1889746de81dac0e7b0a766"}, + {file = "isort-9.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810561edf6f1f5f3600f02aa709603a4360d5290c5fff2ae4b370090dd1a5445"}, + {file = "isort-9.0.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:e2636222848a48cadbd712280058b5da19fa147c501132e04a486a5bddcc9e28"}, + {file = "isort-9.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:71870ac3b1afdf3c259b8404c05076d3ab874122fec6f78339f1c92d2c29b012"}, + {file = "isort-9.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bd8c4fb9829a5e7117d9f71f540ff1e8caafb471e574012057ce6dc35fda2d7b"}, + {file = "isort-9.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eb3e714d64de6eba78ee29051f7fc80613c74e90c6f54f84082f59c429c0a0b"}, + {file = "isort-9.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bf3ef0a91974f29f406e25eef0e04781fd5c2254b8ab55e7655b20d8cd7c5514"}, + {file = "isort-9.0.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:cd1e0e5e61497e95a4e5be269088e6a1013f530aeccf6ebd6134f403285ecd63"}, + {file = "isort-9.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:11da67a30f5a88383c71db075488ca3d081f427f53368f90bb1d74e958a9b040"}, + {file = "isort-9.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f7c2fa33e1c9fbcf9fd639997e4550515c0b712b52ed70a059124a5247825480"}, + {file = "isort-9.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85e859fd72e50c27306d05185f9472ed97fae9e1cce91c0e891260d16f2ecece"}, + {file = "isort-9.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2a960e4252ac5b00f78adc0f731529e122657ee642e650896b36e1ff83028023"}, + {file = "isort-9.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:91b60ce3d96fcb0730d61fc5ab84ee5b56d676fbb92550f7ea333f58778f2f20"}, + {file = "isort-9.0.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:aa810daf72ff5d8ade462b2190dad9c0e16d6d428a3f9aea210f14cca2487d58"}, + {file = "isort-9.0.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8dde4e2d9cfb35390437353f0861ec41378f91ff958d8cd3051fb95cae59315a"}, + {file = "isort-9.0.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:4315e23e701bb1fcdfd364da59da61d78c3332c554318b7eb635ea3924d24c5e"}, + {file = "isort-9.0.2-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:1c134ef9d94943eae14bf31c634db1904dd875e6e7280a60baee10ca06132db6"}, + {file = "isort-9.0.2-cp315-cp315-win_amd64.whl", hash = "sha256:d4da51a99dfd00e5c51e507ed91ebad6aafd44dc65135c17e2ef37355cd9fa98"}, + {file = "isort-9.0.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:16436aefeebe3aa2d5d7ae1ca895b2278f770fc4a41d95c22569a30f7413ec45"}, + {file = "isort-9.0.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a36f30b6b85d9726f79c7623d35f3e966d5d7d9d0a005af91ba19988fccd038b"}, + {file = "isort-9.0.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:d03c68e9d0a83b51ed381d04b0919f2d918fb66c1ca1766761157ff44149366f"}, + {file = "isort-9.0.2-cp315-cp315t-win_amd64.whl", hash = "sha256:29669ea6c410528ffe3b632a41835757f08282257e4ddac892a5e6d01bd35201"}, + {file = "isort-9.0.2-py3-none-any.whl", hash = "sha256:6c29deeb39698a8717823b7f75b2ac58c5e8ab8dcf6cf31205a72a6617fb454e"}, + {file = "isort-9.0.2.tar.gz", hash = "sha256:d2298980ce44350f11d9d24c8150eaef1883431ec203dddbb4e9b5c3ceb54c70"}, +] + +[package.dependencies] +mypy-extensions = ">=1.1.0" + +[package.extras] +colors = ["colorama"] + +[[package]] +name = "librt" +version = "0.16.0" +description = "Mypyc runtime library" +optional = false +python-versions = ">=3.9" +groups = ["lint"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "librt-0.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:96f576f2711f8519152ec76d0e599243555c1f07679fa73606ca8c8c868c0be6"}, + {file = "librt-0.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2bec3818c7da7c96ceae0ef5915a3d16c52dd08f3ea913bf1fe8568c447c7978"}, + {file = "librt-0.16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c79520414a3fdfc6aabd7593e6169afa14d5f8d9908d4b498db068868b08dd"}, + {file = "librt-0.16.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f81b5b19ce748ef68d4746656b7929762eb2fe99269b266e4be07e2ee4de7144"}, + {file = "librt-0.16.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c71d1b76210a36729fedfc5115069b50a3d8619054745f8758fe5d6f19e86671"}, + {file = "librt-0.16.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:473eebc7866bb0a0c8849a292b5e7157c1aba5d14d0f0f610c52158d6d964262"}, + {file = "librt-0.16.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4eb1313a19847089ee81e88742abedf285c60538816640b99742d8534b81d26a"}, + {file = "librt-0.16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ebefd60b42e2a82b32d136bb5f7c94eadfcd29f772b547df6f3291d1ed855a1c"}, + {file = "librt-0.16.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c5da27e8056439f927ea896735da60e616c477a8293feaa3233d4e7781a6726"}, + {file = "librt-0.16.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:67e718c7a43f8db325abbbf1404e2d535f12f8f7a1a82259568385cc5274b82a"}, + {file = "librt-0.16.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:314e703f0c19320dc8094e7a784b9cf29e1b67402515abf580069a6363c0b4f1"}, + {file = "librt-0.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:943c6bbecbdf7fa575a4f2952fcfd848c88ef95507c3fca411e89d4ac3ff8143"}, + {file = "librt-0.16.0-cp310-cp310-win32.whl", hash = "sha256:7cc365f006891afb006b52d5ee5ee74c09306ffa20e2f8705a32a4450af2f3ba"}, + {file = "librt-0.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:0314058469f4d2fd279ce7c62ac274ac82c3918ef7db62ef0697c4c359370155"}, + {file = "librt-0.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fe4372c52d4849096c6cc1cda2817d293ec51440c890474ed59ef38d46556f18"}, + {file = "librt-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c72c5295a84bd249526da9bdca38f2e176d15c31c13bb0063c5053f4ca023421"}, + {file = "librt-0.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be56ba9c884143495b517f23fe794ae367d58cd89ea0fdd6d437e3c024a87f9f"}, + {file = "librt-0.16.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ef46c1a29ffb8c72e882e22618ec618778eacd0578fb22c6e7cf9c11d15f357b"}, + {file = "librt-0.16.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d3c94211ee0c4f8d649ec06b7c115c0ec4eadb873a0e3154ca15cef3f814b071"}, + {file = "librt-0.16.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94aed6a8308818b91677957d1bd03188869cd7aeb23c5dba7912a6c0402f7602"}, + {file = "librt-0.16.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:349c0bcb87ebd07481b6ff781e25cdc699723dbe2212e57dabb27f7a13b7b87d"}, + {file = "librt-0.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:001bfd59a7d45b17e3e75f2a8c6405280b35e7b84471792778e718c4f368950e"}, + {file = "librt-0.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:28e038895b998d7a0c7798922ce8a1dc157675df5cf1c9ef0aca809ed804b7a1"}, + {file = "librt-0.16.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0dbe4096a7ecc00fa835d24510ad8545a4efef738dac96e0e63516783ccde905"}, + {file = "librt-0.16.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5cd5b092441053364af968ea12084692cb9d4a22f3ce9524e377880bf028761e"}, + {file = "librt-0.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ddeb3c9dedb461bb457c6c7d9aa7fbf35329da313d1a7543d00c8d0f3473c96"}, + {file = "librt-0.16.0-cp311-cp311-win32.whl", hash = "sha256:e05108e0849966f53a8d2d3112a7af881d0efaa479bc735bba91108f9f2350a7"}, + {file = "librt-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:5f49cff01bd608ef7d97104cb035c75455e79c2d70bf4a506cf773338ac1860d"}, + {file = "librt-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:1d28ae980ae2218f9c5b95d191e947296f918c9bf0b400d467a9430275bbe678"}, + {file = "librt-0.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe52bf4641069e7978a14253b036cb9002def1926317e710f2e249f8a8c47742"}, + {file = "librt-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5bcc2c4726ced915b00de0c9856a4eeabfb3fddb93e10e0b8f735b7709358b6d"}, + {file = "librt-0.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff7baa55f8e7c69851419e50a666015d02a74198716fd45c0125a2112e0a389f"}, + {file = "librt-0.16.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b95d5d92ab83d39e760a52091bb1baba664f3a2351e39b1e16801e5747c2f0e9"}, + {file = "librt-0.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6d085d70bce51d43c5c7c36d63490770180d8779e71c49305c87b4213918de7"}, + {file = "librt-0.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36e53948e99bbe3ffea257124cfcae1cfb01831555c9a9c903c9f9a72db7fd07"}, + {file = "librt-0.16.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:54d11f726aae9df5a6ffbbf0a03a52449bbac84a53ef03669cb41cdfd4ae41bf"}, + {file = "librt-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4323193ac0cd025f85af531df8ba91bf24d1973b401697347a6282e8fd3fcf5e"}, + {file = "librt-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e42f8e098b9c5396fefa05fb1cc7e33b0e08fc51da106b5de4a45fd22aac6743"}, + {file = "librt-0.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:39ec1d5a14e37baf1450a6cabf03fe552340808bf1ad9d71824ab90117716459"}, + {file = "librt-0.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d1aabe3925cbb4a08d15b7b20ba4011b53019da0c4173a25155139b7b1baed65"}, + {file = "librt-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:300c3ffdc459f4a779a8411ecb188e3ac0b1ff3a3a7b099642555dedae06c69b"}, + {file = "librt-0.16.0-cp312-cp312-win32.whl", hash = "sha256:c17194318e4c0c0348b36f36c2ec7534436fe0a4c15582403162a4f08c80797a"}, + {file = "librt-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:25a58a19ea8d83b68209f04912df765e9260635ef77646542ed4b4abe6bc7940"}, + {file = "librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc"}, + {file = "librt-0.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5e6144e68b577f157519f2ba88ca20e3ed61c29b00e5cdfa76cd2d45acf059a"}, + {file = "librt-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:33f41443a1f4e1f099331b3d8120e409fbff84b9760bc1cc9ea496f37ddaa5cc"}, + {file = "librt-0.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e510b7770bee609617a3374a96548eb114cae048023e3f049ee449e7ff2db32"}, + {file = "librt-0.16.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:efc49c462d4516b8a58b00b490078fa64689fd1fe66970cc190131d7afb8027e"}, + {file = "librt-0.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:92caf82ebef5e12d21c72242b70d1e92536f1711cf2a727a4c276de4b4469087"}, + {file = "librt-0.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17bac7f7a16b328fff77e440287693eb017abde913595b5827ebccbc21ecd8a6"}, + {file = "librt-0.16.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5b976054553670829985ed767feb78fb6bcede0175327c4844dd5c281c1be659"}, + {file = "librt-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0058f9d68721094105917254c72ac0569117bb7b13b9769cf45d26d89f9d21cd"}, + {file = "librt-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b7beaf3f4487b7d8adef1f158b49067cb4d5a19fa7a3bf31a4e7a820e435c5"}, + {file = "librt-0.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:468df902df016a06eb0e40b0747dc8d14e47d7a38b18b63b1fb167d85cb94d63"}, + {file = "librt-0.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:aea7b1f2b125dad5de85f049136651bff256c883c65e6b9209b2da0a1ac3cdef"}, + {file = "librt-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a8afb6557920860b7a3a596eb804cf37e09e7cf8a803db2478c202acc72d8c2e"}, + {file = "librt-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:77c7a2b4fe2c1369e0d5aa1cade26740a7b14be32fbc9a5535d617d20065c39d"}, + {file = "librt-0.16.0-cp313-cp313-win32.whl", hash = "sha256:02d89c813d5ff74b17df72d3a34819d132cd168e56b81bf755b809bd9e46b8c4"}, + {file = "librt-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:14ed6ebe3e4f85f326d7920011ad30ff49ed9334e62cf88caef9ba973d9e3a92"}, + {file = "librt-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:83d4041a3d9b2fd053a8a4e1f22878b3e5833e2712956382d5c048d791454e91"}, + {file = "librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1"}, + {file = "librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11"}, + {file = "librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2"}, + {file = "librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96"}, + {file = "librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86"}, + {file = "librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667"}, + {file = "librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b"}, + {file = "librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311"}, + {file = "librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a"}, + {file = "librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd"}, + {file = "librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55"}, + {file = "librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152"}, + {file = "librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839"}, + {file = "librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7"}, + {file = "librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41"}, + {file = "librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99"}, + {file = "librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc"}, + {file = "librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a"}, + {file = "librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0"}, + {file = "librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9"}, + {file = "librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188"}, + {file = "librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048"}, + {file = "librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143"}, + {file = "librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04"}, + {file = "librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e"}, + {file = "librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b"}, + {file = "librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302"}, + {file = "librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1"}, + {file = "librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7"}, + {file = "librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d"}, + {file = "librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b"}, + {file = "librt-0.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff"}, + {file = "librt-0.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81"}, + {file = "librt-0.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a"}, + {file = "librt-0.16.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4"}, + {file = "librt-0.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154"}, + {file = "librt-0.16.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d"}, + {file = "librt-0.16.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b"}, + {file = "librt-0.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2"}, + {file = "librt-0.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0"}, + {file = "librt-0.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c"}, + {file = "librt-0.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121"}, + {file = "librt-0.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42"}, + {file = "librt-0.16.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a"}, + {file = "librt-0.16.0-cp315-cp315-win32.whl", hash = "sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3"}, + {file = "librt-0.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971"}, + {file = "librt-0.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560"}, + {file = "librt-0.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b"}, + {file = "librt-0.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134"}, + {file = "librt-0.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4"}, + {file = "librt-0.16.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248"}, + {file = "librt-0.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0"}, + {file = "librt-0.16.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8"}, + {file = "librt-0.16.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1"}, + {file = "librt-0.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70"}, + {file = "librt-0.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3"}, + {file = "librt-0.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db"}, + {file = "librt-0.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd"}, + {file = "librt-0.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba"}, + {file = "librt-0.16.0-cp315-cp315t-win32.whl", hash = "sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9"}, + {file = "librt-0.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc"}, + {file = "librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866"}, + {file = "librt-0.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e1967e36ac4cae0c7e9615ad32e1a513cdacff79f9e8afb28bedc91caf48b4f3"}, + {file = "librt-0.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:20fe0bf9053885e21c62b3e091fb5e73e1c54d1daf2e70eb388d70763bcd4220"}, + {file = "librt-0.16.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a63610fa76524edfa605b5b259a603915c7a6e10e54f003e5503030506e81de"}, + {file = "librt-0.16.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:845a511b60ca43b9880dcc84a9784c891d6a2098c829130b320846c69c9c0c68"}, + {file = "librt-0.16.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d608f0bf3b8cbddd0067fe02cb8cab7d13e8eb9386b23a1843d4363044fd9e22"}, + {file = "librt-0.16.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df183721229ae51eef90108c115b43e98cb169b6155d34480338e5fc6616df00"}, + {file = "librt-0.16.0-cp39-cp39-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b6183e2e2e0ee00aac2ec07c7f7d151c97e85666b304f574b71cff0f9fccc4e"}, + {file = "librt-0.16.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:273d00be33792a15189331df10f1f1331621b043881e66c6c4377f7f776e1291"}, + {file = "librt-0.16.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aa9357a1b4d4fc787bb718a59cb1112c28c8a976d6bfa268b71cc0a4ab8f3a94"}, + {file = "librt-0.16.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:e9ce0bc440e7fd09b5f51f372f0f6640658f854b8fb05260f819cc669c93c42d"}, + {file = "librt-0.16.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:6c8893eae2fd13c5488d94056f3e6e5cf3142bfb1c4acaf136cb33d760c5964b"}, + {file = "librt-0.16.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7393c9a48dcce4817dbd4b0d8ff6237efe9b0a0609f5b0adaef315f8541726b5"}, + {file = "librt-0.16.0-cp39-cp39-win32.whl", hash = "sha256:71b93b42784e25b975079573c642a8fedb049a7bb31d70a51721b1666b3b2ced"}, + {file = "librt-0.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:5750a105b42a416f930edc59054927a406effb2550cd5bab92ad7a5842ed5d05"}, + {file = "librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc"}, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +groups = ["lint"] +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "mypy" +version = "2.4.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.10" +groups = ["lint"] +files = [ + {file = "mypy-2.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ffda5244fd1ad71a1e54405e35f50d09b80c3978efa120f58bd1252ae32c62d2"}, + {file = "mypy-2.4.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:615b03922d40e186fd1df73156473db0ba525c639bb4fd1cf28e1694879c9b23"}, + {file = "mypy-2.4.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13fa24f439c0e48a290a3922fa14ccd22f2762bae99d2142931b3e40a9055080"}, + {file = "mypy-2.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3adef556a19eb3b630bf86a79c29d7da3d61e541472d0d897ca01b171c0abf8f"}, + {file = "mypy-2.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:1dc0f64b0a92ae27a49d2175f0bacfa56e15bdc5b420cf92e0c1f79292219cbb"}, + {file = "mypy-2.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a378fc15fb33e321f04652c166ce73eeb8833a97c3d218132844e938cd93220"}, + {file = "mypy-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d20e6c7c35fcbf2a0ebdd0eaeacfbc243009dfd33ab7822d54e213912e6dbbd"}, + {file = "mypy-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3011537be6cf1de4511c0255a324362a812b58184bbe61e15f59c8b31033bd74"}, + {file = "mypy-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a96b07a49b7b1d025ce59c1b3acbcf24bead9a83da4523c4a6bde1bb94e7a0e1"}, + {file = "mypy-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7c4f8f8d1d1c0e2832d8ee7113dd08f6df6c7aad9e863fcbed9f25832be0b8c4"}, + {file = "mypy-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:ba05652540bf12828e52abae807b024b09ca144ff4f75e2450a81d69c376425b"}, + {file = "mypy-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:6306086b87cf7f8a29aa618d9fd9bffb56c59247166b9660fdb54d86d7714ecd"}, + {file = "mypy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e05ff2925d8b37ad26c80c1b9dc43ae5d455da2df1e23c24c095a6425917c57e"}, + {file = "mypy-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29243242cf72582b65f9582ad9e56e8cb281566ed3519f4cd70bb8b9f2977e90"}, + {file = "mypy-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29eb0b9427a6b11b992e452f6cceb8af724f4dceb47e779d0b35e405e996ea5e"}, + {file = "mypy-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e3ebe2f72a2a1156065a9851570ffbf50c0a93cdccadef9c6e05c508a4fd10b1"}, + {file = "mypy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:236e0d68f6941992b0811128e652590f590db444ab29ad8f1324765b9298b946"}, + {file = "mypy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:82d0f94c8587ccb472622ee7795280aaa38a06640d5f45b3f16909d6dd86a989"}, + {file = "mypy-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7da85fbcff6dac1abcc636707bed38b45598131fb7a605d9719c70b5cc733af8"}, + {file = "mypy-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4209da39d85cf240f762af622d8180fcdfcb4727d021f44ade62d613a1a43324"}, + {file = "mypy-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be721bd4bd57576193653b75b4af3461c9d0bf7dd8b528f782e9be210dc75bb"}, + {file = "mypy-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1fde197ae65be856a034a91b70ed747a16562ca69577785f06c661548424bf1"}, + {file = "mypy-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:295ecf2e57542cd836ca537486951289678c8c7d1ee6ad74ebe29b2168a003cf"}, + {file = "mypy-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc378bdad4e9f12b5bd96466083d1e71acf00594ec9c7b2bdb5e02816f77f303"}, + {file = "mypy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:058165f564ccf559c68c70fec2091fca5891110480210c22594635e3f6683437"}, + {file = "mypy-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fa247e02b505a45a2775f69df38d360d197e3790bc60f717595db9eda358b6e"}, + {file = "mypy-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20e9a5cd875837520c43db98dea0b6d0c2197833d95c30127d8f570fb9b1f00b"}, + {file = "mypy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9f03a7828cca2b0adcd6662aee8f2711ff8830e1027641fdea3ab0b787483566"}, + {file = "mypy-2.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9279488933040b638c0ab739084c0ca100efeea6db581bf5d7628d8e89de53fe"}, + {file = "mypy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:2106b55105ba5ea9be4f53a24517fc5fa927ff1585edc9bc1a975abb72caef89"}, + {file = "mypy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:528c8744b8b5e3ecb8774f86af38d2376216816e9908317ad055f3c9c2d74799"}, + {file = "mypy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0bb95cf34899e4619c61ab0a8667804e139e580b30d5df12af2102dfe44d0c97"}, + {file = "mypy-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d616fe84c6eab8026f8c50ab5bcb90db780d2ccd233d971e34e92bede9b359"}, + {file = "mypy-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3f86fd1313dd69d013e265f1fdcd12ea7a9d606f9875b2a3db946cd334555f3"}, + {file = "mypy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:720434d48542ecfe84d32d287b727569d3fc8f5769acd39051130e490a5c295c"}, + {file = "mypy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a6e851b82c0661f69f1630fc16172c68787a6a9cf0991e7c6437d60976cdcd76"}, + {file = "mypy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3bd0e340f0ebe65c548210f53be3fd8192e83964760caf0c28bef368e68b0d37"}, + {file = "mypy-2.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:afa89837d9be67e0cadfa33bca3bb7efdda98c3b07e74dc3b635ebfb1c8a926a"}, + {file = "mypy-2.4.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb443e81057896132d3642d6be219e6efd158691ac7883e3ba8fcb469865f05d"}, + {file = "mypy-2.4.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7f38f57d344f8b6accb40e01c3d83cfc590498231724d16c07ffb7940f157818"}, + {file = "mypy-2.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e76172710bd4e5eeae061abfd68347e5264632e02778be61784671ae3a2132f5"}, + {file = "mypy-2.4.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f83353e47ab520bf6fd4df8f5897d9fe081211f2fbc4b7d37736a3e3c166cbcf"}, + {file = "mypy-2.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:970b221ed5842213d98e3c480c08f795ace4b1f81fb21e1b126bd0476bce1c34"}, + {file = "mypy-2.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:502b94b0b331f7dafe32fd6b151797ddbb4f32385b362e722c783a025e5954a3"}, + {file = "mypy-2.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c9de622fd397495695d0598ddc789222bfcfec9d7c9ec3a1e385c855e3bc5e01"}, + {file = "mypy-2.4.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f459f0b4f0596d9d51fe7716b404b35287b99e77da98a7af90a65dd5fd61141"}, + {file = "mypy-2.4.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9b028548b3af480e2b1ed8df14ccaac86f99c9f600d1580770ab7ba3dcd40f0"}, + {file = "mypy-2.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:cb734b2668c1f40d07ce093bbeb4407e9527c67901627b0e1679825be3f09975"}, + {file = "mypy-2.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:172e30b8fea631fe310f0c665477f52d9ea40bb4e99e0c81dc30118563b13710"}, + {file = "mypy-2.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:5786ef987b3767e51aaa53f20aec104c0252b42ecda7aef8e8b4cbae279b05c5"}, + {file = "mypy-2.4.0-py3-none-any.whl", hash = "sha256:d01c5d26a352acc6d5cf3128225477e1e8465e8d3029d4c345807fbf7f3cf093"}, + {file = "mypy-2.4.0.tar.gz", hash = "sha256:77bdaebd452f43fcfc4cc3ba94352a3ea537cd01e3f2d0879f48673d2ec00d6e"}, +] + +[package.dependencies] +ast-serialize = ">=0.11.2,<1.0.0" +librt = {version = ">=0.16.0", markers = "platform_python_implementation != \"PyPy\""} +mypy_extensions = ">=1.0.0" +pathspec = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing_extensions = [ + {version = ">=4.6.0", markers = "python_version < \"3.15\""}, + {version = ">=4.14.0", markers = "python_version >= \"3.15\""}, +] + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["lint"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "ni-datamonikers-v1-proto" +version = "2.0.0" +description = "Protobuf data types and service stub for NI data moniker gRPC APIs" +optional = false +python-versions = "<4.0,>=3.10" +groups = ["main"] +files = [ + {file = "ni_datamonikers_v1_proto-2.0.0-py3-none-any.whl", hash = "sha256:00775528187c2faf0938559be3dada2650dc4a085c7a7978589a09455d7edf47"}, + {file = "ni_datamonikers_v1_proto-2.0.0.tar.gz", hash = "sha256:994e2a40556b499c7ecdbf2b1b5b67468897a574b068f121dc07388012de6d23"}, +] + +[package.dependencies] +protobuf = ">=4.21" + +[[package]] +name = "ni-datastore" +version = "2.2.0.dev0" +description = "APIs for publishing and retrieving data from NI Measurement Data Services" +optional = false +python-versions = "^3.10" +groups = ["main"] +files = [] +develop = true + +[package.dependencies] +hightime = ">=1.0.0" +ni-measurements-data-v1-client = ">=1.2.0.dev0" +ni-measurements-data-v2-client = {git = "https://github.com/ni/ni-apis-python.git", branch = "users/sudharshana/add-v2-proto-and-client-package", subdirectory = "packages/ni.measurements.data.v2.client"} +ni-measurements-data-v2-proto = {git = "https://github.com/ni/ni-apis-python.git", branch = "users/sudharshana/add-v2-proto-and-client-package", subdirectory = "packages/ni.measurements.data.v2.proto"} +ni-measurements-metadata-v1-client = ">=1.0.0" +ni-protobuf-types = ">=1.3.0.dev0" +protobuf = ">=4.21" + +[package.source] +type = "directory" +url = "../.." + +[[package]] +name = "ni-grpc-extensions" +version = "1.1.0" +description = "gRPC Extensions" +optional = false +python-versions = "<4.0,>=3.10" +groups = ["main"] +files = [ + {file = "ni_grpc_extensions-1.1.0-py3-none-any.whl", hash = "sha256:db0357acd244854f4acccf202c89fe6462b4283d264ed639f4e248e6cc86bc9b"}, + {file = "ni_grpc_extensions-1.1.0.tar.gz", hash = "sha256:028ea33e5c5234bc050bf5dc99f5b61611531de8f012293e9d4c6985b7b37afb"}, +] + +[package.dependencies] +grpcio = ">=1.49.0,<2.0" +traceloggingdynamic = {version = ">=1.0", markers = "sys_platform == \"win32\""} + +[[package]] +name = "ni-measurementlink-discovery-v1-client" +version = "1.2.0" +description = "gRPC Client for NI Discovery Service" +optional = false +python-versions = "<4.0,>=3.10" +groups = ["main"] +files = [ + {file = "ni_measurementlink_discovery_v1_client-1.2.0-py3-none-any.whl", hash = "sha256:652f5bd55d0fac6f0d9fbb6207cc7ef6505a3b8746e9fe2374b5d733c1690ad4"}, + {file = "ni_measurementlink_discovery_v1_client-1.2.0.tar.gz", hash = "sha256:4de77ca1bebb19ee4906ab3c102401e63f432d668b32341807d3c05a20855139"}, +] + +[package.dependencies] +grpcio = ">=1.49.0,<2.0" +ni-grpc-extensions = ">=1.1.0" +ni-measurementlink-discovery-v1-proto = ">=1.2.0" +pywin32 = {version = ">=303", markers = "sys_platform == \"win32\""} + +[[package]] +name = "ni-measurementlink-discovery-v1-proto" +version = "1.2.0" +description = "Protobuf data types for NI discovery gRPC APIs" +optional = false +python-versions = "<4.0,>=3.10" +groups = ["main"] +files = [ + {file = "ni_measurementlink_discovery_v1_proto-1.2.0-py3-none-any.whl", hash = "sha256:06ceb4c94687b18469780739e0600f46695c73de816fef5fe1b4a3588fbe1e8f"}, + {file = "ni_measurementlink_discovery_v1_proto-1.2.0.tar.gz", hash = "sha256:5b767048c60d5dea6d66b6c1e8c1bad7b2d7a8207e88d2d81444e96dfbaa8f36"}, +] + +[package.dependencies] +protobuf = ">=4.21" + +[[package]] +name = "ni-measurements-data-v1-client" +version = "1.3.0" +description = "gRPC Client for NI Data Store Service" +optional = false +python-versions = "<4.0,>=3.10" +groups = ["main"] +files = [ + {file = "ni_measurements_data_v1_client-1.3.0-py3-none-any.whl", hash = "sha256:bf9547d6c9deb5fc88ac10cd32c34510a724266087e2d67181f03f2d26b77e41"}, + {file = "ni_measurements_data_v1_client-1.3.0.tar.gz", hash = "sha256:8110349ca982c1f12deede0352ad237d84c135f85be4712c41ee682fb4f47ada"}, +] + +[package.dependencies] +ni-measurementlink-discovery-v1-client = ">=1.1.0" +ni-measurements-data-v1-proto = ">=1.3.0" + +[[package]] +name = "ni-measurements-data-v1-proto" +version = "1.3.0" +description = "Protobuf data types and service stubs for NI data store gRPC APIs" +optional = false +python-versions = "<4.0,>=3.10" +groups = ["main"] +files = [ + {file = "ni_measurements_data_v1_proto-1.3.0-py3-none-any.whl", hash = "sha256:494f2c29b88861ef2f789431093a238f960d54af80300a9d567f46f39ec5da46"}, + {file = "ni_measurements_data_v1_proto-1.3.0.tar.gz", hash = "sha256:4547620163f8a054aa360e16afde1807f37f1f1faff623e3f24fdaeaa6bca919"}, +] + +[package.dependencies] +ni-datamonikers-v1-proto = ">=1.0.0" +ni-measurements-metadata-v1-proto = ">=1.0.0" +ni-protobuf-types = ">=1.3.0" +protobuf = ">=4.21" + +[[package]] +name = "ni-measurements-data-v2-client" +version = "1.0.0.dev0" +description = "gRPC Client for NI Data Store Service V2" +optional = false +python-versions = ">=3.10,<4.0" +groups = ["main"] +files = [] +develop = false + +[package.dependencies] +grpcio = ">=1.49.1,<2.0" +ni-grpc-extensions = ">=1.1.0" +ni-measurementlink-discovery-v1-client = ">=1.1.0" +ni-measurements-data-v2-proto = ">=1.0.0.dev0" + +[package.source] +type = "git" +url = "https://github.com/ni/ni-apis-python.git" +reference = "users/sudharshana/add-v2-proto-and-client-package" +resolved_reference = "28aee05fe1ce46d2d349311cd600bf0a47937d1d" +subdirectory = "packages/ni.measurements.data.v2.client" + +[[package]] +name = "ni-measurements-data-v2-proto" +version = "1.0.0.dev0" +description = "Protobuf data types and service stubs for NI data store V2 gRPC APIs" +optional = false +python-versions = ">=3.10,<4.0" +groups = ["main"] +files = [] +develop = false + +[package.dependencies] +grpcio = ">=1.49.1" +ni-datamonikers-v1-proto = ">=1.0.0" +ni-measurements-metadata-v1-proto = ">=1.0.0" +ni-protobuf-types = ">=1.3.0" +protobuf = ">=4.21" + +[package.source] +type = "git" +url = "https://github.com/ni/ni-apis-python.git" +reference = "users/sudharshana/add-v2-proto-and-client-package" +resolved_reference = "28aee05fe1ce46d2d349311cd600bf0a47937d1d" +subdirectory = "packages/ni.measurements.data.v2.proto" + +[[package]] +name = "ni-measurements-metadata-v1-client" +version = "1.0.0" +description = "gRPC Client for NI Metadata Store Service" +optional = false +python-versions = "<4.0,>=3.10" +groups = ["main"] +files = [ + {file = "ni_measurements_metadata_v1_client-1.0.0-py3-none-any.whl", hash = "sha256:c697ce4e98105b810f4da844a598e86e359ff6c90ca7a832e1e23a70327551a7"}, + {file = "ni_measurements_metadata_v1_client-1.0.0.tar.gz", hash = "sha256:9f9a10810c4c6693239081abbc026414a18df3e3d04daa3cd59010b1eed07f51"}, +] + +[package.dependencies] +ni-measurementlink-discovery-v1-client = ">=1.1.0" +ni-measurements-metadata-v1-proto = ">=1.0.0" + +[[package]] +name = "ni-measurements-metadata-v1-proto" +version = "1.0.0" +description = "Protobuf data types and service stub for NI metadata store gRPC APIs" +optional = false +python-versions = "<4.0,>=3.10" +groups = ["main"] +files = [ + {file = "ni_measurements_metadata_v1_proto-1.0.0-py3-none-any.whl", hash = "sha256:8844806d6775ac65144100fcd03099aea0c17ed55de696683d0663166f45cee3"}, + {file = "ni_measurements_metadata_v1_proto-1.0.0.tar.gz", hash = "sha256:3283cf7f0c452812a311b2b9274b5ba1e549f994f6a978a125f1746b85746e6f"}, +] + +[package.dependencies] +protobuf = ">=4.21" + +[[package]] +name = "ni-protobuf-types" +version = "1.3.0" +description = "Protobuf data types for NI gRPC APIs" +optional = false +python-versions = "<4.0,>=3.10" +groups = ["main"] +files = [ + {file = "ni_protobuf_types-1.3.0-py3-none-any.whl", hash = "sha256:f4c8e1e6c2f229d1d6e2dd45a21d40c61fd576e50fc4a0276a391296e1e9f478"}, + {file = "ni_protobuf_types-1.3.0.tar.gz", hash = "sha256:448c850934f57ee83862cc93fbf33c4b45b9f54d760279874cbfe8879eed412e"}, +] + +[package.dependencies] +nitypes = ">=1.1.0" +protobuf = ">=4.21" + +[[package]] +name = "ni-python-styleguide" +version = "0.5.2" +description = "NI's internal and external Python linter rules and plugins" +optional = false +python-versions = "<4.0,>=3.9" +groups = ["lint"] +files = [ + {file = "ni_python_styleguide-0.5.2-py3-none-any.whl", hash = "sha256:1d657dee729d8fd019b853bcd78478473fbffd20f9c23589146f2f9f148e1a2b"}, + {file = "ni_python_styleguide-0.5.2.tar.gz", hash = "sha256:d6bc6a190ebff283ef9a184bcef96a6849611d1556890d1423512c8c2d296a52"}, +] + +[package.dependencies] +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\""}, +] +flake8-black = ">=0.2.1" +flake8-docstrings = ">=1.5.0" +flake8-import-order = ">=0.19.2,<0.20.0" +flake8-tidy-imports = ">=4.11.0" +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\""}, +] +toml = ">=0.10.1" + +[[package]] +name = "nitypes" +version = "1.1.0" +description = "Data types for NI Python APIs" +optional = false +python-versions = "<4.0,>=3.9" +groups = ["main"] +files = [ + {file = "nitypes-1.1.0-py3-none-any.whl", hash = "sha256:b43ac7027e1cceeca7ceffa58f31ffadd03feeb1788ca5cb8f9d105e73893b14"}, + {file = "nitypes-1.1.0.tar.gz", hash = "sha256:f273b5131ef0d5b848c37a0a63452626caf5c2938f7744c324f7991b76fa0b60"}, +] + +[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\""}, +] +typing-extensions = ">=4.13.2" + +[[package]] +name = "numpy" +version = "2.2.6" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version < \"3.12\"" +files = [ + {file = "numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf"}, + {file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83"}, + {file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915"}, + {file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680"}, + {file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289"}, + {file = "numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d"}, + {file = "numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491"}, + {file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a"}, + {file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf"}, + {file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1"}, + {file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab"}, + {file = "numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47"}, + {file = "numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282"}, + {file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87"}, + {file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249"}, + {file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49"}, + {file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de"}, + {file = "numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4"}, + {file = "numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566"}, + {file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f"}, + {file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f"}, + {file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868"}, + {file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d"}, + {file = "numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd"}, + {file = "numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8"}, + {file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f"}, + {file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa"}, + {file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571"}, + {file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1"}, + {file = "numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff"}, + {file = "numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00"}, + {file = "numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd"}, +] + +[[package]] +name = "numpy" +version = "2.5.4" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.12" +groups = ["main"] +markers = "python_version >= \"3.12\"" +files = [ + {file = "numpy-2.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c6342f54c67093cae5c0227eb0eb772fdb79f2a2c37a6eb278b9909ee06aa356"}, + {file = "numpy-2.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b11e8fda06a7d69f15ebf542660b74466c2e51094800c1fb794f47ad4faeef17"}, + {file = "numpy-2.5.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9cb18a327b49c5c337f972b03682f6a49855525faaf3c0d3e9c96cd0fd8880a8"}, + {file = "numpy-2.5.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:aec3fc4b32ff82421274f5d205c559c51c840c8df66a78efd7f3612dd005a26a"}, + {file = "numpy-2.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4d21ab149f15e4e6043dfb0de87e6e5f34ac176cde83060e9802981fca2ac2"}, + {file = "numpy-2.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbde6962867ee75b48b0ee29b2b9372ec5d617799dbaf38e82dc0596f2f7738a"}, + {file = "numpy-2.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:381a7a3d2e65e64c0ec302795ab9dc12bb1e73f150904699c153716177eebdaf"}, + {file = "numpy-2.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b89d0aaae2fe498c648f4c4795c084db535af5bd98ef942b2a3681fb74ce8645"}, + {file = "numpy-2.5.4-cp312-cp312-win32.whl", hash = "sha256:9968ab7e49b93ac6e1c3b2239732183152c9150f16308d30b66a372cffe3483c"}, + {file = "numpy-2.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7b1b6353e36a7e50de2973a38d705c88ee93adcf120673cee7f45a4a3fa223a"}, + {file = "numpy-2.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:aa1cce2ff3f8d953de38b76bf44602caeb69f101430208f64a10067f7cb4b1d3"}, + {file = "numpy-2.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2377da2dd3ba2c1200956acbab2a358c83b8e1f8531191672d1cd6ad83250d53"}, + {file = "numpy-2.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7415db95818b39ec475a5eea54d9e3b6bc83e3912158e46da3438cdce399804d"}, + {file = "numpy-2.5.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6d6a71b9d9a97c03633aa12565ef2825ffa036cc1d99cfd50dacf0f128af4fe2"}, + {file = "numpy-2.5.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d8200f16437b289a5bb927c6e184eccc3e8389bc0070fea4cd5b9e13c1757959"}, + {file = "numpy-2.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2e71b04c6cad90026e544501bbe0ab9290fa8a4d845e7e8c0d124fb429c988"}, + {file = "numpy-2.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ffa07666f8da0eef81d149934a626d0d95fbd6838432a33e66245423a9062c0"}, + {file = "numpy-2.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2fa3328f784fc8277fc48026f6cad516f5c561c5d8e2e39b3c9e0c8f23223b34"}, + {file = "numpy-2.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b86966fbe4ad7de710422175572bcdc75fdedadfb54bc6fab7deabccddd7780b"}, + {file = "numpy-2.5.4-cp313-cp313-win32.whl", hash = "sha256:5258bc06526964be5face2fc6f756857a3f24f21ec3e72ca131337a75b165d6c"}, + {file = "numpy-2.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:8b4d2fd2d34e5f8c9235ee787de5631a37a28402b15cb80814df973d2be54129"}, + {file = "numpy-2.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:bc39ac66a7a9a3fbd6134fda43136b60ffde99c8f4501e64e0d2b24da137babf"}, + {file = "numpy-2.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c668b2f0d651605b58892644b0e302c7157f7159544227758c896982ef384b18"}, + {file = "numpy-2.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ffa6ce09a1c6a08e9667dd9c97aa0b14184e8d18f2a14b78b2a2328c9147f076"}, + {file = "numpy-2.5.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:956555e0603a4d38019ae6925711cb9dc43195c076a928accf7ea5d50bddfe53"}, + {file = "numpy-2.5.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2c2c4afffdeb7920e445028dd71eb932cac3e704792e964bc2a232426d4f1255"}, + {file = "numpy-2.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4054173604cd8658796053f1f3bc0befb68ec1c0762c57fdad61e199256a8617"}, + {file = "numpy-2.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d549420b8858885cea8838a727842249218b9c1da24dd517e25c9c7a948310a3"}, + {file = "numpy-2.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:823874a507a84af050493b622affde94b6f7c3a0dc22cb2801381bc03b871c00"}, + {file = "numpy-2.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37"}, + {file = "numpy-2.5.4-cp314-cp314-win32.whl", hash = "sha256:cfd73180400042a7c532d30c5e287bdd03c59ff9ee1b4c0316af0539e29dfe23"}, + {file = "numpy-2.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:2ca144f15135b6212a5c47b1e2aeca6e412f102f95a2d5d88d8aec77eb255de3"}, + {file = "numpy-2.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:468397ba3c64427474706e5c9123fe266395496714dc684294eac75cd4930d1e"}, + {file = "numpy-2.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1ef3aa6d7e29bb13677323114280b05acc57607fa2300e66432d665d5418a162"}, + {file = "numpy-2.5.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:98b053943e5a0474ec0da309d2cb9d3f18ea57f8a2067c2ab7b5f763d1068380"}, + {file = "numpy-2.5.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:b64a85f40e154983960a4167d4c1d57a50c7f109b3d3264a3a984154e90a8454"}, + {file = "numpy-2.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a813ed7719bf45463c51779e6a98d0385fe905e48447526938a4b8337333d551"}, + {file = "numpy-2.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9b80cdf5cedba0e90d93fa5f9a333c4d65bd545cd669b71bb97ce2b703c9d73"}, + {file = "numpy-2.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2199ed071f460487c8db2c0e5c0b564494190edb4772fe80f9aad88b2604def5"}, + {file = "numpy-2.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365"}, + {file = "numpy-2.5.4-cp314-cp314t-win32.whl", hash = "sha256:64d1c8ac28a4077cf987e0a71a7a0ef7e2df70722f07f0baa42dbb7eb6938647"}, + {file = "numpy-2.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb"}, + {file = "numpy-2.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e94aef2c639da4a960ad0db8e06471208d8589974953d78b61d345b4eb99e394"}, + {file = "numpy-2.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8dddfbee2e68d26d0d7d7d9cb247b1fd4409241cce32d815a11d97ec2cfde179"}, + {file = "numpy-2.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:81e3420b27048b65eb14c3acf0c174a8cb0e023277716110347d2dcb26026dad"}, + {file = "numpy-2.5.4-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:0b4724a19de67bea8cfc4970798efa78bcbbe2ac2613cfac16721a42d44de2a5"}, + {file = "numpy-2.5.4-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:2132418bf8dd124a427ca9e6a1daf9ee1a87185344c95119ceae868b99466da1"}, + {file = "numpy-2.5.4-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:325518d4245b9e331387702aa58c2ce1dc4cdcbb41dfb4ccd5dcbc7e08db1266"}, + {file = "numpy-2.5.4-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56733449d2544178beaa4545cee357370440cf056c197f9c7bfb19dbfdd0e86d"}, + {file = "numpy-2.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5ec3753760c1a6d8bb91200666e545c3a9728e6269dfb5d6ce02340996698aa3"}, + {file = "numpy-2.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b1185012870173de7ae33d370bd45b1cf5baee747ea4b97036b65f4e93016877"}, + {file = "numpy-2.5.4-cp315-cp315-win32.whl", hash = "sha256:298eca75243f2cbbfdb460560b9fb2a1792a33cf2ab4286efd43d92e8d3df508"}, + {file = "numpy-2.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:332f3378fe077dd850e677ec01bdcc4f22368fb5d50ef10b2c79230b1bf5a592"}, + {file = "numpy-2.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:d4cccbbc78717966f764cd3af4fb70276fa01fc7a2688af11c78901fa5c04f05"}, + {file = "numpy-2.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:950ea81d57ef070665581b6e1b5f6a029306423cd1739c5b95fe78aa30db6b9d"}, + {file = "numpy-2.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c05ede731b03fb1b7591faca9389ade3267d2bddf1ad8882bb3f2cc5e101694f"}, + {file = "numpy-2.5.4-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:5fbf7141bbfd63aea22f435c9062a032b9ea0082fe9845dad7f021d3f1234e71"}, + {file = "numpy-2.5.4-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:3573cd22564692a5b899ec344e5d5b9cc4576f2985b96f22af3564ed54f2710f"}, + {file = "numpy-2.5.4-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c109eac9cd439193678f69d70733c1108487546ca8eafc107b510ae10c1aecd"}, + {file = "numpy-2.5.4-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80d6ef6e8620eb2c2b4c4caad50b5935d6db3cde2d51581b55dcc79e14016d1d"}, + {file = "numpy-2.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:77045a4b175bbf5316ec08003880804336c78f92281a1b72222b274ea85ec5ac"}, + {file = "numpy-2.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab"}, + {file = "numpy-2.5.4-cp315-cp315t-win32.whl", hash = "sha256:ad62a416ddcf863bf44bba76fbf6b53366ab0692e294f51cae4b5fbe0d246788"}, + {file = "numpy-2.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:38f47be9f74ab870d2633b5456ae519c43758a8d1fd05342f0ce4ecc034396ee"}, + {file = "numpy-2.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:7a14a461d9340f1b46b8648578aed9cdb8b3b018a8fac6c1dde2c9192a01a87f"}, + {file = "numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a"}, +] + +[[package]] +name = "packaging" +version = "26.3" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.9" +groups = ["lint"] +files = [ + {file = "packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c"}, + {file = "packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79"}, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.9" +groups = ["lint"] +files = [ + {file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"}, + {file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"}, +] + +[package.extras] +hyperscan = ["hyperscan (>=0.7)"] +optional = ["typing-extensions (>=4)"] +re2 = ["google-re2 (>=1.1)"] + +[[package]] +name = "pep8-naming" +version = "0.15.1" +description = "Check PEP-8 naming conventions, plugin for flake8" +optional = false +python-versions = ">=3.9" +groups = ["lint"] +files = [ + {file = "pep8_naming-0.15.1-py3-none-any.whl", hash = "sha256:eb63925e7fd9e028c7f7ee7b1e413ec03d1ee5de0e627012102ee0222c273c86"}, + {file = "pep8_naming-0.15.1.tar.gz", hash = "sha256:f6f4a499aba2deeda93c1f26ccc02f3da32b035c8b2db9696b730ef2c9639d29"}, +] + +[package.dependencies] +flake8 = ">=5.0.0" + +[[package]] +name = "platformdirs" +version = "4.12.4" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.10" +groups = ["lint"] +markers = "python_version < \"3.12\"" +files = [ + {file = "platformdirs-4.12.4-py3-none-any.whl", hash = "sha256:78bfb9db2a8471ed7eebe3c3c932da413911042994e699b384fbb4493fa872d7"}, + {file = "platformdirs-4.12.4.tar.gz", hash = "sha256:63743c02414e755de4e31b8f68125c1407495b86c5a006e203c01ff8b9924250"}, +] + +[[package]] +name = "platformdirs" +version = "4.13.5" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.11" +groups = ["lint"] +markers = "python_version >= \"3.12\"" +files = [ + {file = "platformdirs-4.13.5-py3-none-any.whl", hash = "sha256:53d3ec4280b815c1e0b02edad7f9ee8fda1ba9c0865dd9c2e6e63849ce82fc1e"}, + {file = "platformdirs-4.13.5.tar.gz", hash = "sha256:05831adf87d392760f4b5c3a0a95579ab5a10b18b9b9bd43680dcb47544788a0"}, +] + +[[package]] +name = "protobuf" +version = "4.25.9" +description = "" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version <= \"3.12\"" +files = [ + {file = "protobuf-4.25.9-cp310-abi3-win32.whl", hash = "sha256:bde396f568b0b46fc8fbfe9f02facf25b6755b2578a3b8ac61e74b9d69499e03"}, + {file = "protobuf-4.25.9-cp310-abi3-win_amd64.whl", hash = "sha256:3683c05154252206f7cb2d371626514b3708199d9bcf683b503dabf3a2e38e06"}, + {file = "protobuf-4.25.9-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:9560813560e6ee72c11ca8873878bdb7ee003c96a57ebb013245fe84e2540904"}, + {file = "protobuf-4.25.9-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:999146ef02e7fa6a692477badd1528bcd7268df211852a3df2d834ba2b480791"}, + {file = "protobuf-4.25.9-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:438c636de8fb706a0de94a12a268ef1ae8f5ba5ae655a7671fcda5968ba3c9be"}, + {file = "protobuf-4.25.9-cp38-cp38-win32.whl", hash = "sha256:7f7c1abcea3fc215918fba67a2d2a80fbcccc0f84159610eb187e9bbe6f939ee"}, + {file = "protobuf-4.25.9-cp38-cp38-win_amd64.whl", hash = "sha256:79faf4e5a80b231d94dcf3a0a2917ccbacf0f586f12c9b9c91794b41b913a853"}, + {file = "protobuf-4.25.9-cp39-cp39-win32.whl", hash = "sha256:9481e80e8cffb1c492c68e7c4e6726f4ad02eebc4fa97ead7beebeaa3639511d"}, + {file = "protobuf-4.25.9-cp39-cp39-win_amd64.whl", hash = "sha256:b1d467352de666dc1b6d5740b6319d9c08cab7b21b452501e4ee5b0ac5156780"}, + {file = "protobuf-4.25.9-py3-none-any.whl", hash = "sha256:d49b615e7c935194ac161f0965699ac84df6112c378e05ec53da65d2e4cbb6d4"}, + {file = "protobuf-4.25.9.tar.gz", hash = "sha256:b0dc7e7c68de8b1ce831dacb12fb407e838edbb8b6cc0dc3a2a6b4cbf6de9cff"}, +] + +[[package]] +name = "protobuf" +version = "5.29.6" +description = "" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version == \"3.13\"" +files = [ + {file = "protobuf-5.29.6-cp310-abi3-win32.whl", hash = "sha256:62e8a3114992c7c647bce37dcc93647575fc52d50e48de30c6fcb28a6a291eb1"}, + {file = "protobuf-5.29.6-cp310-abi3-win_amd64.whl", hash = "sha256:7e6ad413275be172f67fdee0f43484b6de5a904cc1c3ea9804cb6fe2ff366eda"}, + {file = "protobuf-5.29.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:b5a169e664b4057183a34bdc424540e86eea47560f3c123a0d64de4e137f9269"}, + {file = "protobuf-5.29.6-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:a8866b2cff111f0f863c1b3b9e7572dc7eaea23a7fae27f6fc613304046483e6"}, + {file = "protobuf-5.29.6-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:e3387f44798ac1106af0233c04fb8abf543772ff241169946f698b3a9a3d3ab9"}, + {file = "protobuf-5.29.6-cp38-cp38-win32.whl", hash = "sha256:36ade6ff88212e91aef4e687a971a11d7d24d6948a66751abc1b3238648f5d05"}, + {file = "protobuf-5.29.6-cp38-cp38-win_amd64.whl", hash = "sha256:831e2da16b6cc9d8f1654c041dd594eda43391affd3c03a91bea7f7f6da106d6"}, + {file = "protobuf-5.29.6-cp39-cp39-win32.whl", hash = "sha256:cb4c86de9cd8a7f3a256b9744220d87b847371c6b2f10bde87768918ef33ba49"}, + {file = "protobuf-5.29.6-cp39-cp39-win_amd64.whl", hash = "sha256:76e07e6567f8baf827137e8d5b8204b6c7b6488bbbff1bf0a72b383f77999c18"}, + {file = "protobuf-5.29.6-py3-none-any.whl", hash = "sha256:6b9edb641441b2da9fa8f428760fc136a49cf97a52076010cf22a2ff73438a86"}, + {file = "protobuf-5.29.6.tar.gz", hash = "sha256:da9ee6a5424b6b30fd5e45c5ea663aef540ca95f9ad99d1e887e819cdf9b8723"}, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +description = "" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3"}, + {file = "protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326"}, + {file = "protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a"}, + {file = "protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2"}, + {file = "protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3"}, + {file = "protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593"}, + {file = "protobuf-6.33.6-cp39-cp39-win32.whl", hash = "sha256:bd56799fb262994b2c2faa1799693c95cc2e22c62f56fb43af311cae45d26f0e"}, + {file = "protobuf-6.33.6-cp39-cp39-win_amd64.whl", hash = "sha256:f443a394af5ed23672bc6c486be138628fbe5c651ccbc536873d7da23d1868cf"}, + {file = "protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901"}, + {file = "protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135"}, +] + +[[package]] +name = "pycodestyle" +version = "2.9.1" +description = "Python style guide checker" +optional = false +python-versions = ">=3.6" +groups = ["lint"] +markers = "python_version < \"3.12\"" +files = [ + {file = "pycodestyle-2.9.1-py2.py3-none-any.whl", hash = "sha256:d1735fc58b418fd7c5f658d28d943854f8a849b01a5d0a1e6f3f3fdd0166804b"}, + {file = "pycodestyle-2.9.1.tar.gz", hash = "sha256:2c9607871d58c76354b697b42f5d57e1ada7d261c261efac224b664affdc5785"}, +] + +[[package]] +name = "pycodestyle" +version = "2.11.1" +description = "Python style guide checker" +optional = false +python-versions = ">=3.8" +groups = ["lint"] +markers = "python_version >= \"3.12\"" +files = [ + {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"}, + {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"}, +] + +[[package]] +name = "pydocstyle" +version = "6.3.0" +description = "Python docstring style checker" +optional = false +python-versions = ">=3.6" +groups = ["lint"] +files = [ + {file = "pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019"}, + {file = "pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1"}, +] + +[package.dependencies] +snowballstemmer = ">=2.2.0" + +[package.extras] +toml = ["tomli (>=1.2.3) ; python_version < \"3.11\""] + +[[package]] +name = "pyflakes" +version = "2.5.0" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.6" +groups = ["lint"] +markers = "python_version < \"3.12\"" +files = [ + {file = "pyflakes-2.5.0-py2.py3-none-any.whl", hash = "sha256:4579f67d887f804e67edb544428f264b7b24f435b263c4614f384135cea553d2"}, + {file = "pyflakes-2.5.0.tar.gz", hash = "sha256:491feb020dca48ccc562a8c0cbe8df07ee13078df59813b83959cbdada312ea3"}, +] + +[[package]] +name = "pyflakes" +version = "3.1.0" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.8" +groups = ["lint"] +markers = "python_version >= \"3.12\"" +files = [ + {file = "pyflakes-3.1.0-py2.py3-none-any.whl", hash = "sha256:4132f6d49cb4dae6819e5379898f2b8cce3c5f23994194c24b77d5da2e36f774"}, + {file = "pyflakes-3.1.0.tar.gz", hash = "sha256:a0aae034c444db0071aa077972ba4768d40c830d9539fd45bf4cd3f8f6992efc"}, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons." +optional = false +python-versions = ">=3.8" +groups = ["lint"] +files = [ + {file = "pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5"}, + {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe"}, + {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c"}, + {file = "pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7"}, + {file = "pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2"}, + {file = "pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440"}, + {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc"}, + {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d"}, + {file = "pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16"}, + {file = "pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6"}, + {file = "pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083"}, + {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1"}, + {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1"}, + {file = "pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9"}, + {file = "pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68"}, + {file = "pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b"}, + {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f"}, + {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1"}, + {file = "pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4"}, + {file = "pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78"}, + {file = "pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321"}, + {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa"}, + {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d"}, + {file = "pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324"}, + {file = "pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9"}, + {file = "pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb"}, + {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3"}, + {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975"}, + {file = "pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a"}, + {file = "pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918"}, + {file = "pytokens-0.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:da5baeaf7116dced9c6bb76dc31ba04a2dc3695f3d9f74741d7910122b456edc"}, + {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11edda0942da80ff58c4408407616a310adecae1ddd22eef8c692fe266fa5009"}, + {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fc71786e629cef478cbf29d7ea1923299181d0699dbe7c3c0f4a583811d9fc1"}, + {file = "pytokens-0.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dcafc12c30dbaf1e2af0490978352e0c4041a7cde31f4f81435c2a5e8b9cabb6"}, + {file = "pytokens-0.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:42f144f3aafa5d92bad964d471a581651e28b24434d184871bd02e3a0d956037"}, + {file = "pytokens-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:34bcc734bd2f2d5fe3b34e7b3c0116bfb2397f2d9666139988e7a3eb5f7400e3"}, + {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941d4343bf27b605e9213b26bfa1c4bf197c9c599a9627eb7305b0defcfe40c1"}, + {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ad72b851e781478366288743198101e5eb34a414f1d5627cdd585ca3b25f1db"}, + {file = "pytokens-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:682fa37ff4d8e95f7df6fe6fe6a431e8ed8e788023c6bcc0f0880a12eab80ad1"}, + {file = "pytokens-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:30f51edd9bb7f85c748979384165601d028b84f7bd13fe14d3e065304093916a"}, + {file = "pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de"}, + {file = "pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a"}, +] + +[package.extras] +dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] + +[[package]] +name = "pywin32" +version = "312" +description = "Python for Windows Extensions" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e"}, + {file = "pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db"}, + {file = "pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd"}, + {file = "pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c"}, + {file = "pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a"}, + {file = "pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47"}, + {file = "pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b"}, + {file = "pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc"}, + {file = "pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950"}, + {file = "pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c"}, + {file = "pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9"}, + {file = "pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831"}, + {file = "pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b"}, + {file = "pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e"}, + {file = "pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa"}, + {file = "pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed"}, + {file = "pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5"}, + {file = "pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9"}, + {file = "pywin32-312-cp39-cp39-win32.whl", hash = "sha256:d620900033cc7531e50727c3c8333091df5dd3ffe6d68cdca38c03f5821408d5"}, + {file = "pywin32-312-cp39-cp39-win_amd64.whl", hash = "sha256:dc90147579a905b8635e1b0ec6514967dcb07e6e0d9c42f1477feef14cac23bb"}, + {file = "pywin32-312-cp39-cp39-win_arm64.whl", hash = "sha256:02ebca0f0242b75292e218065004310d6a477407c09fa449bfe4f6022bc0c0fc"}, +] + +[[package]] +name = "setuptools" +version = "84.2.1" +description = "Most extensible Python build backend with support for C/C++ extension modules" +optional = false +python-versions = ">=3.10" +groups = ["main", "lint"] +files = [ + {file = "setuptools-84.2.1-py3-none-any.whl", hash = "sha256:ca140b45d5325a9478f880e351484f62fef15f336462e449ca0c03c873d589c2"}, + {file = "setuptools-84.2.1.tar.gz", hash = "sha256:3ff8b3b5492a0aa66960f4419365a86b84e013070d4313e4d4a6894f5d64d6bc"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.13.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=26.3)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=26.3)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.18.*)", "pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] + +[[package]] +name = "snowballstemmer" +version = "3.1.1" +description = "This package provides 36 stemmers for 34 languages generated from Snowball algorithms." +optional = false +python-versions = ">=3.3" +groups = ["lint"] +files = [ + {file = "snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752"}, + {file = "snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260"}, +] + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["lint"] +files = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] + +[[package]] +name = "tomli" +version = "2.5.0" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["lint"] +markers = "python_version == \"3.10\"" +files = [ + {file = "tomli-2.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4dc1c1781f2f716de763d1e9a7b34c6a894e167e291c7c5d16c72f7a9538545"}, + {file = "tomli-2.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eff8babca5a7999bc137acbc7482a8b7e17ffca5075ab41f5d770ab408c7bfef"}, + {file = "tomli-2.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86665cee9c4835b7a7f1e8ec2c719b5258d4dc782887aded5a8ae7352a96843b"}, + {file = "tomli-2.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e369fd63331746182360977b1892bfc215476a30d61612d732425311639f56"}, + {file = "tomli-2.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ad1ea345759240d6463efa0ed1c704402752e49aa21476620738d74d72d8aa1"}, + {file = "tomli-2.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:96243987194634bd411066ce40c952e108f86af04db533ecd8ac3ff2a85b1885"}, + {file = "tomli-2.5.0-cp311-cp311-win32.whl", hash = "sha256:610b27d99f28ec5f191c7064a48f3ddb179a1fe6ca73d571483ae859f57b605e"}, + {file = "tomli-2.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:c804ae44fe7b4bab5da295e4f980a1ff04670bca9d23fe0a4e887e08ebd741a8"}, + {file = "tomli-2.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:cfac177ebd6236003846ea339981f71457cb6eb748f23381eb257e45092e3980"}, + {file = "tomli-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f4a40d03fb9f63424f0979855bdeaf44dd7696b8d59501822c10ed30ba532df"}, + {file = "tomli-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ebf8d19b17bd0daeb7b7dec81a946a439b753942fd0210d6e96c532249eea6b"}, + {file = "tomli-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf0b5e8e0f68ebb494356e577c06c139161efd8d3b9050f93b39b7c26cc54ff0"}, + {file = "tomli-2.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cf74416bdc94ae458b14e37286c1073081850ac8459a00d0c5efef5d44294c6"}, + {file = "tomli-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:61ea1ebe1e55a34ea8199cc8dbff398d35027b82271c8ac4802fd3a1fd5b1bcc"}, + {file = "tomli-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed53f7e89bb04f6d9e8e7799112360b0c4d5cbff067de0814c98c37c39b920f7"}, + {file = "tomli-2.5.0-cp312-cp312-win32.whl", hash = "sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2"}, + {file = "tomli-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7"}, + {file = "tomli-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea"}, + {file = "tomli-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:943276cf269e0071948d9ff697159c1735e623c1151d88abb09b74659ef0cbea"}, + {file = "tomli-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:463b16086865b97facd8d0b3fb4cb7c544e3f58d2a69dc3113d6db9653fdb043"}, + {file = "tomli-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1245a6638fc4bb0a60af38a7d45413db34a13842027c77597c712c998c62fdf0"}, + {file = "tomli-2.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d8bac3d603c97e6854424e5b2b5b741bdbde387e09f162fb0446812b4a8362b"}, + {file = "tomli-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:21e4cae4114aba25aa0d4f85cdf486d290fb35c0954d7bba536248da64d43066"}, + {file = "tomli-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bbaefc84548d754be821bba7c4141c4787dda182f9e77f2f87b71213529efa7b"}, + {file = "tomli-2.5.0-cp313-cp313-win32.whl", hash = "sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68"}, + {file = "tomli-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc"}, + {file = "tomli-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84"}, + {file = "tomli-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d3182ee2d887e507bd67319a0a61105d1dd33facc111329559a233b772c1a105"}, + {file = "tomli-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:521345fd1f19d45b8df87657aaa38b6f2ca3800059fadf428e7ebf479a383646"}, + {file = "tomli-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95c7614e705bfe2b04b27aa124adec59752d15813df37e2156747cab3a006b"}, + {file = "tomli-2.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac2027d37c3afbdf4bdd377f2676f6f1d2122a5be1f1137b49dced590b37e75"}, + {file = "tomli-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c414be4ed9d3cac80c42e348fa5a956117d1a48227f48026e31f59cb4a7671eb"}, + {file = "tomli-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9b03d7dc168353b4132965bde20feceabaa470e570c6f59660dfae59b1f9eeb3"}, + {file = "tomli-2.5.0-cp314-cp314-win32.whl", hash = "sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b"}, + {file = "tomli-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a"}, + {file = "tomli-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3"}, + {file = "tomli-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e9e15b4a6c7dd6b85b5fbab29488a73f1f70de516942308daa266bf0e0aeb0d4"}, + {file = "tomli-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e12bbcd32897272fb05929110362ae9ff4c1b9bb26bd9e971e71dcd3275b4c3d"}, + {file = "tomli-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20aa36de8f2cf87237143bc1fa1aae8d6612c09118f4da21c6a684db5dd1f6f9"}, + {file = "tomli-2.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22185fad8a1e622f064e78008018a0dd3323550dcb479cb7a1d296888d74024f"}, + {file = "tomli-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:984012f71908165449a951de2050d52f276bfe3aa5d5f570f63ddad814370374"}, + {file = "tomli-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f79203b3965b4000e91808aaa7c040206093f2b8bf86f455982f2274c9ccf442"}, + {file = "tomli-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03"}, + {file = "tomli-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1"}, + {file = "tomli-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0"}, + {file = "tomli-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc"}, + {file = "tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276"}, + {file = "tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52"}, + {file = "tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7"}, + {file = "tomli-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391"}, + {file = "tomli-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859"}, + {file = "tomli-2.5.0-cp315-cp315-win32.whl", hash = "sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb"}, + {file = "tomli-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5"}, + {file = "tomli-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd"}, + {file = "tomli-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57"}, + {file = "tomli-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd"}, + {file = "tomli-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01"}, + {file = "tomli-2.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f"}, + {file = "tomli-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a"}, + {file = "tomli-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142"}, + {file = "tomli-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5"}, + {file = "tomli-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571"}, + {file = "tomli-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7"}, + {file = "tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b"}, + {file = "tomli-2.5.0.tar.gz", hash = "sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6"}, +] + +[[package]] +name = "traceloggingdynamic" +version = "1.0.1" +description = "Generates Event Tracing for Windows events using TraceLogging" +optional = false +python-versions = ">=3.6" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "traceloggingdynamic-1.0.1-py3-none-any.whl", hash = "sha256:0e19da491a8960725b3622366487ae35f49d8f595bb2e4e5ce1795eb5928db7c"}, + {file = "traceloggingdynamic-1.0.1.tar.gz", hash = "sha256:d9dd4b291dd04c15e34181eed06f73fdf4ffa7b1f895b78217163def48ab1a52"}, +] + +[[package]] +name = "types-grpcio" +version = "1.84.0.20261021" +description = "Typing stubs for grpcio" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "types_grpcio-1.84.0.20261021-py3-none-any.whl", hash = "sha256:0cd5f897d7485c448c4819597cb05fd17afac4620c0feaee6f7c5d0ee96a60f1"}, + {file = "types_grpcio-1.84.0.20261021.tar.gz", hash = "sha256:d06d24b31843adaf34656654a7536bd70b40425880bb6162219eb5b1a86220ae"}, +] + +[[package]] +name = "types-protobuf" +version = "7.35.1.20260906" +description = "Typing stubs for protobuf" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "types_protobuf-7.35.1.20260906-py3-none-any.whl", hash = "sha256:5155e48569e0dabff303fdf578db96cd31ea9a4a63b18018a4ceac6b0ae17462"}, + {file = "types_protobuf-7.35.1.20260906.tar.gz", hash = "sha256:efd1a3862d4c967dad5512ef8d56b1530ac84f182c41735b94004756518c4998"}, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "lint"] +files = [ + {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, + {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, +] + +[metadata] +lock-version = "2.1" +python-versions = "^3.10" +content-hash = "78de1b99ca4883655ed0e08c84b8b886dce4c424851717d3de6ff2c0aeb400dc" diff --git a/examples/overview-v2/poetry.toml b/examples/overview-v2/poetry.toml new file mode 100644 index 00000000..ab1033bd --- /dev/null +++ b/examples/overview-v2/poetry.toml @@ -0,0 +1,2 @@ +[virtualenvs] +in-project = true diff --git a/examples/overview-v2/pyproject.toml b/examples/overview-v2/pyproject.toml new file mode 100644 index 00000000..a9e3df1c --- /dev/null +++ b/examples/overview-v2/pyproject.toml @@ -0,0 +1,76 @@ +[project] +name = "overview_v2_example" +version = "1.0.0" +license = "MIT" +description = "Overview of how to use the NI Measurement Data Store V2 APIs for publishing and reading data" +maintainers = [ + {name = "Johann Scholtz", email = "johann.scholtz@emerson.com"}, + {name = "Joel Dixon", email = "joel.dixon@emerson.com"} +] +readme = "README.md" +repository = "https://github.com/ni/datastore-python" +keywords = ["overview", "example", "v2"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: Manufacturing", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: Implementation :: CPython", +] +dynamic = ["dependencies"] +requires-python = '>=3.10,<4.0' + +[[tool.poetry.packages]] +include = "src" + +[project.scripts] +overview-v2 = "src.overview:main" + +[tool.poetry] +requires-poetry = '>=2.1,<3.0' + +[tool.poetry.dependencies] +python = "^3.10" +# The V2 API is not released yet, so use the local ni-datastore code. +ni-datastore = {path = "../..", develop = true} +protobuf = { version=">=4.21" } +ni-protobuf-types = { version = ">=1.1.0" } +hightime = { version = ">=1.0.0" } +grpcio-tools = [ + { version = "1.49.1", python = ">=3.9,<3.12" }, + { version = "1.59.0", python = ">=3.12,<3.13" }, + { version = "1.67.0", python = ">=3.13,<3.14" }, + { version = "1.75.1", python = "^3.14" }, +] + +[tool.poetry.group.dev.dependencies] +types-grpcio = ">=1.0" +types-protobuf = ">=4.21" + +[tool.poetry.group.lint.dependencies] +ni-python-styleguide = ">=0.4.1" +mypy = ">=1.0" + +[build-system] +requires = ["poetry-core>=1.8"] +build-backend = "poetry.core.masonry.api" + +[tool.black] +line-length = 100 + +[tool.mypy] +mypy_path = "src" +files = "." +namespace_packages = true +strict = true +explicit_package_bases = true +exclude = ["docs"] diff --git a/examples/overview-v2/src/__init__.py b/examples/overview-v2/src/__init__.py new file mode 100644 index 00000000..eaca42ab --- /dev/null +++ b/examples/overview-v2/src/__init__.py @@ -0,0 +1 @@ +"""Overview example of NI Measurement Data Store V2 usage.""" diff --git a/examples/overview-v2/src/overview.py b/examples/overview-v2/src/overview.py new file mode 100644 index 00000000..e693908a --- /dev/null +++ b/examples/overview-v2/src/overview.py @@ -0,0 +1,162 @@ +"""Overview example demonstrating V2 data publishing and querying.""" + +import argparse +from datetime import timezone + +import grpc +import hightime as ht +import numpy as np +from ni.datastore.data.v2 import ( + Condition, + DataStoreClient, + Outcome, + Status, + Step, + TestResult, +) +from ni.datastore.metadata import ( + MetadataStoreClient, + Operator, + SoftwareItem, + TestStation, + Uut, + UutInstance, +) +from nitypes.waveform import AnalogWaveform, Timing + + +def main() -> None: + """Main function to demonstrate V2 data publishing and querying.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--grpc-target", + default="", + help="gRPC target of a V2 service, such as [::1]:59606. Uses discovery when omitted.", + ) + args = parser.parse_args() + + # The clients do not close a channel that they did not create, so close it here. + grpc_channel = grpc.insecure_channel(args.grpc_target) if args.grpc_target else None + try: + published_measurement_id = publish_data(grpc_channel) + query_data(published_measurement_id, grpc_channel) + finally: + if grpc_channel is not None: + grpc_channel.close() + + +def publish_data(grpc_channel: grpc.Channel | None) -> str: + """Demonstrate V2 data publishing of an AnalogWaveform.""" + with ( + MetadataStoreClient(grpc_channel=grpc_channel) as metadata_store_client, + DataStoreClient(grpc_channel=grpc_channel) as data_store_client, + ): + # Create UUT instance + uut = Uut(model_name="NI-6508", family="Digital") + uut_id = metadata_store_client.create_uut(uut) + uut_instance = UutInstance(uut_id=uut_id, serial_number="A861-42367") + uut_instance_id = metadata_store_client.create_uut_instance(uut_instance=uut_instance) + + # Create Operator metadata + operator = Operator(name="James Bowery", role="Test Operator") + operator_id = metadata_store_client.create_operator(operator) + print(f"created operator_id: {operator_id}") + + # Create TestStation metadata + test_station = TestStation(name="TestStation_12") + test_station_id = metadata_store_client.create_test_station(test_station) + print(f"created test_station_id: {test_station_id}") + + # Create SoftwareItem metadata + software_item = SoftwareItem(product="Windows", version="10.0.19044") + software_item_id = metadata_store_client.create_software_item(software_item) + print(f"created software_item_id: {software_item_id}") + software_item_2 = SoftwareItem(product="Python", version="3.12") + software_item_2_id = metadata_store_client.create_software_item(software_item_2) + print(f"created software_item_2_id: {software_item_2_id}") + + # Create TestResult. In V2, status tracks progress separately from the outcome. + test_result = TestResult( + name="sample test result", + operator_id=operator_id, + test_station_id=test_station_id, + software_item_ids=[software_item_id, software_item_2_id], + uut_instance_id=uut_instance_id, + status=Status.RUNNING, + ) + test_result_id = data_store_client.create_test_result(test_result) + print(f"created test_result_id: {test_result_id}") + + # Create the step. In V2, conditions belong to the step. + step = Step( + name="Initial step", + test_result_id=test_result_id, + conditions=[ + Condition("Ambient Temperature", 23.0, condition_type="Environment", units="degC") + ], + ) + step_id = data_store_client.create_step(step) + + name = "data publish sample" + # Create waveform data to publish + waveform = AnalogWaveform( + sample_count=3, + raw_data=np.array([1.0, 2.0, 3.0]), + timing=Timing.create_with_regular_interval( + ht.timedelta(seconds=1e-3), ht.datetime.now(timezone.utc) + ), + ) + + # Create the measurement with the waveform data + published_measurement_id = data_store_client.create_measurement( + name=name, + value=waveform, + step_id=step_id, + units="V", + outcome=Outcome.PASSED, + ) + published_measurement = data_store_client.get_measurement(published_measurement_id) + print( + f"Published measurement: '{published_measurement.name}' with id {published_measurement.id}" + ) + + # Mark the test result as done now that publishing is complete. + data_store_client.update_test_result( + test_result_id, status=Status.DONE, outcome=Outcome.PASSED + ) + + return published_measurement_id + + +def query_data(published_measurement_id: str, grpc_channel: grpc.Channel | None) -> None: + """Demonstrate querying a published AnalogWaveform measurement.""" + with ( + MetadataStoreClient(grpc_channel=grpc_channel) as metadata_store_client, + DataStoreClient(grpc_channel=grpc_channel) as data_store_client, + ): + published_measurements = data_store_client.query_measurements( + odata_query=f"$filter=Id eq {published_measurement_id}" + ) + found_measurement = next(iter(published_measurements), None) + + if found_measurement is not None: + print( + f"Found published measurement: '{found_measurement.name}' with id {found_measurement.id}" + ) + test_result = data_store_client.get_test_result(found_measurement.test_result_id) + print(f"test_result: {test_result.name} ({test_result.status.name})") + operator = metadata_store_client.get_operator(test_result.operator_id) + print(f"operator: {operator}") + + step = data_store_client.get_step(found_measurement.step_id, read_fields=["conditions"]) + for condition in step.conditions: + print(f"condition: {condition.name} = {condition.value} {condition.units}") + + waveform = data_store_client.read_measurement_value( + found_measurement, expected_type=AnalogWaveform + ) + print(f"published data is: {waveform.raw_data}") + + +if __name__ == "__main__": + main() From f616cae1cc424ba657e6e8eb8a3d7fc152679a6c Mon Sep 17 00:00:00 2001 From: "Balusamy, Sudharshana" Date: Tue, 27 Oct 2026 14:20:58 +0530 Subject: [PATCH 04/13] self - review --- .github/workflows/check_examples.yml | 2 +- docs/reference/ni-data-store-v2.md | 23 ++++- .../measurement_values.ipynb | 17 +++- .../v2/overview/publish_measurement.ipynb | 4 +- .../notebooks/v2/publish/batch_create.ipynb | 4 +- .../v2/query/publish_sample_data.ipynb | 4 +- .../v2/query/query_measurements.ipynb | 4 +- .../structured_extensions.ipynb | 4 +- .../v2/update-delete/update_delete.ipynb | 33 +++++-- examples/overview-v2/src/overview.py | 7 +- poetry.lock | 4 +- .../datastore/data/v2/_data_store_client.py | 59 ++++++++++-- src/ni/datastore/data/v2/_grpc_conversion.py | 3 +- .../datastore/data/v2/_types/_measurement.py | 24 +++-- test.py | 0 tests/unit/data/test_v2_data_store_client.py | 92 ++++++++++++++++++- tests/unit/data/test_v2_types.py | 15 ++- 17 files changed, 258 insertions(+), 41 deletions(-) delete mode 100644 test.py diff --git a/.github/workflows/check_examples.yml b/.github/workflows/check_examples.yml index bf14dd0a..3708eb1d 100644 --- a/.github/workflows/check_examples.yml +++ b/.github/workflows/check_examples.yml @@ -11,7 +11,7 @@ jobs: matrix: os: [windows-latest, ubuntu-latest, macos-latest] python-version: [3.14] - example-name: ['overview', 'system'] + example-name: ['overview', 'overview-v2', 'system'] runs-on: ${{ matrix.os }} steps: - name: Check out repo diff --git a/docs/reference/ni-data-store-v2.md b/docs/reference/ni-data-store-v2.md index fdcd4a05..70ec0e25 100644 --- a/docs/reference/ni-data-store-v2.md +++ b/docs/reference/ni-data-store-v2.md @@ -59,7 +59,10 @@ The client wraps every method of the V2 low-level client: - `remove_condition` removes a condition from a step. - `add_test_result_metadata`, `remove_test_result_metadata`, `add_measurement_metadata`, and `remove_measurement_metadata` link or unlink - software items, hardware items, and test adapters after creation. + software items, hardware items, and test adapters after creation. As defined + by DEC-017, `remove_test_result_metadata` raises `ValueError`, without + removing anything, if a measurement of the test result still references an + item; unlink it from those measurements first. - `create_measurement` accepts `upper_limit` and `lower_limit` of any supported value type. `read_measurement_values` returns the value and both limits. - `batch_create_*`, `batch_update_*`, and `batch_delete_*` handle many test @@ -100,7 +103,10 @@ The client wraps every method of the V2 low-level client: the Stage 2 New Resource Fields feature. - Measurement values are converted to scalar, vector, supported NI waveform-family, or JSON values; `read_measurement_value` converts them back to - Python or nitypes values. Conditions accept scalar or JSON values. JSON values + Python or nitypes values. A single value is always read back as a `Scalar`, + with or without units, and a list of values as a `Vector`. When a `Scalar` is + given and `units` is not, the measurement's `units` are taken from the + `Scalar`. Conditions accept scalar or JSON values. JSON values require the service's Stage 3 Structured JSON Storage And Query Support. - Software item, hardware item, and test adapter IDs on a measurement must first be linked to its test result; pass them to `create_test_result`. @@ -112,3 +118,16 @@ The client wraps every method of the V2 low-level client: - V1 and V2 hosts both register the metadata store interface. When both run in the same discovery cluster, `MetadataStoreClient()` cannot choose between them; pass a `grpc_channel` for the intended host. +- `status` and `outcome` are separate, but the service requires status `DONE` + (or no status) whenever a test result or step has an outcome other than + `UNSPECIFIED`. For example, `status=Status.RUNNING` with + `outcome=Outcome.FAILED` is rejected with `INVALID_ARGUMENT`. +- When you pass your own `grpc_channel`, gRPC limits received messages to 4 MB + by default, so reading a large value such as a 1-million-sample waveform fails + with `RESOURCE_EXHAUSTED`. Create the channel with + `options=[("grpc.max_receive_message_length", -1)]` to remove the limit. The + default discovery path already does this. +- Waveform values read back with the same data, but their timing start time is a + `nitypes.bintime.DateTime` rather than a `hightime.datetime`. As a result, `==` + between a published waveform and the one read back returns `False`; compare + the data, such as `raw_data`, instead. diff --git a/examples/notebooks/v2/measurement-values/measurement_values.ipynb b/examples/notebooks/v2/measurement-values/measurement_values.ipynb index f54bc5bc..e951f22e 100644 --- a/examples/notebooks/v2/measurement-values/measurement_values.ipynb +++ b/examples/notebooks/v2/measurement-values/measurement_values.ipynb @@ -42,7 +42,9 @@ "import grpc\n", "\n", "grpc_target = os.environ.get(\"MDS_V2_GRPC_TARGET\", \"\")\n", - "grpc_channel = grpc.insecure_channel(grpc_target) if grpc_target else None\n", + "# Remove gRPC's 4 MB receive limit so that large values, such as long waveforms, can be read.\n", + "grpc_options = [(\"grpc.max_receive_message_length\", -1)]\n", + "grpc_channel = grpc.insecure_channel(grpc_target, options=grpc_options) if grpc_target else None\n", "\n", "from ni.datastore.data.v2 import DataStoreClient\n", "\n", @@ -63,7 +65,9 @@ "source": [ "## Scalar Values\n", "\n", - "A `Scalar` carries its own units. For a plain value, pass `units` to `create_measurement`." + "A `Scalar` carries its own units, which are also saved as the measurement's `units`. For a plain value, pass `units` to `create_measurement`.\n", + "\n", + "A single value is always read back as a `Scalar`, so `.value` and `.units` work for every single value." ] }, { @@ -90,8 +94,11 @@ "\n", "for name, measurement_id in scalar_ids.items():\n", " measurement = data_store_client.get_measurement(measurement_id)\n", - " print(f\"{name}: {measurement.value!r}\")\n", - " assert measurement.value == scalar_values[name]" + " print(f\"{name}: value={measurement.value.value!r}, units='{measurement.units}'\")\n", + " expected = scalar_values[name]\n", + " assert measurement.value == (expected if isinstance(expected, Scalar) else Scalar(expected))\n", + "\n", + "assert data_store_client.get_measurement(scalar_ids[\"Example Scalar Measurement\"]).units == \"V\"" ] }, { @@ -130,7 +137,7 @@ "print(f\"value: {values.value}\")\n", "print(f\"upper limit: {values.upper_limit}\")\n", "print(f\"lower limit: {values.lower_limit}\")\n", - "assert values.upper_limit == 5.1 and values.lower_limit == 4.9\n", + "assert values.upper_limit == Scalar(5.1) and values.lower_limit == Scalar(4.9)\n", "\n", "value_only = data_store_client.read_measurement_values(\n", " data_store_client.get_measurement(limited_measurement_id), read_fields=[\"value\"]\n", diff --git a/examples/notebooks/v2/overview/publish_measurement.ipynb b/examples/notebooks/v2/overview/publish_measurement.ipynb index 98eb447b..25af789f 100644 --- a/examples/notebooks/v2/overview/publish_measurement.ipynb +++ b/examples/notebooks/v2/overview/publish_measurement.ipynb @@ -41,7 +41,9 @@ "import grpc\n", "\n", "grpc_target = os.environ.get(\"MDS_V2_GRPC_TARGET\", \"\")\n", - "grpc_channel = grpc.insecure_channel(grpc_target) if grpc_target else None\n", + "# Remove gRPC's 4 MB receive limit so that large values, such as long waveforms, can be read.\n", + "grpc_options = [(\"grpc.max_receive_message_length\", -1)]\n", + "grpc_channel = grpc.insecure_channel(grpc_target, options=grpc_options) if grpc_target else None\n", "\n", "from ni.datastore.data.v2 import DataStoreClient\n", "from ni.datastore.metadata import MetadataStoreClient\n", diff --git a/examples/notebooks/v2/publish/batch_create.ipynb b/examples/notebooks/v2/publish/batch_create.ipynb index 6116a2bb..cb3fb93c 100644 --- a/examples/notebooks/v2/publish/batch_create.ipynb +++ b/examples/notebooks/v2/publish/batch_create.ipynb @@ -32,7 +32,9 @@ "import grpc\n", "\n", "grpc_target = os.environ.get(\"MDS_V2_GRPC_TARGET\", \"\")\n", - "grpc_channel = grpc.insecure_channel(grpc_target) if grpc_target else None\n", + "# Remove gRPC's 4 MB receive limit so that large values, such as long waveforms, can be read.\n", + "grpc_options = [(\"grpc.max_receive_message_length\", -1)]\n", + "grpc_channel = grpc.insecure_channel(grpc_target, options=grpc_options) if grpc_target else None\n", "\n", "from ni.datastore.data.v2 import DataStoreClient\n", "\n", diff --git a/examples/notebooks/v2/query/publish_sample_data.ipynb b/examples/notebooks/v2/query/publish_sample_data.ipynb index 0413b9fc..44060a5a 100644 --- a/examples/notebooks/v2/query/publish_sample_data.ipynb +++ b/examples/notebooks/v2/query/publish_sample_data.ipynb @@ -48,7 +48,9 @@ "import grpc\n", "\n", "grpc_target = os.environ.get(\"MDS_V2_GRPC_TARGET\", \"\")\n", - "grpc_channel = grpc.insecure_channel(grpc_target) if grpc_target else None\n", + "# Remove gRPC's 4 MB receive limit so that large values, such as long waveforms, can be read.\n", + "grpc_options = [(\"grpc.max_receive_message_length\", -1)]\n", + "grpc_channel = grpc.insecure_channel(grpc_target, options=grpc_options) if grpc_target else None\n", "\n", "from ni.datastore.metadata import (\n", " MetadataStoreClient,\n", diff --git a/examples/notebooks/v2/query/query_measurements.ipynb b/examples/notebooks/v2/query/query_measurements.ipynb index 0f8e393f..89dc9220 100644 --- a/examples/notebooks/v2/query/query_measurements.ipynb +++ b/examples/notebooks/v2/query/query_measurements.ipynb @@ -43,7 +43,9 @@ "import grpc\n", "\n", "grpc_target = os.environ.get(\"MDS_V2_GRPC_TARGET\", \"\")\n", - "grpc_channel = grpc.insecure_channel(grpc_target) if grpc_target else None\n", + "# Remove gRPC's 4 MB receive limit so that large values, such as long waveforms, can be read.\n", + "grpc_options = [(\"grpc.max_receive_message_length\", -1)]\n", + "grpc_channel = grpc.insecure_channel(grpc_target, options=grpc_options) if grpc_target else None\n", "\n", "from ni.datastore.data.v2 import DataStoreClient\n", "from ni.datastore.metadata import MetadataStoreClient\n", diff --git a/examples/notebooks/v2/structured-extensions/structured_extensions.ipynb b/examples/notebooks/v2/structured-extensions/structured_extensions.ipynb index 3e5cb057..013267b5 100644 --- a/examples/notebooks/v2/structured-extensions/structured_extensions.ipynb +++ b/examples/notebooks/v2/structured-extensions/structured_extensions.ipynb @@ -40,7 +40,9 @@ "import grpc\n", "\n", "grpc_target = os.environ.get(\"MDS_V2_GRPC_TARGET\", \"\")\n", - "grpc_channel = grpc.insecure_channel(grpc_target) if grpc_target else None\n", + "# Remove gRPC's 4 MB receive limit so that large values, such as long waveforms, can be read.\n", + "grpc_options = [(\"grpc.max_receive_message_length\", -1)]\n", + "grpc_channel = grpc.insecure_channel(grpc_target, options=grpc_options) if grpc_target else None\n", "\n", "from ni.datastore.data.v2 import DataStoreClient\n", "\n", diff --git a/examples/notebooks/v2/update-delete/update_delete.ipynb b/examples/notebooks/v2/update-delete/update_delete.ipynb index b39cca0c..14bea981 100644 --- a/examples/notebooks/v2/update-delete/update_delete.ipynb +++ b/examples/notebooks/v2/update-delete/update_delete.ipynb @@ -42,7 +42,9 @@ "import grpc\n", "\n", "grpc_target = os.environ.get(\"MDS_V2_GRPC_TARGET\", \"\")\n", - "grpc_channel = grpc.insecure_channel(grpc_target) if grpc_target else None\n", + "# Remove gRPC's 4 MB receive limit so that large values, such as long waveforms, can be read.\n", + "grpc_options = [(\"grpc.max_receive_message_length\", -1)]\n", + "grpc_channel = grpc.insecure_channel(grpc_target, options=grpc_options) if grpc_target else None\n", "\n", "from ni.datastore.data.v2 import DataStoreClient\n", "from ni.datastore.metadata import MetadataStoreClient\n", @@ -205,7 +207,9 @@ "source": [ "## Link and Unlink Metadata\n", "\n", - "Link a software item to the test result first. Then a measurement of that test result can also link to it." + "Link a software item to the test result first. Then a measurement of that test result can also link to it.\n", + "\n", + "To unlink, reverse the order: an item cannot be unlinked from a test result while any of its measurements still references it (DEC-017). The service can also link a test result's item to its existing measurements, so find every measurement that references the item, unlink it from each one, and then unlink it from the test result." ] }, { @@ -225,13 +229,30 @@ ")\n", "print(f\"measurement software items: {list(measurement.software_item_ids)}\")\n", "\n", - "measurement = data_store_client.remove_measurement_metadata(\n", - " measurement_ids[0], software_item_ids=[software_item_id]\n", - ")\n", + "try:\n", + " data_store_client.remove_test_result_metadata(\n", + " test_result_id, software_item_ids=[software_item_id]\n", + " )\n", + "except ValueError as e:\n", + " print(f\"Not unlinked yet: {e}\")\n", + "\n", + "linked_measurements = [\n", + " measurement\n", + " for measurement in data_store_client.query_measurements(\n", + " f\"$filter=TestResultId eq {test_result_id}\", read_fields=[\"id\", \"software_item_ids\"]\n", + " )\n", + " if software_item_id in measurement.software_item_ids\n", + "]\n", + "for measurement in linked_measurements:\n", + " data_store_client.remove_measurement_metadata(\n", + " measurement.id, software_item_ids=[software_item_id]\n", + " )\n", + "\n", "test_result = data_store_client.remove_test_result_metadata(\n", " test_result_id, software_item_ids=[software_item_id]\n", ")\n", - "print(f\"after unlinking: {list(measurement.software_item_ids)}, {list(test_result.software_item_ids)}\")" + "print(f\"unlinked from {len(linked_measurements)} measurement(s) and from the test result\")\n", + "assert list(test_result.software_item_ids) == []" ] }, { diff --git a/examples/overview-v2/src/overview.py b/examples/overview-v2/src/overview.py index e693908a..c8960ff1 100644 --- a/examples/overview-v2/src/overview.py +++ b/examples/overview-v2/src/overview.py @@ -24,6 +24,9 @@ ) from nitypes.waveform import AnalogWaveform, Timing +# Remove gRPC's 4 MB receive limit so that large values, such as long waveforms, can be read. +_GRPC_OPTIONS = [("grpc.max_receive_message_length", -1)] + def main() -> None: """Main function to demonstrate V2 data publishing and querying.""" @@ -36,7 +39,9 @@ def main() -> None: args = parser.parse_args() # The clients do not close a channel that they did not create, so close it here. - grpc_channel = grpc.insecure_channel(args.grpc_target) if args.grpc_target else None + grpc_channel = ( + grpc.insecure_channel(args.grpc_target, options=_GRPC_OPTIONS) if args.grpc_target else None + ) try: published_measurement_id = publish_data(grpc_channel) query_data(published_measurement_id, grpc_channel) diff --git a/poetry.lock b/poetry.lock index f2664f3c..1f77bb24 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2522,7 +2522,7 @@ ni-measurements-data-v2-proto = ">=1.0.0.dev0" type = "git" url = "https://github.com/ni/ni-apis-python.git" reference = "users/sudharshana/add-v2-proto-and-client-package" -resolved_reference = "1baeee66c9e9b826dcb7a619dcb41c8bd190dbbb" +resolved_reference = "28aee05fe1ce46d2d349311cd600bf0a47937d1d" subdirectory = "packages/ni.measurements.data.v2.client" [[package]] @@ -2546,7 +2546,7 @@ protobuf = ">=4.21" type = "git" url = "https://github.com/ni/ni-apis-python.git" reference = "users/sudharshana/add-v2-proto-and-client-package" -resolved_reference = "1baeee66c9e9b826dcb7a619dcb41c8bd190dbbb" +resolved_reference = "28aee05fe1ce46d2d349311cd600bf0a47937d1d" subdirectory = "packages/ni.measurements.data.v2.proto" [[package]] diff --git a/src/ni/datastore/data/v2/_data_store_client.py b/src/ni/datastore/data/v2/_data_store_client.py index 8628d3ec..996b855f 100644 --- a/src/ni/datastore/data/v2/_data_store_client.py +++ b/src/ni/datastore/data/v2/_data_store_client.py @@ -222,7 +222,8 @@ def create_measurement( step_id: The ID of the step that owns the measurement. - units: Engineering units of the value. + units: Engineering units of the value. Defaults to the units of a + Scalar value. upper_limit: The publisher-supplied upper limit. Any supported value type is accepted. @@ -260,13 +261,13 @@ def create_measurement( must already be linked to the test result. software_items: Software items to find or create as part of this - request. + request. Each item must already be linked to the test result. hardware_items: Hardware items to find or create as part of this - request. + request. Each item must already be linked to the test result. test_adapters: Test adapters to find or create as part of this - request. + request. Each item must already be linked to the test result. Returns: str: The measurement ID. @@ -375,7 +376,9 @@ def read_measurement_value( expected_type: Optional type to validate the returned data against. Returns: - The value converted to a Python or nitypes value. + The value converted to a nitypes or Python value. A single value is + always returned as a Scalar, with or without units. A list of + values is returned as a Vector. Raises: TypeError: If expected_type is provided and the actual data type @@ -721,8 +724,10 @@ def remove_test_result_metadata( ) -> TestResult: """Unlink software items, hardware items, or test adapters from a test result. - The service rejects the request if a measurement of the test result - still references a removed item. + As defined by DEC-017, an item cannot be unlinked while a measurement of + the test result still references it. Unlink it from those measurements + first with remove_measurement_metadata. The client checks this before + calling the service, so nothing is removed when the check fails. Args: test_result_id: The ID of the test result. @@ -735,7 +740,22 @@ def remove_test_result_metadata( Returns: TestResult: The updated test result. + + Raises: + ValueError: If a measurement of the test result still references an + item to unlink. """ + software_item_ids = list(software_item_ids) + hardware_item_ids = list(hardware_item_ids) + test_adapter_ids = list(test_adapter_ids) + self._raise_if_used_by_measurements( + test_result_id, + { + "software_item_ids": set(software_item_ids), + "hardware_item_ids": set(hardware_item_ids), + "test_adapter_ids": set(test_adapter_ids), + }, + ) remove_request = RemoveTestResultMetadataRequest( test_result_id=test_result_id, software_item_ids=software_item_ids, @@ -911,6 +931,31 @@ def _get_data_store_client(self) -> DataStoreServiceClient: raise RuntimeError(self._DATA_STORE_CLIENT_CLOSED_ERROR) return self._data_store_client + def _raise_if_used_by_measurements( + self, test_result_id: str, removed_ids: Mapping[str, set[str]] + ) -> None: + if not any(removed_ids.values()): + return + query_request = QueryMeasurementsRequest( + odata_query=f"$filter=TestResultId eq {test_result_id}", + read_mask=FieldMask(paths=["id", *removed_ids]), + ) + measurements = self._get_data_store_client().query_measurements(query_request).measurements + in_use = sorted( + { + f"{item_id} (measurement {measurement.id})" + for measurement in measurements + for field, ids in removed_ids.items() + for item_id in ids.intersection(getattr(measurement, field)) + } + ) + if in_use: + raise ValueError( + f"Cannot unlink metadata from test result {test_result_id} while measurements " + f"still reference it: {', '.join(in_use)}. Unlink it from those measurements " + "first with remove_measurement_metadata." + ) + # Mutable update-mask paths from the V2 mutability design, including Stage 2 resource fields. _TEST_RESULT_UPDATE_FIELDS = frozenset( diff --git a/src/ni/datastore/data/v2/_grpc_conversion.py b/src/ni/datastore/data/v2/_grpc_conversion.py index 0ca174d5..dfad8709 100644 --- a/src/ni/datastore/data/v2/_grpc_conversion.py +++ b/src/ni/datastore/data/v2/_grpc_conversion.py @@ -133,8 +133,7 @@ def convert_measurement_value_from_protobuf(measurement_value: MeasurementValue) """Convert a V2 measurement value into a useful Python value.""" value_type = measurement_value.WhichOneof("value") if value_type == "scalar": - scalar = scalar_from_protobuf(measurement_value.scalar) - return scalar if scalar.units else scalar.value + return scalar_from_protobuf(measurement_value.scalar) if value_type == "vector": return vector_from_protobuf(measurement_value.vector) if value_type == "double_analog_waveform": diff --git a/src/ni/datastore/data/v2/_types/_measurement.py b/src/ni/datastore/data/v2/_types/_measurement.py index fa305025..649f179c 100644 --- a/src/ni/datastore/data/v2/_types/_measurement.py +++ b/src/ni/datastore/data/v2/_types/_measurement.py @@ -6,6 +6,7 @@ import hightime as ht from ni.measurements.data.v2.data_store_pb2 import Measurement as MeasurementProto +from nitypes.scalar import Scalar from ni.datastore.data.v2._grpc_conversion import ( duration_from_protobuf, @@ -150,7 +151,8 @@ def __init__( lower_limit (optional): The publisher-supplied lower limit. Any value type supported for value is accepted. value_type (optional): Output only. The service-generated value type. - units (optional): Engineering units of the value. + units (optional): Engineering units of the value. Defaults to the units of a + Scalar value. notes (optional): Additional notes about the measurement. start_date_time (optional): The start date and time of the measurement. end_date_time (optional): The end date and time of the measurement. @@ -163,9 +165,12 @@ def __init__( software_item_ids (optional): IDs of software items used for the measurement. hardware_item_ids (optional): IDs of hardware items used for the measurement. test_adapter_ids (optional): IDs of test adapters used for the measurement. - software_items (optional): Software items to find or create on create. - hardware_items (optional): Hardware items to find or create on create. - test_adapters (optional): Test adapters to find or create on create. + software_items (optional): Software items to find or create on create. Each + item must already be linked to the test result. + hardware_items (optional): Hardware items to find or create on create. Each + item must already be linked to the test result. + test_adapters (optional): Test adapters to find or create on create. Each + item must already be linked to the test result. comparison_type (optional): The publisher-defined comparison operation. conditions (optional): Output only. Conditions copied from the owning step. created_at (optional): Output only. When the service created the measurement. @@ -273,7 +278,7 @@ def to_protobuf(self) -> MeasurementProto: value=measurement_value_to_protobuf(self.value), upper_limit=measurement_value_to_protobuf(self.upper_limit), lower_limit=measurement_value_to_protobuf(self.lower_limit), - units=self.units, + units=self.units or (self.value.units if isinstance(self.value, Scalar) else ""), notes=self.notes, start_date_time=timestamp_to_protobuf(self.start_date_time), end_date_time=timestamp_to_protobuf(self.end_date_time), @@ -338,4 +343,11 @@ def _values_equal(left: object, right: object) -> bool: # Waveform equality returns arrays, so compare through the protobuf form. if left is None or right is None: return left is right - return measurement_value_to_protobuf(left) == measurement_value_to_protobuf(right) + return measurement_value_to_protobuf(_as_scalar(left)) == measurement_value_to_protobuf( + _as_scalar(right) + ) + + +def _as_scalar(value: object) -> object: + # A plain value is read back as a Scalar, so compare it as one. + return Scalar(value) if isinstance(value, (bool, int, float, str)) else value diff --git a/test.py b/test.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/unit/data/test_v2_data_store_client.py b/tests/unit/data/test_v2_data_store_client.py index e19c843a..40718564 100644 --- a/tests/unit/data/test_v2_data_store_client.py +++ b/tests/unit/data/test_v2_data_store_client.py @@ -42,6 +42,12 @@ def service_client(mocker: MockerFixture) -> Any: service_client.update_test_result.return_value = data_store_pb2.TestResult() service_client.update_step.return_value = data_store_pb2.Step() service_client.update_measurement.return_value = data_store_pb2.Measurement() + service_client.query_measurements.return_value = ( + data_store_service_pb2.QueryMeasurementsResponse() + ) + service_client.remove_test_result_metadata.return_value = ( + data_store_service_pb2.RemoveTestResultMetadataResponse() + ) mocker.patch( "ni.datastore.data.v2._data_store_client.DataStoreServiceClient", return_value=service_client, @@ -165,6 +171,26 @@ def test___create_measurement___converts_value_and_metadata( assert list(request.measurement.software_item_ids) == ["sw"] +def test___create_measurement___uses_scalar_units_when_units_not_given( + client: DataStoreClient, service_client: Any +) -> None: + service_client.create_measurement.return_value = data_store_pb2.Measurement(id="m-id") + + client.create_measurement("voltage", Scalar(5.2, "V"), "step-id") + + assert _sent(service_client.create_measurement).measurement.units == "V" + + +def test___create_measurement___keeps_given_units_for_scalar_value( + client: DataStoreClient, service_client: Any +) -> None: + service_client.create_measurement.return_value = data_store_pb2.Measurement(id="m-id") + + client.create_measurement("voltage", Scalar(5.2, "V"), "step-id", units="mV") + + assert _sent(service_client.create_measurement).measurement.units == "mV" + + def test___create_measurement___accepts_inline_metadata_objects( client: DataStoreClient, service_client: Any ) -> None: @@ -239,7 +265,19 @@ def test___read_measurement_value___requests_value_of_measurement_id( request = _sent(service_client.read_measurement_values) assert request.measurement_id == "m-id" assert list(request.read_mask.paths) == ["value"] - assert read_value is True + assert read_value == Scalar(True) + + +def test___read_measurement_value___returns_scalar_without_units( + client: DataStoreClient, service_client: Any +) -> None: + value = data_store_pb2.MeasurementValue() + value.scalar.double_value = 5.2 + _read_response(service_client, value) + + read_value = client.read_measurement_value(Measurement(id="m-id"), Scalar) + + assert (read_value.value, read_value.units) == (5.2, "") def test___read_measurement_value___preserves_scalar_units( @@ -625,6 +663,54 @@ def test___delete___defaults_to_no_force(client: DataStoreClient, service_client # ---------------------------------------------------------------- conditions and metadata links +def test___remove_test_result_metadata___checks_measurements_before_removing( + client: DataStoreClient, service_client: Any +) -> None: + service_client.query_measurements.return_value = ( + data_store_service_pb2.QueryMeasurementsResponse( + measurements=[data_store_pb2.Measurement(id="m-id", software_item_ids=["other"])] + ) + ) + + client.remove_test_result_metadata("tr-id", software_item_ids=["sw"]) + + query = _sent(service_client.query_measurements) + assert query.odata_query == "$filter=TestResultId eq tr-id" + assert list(query.read_mask.paths) == [ + "id", + "software_item_ids", + "hardware_item_ids", + "test_adapter_ids", + ] + assert list(_sent(service_client.remove_test_result_metadata).software_item_ids) == ["sw"] + + +@pytest.mark.parametrize("field", ["software_item_ids", "hardware_item_ids", "test_adapter_ids"]) +def test___remove_test_result_metadata___item_used_by_measurement___raises_without_removing( + client: DataStoreClient, service_client: Any, field: str +) -> None: + measurement = data_store_pb2.Measurement(id="m-id") + getattr(measurement, field).append("item") + service_client.query_measurements.return_value = ( + data_store_service_pb2.QueryMeasurementsResponse(measurements=[measurement]) + ) + item_ids: dict[str, Any] = {field: ["item"]} + + with pytest.raises(ValueError, match=r"item \(measurement m-id\)"): + client.remove_test_result_metadata("tr-id", **item_ids) + + service_client.remove_test_result_metadata.assert_not_called() + + +def test___remove_test_result_metadata___no_ids___skips_measurement_check( + client: DataStoreClient, service_client: Any +) -> None: + client.remove_test_result_metadata("tr-id") + + service_client.query_measurements.assert_not_called() + service_client.remove_test_result_metadata.assert_called_once() + + def test___remove_condition___sends_name_and_returns_step( client: DataStoreClient, service_client: Any ) -> None: @@ -749,8 +835,8 @@ def test___read_measurement_values___returns_value_and_limits( ) assert (result.id, result.value, result.upper_limit, result.lower_limit) == ( "m-id", - 5.0, - 5.5, + Scalar(5.0), + Scalar(5.5), None, ) diff --git a/tests/unit/data/test_v2_types.py b/tests/unit/data/test_v2_types.py index d1f89a2c..6ed99957 100644 --- a/tests/unit/data/test_v2_types.py +++ b/tests/unit/data/test_v2_types.py @@ -230,9 +230,15 @@ def test___measurement___round_trips_value_limits_and_metadata() -> None: assert proto.value.scalar.double_value == 5.0 assert proto.upper_limit.scalar.double_value == 5.5 + assert proto.units == "V" assert proto.schema_id == "schema" assert proto.software_items[0].product == "p" - assert Measurement.from_protobuf(proto) == measurement + round_tripped = Measurement.from_protobuf(proto) + assert round_tripped.units == "V" + assert round_tripped.value == Scalar(5.0, "V") + assert round_tripped.upper_limit == Scalar(5.5) + assert round_tripped.lower_limit == Vector([4.5, 4.6]) + assert round_tripped.software_items == measurement.software_items def test___measurement___moniker_or_missing_value_reads_as_none() -> None: @@ -278,6 +284,13 @@ def test___test_result___round_trips_metadata_objects_and_nested_steps() -> None assert TestResult.from_protobuf(proto) == test_result +def test___measurement___plain_value_equals_scalar_read_back() -> None: + assert Measurement(value=1.0, upper_limit=2) == Measurement( + value=Scalar(1.0), upper_limit=Scalar(2) + ) + assert Measurement(value=1.0) != Measurement(value=Scalar(1.0, "V")) + + def test___test_result___unset_metadata_objects_read_as_none() -> None: test_result = TestResult.from_protobuf(data_store_pb2.TestResult(name="n")) From 96241892ec0fd2456edb76e48dc2892e1f221b62 Mon Sep 17 00:00:00 2001 From: "Balusamy, Sudharshana" Date: Tue, 27 Oct 2026 14:34:53 +0530 Subject: [PATCH 05/13] modify update-delete --- docs/reference/ni-data-store-v2.md | 9 +-- .../v2/update-delete/update_delete.ipynb | 43 ++++++------- .../datastore/data/v2/_data_store_client.py | 62 ++++--------------- tests/unit/data/test_v2_data_store_client.py | 54 ---------------- 4 files changed, 36 insertions(+), 132 deletions(-) diff --git a/docs/reference/ni-data-store-v2.md b/docs/reference/ni-data-store-v2.md index 70ec0e25..8d58c6d5 100644 --- a/docs/reference/ni-data-store-v2.md +++ b/docs/reference/ni-data-store-v2.md @@ -59,10 +59,11 @@ The client wraps every method of the V2 low-level client: - `remove_condition` removes a condition from a step. - `add_test_result_metadata`, `remove_test_result_metadata`, `add_measurement_metadata`, and `remove_measurement_metadata` link or unlink - software items, hardware items, and test adapters after creation. As defined - by DEC-017, `remove_test_result_metadata` raises `ValueError`, without - removing anything, if a measurement of the test result still references an - item; unlink it from those measurements first. + software items, hardware items, and test adapters after creation. Linking an + item to a test result also links it to the test result's existing + measurements, and unlinking it from a test result also unlinks it from all of + its measurements. A measurement can only link items that are already linked to + its test result. - `create_measurement` accepts `upper_limit` and `lower_limit` of any supported value type. `read_measurement_values` returns the value and both limits. - `batch_create_*`, `batch_update_*`, and `batch_delete_*` handle many test diff --git a/examples/notebooks/v2/update-delete/update_delete.ipynb b/examples/notebooks/v2/update-delete/update_delete.ipynb index 14bea981..369f6e5b 100644 --- a/examples/notebooks/v2/update-delete/update_delete.ipynb +++ b/examples/notebooks/v2/update-delete/update_delete.ipynb @@ -207,9 +207,9 @@ "source": [ "## Link and Unlink Metadata\n", "\n", - "Link a software item to the test result first. Then a measurement of that test result can also link to it.\n", + "Linking an item to a test result also links it to the test result's existing measurements. A measurement can only link items that are already linked to its test result, and it can unlink an item on its own.\n", "\n", - "To unlink, reverse the order: an item cannot be unlinked from a test result while any of its measurements still references it (DEC-017). The service can also link a test result's item to its existing measurements, so find every measurement that references the item, unlink it from each one, and then unlink it from the test result." + "Unlinking an item from a test result also unlinks it from all of the test result's measurements." ] }, { @@ -219,40 +219,35 @@ "metadata": {}, "outputs": [], "source": [ + "def linked_measurement_count() -> int:\n", + " measurements = data_store_client.query_measurements(\n", + " f\"$filter=TestResultId eq {test_result_id}\", read_fields=[\"id\", \"software_item_ids\"]\n", + " )\n", + " return sum(software_item_id in measurement.software_item_ids for measurement in measurements)\n", + "\n", + "\n", "test_result = data_store_client.add_test_result_metadata(\n", " test_result_id, software_item_ids=[software_item_id]\n", ")\n", "print(f\"test result software items: {list(test_result.software_item_ids)}\")\n", + "print(f\"measurements linked to the item: {linked_measurement_count()}\")\n", "\n", - "measurement = data_store_client.add_measurement_metadata(\n", + "measurement = data_store_client.remove_measurement_metadata(\n", " measurement_ids[0], software_item_ids=[software_item_id]\n", ")\n", - "print(f\"measurement software items: {list(measurement.software_item_ids)}\")\n", - "\n", - "try:\n", - " data_store_client.remove_test_result_metadata(\n", - " test_result_id, software_item_ids=[software_item_id]\n", - " )\n", - "except ValueError as e:\n", - " print(f\"Not unlinked yet: {e}\")\n", + "print(f\"after unlinking one measurement: {linked_measurement_count()}\")\n", "\n", - "linked_measurements = [\n", - " measurement\n", - " for measurement in data_store_client.query_measurements(\n", - " f\"$filter=TestResultId eq {test_result_id}\", read_fields=[\"id\", \"software_item_ids\"]\n", - " )\n", - " if software_item_id in measurement.software_item_ids\n", - "]\n", - "for measurement in linked_measurements:\n", - " data_store_client.remove_measurement_metadata(\n", - " measurement.id, software_item_ids=[software_item_id]\n", - " )\n", + "measurement = data_store_client.add_measurement_metadata(\n", + " measurement_ids[0], software_item_ids=[software_item_id]\n", + ")\n", + "print(f\"after linking it again: {linked_measurement_count()}\")\n", "\n", "test_result = data_store_client.remove_test_result_metadata(\n", " test_result_id, software_item_ids=[software_item_id]\n", ")\n", - "print(f\"unlinked from {len(linked_measurements)} measurement(s) and from the test result\")\n", - "assert list(test_result.software_item_ids) == []" + "print(f\"after unlinking the test result: {linked_measurement_count()}\")\n", + "assert list(test_result.software_item_ids) == []\n", + "assert linked_measurement_count() == 0" ] }, { diff --git a/src/ni/datastore/data/v2/_data_store_client.py b/src/ni/datastore/data/v2/_data_store_client.py index 996b855f..6e65f0c1 100644 --- a/src/ni/datastore/data/v2/_data_store_client.py +++ b/src/ni/datastore/data/v2/_data_store_client.py @@ -692,14 +692,18 @@ def add_test_result_metadata( ) -> TestResult: """Link existing software items, hardware items, or test adapters to a test result. + The service also links each newly linked item to every existing + measurement of the test result. Items that are already linked are + ignored. + Args: test_result_id: The ID of the test result. - software_item_ids: The software item IDs to link. + software_item_ids: The software item IDs or aliases to link. - hardware_item_ids: The hardware item IDs to link. + hardware_item_ids: The hardware item IDs or aliases to link. - test_adapter_ids: The test adapter IDs to link. + test_adapter_ids: The test adapter IDs or aliases to link. Returns: TestResult: The updated test result. @@ -724,38 +728,21 @@ def remove_test_result_metadata( ) -> TestResult: """Unlink software items, hardware items, or test adapters from a test result. - As defined by DEC-017, an item cannot be unlinked while a measurement of - the test result still references it. Unlink it from those measurements - first with remove_measurement_metadata. The client checks this before - calling the service, so nothing is removed when the check fails. + The service also unlinks each item from every measurement of the test + result. Each item must currently be linked to the test result. Args: test_result_id: The ID of the test result. - software_item_ids: The software item IDs to unlink. + software_item_ids: The software item IDs or aliases to unlink. - hardware_item_ids: The hardware item IDs to unlink. + hardware_item_ids: The hardware item IDs or aliases to unlink. - test_adapter_ids: The test adapter IDs to unlink. + test_adapter_ids: The test adapter IDs or aliases to unlink. Returns: TestResult: The updated test result. - - Raises: - ValueError: If a measurement of the test result still references an - item to unlink. """ - software_item_ids = list(software_item_ids) - hardware_item_ids = list(hardware_item_ids) - test_adapter_ids = list(test_adapter_ids) - self._raise_if_used_by_measurements( - test_result_id, - { - "software_item_ids": set(software_item_ids), - "hardware_item_ids": set(hardware_item_ids), - "test_adapter_ids": set(test_adapter_ids), - }, - ) remove_request = RemoveTestResultMetadataRequest( test_result_id=test_result_id, software_item_ids=software_item_ids, @@ -931,31 +918,6 @@ def _get_data_store_client(self) -> DataStoreServiceClient: raise RuntimeError(self._DATA_STORE_CLIENT_CLOSED_ERROR) return self._data_store_client - def _raise_if_used_by_measurements( - self, test_result_id: str, removed_ids: Mapping[str, set[str]] - ) -> None: - if not any(removed_ids.values()): - return - query_request = QueryMeasurementsRequest( - odata_query=f"$filter=TestResultId eq {test_result_id}", - read_mask=FieldMask(paths=["id", *removed_ids]), - ) - measurements = self._get_data_store_client().query_measurements(query_request).measurements - in_use = sorted( - { - f"{item_id} (measurement {measurement.id})" - for measurement in measurements - for field, ids in removed_ids.items() - for item_id in ids.intersection(getattr(measurement, field)) - } - ) - if in_use: - raise ValueError( - f"Cannot unlink metadata from test result {test_result_id} while measurements " - f"still reference it: {', '.join(in_use)}. Unlink it from those measurements " - "first with remove_measurement_metadata." - ) - # Mutable update-mask paths from the V2 mutability design, including Stage 2 resource fields. _TEST_RESULT_UPDATE_FIELDS = frozenset( diff --git a/tests/unit/data/test_v2_data_store_client.py b/tests/unit/data/test_v2_data_store_client.py index 40718564..626b58c3 100644 --- a/tests/unit/data/test_v2_data_store_client.py +++ b/tests/unit/data/test_v2_data_store_client.py @@ -42,12 +42,6 @@ def service_client(mocker: MockerFixture) -> Any: service_client.update_test_result.return_value = data_store_pb2.TestResult() service_client.update_step.return_value = data_store_pb2.Step() service_client.update_measurement.return_value = data_store_pb2.Measurement() - service_client.query_measurements.return_value = ( - data_store_service_pb2.QueryMeasurementsResponse() - ) - service_client.remove_test_result_metadata.return_value = ( - data_store_service_pb2.RemoveTestResultMetadataResponse() - ) mocker.patch( "ni.datastore.data.v2._data_store_client.DataStoreServiceClient", return_value=service_client, @@ -663,54 +657,6 @@ def test___delete___defaults_to_no_force(client: DataStoreClient, service_client # ---------------------------------------------------------------- conditions and metadata links -def test___remove_test_result_metadata___checks_measurements_before_removing( - client: DataStoreClient, service_client: Any -) -> None: - service_client.query_measurements.return_value = ( - data_store_service_pb2.QueryMeasurementsResponse( - measurements=[data_store_pb2.Measurement(id="m-id", software_item_ids=["other"])] - ) - ) - - client.remove_test_result_metadata("tr-id", software_item_ids=["sw"]) - - query = _sent(service_client.query_measurements) - assert query.odata_query == "$filter=TestResultId eq tr-id" - assert list(query.read_mask.paths) == [ - "id", - "software_item_ids", - "hardware_item_ids", - "test_adapter_ids", - ] - assert list(_sent(service_client.remove_test_result_metadata).software_item_ids) == ["sw"] - - -@pytest.mark.parametrize("field", ["software_item_ids", "hardware_item_ids", "test_adapter_ids"]) -def test___remove_test_result_metadata___item_used_by_measurement___raises_without_removing( - client: DataStoreClient, service_client: Any, field: str -) -> None: - measurement = data_store_pb2.Measurement(id="m-id") - getattr(measurement, field).append("item") - service_client.query_measurements.return_value = ( - data_store_service_pb2.QueryMeasurementsResponse(measurements=[measurement]) - ) - item_ids: dict[str, Any] = {field: ["item"]} - - with pytest.raises(ValueError, match=r"item \(measurement m-id\)"): - client.remove_test_result_metadata("tr-id", **item_ids) - - service_client.remove_test_result_metadata.assert_not_called() - - -def test___remove_test_result_metadata___no_ids___skips_measurement_check( - client: DataStoreClient, service_client: Any -) -> None: - client.remove_test_result_metadata("tr-id") - - service_client.query_measurements.assert_not_called() - service_client.remove_test_result_metadata.assert_called_once() - - def test___remove_condition___sends_name_and_returns_step( client: DataStoreClient, service_client: Any ) -> None: From 8f3aa8a999962403164982c6e583082719eb7434 Mon Sep 17 00:00:00 2001 From: "Balusamy, Sudharshana" Date: Tue, 27 Oct 2026 16:59:08 +0530 Subject: [PATCH 06/13] resolve copilot comment --- docs/reference/ni-data-store-v2.md | 9 +- examples/notebooks/poetry.lock | 65 ++++++++-- .../v2/update-delete/update_delete.ipynb | 43 ++++--- .../datastore/data/v2/_data_store_client.py | 65 ++++++++-- tests/unit/data/test_v2_data_store_client.py | 79 +++++++++++- tests/unit/data/test_v2_grpc_conversion.py | 117 ++++++++++++++++++ 6 files changed, 330 insertions(+), 48 deletions(-) create mode 100644 tests/unit/data/test_v2_grpc_conversion.py diff --git a/docs/reference/ni-data-store-v2.md b/docs/reference/ni-data-store-v2.md index 8d58c6d5..70ec0e25 100644 --- a/docs/reference/ni-data-store-v2.md +++ b/docs/reference/ni-data-store-v2.md @@ -59,11 +59,10 @@ The client wraps every method of the V2 low-level client: - `remove_condition` removes a condition from a step. - `add_test_result_metadata`, `remove_test_result_metadata`, `add_measurement_metadata`, and `remove_measurement_metadata` link or unlink - software items, hardware items, and test adapters after creation. Linking an - item to a test result also links it to the test result's existing - measurements, and unlinking it from a test result also unlinks it from all of - its measurements. A measurement can only link items that are already linked to - its test result. + software items, hardware items, and test adapters after creation. As defined + by DEC-017, `remove_test_result_metadata` raises `ValueError`, without + removing anything, if a measurement of the test result still references an + item; unlink it from those measurements first. - `create_measurement` accepts `upper_limit` and `lower_limit` of any supported value type. `read_measurement_values` returns the value and both limits. - `batch_create_*`, `batch_update_*`, and `batch_delete_*` handle many test diff --git a/examples/notebooks/poetry.lock b/examples/notebooks/poetry.lock index 16bedb3a..5881f68d 100644 --- a/examples/notebooks/poetry.lock +++ b/examples/notebooks/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.5.1 and should not be changed by hand. [[package]] name = "anyio" @@ -885,7 +885,7 @@ comm = ">=0.1.1" debugpy = ">=1.6.5" ipython = ">=7.23.1" jupyter-client = ">=8.9.0" -jupyter-core = ">=5.1,<6.0.dev0 || >=6.1.dev0" +jupyter-core = ">=5.1,<6.0 || >=6.1.dev0" matplotlib-inline = ">=0.1" nest-asyncio2 = ">=1.7.0" packaging = ">=22" @@ -920,7 +920,7 @@ comm = ">=0.1.1" debugpy = ">=1.6.5" ipython = ">=7.23.1" jupyter-client = ">=8.9.0" -jupyter-core = ">=5.1,<6.0.dev0 || >=6.1.dev0" +jupyter-core = ">=5.1,<6.0 || >=6.1.dev0" matplotlib-inline = ">=0.1" nest-asyncio2 = ">=1.7.0" packaging = ">=22" @@ -1243,7 +1243,7 @@ files = [ ipykernel = ">=6.14" ipython = "*" jupyter-client = ">=7.0.0" -jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +jupyter-core = ">=4.12,<5.0 || >=5.1.dev0" prompt-toolkit = ">=3.0.30" pygments = "*" pyzmq = ">=17" @@ -1331,7 +1331,7 @@ anyio = ">=3.1.0" argon2-cffi = ">=21.1" jinja2 = ">=3.0.3" jupyter-client = ">=7.4.4" -jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +jupyter-core = ">=4.12,<5.0 || >=5.1.dev0" jupyter-events = ">=0.11.0" jupyter-server-terminals = ">=0.4.4" nbconvert = ">=6.4.4" @@ -1339,7 +1339,7 @@ nbformat = ">=5.3.0" overrides = {version = ">=5.0", markers = "python_version < \"3.12\""} packaging = ">=22.0" prometheus-client = ">=0.9" -pywinpty = {version = ">=2.0.1,<3.0.4 || >3.0.4", markers = "os_name == \"nt\""} +pywinpty = {version = ">=2.0.1,!=3.0.4", markers = "os_name == \"nt\""} pyzmq = ">=24" send2trash = ">=1.8.2" terminado = ">=0.8.3" @@ -1386,7 +1386,7 @@ files = [ [package.dependencies] async-lru = ">=1.0.0" httpx = ">=0.25.0,<1" -ipykernel = ">=6.5.0,<6.30.0 || >6.30.0" +ipykernel = ">=6.5.0,!=6.30.0" jinja2 = ">=3.0.3" jupyter-builder = ">=1.0.2" jupyter-core = "*" @@ -1769,7 +1769,7 @@ files = [ [package.dependencies] fastjsonschema = ">=2.15" jsonschema = ">=2.6" -jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0" +jupyter-core = ">=4.12,<5.0 || >=5.1.dev0" traitlets = ">=5.1" [package.extras] @@ -1816,6 +1816,8 @@ develop = true [package.dependencies] hightime = ">=1.0.0" ni-measurements-data-v1-client = ">=1.2.0.dev0" +ni-measurements-data-v2-client = {git = "https://github.com/ni/ni-apis-python.git", branch = "users/sudharshana/add-v2-proto-and-client-package", subdirectory = "packages/ni.measurements.data.v2.client"} +ni-measurements-data-v2-proto = {git = "https://github.com/ni/ni-apis-python.git", branch = "users/sudharshana/add-v2-proto-and-client-package", subdirectory = "packages/ni.measurements.data.v2.proto"} ni-measurements-metadata-v1-client = ">=1.0.0" ni-protobuf-types = ">=1.3.0.dev0" protobuf = ">=4.21" @@ -1907,6 +1909,53 @@ ni-measurements-metadata-v1-proto = ">=1.0.0" ni-protobuf-types = ">=1.3.0" protobuf = ">=4.21" +[[package]] +name = "ni-measurements-data-v2-client" +version = "1.0.0.dev0" +description = "gRPC Client for NI Data Store Service V2" +optional = false +python-versions = ">=3.10,<4.0" +groups = ["main"] +files = [] +develop = false + +[package.dependencies] +grpcio = ">=1.49.1,<2.0" +ni-grpc-extensions = ">=1.1.0" +ni-measurementlink-discovery-v1-client = ">=1.1.0" +ni-measurements-data-v2-proto = ">=1.0.0.dev0" + +[package.source] +type = "git" +url = "https://github.com/ni/ni-apis-python.git" +reference = "users/sudharshana/add-v2-proto-and-client-package" +resolved_reference = "28aee05fe1ce46d2d349311cd600bf0a47937d1d" +subdirectory = "packages/ni.measurements.data.v2.client" + +[[package]] +name = "ni-measurements-data-v2-proto" +version = "1.0.0.dev0" +description = "Protobuf data types and service stubs for NI data store V2 gRPC APIs" +optional = false +python-versions = ">=3.10,<4.0" +groups = ["main"] +files = [] +develop = false + +[package.dependencies] +grpcio = ">=1.49.1" +ni-datamonikers-v1-proto = ">=1.0.0" +ni-measurements-metadata-v1-proto = ">=1.0.0" +ni-protobuf-types = ">=1.3.0" +protobuf = ">=4.21" + +[package.source] +type = "git" +url = "https://github.com/ni/ni-apis-python.git" +reference = "users/sudharshana/add-v2-proto-and-client-package" +resolved_reference = "28aee05fe1ce46d2d349311cd600bf0a47937d1d" +subdirectory = "packages/ni.measurements.data.v2.proto" + [[package]] name = "ni-measurements-metadata-v1-client" version = "1.0.0" diff --git a/examples/notebooks/v2/update-delete/update_delete.ipynb b/examples/notebooks/v2/update-delete/update_delete.ipynb index 369f6e5b..14bea981 100644 --- a/examples/notebooks/v2/update-delete/update_delete.ipynb +++ b/examples/notebooks/v2/update-delete/update_delete.ipynb @@ -207,9 +207,9 @@ "source": [ "## Link and Unlink Metadata\n", "\n", - "Linking an item to a test result also links it to the test result's existing measurements. A measurement can only link items that are already linked to its test result, and it can unlink an item on its own.\n", + "Link a software item to the test result first. Then a measurement of that test result can also link to it.\n", "\n", - "Unlinking an item from a test result also unlinks it from all of the test result's measurements." + "To unlink, reverse the order: an item cannot be unlinked from a test result while any of its measurements still references it (DEC-017). The service can also link a test result's item to its existing measurements, so find every measurement that references the item, unlink it from each one, and then unlink it from the test result." ] }, { @@ -219,35 +219,40 @@ "metadata": {}, "outputs": [], "source": [ - "def linked_measurement_count() -> int:\n", - " measurements = data_store_client.query_measurements(\n", - " f\"$filter=TestResultId eq {test_result_id}\", read_fields=[\"id\", \"software_item_ids\"]\n", - " )\n", - " return sum(software_item_id in measurement.software_item_ids for measurement in measurements)\n", - "\n", - "\n", "test_result = data_store_client.add_test_result_metadata(\n", " test_result_id, software_item_ids=[software_item_id]\n", ")\n", "print(f\"test result software items: {list(test_result.software_item_ids)}\")\n", - "print(f\"measurements linked to the item: {linked_measurement_count()}\")\n", - "\n", - "measurement = data_store_client.remove_measurement_metadata(\n", - " measurement_ids[0], software_item_ids=[software_item_id]\n", - ")\n", - "print(f\"after unlinking one measurement: {linked_measurement_count()}\")\n", "\n", "measurement = data_store_client.add_measurement_metadata(\n", " measurement_ids[0], software_item_ids=[software_item_id]\n", ")\n", - "print(f\"after linking it again: {linked_measurement_count()}\")\n", + "print(f\"measurement software items: {list(measurement.software_item_ids)}\")\n", + "\n", + "try:\n", + " data_store_client.remove_test_result_metadata(\n", + " test_result_id, software_item_ids=[software_item_id]\n", + " )\n", + "except ValueError as e:\n", + " print(f\"Not unlinked yet: {e}\")\n", + "\n", + "linked_measurements = [\n", + " measurement\n", + " for measurement in data_store_client.query_measurements(\n", + " f\"$filter=TestResultId eq {test_result_id}\", read_fields=[\"id\", \"software_item_ids\"]\n", + " )\n", + " if software_item_id in measurement.software_item_ids\n", + "]\n", + "for measurement in linked_measurements:\n", + " data_store_client.remove_measurement_metadata(\n", + " measurement.id, software_item_ids=[software_item_id]\n", + " )\n", "\n", "test_result = data_store_client.remove_test_result_metadata(\n", " test_result_id, software_item_ids=[software_item_id]\n", ")\n", - "print(f\"after unlinking the test result: {linked_measurement_count()}\")\n", - "assert list(test_result.software_item_ids) == []\n", - "assert linked_measurement_count() == 0" + "print(f\"unlinked from {len(linked_measurements)} measurement(s) and from the test result\")\n", + "assert list(test_result.software_item_ids) == []" ] }, { diff --git a/src/ni/datastore/data/v2/_data_store_client.py b/src/ni/datastore/data/v2/_data_store_client.py index 6e65f0c1..bbfec1eb 100644 --- a/src/ni/datastore/data/v2/_data_store_client.py +++ b/src/ni/datastore/data/v2/_data_store_client.py @@ -692,18 +692,14 @@ def add_test_result_metadata( ) -> TestResult: """Link existing software items, hardware items, or test adapters to a test result. - The service also links each newly linked item to every existing - measurement of the test result. Items that are already linked are - ignored. - Args: test_result_id: The ID of the test result. - software_item_ids: The software item IDs or aliases to link. + software_item_ids: The software item IDs to link. - hardware_item_ids: The hardware item IDs or aliases to link. + hardware_item_ids: The hardware item IDs to link. - test_adapter_ids: The test adapter IDs or aliases to link. + test_adapter_ids: The test adapter IDs to link. Returns: TestResult: The updated test result. @@ -728,21 +724,38 @@ def remove_test_result_metadata( ) -> TestResult: """Unlink software items, hardware items, or test adapters from a test result. - The service also unlinks each item from every measurement of the test - result. Each item must currently be linked to the test result. + As defined by DEC-017, an item cannot be unlinked while a measurement of + the test result still references it. Unlink it from those measurements + first with remove_measurement_metadata. The client checks this before + calling the service, so nothing is removed when the check fails. Args: test_result_id: The ID of the test result. - software_item_ids: The software item IDs or aliases to unlink. + software_item_ids: The software item IDs to unlink. - hardware_item_ids: The hardware item IDs or aliases to unlink. + hardware_item_ids: The hardware item IDs to unlink. - test_adapter_ids: The test adapter IDs or aliases to unlink. + test_adapter_ids: The test adapter IDs to unlink. Returns: TestResult: The updated test result. + + Raises: + ValueError: If a measurement of the test result still references an + item to unlink. """ + software_item_ids = list(software_item_ids) + hardware_item_ids = list(hardware_item_ids) + test_adapter_ids = list(test_adapter_ids) + self._raise_if_used_by_measurements( + test_result_id, + { + "software_item_ids": set(software_item_ids), + "hardware_item_ids": set(hardware_item_ids), + "test_adapter_ids": set(test_adapter_ids), + }, + ) remove_request = RemoveTestResultMetadataRequest( test_result_id=test_result_id, software_item_ids=software_item_ids, @@ -918,6 +931,31 @@ def _get_data_store_client(self) -> DataStoreServiceClient: raise RuntimeError(self._DATA_STORE_CLIENT_CLOSED_ERROR) return self._data_store_client + def _raise_if_used_by_measurements( + self, test_result_id: str, removed_ids: Mapping[str, set[str]] + ) -> None: + if not any(removed_ids.values()): + return + query_request = QueryMeasurementsRequest( + odata_query=f"$filter=TestResultId eq {test_result_id}", + read_mask=FieldMask(paths=["id", *removed_ids]), + ) + measurements = self._get_data_store_client().query_measurements(query_request).measurements + in_use = sorted( + { + f"{item_id} (measurement {measurement.id})" + for measurement in measurements + for field, ids in removed_ids.items() + for item_id in ids.intersection(getattr(measurement, field)) + } + ) + if in_use: + raise ValueError( + f"Cannot unlink metadata from test result {test_result_id} while measurements " + f"still reference it: {', '.join(in_use)}. Unlink it from those measurements " + "first with remove_measurement_metadata." + ) + # Mutable update-mask paths from the V2 mutability design, including Stage 2 resource fields. _TEST_RESULT_UPDATE_FIELDS = frozenset( @@ -928,6 +966,7 @@ def _get_data_store_client(self) -> DataStoreServiceClient: "test_station_id", "test_description_id", "uut_instance", + "uut", "operator", "test_station", "test_description", @@ -973,7 +1012,7 @@ def _get_data_store_client(self) -> DataStoreServiceClient: # Update fields that take a metadata model, such as Operator, and are sent as its protobuf. _METADATA_UPDATE_FIELDS = frozenset( - {"uut_instance", "operator", "test_station", "test_description", "test"} + {"uut_instance", "uut", "operator", "test_station", "test_description", "test"} ) diff --git a/tests/unit/data/test_v2_data_store_client.py b/tests/unit/data/test_v2_data_store_client.py index 626b58c3..50fe8a32 100644 --- a/tests/unit/data/test_v2_data_store_client.py +++ b/tests/unit/data/test_v2_data_store_client.py @@ -42,6 +42,12 @@ def service_client(mocker: MockerFixture) -> Any: service_client.update_test_result.return_value = data_store_pb2.TestResult() service_client.update_step.return_value = data_store_pb2.Step() service_client.update_measurement.return_value = data_store_pb2.Measurement() + service_client.query_measurements.return_value = ( + data_store_service_pb2.QueryMeasurementsResponse() + ) + service_client.remove_test_result_metadata.return_value = ( + data_store_service_pb2.RemoveTestResultMetadataResponse() + ) mocker.patch( "ni.datastore.data.v2._data_store_client.DataStoreServiceClient", return_value=service_client, @@ -657,6 +663,54 @@ def test___delete___defaults_to_no_force(client: DataStoreClient, service_client # ---------------------------------------------------------------- conditions and metadata links +def test___remove_test_result_metadata___checks_measurements_before_removing( + client: DataStoreClient, service_client: Any +) -> None: + service_client.query_measurements.return_value = ( + data_store_service_pb2.QueryMeasurementsResponse( + measurements=[data_store_pb2.Measurement(id="m-id", software_item_ids=["other"])] + ) + ) + + client.remove_test_result_metadata("tr-id", software_item_ids=["sw"]) + + query = _sent(service_client.query_measurements) + assert query.odata_query == "$filter=TestResultId eq tr-id" + assert list(query.read_mask.paths) == [ + "id", + "software_item_ids", + "hardware_item_ids", + "test_adapter_ids", + ] + assert list(_sent(service_client.remove_test_result_metadata).software_item_ids) == ["sw"] + + +@pytest.mark.parametrize("field", ["software_item_ids", "hardware_item_ids", "test_adapter_ids"]) +def test___remove_test_result_metadata___item_used_by_measurement___raises_without_removing( + client: DataStoreClient, service_client: Any, field: str +) -> None: + measurement = data_store_pb2.Measurement(id="m-id") + getattr(measurement, field).append("item") + service_client.query_measurements.return_value = ( + data_store_service_pb2.QueryMeasurementsResponse(measurements=[measurement]) + ) + item_ids: dict[str, Any] = {field: ["item"]} + + with pytest.raises(ValueError, match=r"item \(measurement m-id\)"): + client.remove_test_result_metadata("tr-id", **item_ids) + + service_client.remove_test_result_metadata.assert_not_called() + + +def test___remove_test_result_metadata___no_ids___skips_measurement_check( + client: DataStoreClient, service_client: Any +) -> None: + client.remove_test_result_metadata("tr-id") + + service_client.query_measurements.assert_not_called() + service_client.remove_test_result_metadata.assert_called_once() + + def test___remove_condition___sends_name_and_returns_step( client: DataStoreClient, service_client: Any ) -> None: @@ -925,13 +979,32 @@ def test___batch_update___rejects_empty_or_invalid_before_rpc( def test___update_test_result___accepts_metadata_models( client: DataStoreClient, service_client: Any ) -> None: - from ni.datastore.metadata import Operator + from ni.datastore.metadata import Operator, Uut - client.update_test_result("tr-id", operator=Operator(name="Ada", role="Engineer")) + client.update_test_result( + "tr-id", operator=Operator(name="Ada", role="Engineer"), uut=Uut(model_name="model") + ) sent = _sent(service_client.update_test_result) assert sent.test_result.operator.name == "Ada" - assert list(sent.update_mask.paths) == ["operator"] + assert sent.test_result.uut.model_name == "model" + assert list(sent.update_mask.paths) == ["operator", "uut"] + + +def test___batch_update_test_results___accepts_inline_uut( + client: DataStoreClient, service_client: Any +) -> None: + from ni.datastore.metadata import Uut + + service_client.batch_update_test_results.return_value = ( + data_store_service_pb2.BatchUpdateTestResultsResponse() + ) + + client.batch_update_test_results({"tr-id": {"uut": Uut(model_name="model")}}) + + request = _sent(service_client.batch_update_test_results).requests[0] + assert request.test_result.uut.model_name == "model" + assert list(request.update_mask.paths) == ["uut"] # ---------------------------------------------------------------- batch delete diff --git a/tests/unit/data/test_v2_grpc_conversion.py b/tests/unit/data/test_v2_grpc_conversion.py new file mode 100644 index 00000000..c289b05d --- /dev/null +++ b/tests/unit/data/test_v2_grpc_conversion.py @@ -0,0 +1,117 @@ +"""Tests for V2 measurement value conversion.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +import pytest +from ni.measurements.data.v2 import data_store_pb2 +from nitypes.complex import ComplexInt32DType +from nitypes.waveform import AnalogWaveform, ComplexWaveform, DigitalWaveform, Spectrum +from nitypes.xy_data import XYData +from pytest_mock import MockerFixture + +from ni.datastore.data.v2 import DataStoreClient +from ni.datastore.data.v2._grpc_conversion import ( + convert_measurement_value_from_protobuf, + measurement_value_to_protobuf, +) + + +def _data(value: Any) -> object: + if isinstance(value, (AnalogWaveform, ComplexWaveform)): + return value.raw_data.tolist() + if isinstance(value, Spectrum): + return value.data.tolist() + if isinstance(value, DigitalWaveform): + return value.data.tolist() + return [value.x_data.tolist(), value.y_data.tolist()] + + +@pytest.mark.parametrize( + ("value", "oneof_field"), + [ + ( + AnalogWaveform.from_array_1d(np.array([1.0, 2.0], np.float64), dtype=np.float64), + "double_analog_waveform", + ), + ( + AnalogWaveform.from_array_1d(np.array([1.0, 2.0], np.float32), dtype=np.float32), + "float_analog_waveform", + ), + ( + AnalogWaveform.from_array_1d(np.array([1, 2], np.int16), dtype=np.int16), + "i16_analog_waveform", + ), + ( + ComplexWaveform.from_array_1d(np.array([1 + 2j], np.complex128), dtype=np.complex128), + "double_complex_waveform", + ), + ( + ComplexWaveform.from_array_1d(np.array([1 + 2j], np.complex64), dtype=np.complex64), + "float_complex_waveform", + ), + ( + ComplexWaveform.from_array_1d( + np.array([(1, 2), (3, 4)], ComplexInt32DType), dtype=ComplexInt32DType + ), + "i16_complex_waveform", + ), + ( + Spectrum.from_array_1d(np.array([1.0, 2.0], np.float64), dtype=np.float64), + "double_spectrum", + ), + ( + Spectrum.from_array_1d(np.array([1.0, 2.0], np.float32), dtype=np.float32), + "float_spectrum", + ), + (DigitalWaveform.from_lines([[0, 1], [1, 0]], signal_count=2), "digital_waveform"), + (XYData.from_arrays_1d([1.0, 2.0], [3.0, 4.0], np.float64), "x_y_data"), + ], +) +def test___waveform_family_value___round_trips_with_matching_type_and_dtype( + value: Any, oneof_field: str +) -> None: + proto = measurement_value_to_protobuf(value) + + assert proto is not None + assert proto.WhichOneof("value") == oneof_field + read_back: Any = convert_measurement_value_from_protobuf(proto) + assert type(read_back) is type(value) + assert read_back.dtype == value.dtype + assert _data(read_back) == _data(value) + + +@pytest.mark.parametrize( + "value", + [ + AnalogWaveform.from_array_1d(np.array([1, 2], np.int32), dtype=np.int32), + Spectrum.from_array_1d(np.array([1, 2], np.int32), dtype=np.int32), + XYData.from_arrays_1d([1.0, 2.0], [3.0, 4.0], np.float32), + ], +) +def test___unsupported_dtype___raises_type_error(value: Any) -> None: + with pytest.raises(TypeError, match="Unsupported"): + measurement_value_to_protobuf(value) + + +def test___create_measurement_with_unsupported_dtype___raises_before_rpc( + mocker: MockerFixture, +) -> None: + service_client = mocker.Mock() + mocker.patch( + "ni.datastore.data.v2._data_store_client.DataStoreServiceClient", + return_value=service_client, + ) + value = AnalogWaveform.from_array_1d(np.array([1, 2], np.int32), dtype=np.int32) + + with pytest.raises(TypeError): + DataStoreClient().create_measurement("voltage", value, "step-id") + + service_client.create_measurement.assert_not_called() + + +def test___missing_value___raises_type_error() -> None: + with pytest.raises(TypeError): + convert_measurement_value_from_protobuf(data_store_pb2.MeasurementValue()) From 03a4b415fb40f82a0a9b1e8457d024ed9f791f34 Mon Sep 17 00:00:00 2001 From: "Balusamy, Sudharshana" Date: Tue, 27 Oct 2026 16:59:43 +0530 Subject: [PATCH 07/13] fix pipeline --- .github/workflows/check_examples.yml | 2 + CONTRIBUTING.md | 4 + poetry.lock | 666 +-------------------------- 3 files changed, 7 insertions(+), 665 deletions(-) diff --git a/.github/workflows/check_examples.yml b/.github/workflows/check_examples.yml index 857b5330..ae368308 100644 --- a/.github/workflows/check_examples.yml +++ b/.github/workflows/check_examples.yml @@ -28,6 +28,8 @@ jobs: python-version: ${{ matrix.python-version }} - name: Set up Poetry uses: ni/python-actions/setup-poetry@dee640bba235ae28fdc6b7337c643bd06358ae90 # v0.9.0 + with: + poetry-version: '2.5.1' - name: Analyze Python Project uses: ni/python-actions/analyze-project@dee640bba235ae28fdc6b7337c643bd06358ae90 # v0.9.0 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0a1ab17b..dfa9daf8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,7 @@ See [GitHub's official documentation](https://help.github.com/articles/using-pul - Install [Git](https://git-scm.com) - Install [Python](https://www.python.org/downloads/), any version from the [README](README.md) - Install [Poetry](https://python-poetry.org/docs/#installation), version >= 2.1.4 + (the Python 3.14 example analyzer jobs use Poetry 2.5.1). ## Install the package and its dependencies @@ -42,6 +43,9 @@ poetry install poetry install --with docs ``` +After changing dependencies or merging dependency changes, run `poetry lock` and +`poetry check --lock` from the repository root before running validation. + ## Activate the virtual environment (if needed) - _(Recommended)_ Activate for each command by prefixing the call with `poetry run {command}` diff --git a/poetry.lock b/poetry.lock index 79e6378e..4cff025f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -744,167 +744,6 @@ files = [ {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] -[[package]] -name = "ipykernel" -version = "7.3.0" -description = "IPython Kernel for Jupyter" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "ipykernel-7.3.0-py3-none-any.whl", hash = "sha256:897eb64da762549ef610698fca5e9675195ec6ac8ec7f19d81ce1ca20c876057"}, - {file = "ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09"}, -] - -[package.dependencies] -appnope = {version = ">=0.1.2", markers = "platform_system == \"Darwin\""} -comm = ">=0.1.1" -debugpy = ">=1.6.5" -ipython = ">=7.23.1" -jupyter-client = ">=8.9.0" -jupyter-core = ">=5.1,<6.0 || >=6.1.dev0" -matplotlib-inline = ">=0.1" -nest-asyncio2 = ">=1.7.0" -packaging = ">=22" -psutil = ">=5.7" -pyzmq = ">=25" -tornado = ">=6.4.1" -traitlets = ">=5.4.0" - -[package.extras] -cov = ["coverage[toml]", "matplotlib", "pytest-cov", "trio"] -docs = ["intersphinx-registry", "myst-parser", "pydata-sphinx-theme", "sphinx", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling", "trio"] -pyqt5 = ["pyqt5"] -pyside6 = ["pyside6"] -test = ["flaky", "ipyparallel", "pre-commit", "pytest (>=7.0,<10)", "pytest-asyncio (>=0.23.5)", "pytest-cov", "pytest-timeout"] - -[[package]] -name = "ipython" -version = "8.39.0" -description = "IPython: Productive Interactive Computing" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f"}, - {file = "ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -decorator = "*" -exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} -jedi = ">=0.16" -matplotlib-inline = "*" -pexpect = {version = ">4.3", markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""} -prompt_toolkit = ">=3.0.41,<3.1.0" -pygments = ">=2.4.0" -stack_data = "*" -traitlets = ">=5.13.0" -typing_extensions = {version = ">=4.6", markers = "python_version < \"3.12\""} - -[package.extras] -all = ["ipython[black,doc,kernel,matplotlib,nbconvert,nbformat,notebook,parallel,qtconsole]", "ipython[test,test-extra]"] -black = ["black"] -doc = ["docrepr", "exceptiongroup", "intersphinx_registry", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinxcontrib-jquery", "tomli ; python_version < \"3.11\"", "typing_extensions"] -kernel = ["ipykernel"] -matplotlib = ["matplotlib"] -nbconvert = ["nbconvert"] -nbformat = ["nbformat"] -notebook = ["ipywidgets", "notebook"] -parallel = ["ipyparallel"] -qtconsole = ["qtconsole"] -test = ["packaging", "pickleshare", "pytest", "pytest-asyncio (<0.22)", "testpath"] -test-extra = ["curio", "ipython[test]", "jupyter_ai", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.23)", "pandas", "trio"] - -[[package]] -name = "ipython" -version = "9.17.1" -description = "IPython: Productive Interactive Computing" -optional = false -python-versions = ">=3.11" -groups = ["dev"] -markers = "python_version >= \"3.11\"" -files = [ - {file = "ipython-9.17.1-py3-none-any.whl", hash = "sha256:6d1645743cfd1a07eb695d85aa2b5fa66721f8cbae9431d4049f7084bbf06509"}, - {file = "ipython-9.17.1.tar.gz", hash = "sha256:8919be8c27f20a6f4423145028063f6637b42a03ce57665bb12015ee1f073529"}, -] - -[package.dependencies] -colorama = {version = ">=0.4.4", markers = "sys_platform == \"win32\""} -ipython-pygments-lexers = ">=1.0.0" -jedi = ">=0.18.2" -matplotlib-inline = ">=0.1.6" -pexpect = {version = ">4.6", markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""} -prompt_toolkit = ">=3.0.41,<3.1.0" -psutil = {version = ">=7", markers = "sys_platform != \"emscripten\" and sys_platform != \"cygwin\""} -pygments = ">=2.14.0" -stack_data = ">=0.6.0" -traitlets = ">=5.13.0" -typing_extensions = {version = ">=4.6", markers = "python_version < \"3.12\""} - -[package.extras] -all = ["argcomplete (>=3.0)", "ipython[doc,matplotlib,test,test-extra]"] -black = ["black"] -doc = ["docrepr", "exceptiongroup", "intersphinx_registry", "ipykernel", "ipython[matplotlib,test]", "setuptools (>=80.0)", "sphinx (>=8.0)", "sphinx-rtd-theme (>=0.1.8)", "sphinx_toml (==0.0.4)", "typing_extensions"] -matplotlib = ["matplotlib (>3.9)"] -test = ["packaging (>=23.0.0)", "pytest (>=7.0.0)", "pytest-asyncio (>=1.0.0)", "setuptools (>=80.0)", "testpath (>=0.2)"] -test-extra = ["curio", "ipykernel (>6.30)", "ipython[matplotlib]", "ipython[test]", "jupyter_ai", "nbclient", "nbformat", "numpy (>=2.0)", "pandas (>2.1)", "trio (>=0.22.0)"] - -[[package]] -name = "ipython-pygments-lexers" -version = "1.1.1" -description = "Defines a variety of Pygments lexers for highlighting IPython code." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version >= \"3.11\"" -files = [ - {file = "ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c"}, - {file = "ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81"}, -] - -[package.dependencies] -pygments = "*" - -[[package]] -name = "ipywidgets" -version = "8.1.8" -description = "Jupyter interactive widgets" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "ipywidgets-8.1.8-py3-none-any.whl", hash = "sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e"}, - {file = "ipywidgets-8.1.8.tar.gz", hash = "sha256:61f969306b95f85fba6b6986b7fe45d73124d1d9e3023a8068710d47a22ea668"}, -] - -[package.dependencies] -comm = ">=0.1.3" -ipython = ">=6.1.0" -jupyterlab_widgets = ">=3.0.15,<3.1.0" -traitlets = ">=4.3.1" -widgetsnbextension = ">=4.0.14,<4.1.0" - -[package.extras] -test = ["ipykernel", "jsonschema", "pytest (>=3.6.0)", "pytest-cov", "pytz"] - -[[package]] -name = "isoduration" -version = "20.11.0" -description = "Operations with ISO 8601 durations" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042"}, - {file = "isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9"}, -] - -[package.dependencies] -arrow = ">=0.15.0" - [[package]] name = "isort" version = "8.0.1" @@ -938,389 +777,6 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] -[[package]] -name = "json5" -version = "0.15.0" -description = "A Python implementation of the JSON5 data format." -optional = false -python-versions = ">=3.8.0" -groups = ["dev"] -files = [ - {file = "json5-0.15.0-py3-none-any.whl", hash = "sha256:56636a30c0e8a4665fe2179c0212f32eae3796dea89ea6f649b9436ecdb39618"}, - {file = "json5-0.15.0.tar.gz", hash = "sha256:7424d1f1eb1d56da6e3d70643f53619862b4ce81440bdb8ecfd6f875e5ba4a71"}, -] - -[[package]] -name = "jsonpointer" -version = "3.1.1" -description = "Identify specific nodes in a JSON document (RFC 6901) " -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca"}, - {file = "jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900"}, -] - -[[package]] -name = "jsonschema" -version = "4.26.0" -description = "An implementation of JSON Schema validation for Python" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"}, - {file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"}, -] - -[package.dependencies] -attrs = ">=22.2.0" -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" -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\""} -rfc3987-syntax = {version = ">=1.1.0", optional = true, markers = "extra == \"format-nongpl\""} -rpds-py = ">=0.25.0" -uri-template = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} -webcolors = {version = ">=24.6.0", optional = true, markers = "extra == \"format-nongpl\""} - -[package.extras] -format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] -format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"}, - {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"}, -] - -[package.dependencies] -referencing = ">=0.31.0" - -[[package]] -name = "jupyter" -version = "1.1.1" -description = "Jupyter metapackage. Install all the Jupyter components in one go." -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "jupyter-1.1.1-py2.py3-none-any.whl", hash = "sha256:7a59533c22af65439b24bbe60373a4e95af8f16ac65a6c00820ad378e3f7cc83"}, - {file = "jupyter-1.1.1.tar.gz", hash = "sha256:d55467bceabdea49d7e3624af7e33d59c37fff53ed3a350e1ac957bed731de7a"}, -] - -[package.dependencies] -ipykernel = "*" -ipywidgets = "*" -jupyter-console = "*" -jupyterlab = "*" -nbconvert = "*" -notebook = "*" - -[[package]] -name = "jupyter-builder" -version = "1.1.1" -description = "JupyterLab build tools" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "jupyter_builder-1.1.1-py3-none-any.whl", hash = "sha256:f9c14bc55c0488a073f62af12d468936fcf9ecb7e9dd802f6f9c33de46ad70db"}, - {file = "jupyter_builder-1.1.1.tar.gz", hash = "sha256:1a13977912b08deda77fce2c803940131c27cf77a27ed64b9ffca25aa0ed7e6c"}, -] - -[package.dependencies] -jupyter-core = "*" -tomli = {version = "*", markers = "python_version < \"3.11\""} -traitlets = "*" - -[package.extras] -dev = ["build", "hatch", "mypy", "pre-commit", "ruff (==0.15.20)"] -test = ["copier (>=9.3,<10)", "coverage", "jinja2-time", "pytest (>=7.0)", "pytest-check-links (>=0.7)", "pytest-cov"] - -[[package]] -name = "jupyter-client" -version = "8.9.1" -description = "Jupyter protocol implementation and client libraries" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "jupyter_client-8.9.1-py3-none-any.whl", hash = "sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81"}, - {file = "jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa"}, -] - -[package.dependencies] -jupyter-core = ">=5.1" -python-dateutil = ">=2.8.2" -pyzmq = ">=25.0" -tornado = ">=6.4.1" -traitlets = ">=5.3" -typing-extensions = ">=4.13.0" - -[package.extras] -docs = ["ipykernel", "myst-parser", "pydata-sphinx-theme", "sphinx (>=4)", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"] -orjson = ["orjson"] -test = ["anyio", "coverage", "ipykernel (>=6.14)", "msgpack", "mypy ; platform_python_implementation != \"PyPy\"", "paramiko ; sys_platform == \"win32\"", "pre-commit", "pytest", "pytest-cov", "pytest-jupyter[client] (>=0.6.2)", "pytest-timeout"] - -[[package]] -name = "jupyter-console" -version = "6.6.3" -description = "Jupyter terminal console" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "jupyter_console-6.6.3-py3-none-any.whl", hash = "sha256:309d33409fcc92ffdad25f0bcdf9a4a9daa61b6f341177570fdac03de5352485"}, - {file = "jupyter_console-6.6.3.tar.gz", hash = "sha256:566a4bf31c87adbfadf22cdf846e3069b59a71ed5da71d6ba4d8aaad14a53539"}, -] - -[package.dependencies] -ipykernel = ">=6.14" -ipython = "*" -jupyter-client = ">=7.0.0" -jupyter-core = ">=4.12,<5.0 || >=5.1.dev0" -prompt-toolkit = ">=3.0.30" -pygments = "*" -pyzmq = ">=17" -traitlets = ">=5.4" - -[package.extras] -test = ["flaky", "pexpect", "pytest"] - -[[package]] -name = "jupyter-core" -version = "5.9.1" -description = "Jupyter core package. A base package on which Jupyter projects rely." -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407"}, - {file = "jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508"}, -] - -[package.dependencies] -platformdirs = ">=2.5" -traitlets = ">=5.3" - -[package.extras] -docs = ["intersphinx-registry", "myst-parser", "pydata-sphinx-theme", "sphinx-autodoc-typehints", "sphinxcontrib-spelling", "traitlets"] -test = ["ipykernel", "pre-commit", "pytest (<9)", "pytest-cov", "pytest-timeout"] - -[[package]] -name = "jupyter-events" -version = "0.12.1" -description = "Jupyter Event System library" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "jupyter_events-0.12.1-py3-none-any.whl", hash = "sha256:c366585253f537a627da52fa7ca7410c5b5301fe893f511e7b077c2d93ec8bcf"}, - {file = "jupyter_events-0.12.1.tar.gz", hash = "sha256:faff25f77218335752f35f23c5fe6e4a392a7bd99a5939ccb9b8fbf594636cf3"}, -] - -[package.dependencies] -jsonschema = {version = ">=4.18.0", extras = ["format-nongpl"]} -packaging = "*" -python-json-logger = ">=2.0.4" -pyyaml = ">=5.3" -referencing = "*" -rfc3339-validator = "*" -rfc3986-validator = ">=0.1.1" -traitlets = ">=5.3" - -[package.extras] -cli = ["click", "rich"] -docs = ["jupyterlite-sphinx", "myst-parser", "pydata-sphinx-theme (>=0.16)", "sphinx (>=8)", "sphinxcontrib-spelling"] -test = ["click", "pre-commit", "pytest (>=7.0)", "pytest-asyncio (>=0.19.0)", "pytest-console-scripts", "rich"] - -[[package]] -name = "jupyter-lsp" -version = "2.3.1" -description = "Multi-Language Server WebSocket proxy for Jupyter Notebook/Lab server" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "jupyter_lsp-2.3.1-py3-none-any.whl", hash = "sha256:71b954d834e85ff3096400554f2eefaf7fe37053036f9a782b0f7c5e42dadb81"}, - {file = "jupyter_lsp-2.3.1.tar.gz", hash = "sha256:fdf8a4aa7d85813976d6e29e95e6a2c8f752701f926f2715305249a3829805a6"}, -] - -[package.dependencies] -jupyter_server = ">=1.1.2" - -[[package]] -name = "jupyter-server" -version = "2.20.0" -description = "The backend—i.e. core services, APIs, and REST endpoints—to Jupyter web applications." -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "jupyter_server-2.20.0-py3-none-any.whl", hash = "sha256:c3b67c93c471e947c18b5026f04f21614218adb706df8f48227d3ee8e0a7cdcc"}, - {file = "jupyter_server-2.20.0.tar.gz", hash = "sha256:b5778ba337d8015a3dc2b80803ecdd5ac18d3797fddf61a50ea5fb472b4ebe14"}, -] - -[package.dependencies] -anyio = ">=3.1.0" -argon2-cffi = ">=21.1" -jinja2 = ">=3.0.3" -jupyter-client = ">=7.4.4" -jupyter-core = ">=4.12,<5.0 || >=5.1.dev0" -jupyter-events = ">=0.11.0" -jupyter-server-terminals = ">=0.4.4" -nbconvert = ">=6.4.4" -nbformat = ">=5.3.0" -overrides = {version = ">=5.0", markers = "python_version < \"3.12\""} -packaging = ">=22.0" -prometheus-client = ">=0.9" -pywinpty = {version = ">=2.0.1,!=3.0.4", markers = "os_name == \"nt\""} -pyzmq = ">=24" -send2trash = ">=1.8.2" -terminado = ">=0.8.3" -tornado = ">=6.2.0" -traitlets = ">=5.6.0" -websocket-client = ">=1.7" - -[package.extras] -docs = ["ipykernel", "jinja2", "jupyter-client", "myst-parser", "nbformat", "prometheus-client", "pydata-sphinx-theme", "send2trash", "sphinx (<9.0)", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-openapi (>=0.8.0)", "sphinxcontrib-spelling", "sphinxemoji", "tornado", "typing-extensions"] -test = ["flaky", "ipykernel", "pre-commit", "pytest (>=7.0,<10)", "pytest-console-scripts", "pytest-jupyter[server] (>=0.7)", "pytest-timeout", "requests"] - -[[package]] -name = "jupyter-server-terminals" -version = "0.5.4" -description = "A Jupyter Server Extension Providing Terminals." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "jupyter_server_terminals-0.5.4-py3-none-any.whl", hash = "sha256:55be353fc74a80bc7f3b20e6be50a55a61cd525626f578dcb66a5708e2007d14"}, - {file = "jupyter_server_terminals-0.5.4.tar.gz", hash = "sha256:bbda128ed41d0be9020349f9f1f2a4ab9952a73ed5f5ac9f1419794761fb87f5"}, -] - -[package.dependencies] -pywinpty = {version = ">=2.0.3", markers = "os_name == \"nt\""} -terminado = ">=0.8.3" - -[package.extras] -docs = ["jinja2", "jupyter-server", "mistune (<4.0)", "myst-parser", "nbformat", "packaging", "pydata-sphinx-theme", "sphinxcontrib-github-alt", "sphinxcontrib-openapi", "sphinxcontrib-spelling", "sphinxemoji", "tornado"] -test = ["jupyter-server (>=2.0.0)", "pytest (>=7.0)", "pytest-jupyter[server] (>=0.5.3)", "pytest-timeout"] - -[[package]] -name = "jupyterlab" -version = "4.6.2" -description = "JupyterLab computational environment" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "jupyterlab-4.6.2-py3-none-any.whl", hash = "sha256:5964447036629adfcd3fc0969effc1da6f47d2cbd0a60b2c2eea7c31be0ec6a8"}, - {file = "jupyterlab-4.6.2.tar.gz", hash = "sha256:e18ce8b34f3de350e93cd5b2c4f3ae884cbe266eb76bf5d6825a4ed34c13bcff"}, -] - -[package.dependencies] -async-lru = ">=1.0.0" -httpx = ">=0.25.0,<1" -ipykernel = ">=6.5.0,!=6.30.0" -jinja2 = ">=3.0.3" -jupyter-builder = ">=1.0.2" -jupyter-core = "*" -jupyter-lsp = ">=2.0.0" -jupyter-server = ">=2.19.0,<3" -jupyterlab-server = ">=2.28.0,<3" -notebook-shim = ">=0.2" -packaging = ">=23.2" -tomli = {version = ">=1.2.2", markers = "python_version < \"3.11\""} -tornado = ">=6.2.0" -traitlets = "*" -typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.12\""} - -[package.extras] -dev = ["build", "bump2version", "coverage", "hatch", "pre-commit", "pytest-cov", "ruff (==0.15.15)", "shellcheck-py"] -docs-screenshots = ["altair (==6.0.0)", "ipykernel (<7.0)", "ipython (==8.16.1)", "ipywidgets (==8.1.5)", "jupyterlab-geojson (==3.4.0)", "jupyterlab-language-pack-zh-cn (==4.3.post1)", "matplotlib (==3.10.0)", "nbconvert (>=7.0.0)", "pandas (==2.2.3)", "scipy (==1.15.1)"] -test = ["coverage", "pytest (>=7.0)", "pytest-check-links (>=0.7)", "pytest-console-scripts", "pytest-cov", "pytest-jupyter (>=0.5.3)", "pytest-timeout", "pytest-tornasync", "pytest-xdist", "requests", "requests-cache", "virtualenv"] -upgrade-extension = ["copier (>=9,<10)", "jinja2-time (<0.3)", "pydantic (<3.0)", "pyyaml-include (<3.0)", "tomli-w (<2.0)"] - -[[package]] -name = "jupyterlab-pygments" -version = "0.3.0" -description = "Pygments theme using JupyterLab CSS variables" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780"}, - {file = "jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d"}, -] - -[[package]] -name = "jupyterlab-server" -version = "2.28.0" -description = "A set of server components for JupyterLab and JupyterLab like applications." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "jupyterlab_server-2.28.0-py3-none-any.whl", hash = "sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968"}, - {file = "jupyterlab_server-2.28.0.tar.gz", hash = "sha256:35baa81898b15f93573e2deca50d11ac0ae407ebb688299d3a5213265033712c"}, -] - -[package.dependencies] -babel = ">=2.10" -jinja2 = ">=3.0.3" -json5 = ">=0.9.0" -jsonschema = ">=4.18.0" -jupyter-server = ">=1.21,<3" -packaging = ">=21.3" -requests = ">=2.31" - -[package.extras] -docs = ["autodoc-traits", "jinja2 (<3.2.0)", "mistune (<4)", "myst-parser", "pydata-sphinx-theme", "sphinx", "sphinx-copybutton", "sphinxcontrib-openapi (>0.8)"] -openapi = ["openapi-core (>=0.18.0,<0.19.0)", "ruamel-yaml"] -test = ["hatch", "ipykernel", "openapi-core (>=0.18.0,<0.19.0)", "openapi-spec-validator (>=0.6.0,<0.8.0)", "pytest (>=7.0,<8)", "pytest-console-scripts", "pytest-cov", "pytest-jupyter[server] (>=0.6.2)", "pytest-timeout", "requests-mock", "ruamel-yaml", "sphinxcontrib-spelling", "strict-rfc3339", "werkzeug"] - -[[package]] -name = "jupyterlab-widgets" -version = "3.0.16" -description = "Jupyter interactive widgets for JupyterLab" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8"}, - {file = "jupyterlab_widgets-3.0.16.tar.gz", hash = "sha256:423da05071d55cf27a9e602216d35a3a65a3e41cdf9c5d3b643b814ce38c19e0"}, -] - -[[package]] -name = "lark" -version = "1.3.1" -description = "a modern parsing library" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12"}, - {file = "lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905"}, -] - -[package.extras] -atomic-cache = ["atomicwrites"] -interegular = ["interegular (>=0.3.1,<0.4.0)"] -nearley = ["js2py"] -regex = ["regex"] - [[package]] name = "librt" version = "0.16.0" @@ -1811,126 +1267,6 @@ rtd = ["ipython", "sphinx (>=8)", "sphinx-autodoc2 (>=0.5.0,<0.6.0)", "sphinx-bo testing = ["beautifulsoup4", "coverage[toml]", "defusedxml", "pygments (<2.21)", "pytest (>=9,<10)", "pytest-cov", "pytest-param-files (>=0.6.0,<0.7.0)", "pytest-regressions", "sphinx-pytest (>=0.3.0,<0.4.0)"] testing-docutils = ["pygments", "pytest (>=9,<10)", "pytest-param-files (>=0.6.0,<0.7.0)"] -[[package]] -name = "narwhals" -version = "2.24.0" -description = "Extremely lightweight compatibility layer between dataframe libraries" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "narwhals-2.24.0-py3-none-any.whl", hash = "sha256:42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489"}, - {file = "narwhals-2.24.0.tar.gz", hash = "sha256:b5c0f684ccd9d7475b564111e319a4964abcf2baf79d3cf6b1003d06ac9b828d"}, -] - -[package.extras] -cudf = ["cudf-cu12 (>=24.10.0) ; sys_platform == \"linux\""] -dask = ["dask[dataframe] (>=2024.8)"] -duckdb = ["duckdb (>=1.1)"] -ibis = ["ibis-framework (>=6.0.0)", "packaging (>=21.3)", "pyarrow-hotfix (>=0.7)"] -modin = ["modin (>=0.22.0)"] -pandas = ["pandas (>=1.3.4)"] -polars = ["polars (>=0.20.4)"] -pyarrow = ["pyarrow (>=13.0.0)"] -pyspark = ["pyspark (>=3.5.0)"] -pyspark-connect = ["pyspark[connect] (>=3.5.0)"] -sql = ["narwhals[duckdb]", "sqlparse (>=0.5.5)"] -sqlframe = ["sqlframe (>=3.22.0,!=3.39.3)"] - -[[package]] -name = "nbclient" -version = "0.11.0" -description = "A client library for executing notebooks. Formerly nbconvert's ExecutePreprocessor." -optional = false -python-versions = ">=3.10.0" -groups = ["dev"] -files = [ - {file = "nbclient-0.11.0-py3-none-any.whl", hash = "sha256:ef7fa0d59d6e1d41103933d8a445a18d5de860ca6b613b87b8574accdb3c2895"}, - {file = "nbclient-0.11.0.tar.gz", hash = "sha256:04a134a5b087f2c5887f228aca155db50169b8cd9334dee6942c8e927e56081a"}, -] - -[package.dependencies] -jupyter-client = ">=7.0.0" -jupyter-core = ">=5.4.0" -nbformat = ">=5.2.0" -traitlets = ">=5.13" - -[package.extras] -dev = ["pre-commit"] -docs = ["autodoc-traits", "flaky", "ipykernel (>=6.19.3)", "ipython", "ipywidgets", "mock", "moto", "myst-parser", "nbconvert (>=7.1.0)", "pytest (>=9.0.1,<10)", "pytest-asyncio (>=1.3.0)", "pytest-cov (>=4.0)", "sphinx (>=1.7)", "sphinx-book-theme", "sphinxcontrib-spelling", "testpath", "xmltodict"] -test = ["flaky", "ipykernel (>=6.19.3)", "ipython", "ipywidgets", "nbconvert (>=7.1.0)", "pytest (>=9.0.1,<10)", "pytest-asyncio (>=1.3.0)", "pytest-cov (>=4.0)", "testpath", "xmltodict"] - -[[package]] -name = "nbconvert" -version = "7.17.1" -description = "Convert Jupyter Notebooks (.ipynb files) to other formats." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8"}, - {file = "nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2"}, -] - -[package.dependencies] -beautifulsoup4 = "*" -bleach = {version = "!=5.0.0", extras = ["css"]} -defusedxml = "*" -jinja2 = ">=3.0" -jupyter-core = ">=4.7" -jupyterlab-pygments = "*" -markupsafe = ">=2.0" -mistune = ">=2.0.3,<4" -nbclient = ">=0.5.0" -nbformat = ">=5.7" -packaging = "*" -pandocfilters = ">=1.4.1" -pygments = ">=2.4.1" -traitlets = ">=5.1" - -[package.extras] -all = ["flaky", "intersphinx-registry", "ipykernel", "ipython", "ipywidgets (>=7.5)", "myst-parser", "nbsphinx (>=0.2.12)", "playwright", "pydata-sphinx-theme", "pyqtwebengine (>=5.15)", "pytest (>=7)", "sphinx (>=5.0.2)", "sphinxcontrib-spelling", "tornado (>=6.1)"] -docs = ["intersphinx-registry", "ipykernel", "ipython", "myst-parser", "nbsphinx (>=0.2.12)", "pydata-sphinx-theme", "sphinx (>=5.0.2)", "sphinxcontrib-spelling"] -qtpdf = ["pyqtwebengine (>=5.15)"] -qtpng = ["pyqtwebengine (>=5.15)"] -serve = ["tornado (>=6.1)"] -test = ["flaky", "ipykernel", "ipywidgets (>=7.5)", "pytest (>=7)"] -webpdf = ["playwright"] - -[[package]] -name = "nbformat" -version = "5.11.1" -description = "The Jupyter Notebook format" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "nbformat-5.11.1-py3-none-any.whl", hash = "sha256:cc6698fa75f4fab8755ead786317815f13a6fee3b53311c0abb1a8b51d52f7ec"}, - {file = "nbformat-5.11.1.tar.gz", hash = "sha256:32d4521c68c6e7d5b29c76defaeed9f42ea733142b9b19f88277ce10390b9c4d"}, -] - -[package.dependencies] -fastjsonschema = ">=2.15" -jsonschema = ">=2.6" -jupyter-core = ">=4.12,<5.0 || >=5.1.dev0" -traitlets = ">=5.1" - -[package.extras] -docs = ["intersphinx-registry", "myst-parser", "pydata-sphinx-theme", "sphinx", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"] -test = ["packaging", "pre-commit", "pytest", "testpath"] - -[[package]] -name = "nest-asyncio2" -version = "1.7.2" -description = "Patch asyncio to allow nested event loops" -optional = false -python-versions = ">=3.5" -groups = ["dev"] -files = [ - {file = "nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01"}, - {file = "nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8"}, -] - [[package]] name = "ni-datamonikers-v1-proto" version = "1.0.0" @@ -3367,4 +2703,4 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "18dd52b86670431ccadf0e87fe14ba84e3d738be4e73ff761483ff466fc709ed" +content-hash = "1a70727e96388ba75899aa627b0a872e8ce7ce5ab7720996236949df4466d5ce" From 572713ce1df34bdbd13668351cfc1fffb256ff58 Mon Sep 17 00:00:00 2001 From: "Balusamy, Sudharshana" Date: Tue, 27 Oct 2026 17:11:16 +0530 Subject: [PATCH 08/13] fix pipeline error --- .github/workflows/check_ni_datastore.yml | 2 ++ .github/workflows/run_unit_tests.yml | 2 ++ CONTRIBUTING.md | 4 ---- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check_ni_datastore.yml b/.github/workflows/check_ni_datastore.yml index db967119..cc7cb488 100644 --- a/.github/workflows/check_ni_datastore.yml +++ b/.github/workflows/check_ni_datastore.yml @@ -26,6 +26,8 @@ jobs: python-version: ${{ matrix.python-version }} - name: Set up Poetry uses: ni/python-actions/setup-poetry@dee640bba235ae28fdc6b7337c643bd06358ae90 # v0.9.0 + with: + poetry-version: '2.5.1' - name: Analyze Python Project uses: ni/python-actions/analyze-project@dee640bba235ae28fdc6b7337c643bd06358ae90 # v0.9.0 - name: Bandit security checks diff --git a/.github/workflows/run_unit_tests.yml b/.github/workflows/run_unit_tests.yml index d50ae682..0b9c742a 100644 --- a/.github/workflows/run_unit_tests.yml +++ b/.github/workflows/run_unit_tests.yml @@ -36,5 +36,7 @@ jobs: python-version: ${{ inputs.python-version }} - name: Set up Poetry uses: ni/python-actions/setup-poetry@dee640bba235ae28fdc6b7337c643bd06358ae90 # v0.9.0 + with: + poetry-version: '2.5.1' - name: Run and upload unit tests for ni.datastore uses: ./.github/actions/run_and_upload_unit_tests \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfa9daf8..0a1ab17b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,7 +27,6 @@ See [GitHub's official documentation](https://help.github.com/articles/using-pul - Install [Git](https://git-scm.com) - Install [Python](https://www.python.org/downloads/), any version from the [README](README.md) - Install [Poetry](https://python-poetry.org/docs/#installation), version >= 2.1.4 - (the Python 3.14 example analyzer jobs use Poetry 2.5.1). ## Install the package and its dependencies @@ -43,9 +42,6 @@ poetry install poetry install --with docs ``` -After changing dependencies or merging dependency changes, run `poetry lock` and -`poetry check --lock` from the repository root before running validation. - ## Activate the virtual environment (if needed) - _(Recommended)_ Activate for each command by prefixing the call with `poetry run {command}` From 04b95849d14357d6bebe196102fbddd3ec8ffec8 Mon Sep 17 00:00:00 2001 From: "Balusamy, Sudharshana" Date: Wed, 28 Oct 2026 18:10:35 +0530 Subject: [PATCH 09/13] resolve comments --- docs/reference/ni-data-store-v2.md | 11 ++- examples/notebooks/v2/README.md | 16 ++-- .../batch_create.ipynb | 4 +- .../v2/update-delete/update_delete.ipynb | 4 +- examples/overview-v2/pyproject.toml | 2 +- .../datastore/data/v2/_data_store_client.py | 90 +++++++++++-------- src/ni/datastore/data/v2/_grpc_conversion.py | 4 +- src/ni/datastore/data/v2/_types/_condition.py | 12 ++- .../datastore/data/v2/_types/_json_value.py | 3 +- .../datastore/data/v2/_types/_measurement.py | 62 +++++++------ .../data/v2/_types/_query_results.py | 8 +- src/ni/datastore/data/v2/_types/_step.py | 27 +++--- .../datastore/data/v2/_types/_test_result.py | 46 +++++----- tests/unit/data/test_v2_data_store_client.py | 80 +++++++++++------ tests/unit/data/test_v2_types.py | 62 +++++++++++-- 15 files changed, 279 insertions(+), 152 deletions(-) rename examples/notebooks/v2/{publish => batch-create}/batch_create.ipynb (97%) diff --git a/docs/reference/ni-data-store-v2.md b/docs/reference/ni-data-store-v2.md index 70ec0e25..5c3a3b8d 100644 --- a/docs/reference/ni-data-store-v2.md +++ b/docs/reference/ni-data-store-v2.md @@ -62,9 +62,12 @@ The client wraps every method of the V2 low-level client: software items, hardware items, and test adapters after creation. As defined by DEC-017, `remove_test_result_metadata` raises `ValueError`, without removing anything, if a measurement of the test result still references an - item; unlink it from those measurements first. + item; unlink it from those measurements first. If the test result's + measurements come back across more than one page of query results, it raises + `RuntimeError` instead of removing, because the check cannot be completed. - `create_measurement` accepts `upper_limit` and `lower_limit` of any supported - value type. `read_measurement_values` returns the value and both limits. + value type. The service stores the limits as given and does not compare them + with the value. `read_measurement_values` returns the value and both limits. - `batch_create_*`, `batch_update_*`, and `batch_delete_*` handle many test results, steps, or measurements in one atomic call. Batch updates take a dictionary of fields keyed by ID, and batch deletes accept one `force` flag or @@ -106,7 +109,9 @@ The client wraps every method of the V2 low-level client: Python or nitypes values. A single value is always read back as a `Scalar`, with or without units, and a list of values as a `Vector`. When a `Scalar` is given and `units` is not, the measurement's `units` are taken from the - `Scalar`. Conditions accept scalar or JSON values. JSON values + `Scalar`. Conditions accept scalar or JSON values. A scalar condition value + reads back as a plain Python value, or as a `Scalar` when it has extended + properties other than units. JSON values require the service's Stage 3 Structured JSON Storage And Query Support. - Software item, hardware item, and test adapter IDs on a measurement must first be linked to its test result; pass them to `create_test_result`. diff --git a/examples/notebooks/v2/README.md b/examples/notebooks/v2/README.md index 1e41c1a8..307d037b 100644 --- a/examples/notebooks/v2/README.md +++ b/examples/notebooks/v2/README.md @@ -9,16 +9,18 @@ V2 reuses the V1 Metadata API, so these notebooks create and read metadata with ## Setup -These notebooks use the main Python environment / kernel of `ni.datastore`. To -execute the code in these notebooks, first execute the following command from -the repo's root directory: +These notebooks use the dedicated Python environment configured for the +notebooks. To create it, run the following commands from the repository root: -```sh +```powershell +cd examples\notebooks poetry install ``` -After executing this command, select the environment that it creates as the -kernel to use for executing any of the provided notebooks. +This creates one environment containing the dependencies used by all notebooks, +including the local `ni.datastore` package and notebook utilities. Select the +environment it creates as the kernel when executing any of the provided +notebooks. The notebooks need NI Measurement Data Services running in V2 mode. By default, the clients use discovery to find the service. To connect to a specific @@ -33,7 +35,7 @@ target, such as `[::1]:59606`, before starting Jupyter. Run this first. - `query/query_measurements.ipynb`: Query test results, steps, measurements, and conditions with OData. -- `publish/batch_create.ipynb`: Create test results, steps, and measurements in +- `batch-create/batch_create.ipynb`: Create test results, steps, and measurements in atomic batches, including nested steps and measurements. - `measurement-values/measurement_values.ipynb`: Scalar and non-scalar measurement values, and limits. diff --git a/examples/notebooks/v2/publish/batch_create.ipynb b/examples/notebooks/v2/batch-create/batch_create.ipynb similarity index 97% rename from examples/notebooks/v2/publish/batch_create.ipynb rename to examples/notebooks/v2/batch-create/batch_create.ipynb index cb3fb93c..f4417198 100644 --- a/examples/notebooks/v2/publish/batch_create.ipynb +++ b/examples/notebooks/v2/batch-create/batch_create.ipynb @@ -99,7 +99,7 @@ "source": [ "## Batch Creating Steps\n", "\n", - "`batch_create_steps` creates several steps at once. Each step names the test result that owns it." + "`batch_create_steps` creates multiple steps at once. Each step names the test result that owns it." ] }, { @@ -133,7 +133,7 @@ "source": [ "## Batch Creating Measurements\n", "\n", - "`batch_create_measurements` creates several measurements at once. The measurements can have different names, different value types, and different steps." + "`batch_create_measurements` creates multiple measurements at once. The measurements can have different names, different value types, and different steps." ] }, { diff --git a/examples/notebooks/v2/update-delete/update_delete.ipynb b/examples/notebooks/v2/update-delete/update_delete.ipynb index 14bea981..b827ade7 100644 --- a/examples/notebooks/v2/update-delete/update_delete.ipynb +++ b/examples/notebooks/v2/update-delete/update_delete.ipynb @@ -147,7 +147,7 @@ "id": "f093a4c0", "metadata": {}, "source": [ - "Update several items in one atomic call by passing the fields for each ID:" + "Update multiple items in one atomic call by passing the fields for each ID:" ] }, { @@ -313,7 +313,7 @@ "id": "6e90f2e8", "metadata": {}, "source": [ - "Delete several items in one atomic call, or use `force=True` to also delete everything the item owns:" + "Delete multiple items in one atomic call, or use `force=True` to also delete everything the item owns:" ] }, { diff --git a/examples/overview-v2/pyproject.toml b/examples/overview-v2/pyproject.toml index a9e3df1c..7cd560a5 100644 --- a/examples/overview-v2/pyproject.toml +++ b/examples/overview-v2/pyproject.toml @@ -4,7 +4,7 @@ version = "1.0.0" license = "MIT" description = "Overview of how to use the NI Measurement Data Store V2 APIs for publishing and reading data" maintainers = [ - {name = "Johann Scholtz", email = "johann.scholtz@emerson.com"}, + {name = "Michael Johansen", email = "michael.johansen@emerson.com"}, {name = "Joel Dixon", email = "joel.dixon@emerson.com"} ] readme = "README.md" diff --git a/src/ni/datastore/data/v2/_data_store_client.py b/src/ni/datastore/data/v2/_data_store_client.py index bbfec1eb..f275e606 100644 --- a/src/ni/datastore/data/v2/_data_store_client.py +++ b/src/ni/datastore/data/v2/_data_store_client.py @@ -135,7 +135,7 @@ def create_test_result(self, test_result: TestResult) -> str: """Create a test result. Args: - test_result: The metadata of the test result to be created. + test_result: The test result object to be created. Returns: str: The test result ID. Generated if not specified. @@ -147,7 +147,7 @@ def create_step(self, step: Step) -> str: """Create a step, including any conditions on the step. Args: - step: The metadata of the step to be created. + step: The step object to be created. Returns: str: The step ID. Generated if not specified. @@ -226,10 +226,12 @@ def create_measurement( Scalar value. upper_limit: The publisher-supplied upper limit. Any supported value - type is accepted. + type is accepted. The service stores it but does not compare it + with the value. lower_limit: The publisher-supplied lower limit. Any supported value - type is accepted. + type is accepted. The service stores it but does not compare it + with the value. comparison_type: The publisher-defined comparison operation. The service stores it but does not derive the outcome from it. @@ -248,26 +250,25 @@ def create_measurement( extension: Custom JSON data for the measurement. - schema_id: Schema that governs the extension. Reserved by the service - for future support. + schema_id: Schema that governs the extension. Reserved for future use. software_item_ids: The software item IDs for the measurement. These - must already be linked to the test result. + must already be linked to the owning test result. hardware_item_ids: The hardware item IDs for the measurement. These - must already be linked to the test result. + must already be linked to the owning test result. test_adapter_ids: The test adapter IDs for the measurement. These - must already be linked to the test result. + must already be linked to the owning test result. software_items: Software items to find or create as part of this - request. Each item must already be linked to the test result. + request. Each item must already be linked to the owning test result. hardware_items: Hardware items to find or create as part of this - request. Each item must already be linked to the test result. + request. Each item must already be linked to the owning test result. test_adapters: Test adapters to find or create as part of this - request. Each item must already be linked to the test result. + request. Each item must already be linked to the owning test result. Returns: str: The measurement ID. @@ -299,7 +300,7 @@ def create_measurement( return self._get_data_store_client().create_measurement(create_request).id def batch_create_test_results(self, test_results: Iterable[TestResult]) -> Sequence[str]: - """Create several test results, including any nested steps, in one atomic call. + """Create multiple test results, including any nested steps, in one atomic call. Args: test_results: The test results to create. @@ -317,7 +318,7 @@ def batch_create_test_results(self, test_results: Iterable[TestResult]) -> Seque return [test_result.id for test_result in batch_response.test_results] def batch_create_steps(self, steps: Iterable[Step]) -> Sequence[str]: - """Create several steps, including any nested children, in one atomic call. + """Create multiple steps, including any nested children, in one atomic call. Args: steps: The steps to create. @@ -332,11 +333,11 @@ def batch_create_steps(self, steps: Iterable[Step]) -> Sequence[str]: return [step.id for step in batch_response.steps] def batch_create_measurements(self, measurements: Iterable[Measurement]) -> Sequence[str]: - """Create several measurements in one atomic call. + """Create multiple measurements in one atomic call. Args: - measurements: The measurements to create. Each must set step_id and - value, and may target a different step or test result. + measurements: The measurements to create. Each must include a step ID and + value. They may target different steps or test results. Returns: Sequence[str]: The measurement IDs, in request order. @@ -427,8 +428,8 @@ def get_test_result( Args: test_result_id: The ID of the test result. - read_fields: Optional field names to return. All fields are returned - when empty. + read_fields: Optional field names to return. When empty, the service returns + its default fields. Returns: TestResult: The requested test result. @@ -444,8 +445,8 @@ def get_step(self, step_id: str, *, read_fields: Iterable[str] = ()) -> Step: Args: step_id: The ID of the step. - read_fields: Optional field names to return, such as "conditions". - All default fields are returned when empty. + read_fields: Optional field names to return, such as "conditions". When + empty, the service returns its default fields. Returns: Step: The requested step. @@ -461,11 +462,11 @@ def get_measurement( Args: measurement_id: The ID of the measurement. - read_fields: Optional field names to return, such as "conditions". - All default fields are returned when empty. + read_fields: Optional field names to return, such as "conditions". When + empty, the service returns its default fields. Returns: - Measurement: The requested measurement metadata. + Measurement: The requested measurement. """ get_request = GetMeasurementRequest( measurement_id=measurement_id, read_mask=_read_mask(read_fields) @@ -612,7 +613,7 @@ def update_measurement(self, measurement_id: str, **fields: object) -> Measureme def batch_update_test_results( self, updates: Mapping[str, Mapping[str, object]] ) -> Sequence[TestResult]: - """Update several test results in one atomic call. + """Update multiple test results in one atomic call. Args: updates: The fields to change, keyed by test result ID. Each value @@ -635,7 +636,7 @@ def batch_update_test_results( return [TestResult.from_protobuf(proto) for proto in batch_response.test_results] def batch_update_steps(self, updates: Mapping[str, Mapping[str, object]]) -> Sequence[Step]: - """Update several steps in one atomic call. + """Update multiple steps in one atomic call. Args: updates: The fields to change, keyed by step ID. Each value follows @@ -660,7 +661,7 @@ def batch_update_steps(self, updates: Mapping[str, Mapping[str, object]]) -> Seq def batch_update_measurements( self, updates: Mapping[str, Mapping[str, object]] ) -> Sequence[Measurement]: - """Update several measurements in one atomic call. + """Update multiple measurements in one atomic call. Args: updates: The fields to change, keyed by measurement ID. Each value @@ -724,10 +725,10 @@ def remove_test_result_metadata( ) -> TestResult: """Unlink software items, hardware items, or test adapters from a test result. - As defined by DEC-017, an item cannot be unlinked while a measurement of - the test result still references it. Unlink it from those measurements - first with remove_measurement_metadata. The client checks this before - calling the service, so nothing is removed when the check fails. + An item cannot be unlinked while a measurement of the test result still + references it. Unlink it from those measurements first with + remove_measurement_metadata. The client checks this before calling the + service, so nothing is removed when the check fails. Args: test_result_id: The ID of the test result. @@ -744,6 +745,9 @@ def remove_test_result_metadata( Raises: ValueError: If a measurement of the test result still references an item to unlink. + + RuntimeError: If the test result's measurements are returned across + more than one page, so the check cannot be completed. """ software_item_ids = list(software_item_ids) hardware_item_ids = list(hardware_item_ids) @@ -776,7 +780,7 @@ def add_measurement_metadata( ) -> Measurement: """Link software items, hardware items, or test adapters to a measurement. - Each item must already be linked to the measurement's test result. + Each item must already be linked to the measurement's owning test result. Args: measurement_id: The ID of the measurement. @@ -871,13 +875,13 @@ def delete_measurement(self, measurement_id: str) -> None: def batch_delete_test_results( self, test_result_ids: Iterable[str], *, force: bool | Iterable[bool] = False ) -> None: - """Delete several test results in one atomic call. + """Delete multiple test results in one atomic call. Args: - test_result_ids: The IDs of the test results to delete. + test_result_ids: The IDs of all test results to delete. force: One flag for every test result, or one flag per test result. - See delete_test_result. + See delete_test_result for details on force behavior. Raises: ValueError: If one flag per item is given and the counts differ. @@ -893,13 +897,14 @@ def batch_delete_test_results( def batch_delete_steps( self, step_ids: Iterable[str], *, force: bool | Iterable[bool] = False ) -> None: - """Delete several steps in one atomic call. + """Delete multiple steps in one atomic call. Args: step_ids: The IDs of the steps to delete. A step and its ancestor or descendant cannot be deleted in the same call. - force: One flag for every step, or one flag per step. See delete_step. + force: One flag for every step, or one flag per step. See delete_step + for details on force behavior. Raises: ValueError: If one flag per item is given and the counts differ. @@ -913,7 +918,7 @@ def batch_delete_steps( self._get_data_store_client().batch_delete_steps(batch_request) def batch_delete_measurements(self, measurement_ids: Iterable[str]) -> None: - """Delete several measurements in one atomic call. + """Delete multiple measurements in one atomic call. Args: measurement_ids: The IDs of the measurements to delete. @@ -940,7 +945,14 @@ def _raise_if_used_by_measurements( odata_query=f"$filter=TestResultId eq {test_result_id}", read_mask=FieldMask(paths=["id", *removed_ids]), ) - measurements = self._get_data_store_client().query_measurements(query_request).measurements + measurements_response = self._get_data_store_client().query_measurements(query_request) + if measurements_response.next_link: + raise RuntimeError( + f"Cannot unlink metadata from test result {test_result_id} because its " + "measurements span more than one page of query results, so the client cannot " + "verify that none of them still reference the metadata." + ) + measurements = measurements_response.measurements in_use = sorted( { f"{item_id} (measurement {measurement.id})" diff --git a/src/ni/datastore/data/v2/_grpc_conversion.py b/src/ni/datastore/data/v2/_grpc_conversion.py index dfad8709..1dc899c9 100644 --- a/src/ni/datastore/data/v2/_grpc_conversion.py +++ b/src/ni/datastore/data/v2/_grpc_conversion.py @@ -57,7 +57,7 @@ def populate_measurement_value(measurement_value: MeasurementValue, value: object) -> None: - """Convert a Python scalar or vector into a V2 measurement value.""" + """Convert a supported Python value into a V2 measurement value.""" if isinstance(value, bool): measurement_value.scalar.bool_value = value elif isinstance(value, int): @@ -162,7 +162,7 @@ def convert_measurement_value_from_protobuf(measurement_value: MeasurementValue) def measurement_value_to_protobuf(value: object) -> MeasurementValue | None: - """Convert an optional measurement value or limit to a MeasurementValue.""" + """Convert an optional measurement value, upper limit, or lower limit to protobuf.""" if value is None: return None measurement_value = MeasurementValue() diff --git a/src/ni/datastore/data/v2/_types/_condition.py b/src/ni/datastore/data/v2/_types/_condition.py index 037d6d2a..63f2d817 100644 --- a/src/ni/datastore/data/v2/_types/_condition.py +++ b/src/ni/datastore/data/v2/_types/_condition.py @@ -12,6 +12,8 @@ from ni.datastore.data.v2._types._json_value import JsonValue, json_value_from_protobuf +_UNIT_DESCRIPTION = "NI_UnitDescription" + class Condition: """An operational condition, such as a temperature or supply voltage, owned by a step. @@ -42,7 +44,8 @@ def __init__( Args: name: The condition name, unique within a step. value: A bool, int, float, str, or Scalar value, a JSON mapping, or a - JsonValue. + JsonValue. A scalar value reads back as a plain Python value, or as + a Scalar when it has extended properties other than units. condition_type (optional): The type of the condition. For example, "Environment" or "Setup". value_label (optional): A label for the value. @@ -61,7 +64,12 @@ def from_protobuf(condition_proto: ConditionProto) -> "Condition": value_kind = condition_proto.value.WhichOneof("value") value: object = None if value_kind == "scalar": - value = scalar_from_protobuf(condition_proto.value.scalar).value + scalar = scalar_from_protobuf(condition_proto.value.scalar) + # Keep the Scalar only when it carries properties a plain value cannot. + has_extra_properties = any( + key != _UNIT_DESCRIPTION for key in scalar.extended_properties + ) + value = scalar if has_extra_properties else scalar.value elif value_kind == "json_value": value = json_value_from_protobuf(condition_proto.value.json_value) return Condition( diff --git a/src/ni/datastore/data/v2/_types/_json_value.py b/src/ni/datastore/data/v2/_types/_json_value.py index 6b15aa62..2b173a81 100644 --- a/src/ni/datastore/data/v2/_types/_json_value.py +++ b/src/ni/datastore/data/v2/_types/_json_value.py @@ -42,8 +42,7 @@ def __init__( Args: value: The JSON object. attributes (optional): Attributes that describe the JSON value. - schema_id (optional): Schema for the JSON content. Reserved by the - service for future support. + schema_id (optional): Schema for the JSON content. Reserved for future use. """ self._value: MutableMapping[str, Any] = dict(value) self._attributes: MutableMapping[str, AttributeValueType] = ( diff --git a/src/ni/datastore/data/v2/_types/_measurement.py b/src/ni/datastore/data/v2/_types/_measurement.py index 649f179c..d02d52cf 100644 --- a/src/ni/datastore/data/v2/_types/_measurement.py +++ b/src/ni/datastore/data/v2/_types/_measurement.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Iterable, Mapping, MutableMapping, MutableSequence +from typing import Any, Iterable, Mapping, MutableMapping, MutableSequence, Sequence import hightime as ht from ni.measurements.data.v2.data_store_pb2 import Measurement as MeasurementProto @@ -86,22 +86,22 @@ def test_adapter_ids(self) -> MutableSequence[str]: @property def software_items(self) -> MutableSequence[SoftwareItem]: - """Software items to find or create on create, or expanded on read.""" + """Software items to find or create on create, or expand on read.""" return self._software_items @property def hardware_items(self) -> MutableSequence[HardwareItem]: - """Hardware items to find or create on create, or expanded on read.""" + """Hardware items to find or create on create, or expand on read.""" return self._hardware_items @property def test_adapters(self) -> MutableSequence[TestAdapter]: - """Test adapters to find or create on create, or expanded on read.""" + """Test adapters to find or create on create, or expand on read.""" return self._test_adapters @property - def conditions(self) -> MutableSequence[Condition]: - """Output only. The conditions copied from the owning step.""" + def conditions(self) -> Sequence[Condition]: + """Read-only output-only conditions copied from the owning step.""" return self._conditions def __init__( @@ -139,6 +139,10 @@ def __init__( ) -> None: """Initialize a Measurement instance. + Output-only fields are accepted so a Measurement can represent a service + response. They are not included in protobuf requests. Update conditions by + calling DataStoreClient.add_condition or remove_condition on the owning step. + Args: id (optional): Unique identifier of the measurement. test_result_id (optional): ID of the test result the measurement belongs to. @@ -147,9 +151,11 @@ def __init__( value (optional): The measured value. See DataStoreClient.create_measurement for the supported types. upper_limit (optional): The publisher-supplied upper limit. Any value type - supported for value is accepted. + supported for value is accepted. The service stores it but does not + compare it with the value. lower_limit (optional): The publisher-supplied lower limit. Any value type - supported for value is accepted. + supported for value is accepted. The service stores it but does not + compare it with the value. value_type (optional): Output only. The service-generated value type. units (optional): Engineering units of the value. Defaults to the units of a Scalar value. @@ -160,21 +166,22 @@ def __init__( outcome (optional): The outcome of the measurement. error_information (optional): Error details for the measurement. extension (optional): Custom JSON data for the measurement. - schema_id (optional): Schema that governs the extension. Reserved by the - service for future support. + schema_id (optional): Schema that governs the extension. Reserved for + future use. software_item_ids (optional): IDs of software items used for the measurement. hardware_item_ids (optional): IDs of hardware items used for the measurement. test_adapter_ids (optional): IDs of test adapters used for the measurement. software_items (optional): Software items to find or create on create. Each - item must already be linked to the test result. + item must already be linked to the owning test result. hardware_items (optional): Hardware items to find or create on create. Each - item must already be linked to the test result. + item must already be linked to the owning test result. test_adapters (optional): Test adapters to find or create on create. Each - item must already be linked to the test result. + item must already be linked to the owning test result. comparison_type (optional): The publisher-defined comparison operation. - conditions (optional): Output only. Conditions copied from the owning step. - created_at (optional): Output only. When the service created the measurement. - updated_at (optional): Output only. When the service last updated the measurement. + conditions (optional): Output only. Read-only conditions copied from the + owning step. + created_at (optional): Output only. When the measurement was created. + updated_at (optional): Output only. When the measurement was last updated. created_by (optional): Output only. Who created the measurement. updated_by (optional): Output only. Who last updated the measurement. """ @@ -214,8 +221,8 @@ def __init__( list(test_adapters) if test_adapters is not None else [] ) self.comparison_type = comparison_type - self._conditions: MutableSequence[Condition] = ( - list(conditions) if conditions is not None else [] + self._conditions: tuple[Condition, ...] = ( + tuple(conditions) if conditions is not None else () ) self.created_at = created_at self.updated_at = updated_at @@ -251,14 +258,18 @@ def from_protobuf(measurement_proto: MeasurementProto) -> "Measurement": hardware_item_ids=measurement_proto.hardware_item_ids, test_adapter_ids=measurement_proto.test_adapter_ids, software_items=[ - SoftwareItem.from_protobuf(i) for i in measurement_proto.software_items + SoftwareItem.from_protobuf(item) for item in measurement_proto.software_items ], hardware_items=[ - HardwareItem.from_protobuf(i) for i in measurement_proto.hardware_items + HardwareItem.from_protobuf(item) for item in measurement_proto.hardware_items + ], + test_adapters=[ + TestAdapter.from_protobuf(adapter) for adapter in measurement_proto.test_adapters ], - test_adapters=[TestAdapter.from_protobuf(i) for i in measurement_proto.test_adapters], comparison_type=measurement_proto.comparison_type, - conditions=[Condition.from_protobuf(c) for c in measurement_proto.conditions], + conditions=[ + Condition.from_protobuf(condition) for condition in measurement_proto.conditions + ], created_at=timestamp_from_protobuf(measurement_proto, "created_at"), updated_at=timestamp_from_protobuf(measurement_proto, "updated_at"), created_by=measurement_proto.created_by, @@ -268,7 +279,8 @@ def from_protobuf(measurement_proto: MeasurementProto) -> "Measurement": def to_protobuf(self) -> MeasurementProto: """Convert this Measurement to a protobuf Measurement message. - Output-only fields, such as value_type and conditions, are not included. + Output-only fields, such as value_type and conditions, are not included in + protobuf requests. """ return MeasurementProto( id=self.id, @@ -294,7 +306,7 @@ def to_protobuf(self) -> MeasurementProto: test_adapter_ids=self.test_adapter_ids, software_items=[item.to_protobuf() for item in self.software_items], hardware_items=[item.to_protobuf() for item in self.hardware_items], - test_adapters=[item.to_protobuf() for item in self.test_adapters], + test_adapters=[adapter.to_protobuf() for adapter in self.test_adapters], comparison_type=self.comparison_type, ) @@ -349,5 +361,5 @@ def _values_equal(left: object, right: object) -> bool: def _as_scalar(value: object) -> object: - # A plain value is read back as a Scalar, so compare it as one. + # Normalize plain values so protobuf comparison includes Scalar unit attributes. return Scalar(value) if isinstance(value, (bool, int, float, str)) else value diff --git a/src/ni/datastore/data/v2/_types/_query_results.py b/src/ni/datastore/data/v2/_types/_query_results.py index 0c141766..3562c22c 100644 --- a/src/ni/datastore/data/v2/_types/_query_results.py +++ b/src/ni/datastore/data/v2/_types/_query_results.py @@ -9,10 +9,14 @@ class QueryResults(list[T]): - """A list of query results with the service's next-page link.""" + """One page of query results with a continuation link when more results exist. + + Page size is not configured by this class; it contains the items and link + returned by the query. + """ next_link: str - """The OData link to the next page of results, or an empty string on the last page.""" + """The link for retrieving the next page, or an empty string when there is none.""" def __init__(self, items: Iterable[T] = (), next_link: str = "") -> None: """Initialize a QueryResults instance. diff --git a/src/ni/datastore/data/v2/_types/_step.py b/src/ni/datastore/data/v2/_types/_step.py index c97a5f2e..4bfdc40e 100644 --- a/src/ni/datastore/data/v2/_types/_step.py +++ b/src/ni/datastore/data/v2/_types/_step.py @@ -78,22 +78,22 @@ def conditions(self) -> MutableSequence[Condition]: @property def measurements(self) -> MutableSequence[Measurement]: - """Measurements created with the step, or expanded on read.""" + """Measurements created with the step, or expand on read.""" return self._measurements @property def children(self) -> MutableSequence[Step]: - """Child steps created with the step, or expanded on read.""" + """Child steps created with the step, or expand on read.""" return self._children @property def path(self) -> MutableSequence[str]: - """Output only. The step-name path, including this step.""" + """Output only. Names of this Step and its ancestors, ordered root-first.""" return self._path @property def path_ids(self) -> MutableSequence[str]: - """Output only. The step-identifier path, including this step.""" + """Output only. IDs of this Step and its ancestors, ordered root-first.""" return self._path_ids def __init__( @@ -134,8 +134,9 @@ def __init__( id (optional): Unique identifier for the step. parent_step_id (optional): ID of the parent step if this is a nested step. test_result_id (optional): ID of the test result this step belongs to. - test_id (optional): ID or alias of the test associated with this step. - test (optional): Test to find or create on create, or expanded on read. + test_id (optional): ID or registered alias of the test associated with this + step. Reads return the ID. + test (optional): Test to find or create on create, or expand on read. step_type (optional): Free-form classification of the step. notes (optional): Additional notes about the step. start_date_time (optional): The start date and time of the step execution. @@ -146,16 +147,16 @@ def __init__( link (optional): A URI associated with the step. keywords (optional): Labels used to classify the step. extension (optional): Custom JSON data for the step. - schema_id (optional): Schema that governs the extension. Reserved by the - service for future support. + schema_id (optional): Schema that governs the extension. Reserved for + future use. error_information (optional): Error details for the step. conditions (optional): Conditions created with the step. measurements (optional): Measurements created with the step. children (optional): Child steps created with the step. path (optional): Output only. The step-name path, including this step. path_ids (optional): Output only. The step-identifier path, including this step. - created_at (optional): Output only. When the service created the step. - updated_at (optional): Output only. When the service last updated the step. + created_at (optional): Output only. When the step was created. + updated_at (optional): Output only. When the step was last updated. created_by (optional): Output only. Who created the step. updated_by (optional): Output only. Who last updated the step. """ @@ -217,8 +218,10 @@ def from_protobuf(step_proto: StepProto) -> "Step": if step_proto.HasField("error_information") else None ), - conditions=[Condition.from_protobuf(c) for c in step_proto.conditions], - measurements=[Measurement.from_protobuf(m) for m in step_proto.measurements], + conditions=[Condition.from_protobuf(condition) for condition in step_proto.conditions], + measurements=[ + Measurement.from_protobuf(measurement) for measurement in step_proto.measurements + ], children=[Step.from_protobuf(child) for child in step_proto.children], path=step_proto.path, path_ids=step_proto.path_ids, diff --git a/src/ni/datastore/data/v2/_types/_test_result.py b/src/ni/datastore/data/v2/_types/_test_result.py index 0c334e8f..0cec45d5 100644 --- a/src/ni/datastore/data/v2/_types/_test_result.py +++ b/src/ni/datastore/data/v2/_types/_test_result.py @@ -92,22 +92,22 @@ def test_adapter_ids(self) -> MutableSequence[str]: @property def software_items(self) -> MutableSequence[SoftwareItem]: - """Software items to find or create on create, or expanded on read.""" + """Software items to find or create on create, or expand on read.""" return self._software_items @property def hardware_items(self) -> MutableSequence[HardwareItem]: - """Hardware items to find or create on create, or expanded on read.""" + """Hardware items to find or create on create, or expand on read.""" return self._hardware_items @property def test_adapters(self) -> MutableSequence[TestAdapter]: - """Test adapters to find or create on create, or expanded on read.""" + """Test adapters to find or create on create, or expand on read.""" return self._test_adapters @property def steps(self) -> MutableSequence[Step]: - """Steps created with the test result, or expanded on read.""" + """Steps created with the test result, or expand on read.""" return self._steps @property @@ -162,19 +162,23 @@ def __init__( Args: name: Human-readable name for the test result. id (optional): Unique identifier for the test result. - uut_instance_id (optional): ID or alias of the UUT instance that was tested. + uut_instance_id (optional): ID or registered alias of the UUT instance that + was tested. Reads return the ID. uut_id (optional): ID of the UUT product definition. - operator_id (optional): ID or alias of the operator who ran the test. - test_station_id (optional): ID or alias of the test station used. - test_description_id (optional): ID or alias of the test description executed. - uut_instance (optional): UUT instance to find or create on create, or expanded + operator_id (optional): ID or registered alias of the operator who ran the + test. Reads return the ID. + test_station_id (optional): ID or registered alias of the test station used. + Reads return the ID. + test_description_id (optional): ID or registered alias of the test + description executed. Reads return the ID. + uut_instance (optional): UUT instance to find or create on create, or expand on read. - uut (optional): UUT to find or create on create, or expanded on read. - operator (optional): Operator to find or create on create, or expanded on read. - test_station (optional): Test station to find or create on create, or expanded + uut (optional): UUT to find or create on create, or expand on read. + operator (optional): Operator to find or create on create, or expand on read. + test_station (optional): Test station to find or create on create, or expand on read. test_description (optional): Test description to find or create on create, or - expanded on read. + expand on read. software_item_ids (optional): IDs of software items used in the test. hardware_item_ids (optional): IDs of hardware items used in the test. test_adapter_ids (optional): IDs of test adapters used in the test. @@ -189,12 +193,12 @@ def __init__( link (optional): A URI associated with the test result. keywords (optional): Labels used to classify the test result. extension (optional): Custom JSON data for the test result. - schema_id (optional): Schema that governs the extension. Reserved by the - service for future support. + schema_id (optional): Schema that governs the extension. Reserved for + future use. error_information (optional): Error details for the test result. steps (optional): Steps created with the test result. - created_at (optional): Output only. When the service created the test result. - updated_at (optional): Output only. When the service last updated the test result. + created_at (optional): Output only. When the test result was created. + updated_at (optional): Output only. When the test result was last updated. created_by (optional): Output only. Who created the test result. updated_by (optional): Output only. Who last updated the test result. """ @@ -276,9 +280,9 @@ def from_protobuf(test_result_proto: TestResultProto) -> "TestResult": software_item_ids=proto.software_item_ids, hardware_item_ids=proto.hardware_item_ids, test_adapter_ids=proto.test_adapter_ids, - software_items=[SoftwareItem.from_protobuf(i) for i in proto.software_items], - hardware_items=[HardwareItem.from_protobuf(i) for i in proto.hardware_items], - test_adapters=[TestAdapter.from_protobuf(i) for i in proto.test_adapters], + software_items=[SoftwareItem.from_protobuf(item) for item in proto.software_items], + hardware_items=[HardwareItem.from_protobuf(item) for item in proto.hardware_items], + test_adapters=[TestAdapter.from_protobuf(adapter) for adapter in proto.test_adapters], start_date_time=timestamp_from_protobuf(proto, "start_date_time"), end_date_time=timestamp_from_protobuf(proto, "end_date_time"), duration=duration_from_protobuf(proto, "duration"), @@ -323,7 +327,7 @@ def to_protobuf(self) -> TestResultProto: test_adapter_ids=self.test_adapter_ids, software_items=[item.to_protobuf() for item in self.software_items], hardware_items=[item.to_protobuf() for item in self.hardware_items], - test_adapters=[item.to_protobuf() for item in self.test_adapters], + test_adapters=[adapter.to_protobuf() for adapter in self.test_adapters], start_date_time=timestamp_to_protobuf(self.start_date_time), end_date_time=timestamp_to_protobuf(self.end_date_time), duration=duration_to_protobuf(self.duration), diff --git a/tests/unit/data/test_v2_data_store_client.py b/tests/unit/data/test_v2_data_store_client.py index 50fe8a32..2fb683f1 100644 --- a/tests/unit/data/test_v2_data_store_client.py +++ b/tests/unit/data/test_v2_data_store_client.py @@ -39,6 +39,8 @@ @pytest.fixture def service_client(mocker: MockerFixture) -> Any: service_client = Mock() + # Default responses for tests that check only the request. Tests that check + # the returned model set their own response. service_client.update_test_result.return_value = data_store_pb2.TestResult() service_client.update_step.return_value = data_store_pb2.Step() service_client.update_measurement.return_value = data_store_pb2.Measurement() @@ -239,6 +241,7 @@ def test___create_measurement___converts_analog_waveform( value = _sent(service_client.create_measurement).measurement.value assert value.WhichOneof("value") == "double_analog_waveform" assert list(value.double_analog_waveform.y_data) == [1.0, 2.0] + assert value.double_analog_waveform.dt == 1.0 def test___create_measurement___rejects_unsupported_value_before_rpc( @@ -312,7 +315,9 @@ def test___read_measurement_value___converts_json( value.json_value.value.update({"enabled": True}) _read_response(service_client, value) - assert client.read_measurement_value(Measurement(id="m-id")) == {"enabled": True} + read_value = client.read_measurement_value(Measurement(id="m-id")) + + assert read_value == {"enabled": True} def test___read_measurement_value___converts_analog_waveform( @@ -405,11 +410,12 @@ def test___query_test_results___returns_models( test_results=[data_store_pb2.TestResult(id="tr-id", name="Power test")] ) ) + odata_query = "$filter=Name eq 'Power test'" - results = client.query_test_results("$filter=Name eq 'Power test'", read_fields=["name"]) + results = client.query_test_results(odata_query, read_fields=["name"]) request = _sent(service_client.query_test_results) - assert request.odata_query == "$filter=Name eq 'Power test'" + assert request.odata_query == odata_query assert list(request.read_mask.paths) == ["name"] assert results == [TestResult("Power test", id="tr-id")] @@ -418,10 +424,11 @@ def test___query_steps___returns_models(client: DataStoreClient, service_client: service_client.query_steps.return_value = data_store_service_pb2.QueryStepsResponse( steps=[data_store_pb2.Step(id="step-id", name="s")] ) + odata_query = "$filter=Name eq 's'" - steps = client.query_steps("$filter=Name eq 's'") + steps = client.query_steps(odata_query) - assert _sent(service_client.query_steps).odata_query == "$filter=Name eq 's'" + assert _sent(service_client.query_steps).odata_query == odata_query assert steps == [Step("s", id="step-id")] @@ -433,19 +440,20 @@ def test___query_measurements___returns_models( measurements=[data_store_pb2.Measurement(id="m-id", name="voltage")] ) ) + odata_query = "$filter=Name eq 'voltage'" - measurements = client.query_measurements("$filter=Name eq 'voltage'") + measurements = client.query_measurements(odata_query) - assert _sent(service_client.query_measurements).odata_query == "$filter=Name eq 'voltage'" + assert _sent(service_client.query_measurements).odata_query == odata_query assert measurements == [Measurement(id="m-id", name="voltage")] @pytest.mark.parametrize( - ("method", "response_type", "items_field"), + ("method", "response_type"), [ - ("query_test_results", "QueryTestResultsResponse", "test_results"), - ("query_steps", "QueryStepsResponse", "steps"), - ("query_measurements", "QueryMeasurementsResponse", "measurements"), + ("query_test_results", "QueryTestResultsResponse"), + ("query_steps", "QueryStepsResponse"), + ("query_measurements", "QueryMeasurementsResponse"), ], ) def test___query___returns_next_link( @@ -453,7 +461,6 @@ def test___query___returns_next_link( service_client: Any, method: str, response_type: str, - items_field: str, ) -> None: getattr(service_client, method).return_value = getattr(data_store_service_pb2, response_type)( next_link="https://next" @@ -470,7 +477,9 @@ def test___query___next_link_is_empty_on_last_page( ) -> None: service_client.query_steps.return_value = data_store_service_pb2.QueryStepsResponse() - assert client.query_steps().next_link == "" + results = client.query_steps() + + assert results.next_link == "" def test___json_value_measurement___sends_and_reads_attributes( @@ -483,7 +492,9 @@ def test___json_value_measurement___sends_and_reads_attributes( sent = _sent(service_client.create_measurement).measurement.value _read_response(service_client, sent) - assert client.read_measurement_value(Measurement(id="m-id")) == value + read_value = client.read_measurement_value(Measurement(id="m-id")) + + assert read_value == value # ---------------------------------------------------------------- update @@ -526,8 +537,6 @@ def test___update_step___builds_mask_and_returns_model( def test___update_measurement___converts_error_information_and_extension( client: DataStoreClient, service_client: Any ) -> None: - service_client.update_measurement.return_value = data_store_pb2.Measurement(id="m-id") - client.update_measurement( "m-id", error_information=ErrorInformation(error_code=2, message="boom"), @@ -576,18 +585,21 @@ def test___update___replaces_keywords( def test___update___converts_duration( client: DataStoreClient, service_client: Any, method: str, resource_field: str ) -> None: - getattr(client, method)("resource-id", duration=timedelta(seconds=3)) + duration = timedelta(seconds=3) + + getattr(client, method)("resource-id", duration=duration) request = _sent(getattr(service_client, method)) - assert getattr(request, resource_field).duration == hightime_timedelta_to_protobuf( - timedelta(seconds=3) - ) + assert getattr(request, resource_field).duration == hightime_timedelta_to_protobuf(duration) assert list(request.update_mask.paths) == ["duration"] -def test___update___none_clears_field(client: DataStoreClient, service_client: Any) -> None: +def test___update___none_sends_field_in_mask_with_empty_value( + client: DataStoreClient, service_client: Any +) -> None: client.update_test_result("tr-id", link=None) + # The service clears a field that is in the update mask but has an empty value. request = _sent(service_client.update_test_result) assert request.test_result.link == "" assert list(request.update_mask.paths) == ["link"] @@ -702,6 +714,24 @@ def test___remove_test_result_metadata___item_used_by_measurement___raises_witho service_client.remove_test_result_metadata.assert_not_called() +def test___remove_test_result_metadata___measurements_span_multiple_pages___raises_without_removing( + client: DataStoreClient, service_client: Any +) -> None: + first_page = data_store_service_pb2.QueryMeasurementsResponse( + measurements=[data_store_pb2.Measurement(id="m-1", software_item_ids=["other"])], + next_link="https://next", + ) + second_page = data_store_service_pb2.QueryMeasurementsResponse( + measurements=[data_store_pb2.Measurement(id="m-2", software_item_ids=["sw"])] + ) + service_client.query_measurements.side_effect = [first_page, second_page] + + with pytest.raises(RuntimeError, match="more than one page"): + client.remove_test_result_metadata("tr-id", software_item_ids=["sw"]) + + service_client.remove_test_result_metadata.assert_not_called() + + def test___remove_test_result_metadata___no_ids___skips_measurement_check( client: DataStoreClient, service_client: Any ) -> None: @@ -773,11 +803,9 @@ def test___metadata_link_methods___send_ids_and_return_model( request = _sent(getattr(service_client, method)) assert getattr(request, id_field) == "resource-id" - assert ( - list(request.software_item_ids), - list(request.hardware_item_ids), - list(request.test_adapter_ids), - ) == (["sw"], ["hw"], ["ta"]) + assert list(request.software_item_ids) == ["sw"] + assert list(request.hardware_item_ids) == ["hw"] + assert list(request.test_adapter_ids) == ["ta"] assert result.id == "resource-id" diff --git a/tests/unit/data/test_v2_types.py b/tests/unit/data/test_v2_types.py index 6ed99957..427ca8e7 100644 --- a/tests/unit/data/test_v2_types.py +++ b/tests/unit/data/test_v2_types.py @@ -80,7 +80,7 @@ def test___test_result___round_trips_through_protobuf() -> None: assert TestResult.from_protobuf(proto) == test_result -def test___test_result___unset_optional_fields_read_as_none() -> None: +def test___test_result___unset_optional_fields_read_as_default() -> None: test_result = TestResult.from_protobuf(data_store_pb2.TestResult(id="id", name="name")) assert test_result.start_date_time is None @@ -150,22 +150,49 @@ def test___measurement___reads_metadata_and_conditions_from_protobuf() -> None: assert measurement.outcome == Outcome.PASSED assert measurement.start_date_time == START assert list(measurement.software_item_ids) == ["sw"] - assert measurement.conditions == [Condition("temperature", 25.0)] + assert measurement.conditions == (Condition("temperature", 25.0),) -def test___measurement___to_protobuf_does_not_send_value_or_output_fields() -> None: +def test___measurement___conditions_are_read_only() -> None: + condition = Condition("temperature", 25.0) + measurement = Measurement(conditions=[condition]) + + assert isinstance(measurement.conditions, tuple) + with pytest.raises(AttributeError): + getattr(measurement.conditions, "append")(Condition("humidity", 50.0)) + with pytest.raises(AttributeError): + getattr(measurement.conditions, "pop")(0) + + +def test___measurement___accepts_but_does_not_send_output_fields() -> None: + condition = Condition("temperature", 25.0) measurement = Measurement( - id="measurement-id", name="voltage", step_id="step-id", value_type="x" + id="measurement-id", + name="voltage", + step_id="step-id", + value_type="x", + conditions=[condition], ) proto = measurement.to_protobuf() assert proto.id == "measurement-id" - assert not proto.HasField("value") + assert measurement.value_type == "x" + assert measurement.conditions == (condition,) assert proto.value_type == "" assert len(proto.conditions) == 0 +def test___measurement___to_protobuf_omits_unset_value() -> None: + measurement = Measurement(name="voltage", step_id="step-id") + + proto = measurement.to_protobuf() + + assert not proto.HasField("value") + assert not proto.HasField("upper_limit") + assert not proto.HasField("lower_limit") + + @pytest.mark.parametrize( ("value", "field", "expected"), [ @@ -189,6 +216,29 @@ def test___condition___uses_scalar_units_when_units_not_given() -> None: assert proto.value.scalar.double_value == 3.3 +def test___condition___scalar_with_only_units_reads_as_plain_value() -> None: + proto = Condition("voltage", Scalar(3.3, "V")).to_protobuf() + + condition = Condition.from_protobuf(proto) + + assert condition.value == 3.3 + assert not isinstance(condition.value, Scalar) + assert condition.units == "V" + + +def test___condition___scalar_extended_properties_round_trip() -> None: + proto = Condition( + "supply", Scalar(1.25, "V", extended_properties={"custom": "source"}) + ).to_protobuf() + + condition = Condition.from_protobuf(proto) + + assert isinstance(condition.value, Scalar) + assert condition.value.value == 1.25 + assert condition.value.extended_properties["custom"] == "source" + assert condition.units == "V" + + def test___condition___converts_json_mapping() -> None: proto = Condition("config", {"mode": "fast"}).to_protobuf() @@ -284,7 +334,7 @@ def test___test_result___round_trips_metadata_objects_and_nested_steps() -> None assert TestResult.from_protobuf(proto) == test_result -def test___measurement___plain_value_equals_scalar_read_back() -> None: +def test___measurement___plain_value_equals_scalar() -> None: assert Measurement(value=1.0, upper_limit=2) == Measurement( value=Scalar(1.0), upper_limit=Scalar(2) ) From 132111e6884f75c84529e57a992eaf133749eafd Mon Sep 17 00:00:00 2001 From: "Balusamy, Sudharshana" Date: Thu, 29 Oct 2026 12:33:12 +0530 Subject: [PATCH 10/13] rseolve comments and update dependency packages --- poetry.lock | 4 ++-- src/ni/datastore/data/v2/_data_store_client.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 4cff025f..1cbf3158 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1385,7 +1385,7 @@ ni-measurements-data-v2-proto = ">=1.0.0.dev0" type = "git" url = "https://github.com/ni/ni-apis-python.git" reference = "users/sudharshana/add-v2-proto-and-client-package" -resolved_reference = "28aee05fe1ce46d2d349311cd600bf0a47937d1d" +resolved_reference = "ec5f96947086e862712f3def3f7f745a89cc2ef9" subdirectory = "packages/ni.measurements.data.v2.client" [[package]] @@ -1409,7 +1409,7 @@ protobuf = ">=4.21" type = "git" url = "https://github.com/ni/ni-apis-python.git" reference = "users/sudharshana/add-v2-proto-and-client-package" -resolved_reference = "28aee05fe1ce46d2d349311cd600bf0a47937d1d" +resolved_reference = "ec5f96947086e862712f3def3f7f745a89cc2ef9" subdirectory = "packages/ni.measurements.data.v2.proto" [[package]] diff --git a/src/ni/datastore/data/v2/_data_store_client.py b/src/ni/datastore/data/v2/_data_store_client.py index f275e606..d70513f4 100644 --- a/src/ni/datastore/data/v2/_data_store_client.py +++ b/src/ni/datastore/data/v2/_data_store_client.py @@ -878,9 +878,9 @@ def batch_delete_test_results( """Delete multiple test results in one atomic call. Args: - test_result_ids: The IDs of all test results to delete. + test_result_ids: The IDs of the test results to delete. - force: One flag for every test result, or one flag per test result. + force: One flag for all test results, or one flag per test result. See delete_test_result for details on force behavior. Raises: @@ -903,7 +903,7 @@ def batch_delete_steps( step_ids: The IDs of the steps to delete. A step and its ancestor or descendant cannot be deleted in the same call. - force: One flag for every step, or one flag per step. See delete_step + force: One flag for all steps, or one flag per step. See delete_step for details on force behavior. Raises: From eb276fe342ea3d9cdcb446129c1a169d9bbbdd87 Mon Sep 17 00:00:00 2001 From: "Balusamy, Sudharshana" Date: Mon, 2 Nov 2026 18:29:59 +0530 Subject: [PATCH 11/13] resolve comments --- docs/reference/ni-data-store-v2.md | 15 +- .../v2/update-delete/update_delete.ipynb | 28 +-- .../datastore/data/v2/_data_store_client.py | 104 +++------- src/ni/datastore/data/v2/_grpc_conversion.py | 9 + .../datastore/data/v2/_types/_measurement.py | 13 +- src/ni/datastore/data/v2/_types/_step.py | 7 +- .../datastore/data/v2/_types/_test_result.py | 15 +- tests/unit/data/test_v2_data_store_client.py | 191 ++++++++++++------ tests/unit/data/test_v2_types.py | 49 +++++ 9 files changed, 253 insertions(+), 178 deletions(-) diff --git a/docs/reference/ni-data-store-v2.md b/docs/reference/ni-data-store-v2.md index 5c3a3b8d..3e1b2197 100644 --- a/docs/reference/ni-data-store-v2.md +++ b/docs/reference/ni-data-store-v2.md @@ -59,12 +59,11 @@ The client wraps every method of the V2 low-level client: - `remove_condition` removes a condition from a step. - `add_test_result_metadata`, `remove_test_result_metadata`, `add_measurement_metadata`, and `remove_measurement_metadata` link or unlink - software items, hardware items, and test adapters after creation. As defined - by DEC-017, `remove_test_result_metadata` raises `ValueError`, without - removing anything, if a measurement of the test result still references an - item; unlink it from those measurements first. If the test result's - measurements come back across more than one page of query results, it raises - `RuntimeError` instead of removing, because the check cannot be completed. + software items, hardware items, and test adapters after creation. A + measurement can only link items that are linked to its test result, and each + measurement keeps its own selection. Linking an item to a test result does not + link it to the test result's measurements. Unlinking an item from a test + result also unlinks it from any of its measurements that still reference it. - `create_measurement` accepts `upper_limit` and `lower_limit` of any supported value type. The service stores the limits as given and does not compare them with the value. `read_measurement_values` returns the value and both limits. @@ -115,6 +114,10 @@ The client wraps every method of the V2 low-level client: require the service's Stage 3 Structured JSON Storage And Query Support. - Software item, hardware item, and test adapter IDs on a measurement must first be linked to its test result; pass them to `create_test_result`. +- Arguments that take several strings, such as `keywords`, item IDs, + `read_fields`, and the IDs passed to batch deletes, raise `TypeError` when + given a single string. Pass a list, for example `keywords=["smoke"]`, so a + string is not split into its characters. - Discovery, channel configuration, and cleanup are delegated to the low-level V2 client. Its gRPC transport exceptions pass through unchanged; invalid wrapper values and update fields fail locally with `TypeError` or diff --git a/examples/notebooks/v2/update-delete/update_delete.ipynb b/examples/notebooks/v2/update-delete/update_delete.ipynb index b827ade7..8563c286 100644 --- a/examples/notebooks/v2/update-delete/update_delete.ipynb +++ b/examples/notebooks/v2/update-delete/update_delete.ipynb @@ -207,9 +207,9 @@ "source": [ "## Link and Unlink Metadata\n", "\n", - "Link a software item to the test result first. Then a measurement of that test result can also link to it.\n", + "Link a software item to the test result first. Then a measurement of that test result can also link to it. Each measurement keeps its own selection: linking an item to the test result does not link it to the test result's measurements.\n", "\n", - "To unlink, reverse the order: an item cannot be unlinked from a test result while any of its measurements still references it (DEC-017). The service can also link a test result's item to its existing measurements, so find every measurement that references the item, unlink it from each one, and then unlink it from the test result." + "To unlink, reverse the order: unlink the item from the measurement, then from the test result. Unlinking an item from a test result also unlinks it from any of its measurements that still reference it." ] }, { @@ -229,29 +229,15 @@ ")\n", "print(f\"measurement software items: {list(measurement.software_item_ids)}\")\n", "\n", - "try:\n", - " data_store_client.remove_test_result_metadata(\n", - " test_result_id, software_item_ids=[software_item_id]\n", - " )\n", - "except ValueError as e:\n", - " print(f\"Not unlinked yet: {e}\")\n", - "\n", - "linked_measurements = [\n", - " measurement\n", - " for measurement in data_store_client.query_measurements(\n", - " f\"$filter=TestResultId eq {test_result_id}\", read_fields=[\"id\", \"software_item_ids\"]\n", - " )\n", - " if software_item_id in measurement.software_item_ids\n", - "]\n", - "for measurement in linked_measurements:\n", - " data_store_client.remove_measurement_metadata(\n", - " measurement.id, software_item_ids=[software_item_id]\n", - " )\n", + "measurement = data_store_client.remove_measurement_metadata(\n", + " measurement_ids[0], software_item_ids=[software_item_id]\n", + ")\n", + "print(f\"measurement software items after unlinking: {list(measurement.software_item_ids)}\")\n", "\n", "test_result = data_store_client.remove_test_result_metadata(\n", " test_result_id, software_item_ids=[software_item_id]\n", ")\n", - "print(f\"unlinked from {len(linked_measurements)} measurement(s) and from the test result\")\n", + "print(f\"test result software items after unlinking: {list(test_result.software_item_ids)}\")\n", "assert list(test_result.software_item_ids) == []" ] }, diff --git a/src/ni/datastore/data/v2/_data_store_client.py b/src/ni/datastore/data/v2/_data_store_client.py index d70513f4..ddeb69db 100644 --- a/src/ni/datastore/data/v2/_data_store_client.py +++ b/src/ni/datastore/data/v2/_data_store_client.py @@ -58,6 +58,7 @@ from ni.datastore.data.v2._grpc_conversion import ( convert_measurement_value_from_protobuf, measurement_value_from_protobuf, + string_list, ) from ni.datastore.data.v2._types._condition import Condition from ni.datastore.data.v2._types._error_information import ErrorInformation @@ -693,6 +694,10 @@ def add_test_result_metadata( ) -> TestResult: """Link existing software items, hardware items, or test adapters to a test result. + The items become available to the test result's measurements, but are not + linked to them. Link an item to a specific measurement with + add_measurement_metadata. + Args: test_result_id: The ID of the test result. @@ -707,9 +712,9 @@ def add_test_result_metadata( """ add_request = AddTestResultMetadataRequest( test_result_id=test_result_id, - software_item_ids=software_item_ids, - hardware_item_ids=hardware_item_ids, - test_adapter_ids=test_adapter_ids, + software_item_ids=string_list(software_item_ids, "software_item_ids"), + hardware_item_ids=string_list(hardware_item_ids, "hardware_item_ids"), + test_adapter_ids=string_list(test_adapter_ids, "test_adapter_ids"), ) return TestResult.from_protobuf( self._get_data_store_client().add_test_result_metadata(add_request).test_result @@ -725,10 +730,9 @@ def remove_test_result_metadata( ) -> TestResult: """Unlink software items, hardware items, or test adapters from a test result. - An item cannot be unlinked while a measurement of the test result still - references it. Unlink it from those measurements first with - remove_measurement_metadata. The client checks this before calling the - service, so nothing is removed when the check fails. + The service also unlinks the items from any measurements of the test result + that still reference them, because a measurement can only reference items + that are linked to its owning test result. Args: test_result_id: The ID of the test result. @@ -741,30 +745,12 @@ def remove_test_result_metadata( Returns: TestResult: The updated test result. - - Raises: - ValueError: If a measurement of the test result still references an - item to unlink. - - RuntimeError: If the test result's measurements are returned across - more than one page, so the check cannot be completed. """ - software_item_ids = list(software_item_ids) - hardware_item_ids = list(hardware_item_ids) - test_adapter_ids = list(test_adapter_ids) - self._raise_if_used_by_measurements( - test_result_id, - { - "software_item_ids": set(software_item_ids), - "hardware_item_ids": set(hardware_item_ids), - "test_adapter_ids": set(test_adapter_ids), - }, - ) remove_request = RemoveTestResultMetadataRequest( test_result_id=test_result_id, - software_item_ids=software_item_ids, - hardware_item_ids=hardware_item_ids, - test_adapter_ids=test_adapter_ids, + software_item_ids=string_list(software_item_ids, "software_item_ids"), + hardware_item_ids=string_list(hardware_item_ids, "hardware_item_ids"), + test_adapter_ids=string_list(test_adapter_ids, "test_adapter_ids"), ) return TestResult.from_protobuf( self._get_data_store_client().remove_test_result_metadata(remove_request).test_result @@ -796,9 +782,9 @@ def add_measurement_metadata( """ add_request = AddMeasurementMetadataRequest( measurement_id=measurement_id, - software_item_ids=software_item_ids, - hardware_item_ids=hardware_item_ids, - test_adapter_ids=test_adapter_ids, + software_item_ids=string_list(software_item_ids, "software_item_ids"), + hardware_item_ids=string_list(hardware_item_ids, "hardware_item_ids"), + test_adapter_ids=string_list(test_adapter_ids, "test_adapter_ids"), ) return Measurement.from_protobuf( self._get_data_store_client().add_measurement_metadata(add_request).measurement @@ -828,9 +814,9 @@ def remove_measurement_metadata( """ remove_request = RemoveMeasurementMetadataRequest( measurement_id=measurement_id, - software_item_ids=software_item_ids, - hardware_item_ids=hardware_item_ids, - test_adapter_ids=test_adapter_ids, + software_item_ids=string_list(software_item_ids, "software_item_ids"), + hardware_item_ids=string_list(hardware_item_ids, "hardware_item_ids"), + test_adapter_ids=string_list(test_adapter_ids, "test_adapter_ids"), ) return Measurement.from_protobuf( self._get_data_store_client().remove_measurement_metadata(remove_request).measurement @@ -889,7 +875,9 @@ def batch_delete_test_results( batch_request = BatchDeleteTestResultsRequest( requests=[ DeleteTestResultRequest(test_result_id=test_result_id, force=item_force) - for test_result_id, item_force in _with_force(test_result_ids, force) + for test_result_id, item_force in _with_force( + test_result_ids, force, "test_result_ids" + ) ] ) self._get_data_store_client().batch_delete_test_results(batch_request) @@ -912,7 +900,7 @@ def batch_delete_steps( batch_request = BatchDeleteStepsRequest( requests=[ DeleteStepRequest(step_id=step_id, force=item_force) - for step_id, item_force in _with_force(step_ids, force) + for step_id, item_force in _with_force(step_ids, force, "step_ids") ] ) self._get_data_store_client().batch_delete_steps(batch_request) @@ -926,7 +914,7 @@ def batch_delete_measurements(self, measurement_ids: Iterable[str]) -> None: batch_request = BatchDeleteMeasurementsRequest( requests=[ DeleteMeasurementRequest(measurement_id=measurement_id) - for measurement_id in measurement_ids + for measurement_id in string_list(measurement_ids, "measurement_ids") ] ) self._get_data_store_client().batch_delete_measurements(batch_request) @@ -936,38 +924,6 @@ def _get_data_store_client(self) -> DataStoreServiceClient: raise RuntimeError(self._DATA_STORE_CLIENT_CLOSED_ERROR) return self._data_store_client - def _raise_if_used_by_measurements( - self, test_result_id: str, removed_ids: Mapping[str, set[str]] - ) -> None: - if not any(removed_ids.values()): - return - query_request = QueryMeasurementsRequest( - odata_query=f"$filter=TestResultId eq {test_result_id}", - read_mask=FieldMask(paths=["id", *removed_ids]), - ) - measurements_response = self._get_data_store_client().query_measurements(query_request) - if measurements_response.next_link: - raise RuntimeError( - f"Cannot unlink metadata from test result {test_result_id} because its " - "measurements span more than one page of query results, so the client cannot " - "verify that none of them still reference the metadata." - ) - measurements = measurements_response.measurements - in_use = sorted( - { - f"{item_id} (measurement {measurement.id})" - for measurement in measurements - for field, ids in removed_ids.items() - for item_id in ids.intersection(getattr(measurement, field)) - } - ) - if in_use: - raise ValueError( - f"Cannot unlink metadata from test result {test_result_id} while measurements " - f"still reference it: {', '.join(in_use)}. Unlink it from those measurements " - "first with remove_measurement_metadata." - ) - # Mutable update-mask paths from the V2 mutability design, including Stage 2 resource fields. _TEST_RESULT_UPDATE_FIELDS = frozenset( @@ -1029,7 +985,7 @@ def _raise_if_used_by_measurements( def _read_mask(fields: Iterable[str]) -> FieldMask | None: - paths = list(fields) + paths = string_list(fields, "read_fields") return FieldMask(paths=paths) if paths else None @@ -1041,8 +997,10 @@ def _require_items( return updates -def _with_force(ids: Iterable[str], force: bool | Iterable[bool]) -> list[tuple[str, bool]]: - id_list = list(ids) +def _with_force( + ids: Iterable[str], force: bool | Iterable[bool], ids_name: str +) -> list[tuple[str, bool]]: + id_list = string_list(ids, ids_name) if isinstance(force, bool): return [(resource_id, force) for resource_id in id_list] force_list = list(force) @@ -1073,7 +1031,7 @@ def _populate_update( elif name == "duration": target.duration.CopyFrom(hightime_timedelta_to_protobuf(cast(ht.timedelta, value))) elif name == "keywords": - target.keywords[:] = cast(Iterable[str], value) + target.keywords[:] = string_list(cast(Iterable[str], value), "keywords") elif name == "extension": json_format.ParseDict(dict(cast(Mapping[str, Any], value)), target.extension) elif name == "error_information": diff --git a/src/ni/datastore/data/v2/_grpc_conversion.py b/src/ni/datastore/data/v2/_grpc_conversion.py index 1dc899c9..43ea7535 100644 --- a/src/ni/datastore/data/v2/_grpc_conversion.py +++ b/src/ni/datastore/data/v2/_grpc_conversion.py @@ -217,3 +217,12 @@ def extension_to_protobuf(value: Mapping[str, Any]) -> Struct | None: def extension_from_protobuf(value: Struct) -> dict[str, Any]: """Convert a protobuf Struct extension to a Python dictionary.""" return json_format.MessageToDict(value) + + +def string_list(values: Iterable[str] | None, name: str) -> list[str]: + """Convert an iterable of strings to a list, rejecting a single string.""" + # A str is itself an iterable of one-character strings, so list("smoke") would + # silently become ["s", "m", "o", "k", "e"]. + if isinstance(values, str): + raise TypeError(f"{name} must be a list or other iterable of strings, not a single str.") + return list(values) if values is not None else [] diff --git a/src/ni/datastore/data/v2/_types/_measurement.py b/src/ni/datastore/data/v2/_types/_measurement.py index d02d52cf..afee5ded 100644 --- a/src/ni/datastore/data/v2/_types/_measurement.py +++ b/src/ni/datastore/data/v2/_types/_measurement.py @@ -15,6 +15,7 @@ extension_to_protobuf, measurement_value_from_protobuf, measurement_value_to_protobuf, + string_list, timestamp_from_protobuf, timestamp_to_protobuf, ) @@ -202,14 +203,14 @@ def __init__( self.error_information = error_information self._extension: MutableMapping[str, Any] = dict(extension) if extension is not None else {} self.schema_id = schema_id - self._software_item_ids: MutableSequence[str] = ( - list(software_item_ids) if software_item_ids is not None else [] + self._software_item_ids: MutableSequence[str] = string_list( + software_item_ids, "software_item_ids" ) - self._hardware_item_ids: MutableSequence[str] = ( - list(hardware_item_ids) if hardware_item_ids is not None else [] + self._hardware_item_ids: MutableSequence[str] = string_list( + hardware_item_ids, "hardware_item_ids" ) - self._test_adapter_ids: MutableSequence[str] = ( - list(test_adapter_ids) if test_adapter_ids is not None else [] + self._test_adapter_ids: MutableSequence[str] = string_list( + test_adapter_ids, "test_adapter_ids" ) self._software_items: MutableSequence[SoftwareItem] = ( list(software_items) if software_items is not None else [] diff --git a/src/ni/datastore/data/v2/_types/_step.py b/src/ni/datastore/data/v2/_types/_step.py index 4bfdc40e..8166e808 100644 --- a/src/ni/datastore/data/v2/_types/_step.py +++ b/src/ni/datastore/data/v2/_types/_step.py @@ -12,6 +12,7 @@ duration_to_protobuf, extension_from_protobuf, extension_to_protobuf, + string_list, timestamp_from_protobuf, timestamp_to_protobuf, ) @@ -174,7 +175,7 @@ def __init__( self.outcome = outcome self.status = status self.link = link - self._keywords: MutableSequence[str] = list(keywords) if keywords is not None else [] + self._keywords: MutableSequence[str] = string_list(keywords, "keywords") self._extension: MutableMapping[str, Any] = dict(extension) if extension is not None else {} self.schema_id = schema_id self.error_information = error_information @@ -185,8 +186,8 @@ def __init__( list(measurements) if measurements is not None else [] ) self._children: MutableSequence[Step] = list(children) if children is not None else [] - self._path: MutableSequence[str] = list(path) if path is not None else [] - self._path_ids: MutableSequence[str] = list(path_ids) if path_ids is not None else [] + self._path: MutableSequence[str] = string_list(path, "path") + self._path_ids: MutableSequence[str] = string_list(path_ids, "path_ids") self.created_at = created_at self.updated_at = updated_at self.created_by = created_by diff --git a/src/ni/datastore/data/v2/_types/_test_result.py b/src/ni/datastore/data/v2/_types/_test_result.py index 0cec45d5..1b9cbe87 100644 --- a/src/ni/datastore/data/v2/_types/_test_result.py +++ b/src/ni/datastore/data/v2/_types/_test_result.py @@ -12,6 +12,7 @@ duration_to_protobuf, extension_from_protobuf, extension_to_protobuf, + string_list, timestamp_from_protobuf, timestamp_to_protobuf, ) @@ -214,14 +215,14 @@ def __init__( self.operator = operator self.test_station = test_station self.test_description = test_description - self._software_item_ids: MutableSequence[str] = ( - list(software_item_ids) if software_item_ids is not None else [] + self._software_item_ids: MutableSequence[str] = string_list( + software_item_ids, "software_item_ids" ) - self._hardware_item_ids: MutableSequence[str] = ( - list(hardware_item_ids) if hardware_item_ids is not None else [] + self._hardware_item_ids: MutableSequence[str] = string_list( + hardware_item_ids, "hardware_item_ids" ) - self._test_adapter_ids: MutableSequence[str] = ( - list(test_adapter_ids) if test_adapter_ids is not None else [] + self._test_adapter_ids: MutableSequence[str] = string_list( + test_adapter_ids, "test_adapter_ids" ) self._software_items: MutableSequence[SoftwareItem] = ( list(software_items) if software_items is not None else [] @@ -238,7 +239,7 @@ def __init__( self.outcome = outcome self.status = status self.link = link - self._keywords: MutableSequence[str] = list(keywords) if keywords is not None else [] + self._keywords: MutableSequence[str] = string_list(keywords, "keywords") self._extension: MutableMapping[str, Any] = dict(extension) if extension is not None else {} self.schema_id = schema_id self.error_information = error_information diff --git a/tests/unit/data/test_v2_data_store_client.py b/tests/unit/data/test_v2_data_store_client.py index 2fb683f1..0e9dd8d2 100644 --- a/tests/unit/data/test_v2_data_store_client.py +++ b/tests/unit/data/test_v2_data_store_client.py @@ -3,6 +3,7 @@ from __future__ import annotations import datetime as std_datetime +from collections.abc import Callable from typing import Any from unittest.mock import Mock @@ -44,9 +45,6 @@ def service_client(mocker: MockerFixture) -> Any: service_client.update_test_result.return_value = data_store_pb2.TestResult() service_client.update_step.return_value = data_store_pb2.Step() service_client.update_measurement.return_value = data_store_pb2.Measurement() - service_client.query_measurements.return_value = ( - data_store_service_pb2.QueryMeasurementsResponse() - ) service_client.remove_test_result_metadata.return_value = ( data_store_service_pb2.RemoveTestResultMetadataResponse() ) @@ -675,70 +673,22 @@ def test___delete___defaults_to_no_force(client: DataStoreClient, service_client # ---------------------------------------------------------------- conditions and metadata links -def test___remove_test_result_metadata___checks_measurements_before_removing( - client: DataStoreClient, service_client: Any -) -> None: - service_client.query_measurements.return_value = ( - data_store_service_pb2.QueryMeasurementsResponse( - measurements=[data_store_pb2.Measurement(id="m-id", software_item_ids=["other"])] - ) - ) - - client.remove_test_result_metadata("tr-id", software_item_ids=["sw"]) - - query = _sent(service_client.query_measurements) - assert query.odata_query == "$filter=TestResultId eq tr-id" - assert list(query.read_mask.paths) == [ - "id", - "software_item_ids", - "hardware_item_ids", - "test_adapter_ids", - ] - assert list(_sent(service_client.remove_test_result_metadata).software_item_ids) == ["sw"] - - -@pytest.mark.parametrize("field", ["software_item_ids", "hardware_item_ids", "test_adapter_ids"]) -def test___remove_test_result_metadata___item_used_by_measurement___raises_without_removing( - client: DataStoreClient, service_client: Any, field: str -) -> None: - measurement = data_store_pb2.Measurement(id="m-id") - getattr(measurement, field).append("item") - service_client.query_measurements.return_value = ( - data_store_service_pb2.QueryMeasurementsResponse(measurements=[measurement]) - ) - item_ids: dict[str, Any] = {field: ["item"]} - - with pytest.raises(ValueError, match=r"item \(measurement m-id\)"): - client.remove_test_result_metadata("tr-id", **item_ids) - - service_client.remove_test_result_metadata.assert_not_called() - - -def test___remove_test_result_metadata___measurements_span_multiple_pages___raises_without_removing( +def test___remove_test_result_metadata___sends_request_without_checking_measurements( client: DataStoreClient, service_client: Any ) -> None: - first_page = data_store_service_pb2.QueryMeasurementsResponse( - measurements=[data_store_pb2.Measurement(id="m-1", software_item_ids=["other"])], - next_link="https://next", - ) - second_page = data_store_service_pb2.QueryMeasurementsResponse( - measurements=[data_store_pb2.Measurement(id="m-2", software_item_ids=["sw"])] + client.remove_test_result_metadata( + "tr-id", software_item_ids=["sw"], hardware_item_ids=["hw"], test_adapter_ids=["ta"] ) - service_client.query_measurements.side_effect = [first_page, second_page] - - with pytest.raises(RuntimeError, match="more than one page"): - client.remove_test_result_metadata("tr-id", software_item_ids=["sw"]) - - service_client.remove_test_result_metadata.assert_not_called() - - -def test___remove_test_result_metadata___no_ids___skips_measurement_check( - client: DataStoreClient, service_client: Any -) -> None: - client.remove_test_result_metadata("tr-id") service_client.query_measurements.assert_not_called() - service_client.remove_test_result_metadata.assert_called_once() + assert _sent(service_client.remove_test_result_metadata) == ( + data_store_service_pb2.RemoveTestResultMetadataRequest( + test_result_id="tr-id", + software_item_ids=["sw"], + hardware_item_ids=["hw"], + test_adapter_ids=["ta"], + ) + ) def test___remove_condition___sends_name_and_returns_step( @@ -1074,6 +1024,123 @@ def test___batch_delete_measurements___sends_ids( assert [r.measurement_id for r in requests] == ["a", "b"] +# ---------------------------------------------------------------- string list arguments + + +@pytest.mark.parametrize( + ("argument", "call"), + [ + pytest.param( + "keywords", + lambda client: client.update_test_result("tr-id", keywords="smoke"), + id="update_test_result", + ), + pytest.param( + "keywords", + lambda client: client.update_step("step-id", keywords="smoke"), + id="update_step", + ), + pytest.param( + "keywords", + lambda client: client.batch_update_test_results({"tr-id": {"keywords": "smoke"}}), + id="batch_update_test_results", + ), + pytest.param( + "keywords", + lambda client: client.batch_update_steps({"step-id": {"keywords": "smoke"}}), + id="batch_update_steps", + ), + pytest.param( + "software_item_ids", + lambda client: client.create_measurement("v", 1.0, "step-id", software_item_ids="sw"), + id="create_measurement", + ), + pytest.param( + "software_item_ids", + lambda client: client.add_test_result_metadata("tr-id", software_item_ids="sw"), + id="add_test_result_metadata", + ), + pytest.param( + "hardware_item_ids", + lambda client: client.remove_test_result_metadata("tr-id", hardware_item_ids="hw"), + id="remove_test_result_metadata", + ), + pytest.param( + "test_adapter_ids", + lambda client: client.add_measurement_metadata("m-id", test_adapter_ids="ta"), + id="add_measurement_metadata", + ), + pytest.param( + "software_item_ids", + lambda client: client.remove_measurement_metadata("m-id", software_item_ids="sw"), + id="remove_measurement_metadata", + ), + pytest.param( + "read_fields", + lambda client: client.get_test_result("tr-id", read_fields="name"), + id="get_test_result", + ), + pytest.param( + "read_fields", + lambda client: client.get_step("step-id", read_fields="name"), + id="get_step", + ), + pytest.param( + "read_fields", + lambda client: client.get_measurement("m-id", read_fields="name"), + id="get_measurement", + ), + pytest.param( + "read_fields", + lambda client: client.query_test_results(read_fields="name"), + id="query_test_results", + ), + pytest.param( + "read_fields", + lambda client: client.query_steps(read_fields="name"), + id="query_steps", + ), + pytest.param( + "read_fields", + lambda client: client.query_measurements(read_fields="name"), + id="query_measurements", + ), + pytest.param( + "read_fields", + lambda client: client.read_measurement_values( + Measurement(id="m-id"), read_fields="value" + ), + id="read_measurement_values", + ), + pytest.param( + "test_result_ids", + lambda client: client.batch_delete_test_results("tr-id"), + id="batch_delete_test_results", + ), + pytest.param( + "step_ids", + lambda client: client.batch_delete_steps("step-id"), + id="batch_delete_steps", + ), + pytest.param( + "measurement_ids", + lambda client: client.batch_delete_measurements("m-id"), + id="batch_delete_measurements", + ), + ], +) +def test___string_list_argument___single_string___raises_before_rpc( + client: DataStoreClient, + service_client: Any, + argument: str, + call: Callable[[DataStoreClient], object], +) -> None: + with pytest.raises(TypeError, match=f"^{argument} must be"): + call(client) + + assert service_client.method_calls == [] + + # ---------------------------------------------------------------- lifecycle diff --git a/tests/unit/data/test_v2_types.py b/tests/unit/data/test_v2_types.py index 427ca8e7..9a4abcc0 100644 --- a/tests/unit/data/test_v2_types.py +++ b/tests/unit/data/test_v2_types.py @@ -3,6 +3,7 @@ from __future__ import annotations import datetime as std_datetime +from collections.abc import Callable from typing import Any import pytest @@ -263,6 +264,54 @@ def test___enums___reject_unknown_protobuf_values(enum_type: type[Outcome] | typ enum_type.from_protobuf(999) # type: ignore[arg-type] +@pytest.mark.parametrize( + ("argument", "create_model"), + [ + pytest.param( + "keywords", lambda: TestResult("t", keywords="smoke"), id="test_result_keywords" + ), + pytest.param( + "software_item_ids", + lambda: TestResult("t", software_item_ids="sw"), + id="test_result_software_item_ids", + ), + pytest.param( + "hardware_item_ids", + lambda: TestResult("t", hardware_item_ids="hw"), + id="test_result_hardware_item_ids", + ), + pytest.param( + "test_adapter_ids", + lambda: TestResult("t", test_adapter_ids="ta"), + id="test_result_test_adapter_ids", + ), + pytest.param("keywords", lambda: Step("s", keywords="smoke"), id="step_keywords"), + pytest.param("path", lambda: Step("s", path="root"), id="step_path"), + pytest.param("path_ids", lambda: Step("s", path_ids="root-id"), id="step_path_ids"), + pytest.param( + "software_item_ids", + lambda: Measurement(software_item_ids="sw"), + id="measurement_software_item_ids", + ), + pytest.param( + "hardware_item_ids", + lambda: Measurement(hardware_item_ids="hw"), + id="measurement_hardware_item_ids", + ), + pytest.param( + "test_adapter_ids", + lambda: Measurement(test_adapter_ids="ta"), + id="measurement_test_adapter_ids", + ), + ], +) +def test___models___single_string_for_string_list___raises_type_error( + argument: str, create_model: Callable[[], object] +) -> None: + with pytest.raises(TypeError, match=f"^{argument} must be"): + create_model() + + def test___measurement___round_trips_value_limits_and_metadata() -> None: measurement = Measurement( name="voltage", From ddf0900ecb03db19df6104147d2b8b12bc9314c6 Mon Sep 17 00:00:00 2001 From: "Balusamy, Sudharshana" Date: Mon, 2 Nov 2026 21:20:06 +0530 Subject: [PATCH 12/13] resolve comments --- docs/reference/ni-data-store-v2.md | 5 +++-- examples/notebooks/v2/update-delete/update_delete.ipynb | 2 +- src/ni/datastore/data/v2/_data_store_client.py | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/reference/ni-data-store-v2.md b/docs/reference/ni-data-store-v2.md index 3e1b2197..04a6aa6d 100644 --- a/docs/reference/ni-data-store-v2.md +++ b/docs/reference/ni-data-store-v2.md @@ -62,8 +62,9 @@ The client wraps every method of the V2 low-level client: software items, hardware items, and test adapters after creation. A measurement can only link items that are linked to its test result, and each measurement keeps its own selection. Linking an item to a test result does not - link it to the test result's measurements. Unlinking an item from a test - result also unlinks it from any of its measurements that still reference it. + link it to the test result's measurements. An item cannot be unlinked from a + test result while a measurement of that test result still references it; + unlink it from those measurements first. - `create_measurement` accepts `upper_limit` and `lower_limit` of any supported value type. The service stores the limits as given and does not compare them with the value. `read_measurement_values` returns the value and both limits. diff --git a/examples/notebooks/v2/update-delete/update_delete.ipynb b/examples/notebooks/v2/update-delete/update_delete.ipynb index 8563c286..16aee3e2 100644 --- a/examples/notebooks/v2/update-delete/update_delete.ipynb +++ b/examples/notebooks/v2/update-delete/update_delete.ipynb @@ -209,7 +209,7 @@ "\n", "Link a software item to the test result first. Then a measurement of that test result can also link to it. Each measurement keeps its own selection: linking an item to the test result does not link it to the test result's measurements.\n", "\n", - "To unlink, reverse the order: unlink the item from the measurement, then from the test result. Unlinking an item from a test result also unlinks it from any of its measurements that still reference it." + "To unlink, reverse the order: unlink the item from the measurement, then from the test result. An item cannot be unlinked from a test result while any of its measurements still references it." ] }, { diff --git a/src/ni/datastore/data/v2/_data_store_client.py b/src/ni/datastore/data/v2/_data_store_client.py index ddeb69db..5f67642b 100644 --- a/src/ni/datastore/data/v2/_data_store_client.py +++ b/src/ni/datastore/data/v2/_data_store_client.py @@ -730,9 +730,9 @@ def remove_test_result_metadata( ) -> TestResult: """Unlink software items, hardware items, or test adapters from a test result. - The service also unlinks the items from any measurements of the test result - that still reference them, because a measurement can only reference items - that are linked to its owning test result. + An item cannot be unlinked from a test result while a measurement of that + test result still references it. Unlink it from those measurements first + with remove_measurement_metadata. Args: test_result_id: The ID of the test result. From 57bcc0d5c8bba930d11fbbabbc1e8201d39e4606 Mon Sep 17 00:00:00 2001 From: "Balusamy, Sudharshana" Date: Thu, 5 Nov 2026 19:00:54 +0530 Subject: [PATCH 13/13] resolve comments --- docs/reference/ni-data-store-v2.md | 81 +- examples/notebooks/poetry.lock | 2 +- examples/notebooks/v2/README.md | 2 +- .../v2/batch-create/batch_create.ipynb | 30 +- .../measurement_values.ipynb | 16 +- .../v2/overview/publish_measurement.ipynb | 6 +- .../v2/query/publish_sample_data.ipynb | 10 +- ...ry_measurements.ipynb => query_data.ipynb} | 2 +- .../structured_extensions.ipynb | 6 +- .../v2/update-delete/update_delete.ipynb | 115 +-- examples/overview-v2/poetry.lock | 2 +- examples/overview-v2/src/overview.py | 66 +- pyproject.toml | 3 +- src/ni/datastore/data/v2/__init__.py | 45 + .../datastore/data/v2/_data_store_client.py | 794 +++++++++++------- src/ni/datastore/data/v2/_grpc_conversion.py | 32 +- src/ni/datastore/data/v2/_types/_condition.py | 18 +- src/ni/datastore/data/v2/_types/_create.py | 91 ++ .../datastore/data/v2/_types/_measurement.py | 57 +- .../data/v2/_types/_measurement_value.py | 45 + .../datastore/data/v2/_types/_read_fields.py | 117 +++ src/ni/datastore/data/v2/_types/_step.py | 18 +- .../datastore/data/v2/_types/_test_result.py | 41 +- src/ni/datastore/data/v2/_types/_update.py | 431 ++++++++++ .../datastore/data/v2/_update_conversion.py | 158 ++++ tests/unit/data/test_v2_data_store_client.py | 581 ++++++++++++- tests/unit/data/test_v2_types.py | 158 +++- tests/unit/data/test_v2_update_builders.py | 201 +++++ 28 files changed, 2605 insertions(+), 523 deletions(-) rename examples/notebooks/v2/query/{query_measurements.ipynb => query_data.ipynb} (99%) create mode 100644 src/ni/datastore/data/v2/_types/_create.py create mode 100644 src/ni/datastore/data/v2/_types/_measurement_value.py create mode 100644 src/ni/datastore/data/v2/_types/_read_fields.py create mode 100644 src/ni/datastore/data/v2/_types/_update.py create mode 100644 src/ni/datastore/data/v2/_update_conversion.py create mode 100644 tests/unit/data/test_v2_update_builders.py diff --git a/docs/reference/ni-data-store-v2.md b/docs/reference/ni-data-store-v2.md index 04a6aa6d..fdd04e79 100644 --- a/docs/reference/ni-data-store-v2.md +++ b/docs/reference/ni-data-store-v2.md @@ -20,36 +20,75 @@ from ni.datastore.data.v2 import ( ) with DataStoreClient() as client: - test_result_id = client.create_test_result( + test_result = client.create_test_result( TestResult("Power test", start_date_time=datetime.now(tz=timezone.utc)) ) - step_id = client.create_step( - Step("Measure voltage", test_result_id=test_result_id, status=Status.RUNNING) + step = client.create_step( + Step("Measure voltage", test_result_id=test_result.id, status=Status.RUNNING) ) client.add_condition( - step_id, Condition("supply-voltage", 5.0, condition_type="Nominal", units="V") + step.id, Condition("supply-voltage", 5.0, condition_type="Nominal", units="V") ) - measurement_id = client.create_measurement( + measurement = client.create_measurement( "voltage", 5.02, - step_id, + step.id, units="V", outcome=Outcome.PASSED, ) - measurement = client.get_measurement(measurement_id) voltage = client.read_measurement_value(measurement) ``` -The client also provides `get_test_result`, `get_step`, `query_test_results`, -`query_steps`, `query_measurements`, and update methods for test results, steps, -and measurements. These return the Python types, with timestamps as -`hightime.datetime`, durations as `hightime.timedelta`, and extensions as -dictionaries. Queries accept an OData expression and optional `read_fields`. -Updates accept mutable fields as keyword arguments and build the protobuf field -mask automatically. Empty updates and fields that the service does not allow to -change raise `ValueError` before an RPC is made. +Each create method accepts either the object or its fields as arguments, so +`client.create_step("Measure voltage", test_result_id=test_result.id)` and +`client.create_measurement(Measurement(name="voltage", value=5.02, step_id=step.id))` +also work. `TestResultCreateFields` and `StepCreateFields` type the keyword fields. +Create methods return the created object as saved by the service, including its +generated ID. Nested resources, such as the steps of a test result, and expanded +metadata are not included. The client also provides `get_test_result`, `get_step`, +`query_test_results`, `query_steps`, `query_measurements`, and update methods for +test results, steps, and measurements. These return the Python types, with +timestamps as `hightime.datetime`, durations as `hightime.timedelta`, and +extensions as dictionaries. Queries accept an OData expression and optional +`read_fields`. Read fields are selected with `TestResultField`, `StepField`, or +`MeasurementField`, for example `read_fields=[StepField.CONDITIONS]`; field names +as strings are also accepted. + +Updates change only the fields named in the protobuf update mask, and the client +builds the mask for you. Pass a `Step`, or its ID, with keyword fields, an update +builder, or the whole object: + +```python +from ni.datastore.data.v2 import Outcome, StepUpdateBuilder + +# Keyword fields: only these fields change. None clears a field. +data_store_client.update_step(step, notes="Verified", outcome=Outcome.PASSED) + +# Update builder: each with_ call selects one field, like the .NET client builders. +update = StepUpdateBuilder(step).with_notes("Verified").with_outcome(Outcome.PASSED) +data_store_client.update_step(update) + +# Whole object: every updatable field is set from the object, so empty fields are cleared. +step.notes = "Verified" +data_store_client.update_step(step) +``` + +Keyword fields are typed by `TestResultUpdateFields`, `StepUpdateFields`, and +`MeasurementUpdateFields`, so type checkers report unknown fields and wrong value +types. `TestResultUpdateBuilder`, `StepUpdateBuilder`, and +`MeasurementUpdateBuilder` follow the .NET builders: setting a relationship by ID, +such as `with_operator_id`, replaces inline metadata set earlier, and inline +metadata replaces an ID. A whole-object update sends Stage 2 fields (`keywords`, +`duration`, and `uut_id`/`uut`) only when they have a value, and sends only the ID +when a relationship has both an ID and inline metadata. Empty updates and fields +that the service does not allow to change raise `ValueError` before an RPC is made. + +IDs can be passed as `str` or `uuid.UUID`, and reads return them as `str`. +Test result, step, and measurement IDs must be UUIDs. Metadata ID fields, such as +`operator_id` or `software_item_ids`, also accept registered aliases. `delete_test_result`, `delete_step`, and `delete_measurement` remove resources. +They and the batch delete methods accept the objects or their IDs. Without `force=True`, deleting a test result with steps, or a step with measurements, fails with `FAILED_PRECONDITION`; with `force=True`, the owned children are deleted too. A step that has child steps cannot be deleted. @@ -67,11 +106,13 @@ The client wraps every method of the V2 low-level client: unlink it from those measurements first. - `create_measurement` accepts `upper_limit` and `lower_limit` of any supported value type. The service stores the limits as given and does not compare them - with the value. `read_measurement_values` returns the value and both limits. + with the value. `read_measurement_values` returns the value and both limits; + select fields with `MeasurementValueField`. `read_measurement_value` and + `read_measurement_values` accept a `Measurement` or its ID. - `batch_create_*`, `batch_update_*`, and `batch_delete_*` handle many test results, steps, or measurements in one atomic call. Batch updates take a - dictionary of fields keyed by ID, and batch deletes accept one `force` flag or - one flag per ID. + dictionary of fields keyed by ID, or a list of builders and objects. Batch + deletes accept one `force` flag or one flag per ID. - `TestResult.steps`, `Step.children`, and `Step.measurements` create a whole tree in one call. Metadata objects such as `Operator` or `SoftwareItem` can be passed instead of IDs to find or create them. @@ -106,7 +147,9 @@ The client wraps every method of the V2 low-level client: the Stage 2 New Resource Fields feature. - Measurement values are converted to scalar, vector, supported NI waveform-family, or JSON values; `read_measurement_value` converts them back to - Python or nitypes values. A single value is always read back as a `Scalar`, + Python or nitypes values. The `MeasurementValue` type lists the values that can + be written, and `MeasurementReadValue` lists the values that can be read. A + single value is always read back as a `Scalar`, with or without units, and a list of values as a `Vector`. When a `Scalar` is given and `units` is not, the measurement's `units` are taken from the `Scalar`. Conditions accept scalar or JSON values. A scalar condition value diff --git a/examples/notebooks/poetry.lock b/examples/notebooks/poetry.lock index 5881f68d..d43f8758 100644 --- a/examples/notebooks/poetry.lock +++ b/examples/notebooks/poetry.lock @@ -1805,7 +1805,7 @@ protobuf = ">=4.21" [[package]] name = "ni-datastore" -version = "2.2.0.dev0" +version = "3.0.0.dev0" description = "APIs for publishing and retrieving data from NI Measurement Data Services" optional = false python-versions = "^3.10" diff --git a/examples/notebooks/v2/README.md b/examples/notebooks/v2/README.md index 307d037b..cc1010d0 100644 --- a/examples/notebooks/v2/README.md +++ b/examples/notebooks/v2/README.md @@ -33,7 +33,7 @@ target, such as `[::1]:59606`, before starting Jupyter. condition, then query and read it back. - `query/publish_sample_data.ipynb`: Create sample data for the query notebook. Run this first. -- `query/query_measurements.ipynb`: Query test results, steps, measurements, and +- `query/query_data.ipynb`: Query test results, steps, measurements, and conditions with OData. - `batch-create/batch_create.ipynb`: Create test results, steps, and measurements in atomic batches, including nested steps and measurements. diff --git a/examples/notebooks/v2/batch-create/batch_create.ipynb b/examples/notebooks/v2/batch-create/batch_create.ipynb index f4417198..c035e2fd 100644 --- a/examples/notebooks/v2/batch-create/batch_create.ipynb +++ b/examples/notebooks/v2/batch-create/batch_create.ipynb @@ -46,9 +46,11 @@ "id": "6e3908b8", "metadata": {}, "source": [ - "## Create a Test Result with Nested Steps and Measurements\n", + "## Create a Test Result with Nested Steps, Measurements, and Metadata\n", "\n", - "A test result can include its steps, and a step can include its conditions, measurements, and child steps. One `batch_create_test_results` call creates the whole tree." + "A test result can include its steps, and a step can include its conditions, measurements, and child steps. One `batch_create_test_results` call creates the whole tree.\n", + "\n", + "Metadata, such as the operator, test station, software items, and the test a step runs, can be written inline as objects. The service finds a matching record or creates a new one in the same call, so there is no need to create metadata first. Create metadata separately only when you want to share the same records across many test results by ID." ] }, { @@ -59,12 +61,18 @@ "outputs": [], "source": [ "from ni.datastore.data.v2 import Condition, Measurement, Step, TestResult\n", + "from ni.datastore.metadata import HardwareItem, Operator, SoftwareItem, Test, TestStation\n", "\n", "test_result = TestResult(\n", " name=\"Batch Create Example\",\n", + " operator=Operator(name=\"Example Operator\", role=\"Test Engineer\"),\n", + " test_station=TestStation(name=\"Example Station\"),\n", + " software_items=[SoftwareItem(product=\"Example Driver\", version=\"1.0.0\")],\n", + " hardware_items=[HardwareItem(manufacturer=\"NI\", model=\"PXIe-4081\", serial_number=\"EX-001\")],\n", " steps=[\n", " Step(\n", " name=\"Example Parent Step\",\n", + " test=Test(name=\"Example Voltage Test\", description=\"Checks the supply voltage.\"),\n", " conditions=[Condition(\"Ambient Temperature\", 25.0, condition_type=\"Environment\", units=\"degC\")],\n", " measurements=[Measurement(name=\"Example Parent Measurement\", value=1.0, units=\"V\")],\n", " children=[\n", @@ -77,17 +85,25 @@ " ],\n", ")\n", "\n", - "[test_result_id] = data_store_client.batch_create_test_results([test_result])\n", + "[created_test_result] = data_store_client.batch_create_test_results([test_result])\n", + "test_result_id = created_test_result.id\n", "print(f\"Created Test Result: {test_result_id}\")\n", + "print(f\"Linked operator: {created_test_result.operator_id}\")\n", + "print(f\"Linked test station: {created_test_result.test_station_id}\")\n", + "print(f\"Linked software items: {list(created_test_result.software_item_ids)}\")\n", + "print(f\"Linked hardware items: {list(created_test_result.hardware_item_ids)}\")\n", "\n", "created_steps = data_store_client.query_steps(f\"$filter=TestResultId eq {test_result_id}\")\n", "created_measurements = data_store_client.query_measurements(f\"$filter=TestResultId eq {test_result_id}\")\n", "for step in created_steps:\n", " kind = \"child\" if step.parent_step_id else \"parent\"\n", - " print(f\"Created {kind} step: {step.name}\")\n", + " test_text = f\", test: {step.test_id}\" if step.test_id else \"\"\n", + " print(f\"Created {kind} step: {step.name}{test_text}\")\n", "for measurement in created_measurements:\n", " print(f\"Created measurement: {measurement.name} = {measurement.value}\")\n", "\n", + "assert created_test_result.operator_id\n", + "assert len(created_test_result.software_item_ids) == 1\n", "assert len(created_steps) == 2\n", "assert len(created_measurements) == 2" ] @@ -109,7 +125,7 @@ "metadata": {}, "outputs": [], "source": [ - "step_ids = data_store_client.batch_create_steps(\n", + "new_steps = data_store_client.batch_create_steps(\n", " [\n", " Step(\n", " name=\"Example Step 1\",\n", @@ -123,6 +139,7 @@ " ),\n", " ]\n", ")\n", + "step_ids = [step.id for step in new_steps]\n", "print(f\"Created Steps: {step_ids}\")" ] }, @@ -160,7 +177,7 @@ " ),\n", ")\n", "\n", - "measurement_ids = data_store_client.batch_create_measurements(\n", + "new_measurements = data_store_client.batch_create_measurements(\n", " [\n", " Measurement(name=\"Example Float Measurement\", value=Scalar(4.98, \"V\"), step_id=step_ids[0]),\n", " Measurement(name=\"Example String Measurement\", value=\"nominal\", step_id=step_ids[0]),\n", @@ -168,6 +185,7 @@ " Measurement(name=\"Example AnalogWaveform Measurement\", value=waveform, step_id=step_ids[1]),\n", " ]\n", ")\n", + "measurement_ids = [measurement.id for measurement in new_measurements]\n", "print(f\"Created Measurements: {measurement_ids}\")" ] }, diff --git a/examples/notebooks/v2/measurement-values/measurement_values.ipynb b/examples/notebooks/v2/measurement-values/measurement_values.ipynb index e951f22e..66335176 100644 --- a/examples/notebooks/v2/measurement-values/measurement_values.ipynb +++ b/examples/notebooks/v2/measurement-values/measurement_values.ipynb @@ -52,8 +52,8 @@ "\n", "from ni.datastore.data.v2 import Step, TestResult\n", "\n", - "test_result_id = data_store_client.create_test_result(TestResult(name=\"Measurement Values Example\"))\n", - "step_id = data_store_client.create_step(Step(name=\"Example Step\", test_result_id=test_result_id))\n", + "test_result_id = data_store_client.create_test_result(TestResult(name=\"Measurement Values Example\")).id\n", + "step_id = data_store_client.create_step(Step(name=\"Example Step\", test_result_id=test_result_id)).id\n", "print(f\"Created Test Result: {test_result_id}\")\n", "print(f\"Created Step: {step_id}\")" ] @@ -88,7 +88,7 @@ "}\n", "\n", "scalar_ids = {\n", - " name: data_store_client.create_measurement(name, value, step_id)\n", + " name: data_store_client.create_measurement(name, value, step_id).id\n", " for name, value in scalar_values.items()\n", "}\n", "\n", @@ -120,7 +120,7 @@ "metadata": {}, "outputs": [], "source": [ - "from ni.datastore.data.v2 import Outcome\n", + "from ni.datastore.data.v2 import MeasurementValueField, Outcome\n", "\n", "limited_measurement_id = data_store_client.create_measurement(\n", " \"Example Limited Measurement\",\n", @@ -129,7 +129,7 @@ " upper_limit=5.1,\n", " lower_limit=4.9,\n", " outcome=Outcome.PASSED,\n", - ")\n", + ").id\n", "\n", "values = data_store_client.read_measurement_values(\n", " data_store_client.get_measurement(limited_measurement_id)\n", @@ -140,7 +140,7 @@ "assert values.upper_limit == Scalar(5.1) and values.lower_limit == Scalar(4.9)\n", "\n", "value_only = data_store_client.read_measurement_values(\n", - " data_store_client.get_measurement(limited_measurement_id), read_fields=[\"value\"]\n", + " data_store_client.get_measurement(limited_measurement_id), read_fields=[MeasurementValueField.VALUE]\n", ")\n", "print(f\"value only: {value_only.value}, upper limit: {value_only.upper_limit}\")" ] @@ -193,7 +193,7 @@ "}\n", "\n", "non_scalar_ids = {\n", - " name: data_store_client.create_measurement(name, value, step_id)\n", + " name: data_store_client.create_measurement(name, value, step_id).id\n", " for name, value in non_scalar_values.items()\n", "}\n", "\n", @@ -240,7 +240,7 @@ " step_id,\n", " upper_limit=Vector([3.0, 3.0], units=\"V\"),\n", " lower_limit=Vector([0.0, 0.0], units=\"V\"),\n", - ")\n", + ").id\n", "vector_values = data_store_client.read_measurement_values(\n", " data_store_client.get_measurement(vector_limit_id)\n", ")\n", diff --git a/examples/notebooks/v2/overview/publish_measurement.ipynb b/examples/notebooks/v2/overview/publish_measurement.ipynb index 25af789f..5d7e061c 100644 --- a/examples/notebooks/v2/overview/publish_measurement.ipynb +++ b/examples/notebooks/v2/overview/publish_measurement.ipynb @@ -164,7 +164,7 @@ "metadata": {}, "outputs": [], "source": [ - "test_result_id = data_store_client.create_test_result(test_result)\n", + "test_result_id = data_store_client.create_test_result(test_result).id\n", "print(f\"created test result id: {test_result_id}\")" ] }, @@ -209,14 +209,14 @@ " Condition(\"Ambient Temperature\", 23.0, condition_type=\"Environment\", units=\"degC\"),\n", " ],\n", ")\n", - "step_id = data_store_client.create_step(step)\n", + "step_id = data_store_client.create_step(step).id\n", "published_measurement_id = data_store_client.create_measurement(\n", " name=name,\n", " value=waveform,\n", " step_id=step_id,\n", " units=\"V\",\n", " outcome=Outcome.PASSED,\n", - ")\n", + ").id\n", "print(f\"The published measurement id is {published_measurement_id}\")\n", "\n", "# The test is finished, so mark it as done and record the outcome.\n", diff --git a/examples/notebooks/v2/query/publish_sample_data.ipynb b/examples/notebooks/v2/query/publish_sample_data.ipynb index 44060a5a..4f70cc59 100644 --- a/examples/notebooks/v2/query/publish_sample_data.ipynb +++ b/examples/notebooks/v2/query/publish_sample_data.ipynb @@ -9,7 +9,7 @@ "\n", "This notebook is the V2 companion of `../../query/publish_sample_data.ipynb`. It creates structured sample data in NI Measurement Data Services (V2 mode) that the V2 query notebook uses.\n", "\n", - "**Run this notebook first** before using `query_measurements.ipynb`.\n", + "**Run this notebook first** before using `query_data.ipynb`.\n", "\n", "## What This Creates\n", "\n", @@ -263,7 +263,7 @@ "\n", " Returns the test result ID, the number of measurements, and the number of conditions.\n", " \"\"\"\n", - " test_result_id = data_store_client.create_test_result(test_result)\n", + " test_result_id = data_store_client.create_test_result(test_result).id\n", " measurement_count = 0\n", " condition_count = 0\n", " any_failed = False\n", @@ -276,7 +276,7 @@ " for name, value, units, condition_type in conditions\n", " ],\n", " )\n", - " step_id = data_store_client.create_step(step)\n", + " step_id = data_store_client.create_step(step).id\n", " condition_count += len(conditions)\n", " for measurement_name in measurement_names:\n", " value, units = sample_value(measurement_name)\n", @@ -390,7 +390,7 @@ "print(\" - Steps: 12\")\n", "print(f\" - Measurements: {total_measurements}\")\n", "print(f\" - Conditions: {total_conditions}\")\n", - "print(\"\\n✅ Ready to run query_measurements.ipynb!\")" + "print(\"\\n✅ Ready to run query_data.ipynb!\")" ] }, { @@ -425,7 +425,7 @@ "\n", "✅ **Sample data has been successfully created!**\n", "\n", - "You can now run **`query_measurements.ipynb`** to learn how to query test results, steps, measurements, and conditions with the V2 API.\n", + "You can now run **`query_data.ipynb`** to learn how to query test results, steps, measurements, and conditions with the V2 API.\n", "\n", "To query metadata, such as operators and hardware, use `../../query/query_metadata.ipynb`. The Metadata API is the same in V1 and V2." ] diff --git a/examples/notebooks/v2/query/query_measurements.ipynb b/examples/notebooks/v2/query/query_data.ipynb similarity index 99% rename from examples/notebooks/v2/query/query_measurements.ipynb rename to examples/notebooks/v2/query/query_data.ipynb index 89dc9220..fd698e3a 100644 --- a/examples/notebooks/v2/query/query_measurements.ipynb +++ b/examples/notebooks/v2/query/query_data.ipynb @@ -5,7 +5,7 @@ "id": "4e8596e4", "metadata": {}, "source": [ - "# OData Measurement and Test Result Queries (V2)\n", + "# Querying Data with OData (V2)\n", "\n", "This notebook is the V2 companion of `../../query/query_measurements.ipynb`. It shows how to use OData queries to find test results, steps, measurements, and conditions in NI Measurement Data Services (V2 mode).\n", "\n", diff --git a/examples/notebooks/v2/structured-extensions/structured_extensions.ipynb b/examples/notebooks/v2/structured-extensions/structured_extensions.ipynb index 013267b5..d885392a 100644 --- a/examples/notebooks/v2/structured-extensions/structured_extensions.ipynb +++ b/examples/notebooks/v2/structured-extensions/structured_extensions.ipynb @@ -75,21 +75,21 @@ " \"operator_notes\": \"Second run after recalibration.\",\n", " },\n", " )\n", - ")\n", + ").id\n", "step_id = data_store_client.create_step(\n", " Step(\n", " name=\"Warm-up\",\n", " test_result_id=test_result_id,\n", " extension={\"phase\": {\"name\": \"warmup\", \"seconds\": 30}},\n", " )\n", - ")\n", + ").id\n", "measurement_id = data_store_client.create_measurement(\n", " \"Output Voltage\",\n", " 5.01,\n", " step_id,\n", " units=\"V\",\n", " extension={\"probe\": {\"channel\": 3, \"attenuation\": \"10x\"}},\n", - ")\n", + ").id\n", "print(f\"Created Test Result: {test_result_id}\")" ] }, diff --git a/examples/notebooks/v2/update-delete/update_delete.ipynb b/examples/notebooks/v2/update-delete/update_delete.ipynb index 16aee3e2..8842e798 100644 --- a/examples/notebooks/v2/update-delete/update_delete.ipynb +++ b/examples/notebooks/v2/update-delete/update_delete.ipynb @@ -75,13 +75,14 @@ " SoftwareItem(product=\"Example Driver\", version=\"1.0.0\")\n", ")\n", "\n", - "test_result_id = data_store_client.create_test_result(\n", + "# Create methods return the created objects. Keep them to update and delete them later.\n", + "test_result = data_store_client.create_test_result(\n", " TestResult(name=\"Update and Delete Example\", status=Status.RUNNING)\n", ")\n", - "step_id = data_store_client.create_step(\n", + "step = data_store_client.create_step(\n", " Step(\n", " name=\"Example Step\",\n", - " test_result_id=test_result_id,\n", + " test_result_id=test_result.id,\n", " status=Status.RUNNING,\n", " conditions=[\n", " Condition(\"Ambient Temperature\", 23.0, condition_type=\"Environment\", units=\"degC\"),\n", @@ -89,13 +90,13 @@ " ],\n", " )\n", ")\n", - "measurement_ids = [\n", - " data_store_client.create_measurement(f\"Example Measurement {index}\", float(index), step_id)\n", + "measurements = [\n", + " data_store_client.create_measurement(f\"Example Measurement {index}\", float(index), step.id)\n", " for index in range(3)\n", "]\n", - "print(f\"Created Test Result: {test_result_id}\")\n", - "print(f\"Created Step: {step_id}\")\n", - "print(f\"Created Measurements: {measurement_ids}\")" + "print(f\"Created Test Result: {test_result.id}\")\n", + "print(f\"Created Step: {step.id}\")\n", + "print(f\"Created Measurements: {[measurement.id for measurement in measurements]}\")" ] }, { @@ -105,7 +106,10 @@ "source": [ "## Update Fields\n", "\n", - "Pass the fields to change as keyword arguments. The update returns the changed object.\n", + "There are three ways to update an object. Each returns the changed object.\n", + "- Pass the object, or its ID, with the fields to change as keyword arguments. Only those fields change.\n", + "- Pass an update builder, such as `TestResultUpdateBuilder`. Each `with_` call selects one field to change.\n", + "- Change the object and pass it on its own. Every field that can be updated is sent, so a field that is empty is cleared.\n", "\n", "`status` describes how far the work has progressed, and `outcome` describes the result. They are separate, so a test can be `DONE` and `FAILED`." ] @@ -117,29 +121,30 @@ "metadata": {}, "outputs": [], "source": [ - "updated_step = data_store_client.update_step(\n", - " step_id, status=Status.DONE, outcome=Outcome.FAILED, notes=\"Output was out of range.\"\n", - ")\n", - "print(f\"step: {updated_step.status.name}, {updated_step.outcome.name}, notes='{updated_step.notes}'\")\n", + "from ni.datastore.data.v2 import TestResultUpdateBuilder\n", "\n", - "updated_test_result = data_store_client.update_test_result(\n", - " test_result_id,\n", - " status=Status.DONE,\n", - " outcome=Outcome.FAILED,\n", - " link=\"https://example.com/runs/1\",\n", - ")\n", - "print(\n", - " f\"test result: {updated_test_result.status.name}, {updated_test_result.outcome.name}, \"\n", - " f\"link={updated_test_result.link}\"\n", + "# Keyword fields: only the fields that are passed change.\n", + "step = data_store_client.update_step(\n", + " step, status=Status.DONE, outcome=Outcome.FAILED, notes=\"Output was out of range.\"\n", ")\n", + "print(f\"step: {step.status.name}, {step.outcome.name}, notes='{step.notes}'\")\n", "\n", - "updated_measurement = data_store_client.update_measurement(\n", - " measurement_ids[0], notes=\"Rechecked by operator.\"\n", + "# Update builder: each with_ call selects one field to change.\n", + "test_result = data_store_client.update_test_result(\n", + " TestResultUpdateBuilder(test_result)\n", + " .with_status(Status.DONE)\n", + " .with_outcome(Outcome.FAILED)\n", + " .with_link(\"https://example.com/runs/1\")\n", ")\n", - "print(f\"measurement notes: '{updated_measurement.notes}'\")\n", + "print(f\"test result: {test_result.status.name}, {test_result.outcome.name}, link={test_result.link}\")\n", + "\n", + "# Whole object: change the returned object, then send it back.\n", + "measurements[0].notes = \"Rechecked by operator.\"\n", + "measurements[0] = data_store_client.update_measurement(measurements[0])\n", + "print(f\"measurement notes: '{measurements[0].notes}'\")\n", "\n", - "assert updated_test_result.status == Status.DONE\n", - "assert updated_test_result.outcome == Outcome.FAILED" + "assert test_result.status == Status.DONE\n", + "assert test_result.outcome == Outcome.FAILED" ] }, { @@ -147,7 +152,7 @@ "id": "f093a4c0", "metadata": {}, "source": [ - "Update multiple items in one atomic call by passing the fields for each ID:" + "Update multiple items in one atomic call. Pass update builders or objects, or a dictionary of fields keyed by ID:" ] }, { @@ -157,14 +162,20 @@ "metadata": {}, "outputs": [], "source": [ - "updated_measurements = data_store_client.batch_update_measurements(\n", - " {\n", - " measurement_ids[0]: {\"outcome\": Outcome.PASSED},\n", - " measurement_ids[1]: {\"outcome\": Outcome.FAILED},\n", - " measurement_ids[2]: {\"outcome\": Outcome.PASSED, \"notes\": \"Borderline.\"},\n", - " }\n", + "from ni.datastore.data.v2 import MeasurementUpdateBuilder\n", + "\n", + "measurements = list(\n", + " data_store_client.batch_update_measurements(\n", + " [\n", + " MeasurementUpdateBuilder(measurements[0]).with_outcome(Outcome.PASSED),\n", + " MeasurementUpdateBuilder(measurements[1]).with_outcome(Outcome.FAILED),\n", + " MeasurementUpdateBuilder(measurements[2])\n", + " .with_outcome(Outcome.PASSED)\n", + " .with_notes(\"Borderline.\"),\n", + " ]\n", + " )\n", ")\n", - "for measurement in updated_measurements:\n", + "for measurement in measurements:\n", " print(f\"{measurement.name}: {measurement.outcome.name}\")" ] }, @@ -186,12 +197,12 @@ "outputs": [], "source": [ "step = data_store_client.add_condition(\n", - " step_id, Condition(\"Relative Humidity\", 45.0, condition_type=\"Environment\", units=\"%\")\n", + " step.id, Condition(\"Relative Humidity\", 45.0, condition_type=\"Environment\", units=\"%\")\n", ")\n", "step = data_store_client.add_condition(\n", - " step_id, Condition(\"Ambient Temperature\", 24.5, condition_type=\"Environment\", units=\"degC\")\n", + " step.id, Condition(\"Ambient Temperature\", 24.5, condition_type=\"Environment\", units=\"degC\")\n", ")\n", - "step = data_store_client.remove_condition(step_id, \"Test Fixture\")\n", + "step = data_store_client.remove_condition(step.id, \"Test Fixture\")\n", "\n", "for condition in step.conditions:\n", " print(f\"{condition.name} = {condition.value} {condition.units}\")\n", @@ -220,22 +231,22 @@ "outputs": [], "source": [ "test_result = data_store_client.add_test_result_metadata(\n", - " test_result_id, software_item_ids=[software_item_id]\n", + " test_result.id, software_item_ids=[software_item_id]\n", ")\n", "print(f\"test result software items: {list(test_result.software_item_ids)}\")\n", "\n", "measurement = data_store_client.add_measurement_metadata(\n", - " measurement_ids[0], software_item_ids=[software_item_id]\n", + " measurements[0].id, software_item_ids=[software_item_id]\n", ")\n", "print(f\"measurement software items: {list(measurement.software_item_ids)}\")\n", "\n", "measurement = data_store_client.remove_measurement_metadata(\n", - " measurement_ids[0], software_item_ids=[software_item_id]\n", + " measurements[0].id, software_item_ids=[software_item_id]\n", ")\n", "print(f\"measurement software items after unlinking: {list(measurement.software_item_ids)}\")\n", "\n", "test_result = data_store_client.remove_test_result_metadata(\n", - " test_result_id, software_item_ids=[software_item_id]\n", + " test_result.id, software_item_ids=[software_item_id]\n", ")\n", "print(f\"test result software items after unlinking: {list(test_result.software_item_ids)}\")\n", "assert list(test_result.software_item_ids) == []" @@ -248,7 +259,7 @@ "source": [ "## Delete\n", "\n", - "Deleting a measurement removes it. Getting it afterward fails with `NOT_FOUND`." + "Delete methods accept the object or its ID. Deleting a measurement removes it. Getting it afterward fails with `NOT_FOUND`." ] }, { @@ -260,10 +271,10 @@ "source": [ "import grpc\n", "\n", - "data_store_client.delete_measurement(measurement_ids[2])\n", + "data_store_client.delete_measurement(measurements[2])\n", "\n", "try:\n", - " data_store_client.get_measurement(measurement_ids[2])\n", + " data_store_client.get_measurement(measurements[2].id)\n", "except grpc.RpcError as e:\n", " print(f\"{e.code().name}: {e.details()}\")" ] @@ -284,12 +295,12 @@ "outputs": [], "source": [ "try:\n", - " data_store_client.delete_step(step_id)\n", + " data_store_client.delete_step(step)\n", "except grpc.RpcError as e:\n", " print(f\"{e.code().name}: {e.details()}\")\n", "\n", "try:\n", - " data_store_client.delete_test_result(test_result_id)\n", + " data_store_client.delete_test_result(test_result)\n", "except grpc.RpcError as e:\n", " print(f\"{e.code().name}: {e.details()}\")" ] @@ -309,18 +320,18 @@ "metadata": {}, "outputs": [], "source": [ - "data_store_client.batch_delete_measurements(measurement_ids[:2])\n", - "data_store_client.delete_step(step_id) # No measurements are left, so force is not needed.\n", - "data_store_client.delete_test_result(test_result_id)\n", + "data_store_client.batch_delete_measurements(measurements[:2])\n", + "data_store_client.delete_step(step) # No measurements are left, so force is not needed.\n", + "data_store_client.delete_test_result(test_result)\n", "\n", "# Create two more test results, each with a step, and delete them together.\n", - "extra_test_result_ids = data_store_client.batch_create_test_results(\n", + "extra_test_results = data_store_client.batch_create_test_results(\n", " [\n", " TestResult(name=\"Delete Example A\", steps=[Step(name=\"Step A\")]),\n", " TestResult(name=\"Delete Example B\", steps=[Step(name=\"Step B\")]),\n", " ]\n", ")\n", - "data_store_client.batch_delete_test_results(extra_test_result_ids, force=True)\n", + "data_store_client.batch_delete_test_results(extra_test_results, force=True)\n", "\n", "remaining = data_store_client.query_test_results(\n", " \"$filter=Name eq 'Update and Delete Example' or startswith(Name,'Delete Example')\"\n", diff --git a/examples/overview-v2/poetry.lock b/examples/overview-v2/poetry.lock index eccd1a68..c0de75a1 100644 --- a/examples/overview-v2/poetry.lock +++ b/examples/overview-v2/poetry.lock @@ -937,7 +937,7 @@ protobuf = ">=4.21" [[package]] name = "ni-datastore" -version = "2.2.0.dev0" +version = "3.0.0.dev0" description = "APIs for publishing and retrieving data from NI Measurement Data Services" optional = false python-versions = "^3.10" diff --git a/examples/overview-v2/src/overview.py b/examples/overview-v2/src/overview.py index c8960ff1..b3e75308 100644 --- a/examples/overview-v2/src/overview.py +++ b/examples/overview-v2/src/overview.py @@ -12,6 +12,7 @@ Outcome, Status, Step, + StepField, TestResult, ) from ni.datastore.metadata import ( @@ -56,51 +57,37 @@ def publish_data(grpc_channel: grpc.Channel | None) -> str: MetadataStoreClient(grpc_channel=grpc_channel) as metadata_store_client, DataStoreClient(grpc_channel=grpc_channel) as data_store_client, ): - # Create UUT instance - uut = Uut(model_name="NI-6508", family="Digital") - uut_id = metadata_store_client.create_uut(uut) - uut_instance = UutInstance(uut_id=uut_id, serial_number="A861-42367") - uut_instance_id = metadata_store_client.create_uut_instance(uut_instance=uut_instance) - - # Create Operator metadata - operator = Operator(name="James Bowery", role="Test Operator") - operator_id = metadata_store_client.create_operator(operator) - print(f"created operator_id: {operator_id}") - - # Create TestStation metadata - test_station = TestStation(name="TestStation_12") - test_station_id = metadata_store_client.create_test_station(test_station) - print(f"created test_station_id: {test_station_id}") - - # Create SoftwareItem metadata - software_item = SoftwareItem(product="Windows", version="10.0.19044") - software_item_id = metadata_store_client.create_software_item(software_item) - print(f"created software_item_id: {software_item_id}") - software_item_2 = SoftwareItem(product="Python", version="3.12") - software_item_2_id = metadata_store_client.create_software_item(software_item_2) - print(f"created software_item_2_id: {software_item_2_id}") - - # Create TestResult. In V2, status tracks progress separately from the outcome. + # A UUT describes a product model that many test results share, so create it once + # and link it by ID. Each UUT instance refers to its UUT by ID. + uut_id = metadata_store_client.create_uut(Uut(model_name="NI-6508", family="Digital")) + + # Create the TestResult with its metadata inline. The service finds a matching + # operator, test station, UUT instance, or software item, or creates a new one. + # In V2, status tracks progress separately from the outcome. test_result = TestResult( name="sample test result", - operator_id=operator_id, - test_station_id=test_station_id, - software_item_ids=[software_item_id, software_item_2_id], - uut_instance_id=uut_instance_id, + uut_instance=UutInstance(uut_id=uut_id, serial_number="A861-42367"), + operator=Operator(name="James Bowery", role="Test Operator"), + test_station=TestStation(name="TestStation_12"), + software_items=[ + SoftwareItem(product="Windows", version="10.0.19044"), + SoftwareItem(product="Python", version="3.12"), + ], status=Status.RUNNING, ) - test_result_id = data_store_client.create_test_result(test_result) - print(f"created test_result_id: {test_result_id}") + created_test_result = data_store_client.create_test_result(test_result) + print(f"created test_result_id: {created_test_result.id}") + print(f"linked operator_id: {created_test_result.operator_id}") # Create the step. In V2, conditions belong to the step. step = Step( name="Initial step", - test_result_id=test_result_id, + test_result_id=created_test_result.id, conditions=[ Condition("Ambient Temperature", 23.0, condition_type="Environment", units="degC") ], ) - step_id = data_store_client.create_step(step) + created_step = data_store_client.create_step(step) name = "data publish sample" # Create waveform data to publish @@ -113,24 +100,23 @@ def publish_data(grpc_channel: grpc.Channel | None) -> str: ) # Create the measurement with the waveform data - published_measurement_id = data_store_client.create_measurement( + published_measurement = data_store_client.create_measurement( name=name, value=waveform, - step_id=step_id, + step_id=created_step.id, units="V", outcome=Outcome.PASSED, ) - published_measurement = data_store_client.get_measurement(published_measurement_id) print( f"Published measurement: '{published_measurement.name}' with id {published_measurement.id}" ) # Mark the test result as done now that publishing is complete. data_store_client.update_test_result( - test_result_id, status=Status.DONE, outcome=Outcome.PASSED + created_test_result.id, status=Status.DONE, outcome=Outcome.PASSED ) - return published_measurement_id + return published_measurement.id def query_data(published_measurement_id: str, grpc_channel: grpc.Channel | None) -> None: @@ -153,7 +139,9 @@ def query_data(published_measurement_id: str, grpc_channel: grpc.Channel | None) operator = metadata_store_client.get_operator(test_result.operator_id) print(f"operator: {operator}") - step = data_store_client.get_step(found_measurement.step_id, read_fields=["conditions"]) + step = data_store_client.get_step( + found_measurement.step_id, read_fields=[StepField.CONDITIONS] + ) for condition in step.conditions: print(f"condition: {condition.name} = {condition.value} {condition.units}") diff --git a/pyproject.toml b/pyproject.toml index 5c9e4d05..941faf35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ni.datastore" -version = "2.2.0.dev0" +version = "3.0.0.dev0" license = "MIT" description = "APIs for publishing and retrieving data from NI Measurement Data Services" authors = [{name = "NI", email = "opensource@ni.com"}] @@ -114,4 +114,5 @@ testpaths = ["tests"] norecursedirs = [".venv"] filterwarnings = [ "ignore:cannot collect test class.*because it has a __init__ constructor:pytest.PytestCollectionWarning", + "ignore:cannot collect test class.*because it has a __new__ constructor:pytest.PytestCollectionWarning", ] diff --git a/src/ni/datastore/data/v2/__init__.py b/src/ni/datastore/data/v2/__init__.py index 4111f761..7dc42c38 100644 --- a/src/ni/datastore/data/v2/__init__.py +++ b/src/ni/datastore/data/v2/__init__.py @@ -2,14 +2,33 @@ from ni.datastore.data.v2._data_store_client import DataStoreClient from ni.datastore.data.v2._types._condition import Condition +from ni.datastore.data.v2._types._create import StepCreateFields, TestResultCreateFields from ni.datastore.data.v2._types._error_information import ErrorInformation from ni.datastore.data.v2._types._json_value import JsonValue from ni.datastore.data.v2._types._measurement import Measurement +from ni.datastore.data.v2._types._measurement_value import ( + MeasurementReadValue, + MeasurementValue, +) from ni.datastore.data.v2._types._outcome import Outcome from ni.datastore.data.v2._types._query_results import QueryResults +from ni.datastore.data.v2._types._read_fields import ( + MeasurementField, + MeasurementValueField, + StepField, + TestResultField, +) from ni.datastore.data.v2._types._status import Status from ni.datastore.data.v2._types._step import Step from ni.datastore.data.v2._types._test_result import TestResult +from ni.datastore.data.v2._types._update import ( + MeasurementUpdateBuilder, + MeasurementUpdateFields, + StepUpdateBuilder, + StepUpdateFields, + TestResultUpdateBuilder, + TestResultUpdateFields, +) __all__ = [ "Condition", @@ -17,11 +36,25 @@ "ErrorInformation", "JsonValue", "Measurement", + "MeasurementField", + "MeasurementReadValue", + "MeasurementUpdateBuilder", + "MeasurementUpdateFields", + "MeasurementValue", + "MeasurementValueField", "Outcome", "QueryResults", "Status", "Step", + "StepCreateFields", + "StepField", + "StepUpdateBuilder", + "StepUpdateFields", "TestResult", + "TestResultCreateFields", + "TestResultField", + "TestResultUpdateBuilder", + "TestResultUpdateFields", ] # Hide that it was not defined in this top-level package @@ -30,8 +63,20 @@ ErrorInformation.__module__ = __name__ JsonValue.__module__ = __name__ Measurement.__module__ = __name__ +MeasurementField.__module__ = __name__ +MeasurementUpdateBuilder.__module__ = __name__ +MeasurementUpdateFields.__module__ = __name__ +MeasurementValueField.__module__ = __name__ Outcome.__module__ = __name__ QueryResults.__module__ = __name__ Status.__module__ = __name__ Step.__module__ = __name__ +StepCreateFields.__module__ = __name__ +StepField.__module__ = __name__ +StepUpdateBuilder.__module__ = __name__ +StepUpdateFields.__module__ = __name__ TestResult.__module__ = __name__ +TestResultCreateFields.__module__ = __name__ +TestResultField.__module__ = __name__ +TestResultUpdateBuilder.__module__ = __name__ +TestResultUpdateFields.__module__ = __name__ diff --git a/src/ni/datastore/data/v2/_data_store_client.py b/src/ni/datastore/data/v2/_data_store_client.py index 5f67642b..8a3f70a7 100644 --- a/src/ni/datastore/data/v2/_data_store_client.py +++ b/src/ni/datastore/data/v2/_data_store_client.py @@ -4,11 +4,12 @@ import sys from collections.abc import Iterable, Mapping, Sequence +from enum import Enum from types import TracebackType -from typing import Any, cast, overload, Type, TYPE_CHECKING, TypeVar +from typing import Any, overload, Type, TYPE_CHECKING, TypeVar +from uuid import UUID import hightime as ht -from google.protobuf import json_format from google.protobuf.field_mask_pb2 import FieldMask from grpc import Channel from ni.measurementlink.discovery.v1.client import DiscoveryClient @@ -51,31 +52,57 @@ UpdateStepRequest, UpdateTestResultRequest, ) -from ni.protobuf.types.precision_duration_conversion import hightime_timedelta_to_protobuf -from ni.protobuf.types.precision_timestamp_conversion import hightime_datetime_to_protobuf from ni_grpc_extensions.channelpool import GrpcChannelPool from ni.datastore.data.v2._grpc_conversion import ( convert_measurement_value_from_protobuf, + id_list, + id_string, measurement_value_from_protobuf, string_list, ) from ni.datastore.data.v2._types._condition import Condition +from ni.datastore.data.v2._types._create import StepCreateFields, TestResultCreateFields from ni.datastore.data.v2._types._error_information import ErrorInformation from ni.datastore.data.v2._types._measurement import Measurement +from ni.datastore.data.v2._types._measurement_value import MeasurementReadValue, MeasurementValue from ni.datastore.data.v2._types._outcome import Outcome from ni.datastore.data.v2._types._query_results import QueryResults +from ni.datastore.data.v2._types._read_fields import ( + MeasurementField, + MeasurementValueField, + StepField, + TestResultField, +) from ni.datastore.data.v2._types._step import Step from ni.datastore.data.v2._types._test_result import TestResult +from ni.datastore.data.v2._types._update import ( + MeasurementUpdateBuilder, + MeasurementUpdateFields, + StepUpdateBuilder, + StepUpdateFields, + TestResultUpdateBuilder, + TestResultUpdateFields, +) +from ni.datastore.data.v2._update_conversion import ( + MEASUREMENT_UPDATE_FIELDS, + populate_update, + resource_update_fields, + STEP_RELATIONSHIPS, + STEP_UPDATE_FIELDS, + TEST_RESULT_RELATIONSHIPS, + TEST_RESULT_UPDATE_FIELDS, +) from ni.datastore.metadata import HardwareItem, SoftwareItem, TestAdapter if TYPE_CHECKING: if sys.version_info >= (3, 11): - from typing import Self + from typing import Self, Unpack else: - from typing_extensions import Self + from typing_extensions import Self, Unpack TRead = TypeVar("TRead") +_T = TypeVar("_T") class DataStoreClient: @@ -132,31 +159,77 @@ def close(self) -> None: self._closed = True self._data_store_client.close() - def create_test_result(self, test_result: TestResult) -> str: - """Create a test result. + @overload + def create_test_result(self, test_result: TestResult, /) -> TestResult: ... + + @overload + def create_test_result( + self, name: str, /, **fields: Unpack[TestResultCreateFields] + ) -> TestResult: ... + + def create_test_result( + self, test_result: TestResult | str, /, **fields: Unpack[TestResultCreateFields] + ) -> TestResult: + """Create a test result, including any nested steps. + + Pass either a TestResult, or its name and fields as keyword arguments. Args: - test_result: The test result object to be created. + test_result: The test result to create, or its name. + + **fields: The test result fields, when a name is given. Each has the + same meaning as the matching TestResult argument. Returns: - str: The test result ID. Generated if not specified. + TestResult: The created test result as returned by the service, including + its ID. Nested steps and expanded metadata are not included. + + Raises: + TypeError: If fields are given with a TestResult. """ + if isinstance(test_result, TestResult): + _reject_model_fields(fields, "a TestResult", "test result") + else: + test_result = TestResult(test_result, **fields) create_request = CreateTestResultRequest(test_result=test_result.to_protobuf()) - return self._get_data_store_client().create_test_result(create_request).id + return TestResult.from_protobuf( + self._get_data_store_client().create_test_result(create_request) + ) + + @overload + def create_step(self, step: Step, /) -> Step: ... + + @overload + def create_step(self, name: str, /, **fields: Unpack[StepCreateFields]) -> Step: ... + + def create_step(self, step: Step | str, /, **fields: Unpack[StepCreateFields]) -> Step: + """Create a step, including any conditions, child steps, and measurements. - def create_step(self, step: Step) -> str: - """Create a step, including any conditions on the step. + Pass either a Step, or its name and fields as keyword arguments. Args: - step: The step object to be created. + step: The step to create, or its name. + + **fields: The step fields, when a name is given, such as + test_result_id. Each has the same meaning as the matching Step + argument. Returns: - str: The step ID. Generated if not specified. + Step: The created step as returned by the service, including its ID. + Conditions, child steps, measurements, and expanded metadata are not + included. + + Raises: + TypeError: If fields are given with a Step. """ + if isinstance(step, Step): + _reject_model_fields(fields, "a Step", "step") + else: + step = Step(step, **fields) create_request = CreateStepRequest(step=step.to_protobuf()) - return self._get_data_store_client().create_step(create_request).id + return Step.from_protobuf(self._get_data_store_client().create_step(create_request)) - def add_condition(self, step_id: str, condition: Condition) -> Step: + def add_condition(self, step_id: str | UUID, condition: Condition) -> Step: """Add a condition to a step, replacing any condition with the same name. Args: @@ -167,10 +240,12 @@ def add_condition(self, step_id: str, condition: Condition) -> Step: Returns: Step: The updated step. """ - add_request = AddConditionRequest(step_id=step_id, condition=condition.to_protobuf()) + add_request = AddConditionRequest( + step_id=id_string(step_id), condition=condition.to_protobuf() + ) return Step.from_protobuf(self._get_data_store_client().add_condition(add_request).step) - def remove_condition(self, step_id: str, condition_name: str) -> Step: + def remove_condition(self, step_id: str | UUID, condition_name: str) -> Step: """Remove a condition from a step. Args: @@ -181,20 +256,26 @@ def remove_condition(self, step_id: str, condition_name: str) -> Step: Returns: Step: The updated step. """ - remove_request = RemoveConditionRequest(step_id=step_id, condition_name=condition_name) + remove_request = RemoveConditionRequest( + step_id=id_string(step_id), condition_name=condition_name + ) return Step.from_protobuf( self._get_data_store_client().remove_condition(remove_request).step ) + @overload + def create_measurement(self, measurement: Measurement, /) -> Measurement: ... + + @overload def create_measurement( self, name: str, - value: object, - step_id: str, + value: MeasurementValue, + step_id: str | UUID, *, units: str = "", - upper_limit: object = None, - lower_limit: object = None, + upper_limit: MeasurementValue | None = None, + lower_limit: MeasurementValue | None = None, comparison_type: str = "", notes: str = "", start_date_time: ht.datetime | None = None, @@ -204,16 +285,23 @@ def create_measurement( error_information: ErrorInformation | None = None, extension: Mapping[str, Any] | None = None, schema_id: str = "", - software_item_ids: Iterable[str] = (), - hardware_item_ids: Iterable[str] = (), - test_adapter_ids: Iterable[str] = (), + software_item_ids: Iterable[str | UUID] = (), + hardware_item_ids: Iterable[str | UUID] = (), + test_adapter_ids: Iterable[str | UUID] = (), software_items: Iterable[SoftwareItem] = (), hardware_items: Iterable[HardwareItem] = (), test_adapters: Iterable[TestAdapter] = (), - ) -> str: + ) -> Measurement: ... + + def create_measurement(self, *args: Any, **kwargs: Any) -> Measurement: """Create a measurement associated with a test step. + Pass either a Measurement, or the measurement fields as arguments. + Args: + measurement: The measurement to create. It must include a step ID and + value. Cannot be combined with the other arguments. + name: The name used for grouping conceptually alike measurements. value: The measured value. Supported types are bool, int, float, str, @@ -272,42 +360,33 @@ def create_measurement( request. Each item must already be linked to the owning test result. Returns: - str: The measurement ID. + Measurement: The created measurement as returned by the service, + including its ID. Expanded metadata is not included. + + Raises: + TypeError: If fields are given with a Measurement. """ - measurement = Measurement( - name=name, - value=value, - step_id=step_id, - units=units, - upper_limit=upper_limit, - lower_limit=lower_limit, - comparison_type=comparison_type, - notes=notes, - start_date_time=start_date_time, - end_date_time=end_date_time, - duration=duration, - outcome=outcome, - error_information=error_information, - extension=extension, - schema_id=schema_id, - software_item_ids=software_item_ids, - hardware_item_ids=hardware_item_ids, - test_adapter_ids=test_adapter_ids, - software_items=software_items, - hardware_items=hardware_items, - test_adapters=test_adapters, - ) + if args and isinstance(args[0], Measurement): + _reject_model_fields( + {**kwargs, **dict(enumerate(args[1:]))}, "a Measurement", "measurement" + ) + measurement: Measurement = args[0] + else: + measurement = _measurement_from_fields(*args, **kwargs) create_request = CreateMeasurementRequest(measurement=measurement.to_protobuf()) - return self._get_data_store_client().create_measurement(create_request).id + return Measurement.from_protobuf( + self._get_data_store_client().create_measurement(create_request) + ) - def batch_create_test_results(self, test_results: Iterable[TestResult]) -> Sequence[str]: + def batch_create_test_results(self, test_results: Iterable[TestResult]) -> Sequence[TestResult]: """Create multiple test results, including any nested steps, in one atomic call. Args: test_results: The test results to create. Returns: - Sequence[str]: The test result IDs, in request order. + Sequence[TestResult]: The created test results, in request order. Nested + steps and expanded metadata are not included. """ batch_request = BatchCreateTestResultsRequest( requests=[ @@ -316,24 +395,29 @@ def batch_create_test_results(self, test_results: Iterable[TestResult]) -> Seque ] ) batch_response = self._get_data_store_client().batch_create_test_results(batch_request) - return [test_result.id for test_result in batch_response.test_results] + return [ + TestResult.from_protobuf(test_result) for test_result in batch_response.test_results + ] - def batch_create_steps(self, steps: Iterable[Step]) -> Sequence[str]: + def batch_create_steps(self, steps: Iterable[Step]) -> Sequence[Step]: """Create multiple steps, including any nested children, in one atomic call. Args: steps: The steps to create. Returns: - Sequence[str]: The step IDs, in request order. + Sequence[Step]: The created steps, in request order. Conditions, child + steps, measurements, and expanded metadata are not included. """ batch_request = BatchCreateStepsRequest( requests=[CreateStepRequest(step=step.to_protobuf()) for step in steps] ) batch_response = self._get_data_store_client().batch_create_steps(batch_request) - return [step.id for step in batch_response.steps] + return [Step.from_protobuf(step) for step in batch_response.steps] - def batch_create_measurements(self, measurements: Iterable[Measurement]) -> Sequence[str]: + def batch_create_measurements( + self, measurements: Iterable[Measurement] + ) -> Sequence[Measurement]: """Create multiple measurements in one atomic call. Args: @@ -341,7 +425,8 @@ def batch_create_measurements(self, measurements: Iterable[Measurement]) -> Sequ value. They may target different steps or test results. Returns: - Sequence[str]: The measurement IDs, in request order. + Sequence[Measurement]: The created measurements, in request order. + Expanded metadata is not included. """ batch_request = BatchCreateMeasurementsRequest( requests=[ @@ -350,30 +435,32 @@ def batch_create_measurements(self, measurements: Iterable[Measurement]) -> Sequ ] ) batch_response = self._get_data_store_client().batch_create_measurements(batch_request) - return [measurement.id for measurement in batch_response.measurements] + return [ + Measurement.from_protobuf(measurement) for measurement in batch_response.measurements + ] @overload def read_measurement_value( self, - read_source: Measurement, + read_source: Measurement | str | UUID, expected_type: Type[TRead], ) -> TRead: ... @overload def read_measurement_value( self, - read_source: Measurement, - ) -> object: ... + read_source: Measurement | str | UUID, + ) -> MeasurementReadValue: ... def read_measurement_value( self, - read_source: Measurement, + read_source: Measurement | str | UUID, expected_type: Type[TRead] | None = None, - ) -> TRead | object: + ) -> TRead | MeasurementReadValue: """Read the value of a measurement. Args: - read_source: The measurement to read. Only its id is used. + read_source: The measurement to read, or its ID. Only the ID is used. expected_type: Optional type to validate the returned data against. @@ -387,7 +474,7 @@ def read_measurement_value( doesn't match. """ read_request = ReadMeasurementValuesRequest( - measurement_id=read_source.id, read_mask=FieldMask(paths=["value"]) + measurement_id=_resource_id(read_source), read_mask=FieldMask(paths=["value"]) ) response = self._get_data_store_client().read_measurement_values(read_request) read_value = convert_measurement_value_from_protobuf(response.value) @@ -396,86 +483,93 @@ def read_measurement_value( return read_value def read_measurement_values( - self, read_source: Measurement, *, read_fields: Iterable[str] = () + self, + read_source: Measurement | str | UUID, + *, + read_fields: Iterable[MeasurementValueField] = (), ) -> Measurement: """Read the value and limits of a measurement. Args: - read_source: The measurement to read. Only its id is used. + read_source: The measurement to read, or its ID. Only the ID is used. - read_fields: Optional fields to read: "value", "upper_limit", and - "lower_limit". All three are read when empty. + read_fields: Optional fields to read, such as MeasurementValueField.VALUE. + The value and both limits are read when empty. Returns: Measurement: A measurement with id, value, upper_limit, and lower_limit set. A limit that was not published is None. """ + measurement_id = _resource_id(read_source) read_request = ReadMeasurementValuesRequest( - measurement_id=read_source.id, read_mask=_read_mask(read_fields) + measurement_id=measurement_id, read_mask=_read_mask(read_fields) ) response = self._get_data_store_client().read_measurement_values(read_request) return Measurement( - id=read_source.id, + id=measurement_id, value=measurement_value_from_protobuf(response, "value"), upper_limit=measurement_value_from_protobuf(response, "upper_limit"), lower_limit=measurement_value_from_protobuf(response, "lower_limit"), ) def get_test_result( - self, test_result_id: str, *, read_fields: Iterable[str] = () + self, test_result_id: str | UUID, *, read_fields: Iterable[TestResultField | str] = () ) -> TestResult: """Get the test result with the given ID. Args: test_result_id: The ID of the test result. - read_fields: Optional field names to return. When empty, the service returns - its default fields. + read_fields: Optional fields to return, such as TestResultField.NAME. Field + names as strings are also accepted. When empty, the service returns its + default fields. Returns: TestResult: The requested test result. """ get_request = GetTestResultRequest( - test_result_id=test_result_id, read_mask=_read_mask(read_fields) + test_result_id=id_string(test_result_id), read_mask=_read_mask(read_fields) ) return TestResult.from_protobuf(self._get_data_store_client().get_test_result(get_request)) - def get_step(self, step_id: str, *, read_fields: Iterable[str] = ()) -> Step: + def get_step(self, step_id: str | UUID, *, read_fields: Iterable[StepField | str] = ()) -> Step: """Get the step with the given ID. Args: step_id: The ID of the step. - read_fields: Optional field names to return, such as "conditions". When - empty, the service returns its default fields. + read_fields: Optional fields to return, such as StepField.CONDITIONS. Field + names as strings are also accepted. When empty, the service returns its + default fields. Returns: Step: The requested step. """ - get_request = GetStepRequest(step_id=step_id, read_mask=_read_mask(read_fields)) + get_request = GetStepRequest(step_id=id_string(step_id), read_mask=_read_mask(read_fields)) return Step.from_protobuf(self._get_data_store_client().get_step(get_request)) def get_measurement( - self, measurement_id: str, *, read_fields: Iterable[str] = () + self, measurement_id: str | UUID, *, read_fields: Iterable[MeasurementField | str] = () ) -> Measurement: """Get the measurement with the given ID. Args: measurement_id: The ID of the measurement. - read_fields: Optional field names to return, such as "conditions". When - empty, the service returns its default fields. + read_fields: Optional fields to return, such as MeasurementField.CONDITIONS. + Field names as strings are also accepted. When empty, the service returns + its default fields. Returns: Measurement: The requested measurement. """ get_request = GetMeasurementRequest( - measurement_id=measurement_id, read_mask=_read_mask(read_fields) + measurement_id=id_string(measurement_id), read_mask=_read_mask(read_fields) ) return Measurement.from_protobuf(self._get_data_store_client().get_measurement(get_request)) def query_test_results( - self, odata_query: str = "", *, read_fields: Iterable[str] = () + self, odata_query: str = "", *, read_fields: Iterable[TestResultField | str] = () ) -> QueryResults[TestResult]: """Query test results using OData query syntax. @@ -483,7 +577,8 @@ def query_test_results( odata_query: An OData query string. An empty string returns all test results. $select, $count, $expand, and $apply are not supported. - read_fields: Optional field names to return for each test result. + read_fields: Optional fields to return for each test result, such as + TestResultField.NAME. Field names as strings are also accepted. Returns: QueryResults[TestResult]: The matching test results, with next_link @@ -499,7 +594,7 @@ def query_test_results( ) def query_steps( - self, odata_query: str = "", *, read_fields: Iterable[str] = () + self, odata_query: str = "", *, read_fields: Iterable[StepField | str] = () ) -> QueryResults[Step]: """Query steps using OData query syntax. @@ -507,7 +602,8 @@ def query_steps( odata_query: An OData query string. An empty string returns all steps. $select, $count, $expand, and $apply are not supported. - read_fields: Optional field names to return for each step. + read_fields: Optional fields to return for each step, such as + StepField.CONDITIONS. Field names as strings are also accepted. Returns: QueryResults[Step]: The matching steps, with next_link set when more @@ -523,7 +619,7 @@ def query_steps( ) def query_measurements( - self, odata_query: str = "", *, read_fields: Iterable[str] = () + self, odata_query: str = "", *, read_fields: Iterable[MeasurementField | str] = () ) -> QueryResults[Measurement]: """Query measurements using OData query syntax. @@ -532,7 +628,8 @@ def query_measurements( measurements. $select, $count, $expand, and $apply are not supported. - read_fields: Optional field names to return for each measurement. + read_fields: Optional fields to return for each measurement, such as + MeasurementField.CONDITIONS. Field names as strings are also accepted. Returns: QueryResults[Measurement]: The matching measurements, with next_link @@ -547,78 +644,159 @@ def query_measurements( query_response.next_link, ) - def update_test_result(self, test_result_id: str, **fields: object) -> TestResult: + @overload + def update_test_result(self, test_result: TestResultUpdateBuilder, /) -> TestResult: ... + + @overload + def update_test_result( + self, test_result: TestResult | str | UUID, /, **fields: Unpack[TestResultUpdateFields] + ) -> TestResult: ... + + def update_test_result( + self, + test_result: TestResult | TestResultUpdateBuilder | str | UUID, + /, + **fields: Unpack[TestResultUpdateFields], + ) -> TestResult: """Update mutable test result fields. Args: - test_result_id: The ID of the test result to update. + test_result: What to update. One of: + + * A TestResultUpdateBuilder. Only the fields selected on the + builder change. + * An ID or a TestResult with keyword fields, such as + link="..." or outcome=Outcome.FAILED. Only those fields + change. A value of None clears the field. + * A TestResult on its own. Every updatable field is set from + it, so empty fields are cleared. Stage 2 fields are sent only + when they have a value. When a relationship has both an ID + and inline metadata, only the ID is sent. - **fields: The fields to change, such as link="..." or - outcome=Outcome.FAILED. A value of None clears the field. + **fields: The fields to change. Not allowed with a builder. Returns: TestResult: The updated test result. Raises: + TypeError: If keyword fields are given with a builder. ValueError: If no fields are given or a field cannot be updated. """ - test_result = TestResultProto(id=test_result_id) - mask = _populate_update(test_result, fields, _TEST_RESULT_UPDATE_FIELDS) - update_request = UpdateTestResultRequest(test_result=test_result, update_mask=mask) + update_request = _test_result_update(test_result, fields) return TestResult.from_protobuf( self._get_data_store_client().update_test_result(update_request) ) - def update_step(self, step_id: str, **fields: object) -> Step: + @overload + def update_step(self, step: StepUpdateBuilder, /) -> Step: ... + + @overload + def update_step( + self, step: Step | str | UUID, /, **fields: Unpack[StepUpdateFields] + ) -> Step: ... + + def update_step( + self, step: Step | StepUpdateBuilder | str | UUID, /, **fields: Unpack[StepUpdateFields] + ) -> Step: """Update mutable step fields. Args: - step_id: The ID of the step to update. + step: What to update. One of: - **fields: The fields to change, such as notes="...". A value of None - clears the field. + * A StepUpdateBuilder. Only the fields selected on the builder + change. + * An ID or a Step with keyword fields, such as notes="...". + Only those fields change. A value of None clears the field. + * A Step on its own. Every updatable field is set from it, so + empty fields are cleared. Stage 2 fields are sent only when + they have a value. When both test_id and test are set, only + test_id is sent. + + **fields: The fields to change. Not allowed with a builder. Returns: Step: The updated step. Raises: + TypeError: If keyword fields are given with a builder. ValueError: If no fields are given or a field cannot be updated. """ - step = StepProto(id=step_id) - mask = _populate_update(step, fields, _STEP_UPDATE_FIELDS) - update_request = UpdateStepRequest(step=step, update_mask=mask) + update_request = _step_update(step, fields) return Step.from_protobuf(self._get_data_store_client().update_step(update_request)) - def update_measurement(self, measurement_id: str, **fields: object) -> Measurement: + @overload + def update_measurement(self, measurement: MeasurementUpdateBuilder, /) -> Measurement: ... + + @overload + def update_measurement( + self, measurement: Measurement | str | UUID, /, **fields: Unpack[MeasurementUpdateFields] + ) -> Measurement: ... + + def update_measurement( + self, + measurement: Measurement | MeasurementUpdateBuilder | str | UUID, + /, + **fields: Unpack[MeasurementUpdateFields], + ) -> Measurement: """Update mutable measurement fields. + The value, units, and limits cannot be updated. + Args: - measurement_id: The ID of the measurement to update. + measurement: What to update. One of: + + * A MeasurementUpdateBuilder. Only the fields selected on the + builder change. + * An ID or a Measurement with keyword fields, such as + notes="...". Only those fields change. A value of None + clears the field. + * A Measurement on its own. Every updatable field is set from + it, so empty fields are cleared. duration is sent only when + it has a value. - **fields: The fields to change, such as notes="...". A value of None - clears the field. + **fields: The fields to change. Not allowed with a builder. Returns: Measurement: The updated measurement metadata. Raises: + TypeError: If keyword fields are given with a builder. ValueError: If no fields are given or a field cannot be updated. """ - measurement = MeasurementProto(id=measurement_id) - mask = _populate_update(measurement, fields, _MEASUREMENT_UPDATE_FIELDS) - update_request = UpdateMeasurementRequest(measurement=measurement, update_mask=mask) + update_request = _measurement_update(measurement, fields) return Measurement.from_protobuf( self._get_data_store_client().update_measurement(update_request) ) + @overload + def batch_update_test_results( + self, updates: Mapping[UUID, TestResultUpdateFields] + ) -> Sequence[TestResult]: ... + + @overload + def batch_update_test_results( + self, updates: Mapping[str, TestResultUpdateFields] + ) -> Sequence[TestResult]: ... + + @overload + def batch_update_test_results( + self, updates: Iterable[TestResult | TestResultUpdateBuilder] + ) -> Sequence[TestResult]: ... + def batch_update_test_results( - self, updates: Mapping[str, Mapping[str, object]] + self, + updates: ( + Mapping[str, TestResultUpdateFields] + | Mapping[UUID, TestResultUpdateFields] + | Iterable[TestResult | TestResultUpdateBuilder] + ), ) -> Sequence[TestResult]: """Update multiple test results in one atomic call. Args: - updates: The fields to change, keyed by test result ID. Each value - follows the update_test_result field rules. + updates: Either the fields to change, keyed by test result ID, or + builders and test results. Each item follows the + update_test_result rules. Returns: Sequence[TestResult]: The updated test results, in request order. @@ -627,21 +805,35 @@ def batch_update_test_results( ValueError: If no updates are given, or an update has no fields or a field that cannot be updated. """ - requests = [] - for test_result_id, fields in _require_items(updates).items(): - test_result = TestResultProto(id=test_result_id) - mask = _populate_update(test_result, fields, _TEST_RESULT_UPDATE_FIELDS) - requests.append(UpdateTestResultRequest(test_result=test_result, update_mask=mask)) + requests = [ + _test_result_update(target, fields) for target, fields in _update_items(updates) + ] batch_request = BatchUpdateTestResultsRequest(requests=requests) batch_response = self._get_data_store_client().batch_update_test_results(batch_request) return [TestResult.from_protobuf(proto) for proto in batch_response.test_results] - def batch_update_steps(self, updates: Mapping[str, Mapping[str, object]]) -> Sequence[Step]: + @overload + def batch_update_steps(self, updates: Mapping[UUID, StepUpdateFields]) -> Sequence[Step]: ... + + @overload + def batch_update_steps(self, updates: Mapping[str, StepUpdateFields]) -> Sequence[Step]: ... + + @overload + def batch_update_steps(self, updates: Iterable[Step | StepUpdateBuilder]) -> Sequence[Step]: ... + + def batch_update_steps( + self, + updates: ( + Mapping[str, StepUpdateFields] + | Mapping[UUID, StepUpdateFields] + | Iterable[Step | StepUpdateBuilder] + ), + ) -> Sequence[Step]: """Update multiple steps in one atomic call. Args: - updates: The fields to change, keyed by step ID. Each value follows - the update_step field rules. + updates: Either the fields to change, keyed by step ID, or builders + and steps. Each item follows the update_step rules. Returns: Sequence[Step]: The updated steps, in request order. @@ -650,23 +842,40 @@ def batch_update_steps(self, updates: Mapping[str, Mapping[str, object]]) -> Seq ValueError: If no updates are given, or an update has no fields or a field that cannot be updated. """ - requests = [] - for step_id, fields in _require_items(updates).items(): - step = StepProto(id=step_id) - mask = _populate_update(step, fields, _STEP_UPDATE_FIELDS) - requests.append(UpdateStepRequest(step=step, update_mask=mask)) + requests = [_step_update(target, fields) for target, fields in _update_items(updates)] batch_request = BatchUpdateStepsRequest(requests=requests) batch_response = self._get_data_store_client().batch_update_steps(batch_request) return [Step.from_protobuf(proto) for proto in batch_response.steps] + @overload + def batch_update_measurements( + self, updates: Mapping[UUID, MeasurementUpdateFields] + ) -> Sequence[Measurement]: ... + + @overload def batch_update_measurements( - self, updates: Mapping[str, Mapping[str, object]] + self, updates: Mapping[str, MeasurementUpdateFields] + ) -> Sequence[Measurement]: ... + + @overload + def batch_update_measurements( + self, updates: Iterable[Measurement | MeasurementUpdateBuilder] + ) -> Sequence[Measurement]: ... + + def batch_update_measurements( + self, + updates: ( + Mapping[str, MeasurementUpdateFields] + | Mapping[UUID, MeasurementUpdateFields] + | Iterable[Measurement | MeasurementUpdateBuilder] + ), ) -> Sequence[Measurement]: """Update multiple measurements in one atomic call. Args: - updates: The fields to change, keyed by measurement ID. Each value - follows the update_measurement field rules. + updates: Either the fields to change, keyed by measurement ID, or + builders and measurements. Each item follows the + update_measurement rules. Returns: Sequence[Measurement]: The updated measurements, in request order. @@ -675,22 +884,20 @@ def batch_update_measurements( ValueError: If no updates are given, or an update has no fields or a field that cannot be updated. """ - requests = [] - for measurement_id, fields in _require_items(updates).items(): - measurement = MeasurementProto(id=measurement_id) - mask = _populate_update(measurement, fields, _MEASUREMENT_UPDATE_FIELDS) - requests.append(UpdateMeasurementRequest(measurement=measurement, update_mask=mask)) + requests = [ + _measurement_update(target, fields) for target, fields in _update_items(updates) + ] batch_request = BatchUpdateMeasurementsRequest(requests=requests) batch_response = self._get_data_store_client().batch_update_measurements(batch_request) return [Measurement.from_protobuf(proto) for proto in batch_response.measurements] def add_test_result_metadata( self, - test_result_id: str, + test_result_id: str | UUID, *, - software_item_ids: Iterable[str] = (), - hardware_item_ids: Iterable[str] = (), - test_adapter_ids: Iterable[str] = (), + software_item_ids: Iterable[str | UUID] = (), + hardware_item_ids: Iterable[str | UUID] = (), + test_adapter_ids: Iterable[str | UUID] = (), ) -> TestResult: """Link existing software items, hardware items, or test adapters to a test result. @@ -711,10 +918,10 @@ def add_test_result_metadata( TestResult: The updated test result. """ add_request = AddTestResultMetadataRequest( - test_result_id=test_result_id, - software_item_ids=string_list(software_item_ids, "software_item_ids"), - hardware_item_ids=string_list(hardware_item_ids, "hardware_item_ids"), - test_adapter_ids=string_list(test_adapter_ids, "test_adapter_ids"), + test_result_id=id_string(test_result_id), + software_item_ids=id_list(software_item_ids, "software_item_ids"), + hardware_item_ids=id_list(hardware_item_ids, "hardware_item_ids"), + test_adapter_ids=id_list(test_adapter_ids, "test_adapter_ids"), ) return TestResult.from_protobuf( self._get_data_store_client().add_test_result_metadata(add_request).test_result @@ -722,11 +929,11 @@ def add_test_result_metadata( def remove_test_result_metadata( self, - test_result_id: str, + test_result_id: str | UUID, *, - software_item_ids: Iterable[str] = (), - hardware_item_ids: Iterable[str] = (), - test_adapter_ids: Iterable[str] = (), + software_item_ids: Iterable[str | UUID] = (), + hardware_item_ids: Iterable[str | UUID] = (), + test_adapter_ids: Iterable[str | UUID] = (), ) -> TestResult: """Unlink software items, hardware items, or test adapters from a test result. @@ -747,10 +954,10 @@ def remove_test_result_metadata( TestResult: The updated test result. """ remove_request = RemoveTestResultMetadataRequest( - test_result_id=test_result_id, - software_item_ids=string_list(software_item_ids, "software_item_ids"), - hardware_item_ids=string_list(hardware_item_ids, "hardware_item_ids"), - test_adapter_ids=string_list(test_adapter_ids, "test_adapter_ids"), + test_result_id=id_string(test_result_id), + software_item_ids=id_list(software_item_ids, "software_item_ids"), + hardware_item_ids=id_list(hardware_item_ids, "hardware_item_ids"), + test_adapter_ids=id_list(test_adapter_ids, "test_adapter_ids"), ) return TestResult.from_protobuf( self._get_data_store_client().remove_test_result_metadata(remove_request).test_result @@ -758,11 +965,11 @@ def remove_test_result_metadata( def add_measurement_metadata( self, - measurement_id: str, + measurement_id: str | UUID, *, - software_item_ids: Iterable[str] = (), - hardware_item_ids: Iterable[str] = (), - test_adapter_ids: Iterable[str] = (), + software_item_ids: Iterable[str | UUID] = (), + hardware_item_ids: Iterable[str | UUID] = (), + test_adapter_ids: Iterable[str | UUID] = (), ) -> Measurement: """Link software items, hardware items, or test adapters to a measurement. @@ -781,10 +988,10 @@ def add_measurement_metadata( Measurement: The updated measurement. """ add_request = AddMeasurementMetadataRequest( - measurement_id=measurement_id, - software_item_ids=string_list(software_item_ids, "software_item_ids"), - hardware_item_ids=string_list(hardware_item_ids, "hardware_item_ids"), - test_adapter_ids=string_list(test_adapter_ids, "test_adapter_ids"), + measurement_id=id_string(measurement_id), + software_item_ids=id_list(software_item_ids, "software_item_ids"), + hardware_item_ids=id_list(hardware_item_ids, "hardware_item_ids"), + test_adapter_ids=id_list(test_adapter_ids, "test_adapter_ids"), ) return Measurement.from_protobuf( self._get_data_store_client().add_measurement_metadata(add_request).measurement @@ -792,11 +999,11 @@ def add_measurement_metadata( def remove_measurement_metadata( self, - measurement_id: str, + measurement_id: str | UUID, *, - software_item_ids: Iterable[str] = (), - hardware_item_ids: Iterable[str] = (), - test_adapter_ids: Iterable[str] = (), + software_item_ids: Iterable[str | UUID] = (), + hardware_item_ids: Iterable[str | UUID] = (), + test_adapter_ids: Iterable[str | UUID] = (), ) -> Measurement: """Unlink software items, hardware items, or test adapters from a measurement. @@ -813,58 +1020,65 @@ def remove_measurement_metadata( Measurement: The updated measurement. """ remove_request = RemoveMeasurementMetadataRequest( - measurement_id=measurement_id, - software_item_ids=string_list(software_item_ids, "software_item_ids"), - hardware_item_ids=string_list(hardware_item_ids, "hardware_item_ids"), - test_adapter_ids=string_list(test_adapter_ids, "test_adapter_ids"), + measurement_id=id_string(measurement_id), + software_item_ids=id_list(software_item_ids, "software_item_ids"), + hardware_item_ids=id_list(hardware_item_ids, "hardware_item_ids"), + test_adapter_ids=id_list(test_adapter_ids, "test_adapter_ids"), ) return Measurement.from_protobuf( self._get_data_store_client().remove_measurement_metadata(remove_request).measurement ) - def delete_test_result(self, test_result_id: str, *, force: bool = False) -> None: + def delete_test_result( + self, test_result: TestResult | str | UUID, /, *, force: bool = False + ) -> None: """Delete a test result. Args: - test_result_id: The ID of the test result to delete. + test_result: The test result to delete, or its ID. force: If True, also delete the steps, measurements, and conditions that the test result owns. If False, the service rejects the delete with FAILED_PRECONDITION when owned children exist. """ - delete_request = DeleteTestResultRequest(test_result_id=test_result_id, force=force) + delete_request = DeleteTestResultRequest( + test_result_id=_resource_id(test_result), force=force + ) self._get_data_store_client().delete_test_result(delete_request) - def delete_step(self, step_id: str, *, force: bool = False) -> None: + def delete_step(self, step: Step | str | UUID, /, *, force: bool = False) -> None: """Delete a step. A step that has child steps cannot be deleted. Args: - step_id: The ID of the step to delete. + step: The step to delete, or its ID. force: If True, also delete the measurements of the step. If False, the service rejects the delete with FAILED_PRECONDITION when measurements exist. """ - delete_request = DeleteStepRequest(step_id=step_id, force=force) + delete_request = DeleteStepRequest(step_id=_resource_id(step), force=force) self._get_data_store_client().delete_step(delete_request) - def delete_measurement(self, measurement_id: str) -> None: + def delete_measurement(self, measurement: Measurement | str | UUID, /) -> None: """Delete a measurement. Args: - measurement_id: The ID of the measurement to delete. + measurement: The measurement to delete, or its ID. """ - delete_request = DeleteMeasurementRequest(measurement_id=measurement_id) + delete_request = DeleteMeasurementRequest(measurement_id=_resource_id(measurement)) self._get_data_store_client().delete_measurement(delete_request) def batch_delete_test_results( - self, test_result_ids: Iterable[str], *, force: bool | Iterable[bool] = False + self, + test_results: Iterable[TestResult | str | UUID], + *, + force: bool | Iterable[bool] = False, ) -> None: """Delete multiple test results in one atomic call. Args: - test_result_ids: The IDs of the test results to delete. + test_results: The test results to delete, or their IDs. force: One flag for all test results, or one flag per test result. See delete_test_result for details on force behavior. @@ -875,20 +1089,18 @@ def batch_delete_test_results( batch_request = BatchDeleteTestResultsRequest( requests=[ DeleteTestResultRequest(test_result_id=test_result_id, force=item_force) - for test_result_id, item_force in _with_force( - test_result_ids, force, "test_result_ids" - ) + for test_result_id, item_force in _with_force(test_results, force, "test_results") ] ) self._get_data_store_client().batch_delete_test_results(batch_request) def batch_delete_steps( - self, step_ids: Iterable[str], *, force: bool | Iterable[bool] = False + self, steps: Iterable[Step | str | UUID], *, force: bool | Iterable[bool] = False ) -> None: """Delete multiple steps in one atomic call. Args: - step_ids: The IDs of the steps to delete. A step and its ancestor or + steps: The steps to delete, or their IDs. A step and its ancestor or descendant cannot be deleted in the same call. force: One flag for all steps, or one flag per step. See delete_step @@ -900,21 +1112,21 @@ def batch_delete_steps( batch_request = BatchDeleteStepsRequest( requests=[ DeleteStepRequest(step_id=step_id, force=item_force) - for step_id, item_force in _with_force(step_ids, force, "step_ids") + for step_id, item_force in _with_force(steps, force, "steps") ] ) self._get_data_store_client().batch_delete_steps(batch_request) - def batch_delete_measurements(self, measurement_ids: Iterable[str]) -> None: + def batch_delete_measurements(self, measurements: Iterable[Measurement | str | UUID]) -> None: """Delete multiple measurements in one atomic call. Args: - measurement_ids: The IDs of the measurements to delete. + measurements: The measurements to delete, or their IDs. """ batch_request = BatchDeleteMeasurementsRequest( requests=[ DeleteMeasurementRequest(measurement_id=measurement_id) - for measurement_id in string_list(measurement_ids, "measurement_ids") + for measurement_id in _resource_ids(measurements, "measurements") ] ) self._get_data_store_client().batch_delete_measurements(batch_request) @@ -925,82 +1137,19 @@ def _get_data_store_client(self) -> DataStoreServiceClient: return self._data_store_client -# Mutable update-mask paths from the V2 mutability design, including Stage 2 resource fields. -_TEST_RESULT_UPDATE_FIELDS = frozenset( - { - "uut_instance_id", - "uut_id", - "operator_id", - "test_station_id", - "test_description_id", - "uut_instance", - "uut", - "operator", - "test_station", - "test_description", - "start_date_time", - "end_date_time", - "duration", - "outcome", - "status", - "error_information", - "link", - "keywords", - "extension", - } -) -_STEP_UPDATE_FIELDS = frozenset( - { - "test_id", - "test", - "notes", - "start_date_time", - "end_date_time", - "duration", - "outcome", - "status", - "error_information", - "link", - "keywords", - "extension", - } -) -_MEASUREMENT_UPDATE_FIELDS = frozenset( - { - "notes", - "outcome", - "error_information", - "extension", - "start_date_time", - "end_date_time", - "duration", - } -) - - -# Update fields that take a metadata model, such as Operator, and are sent as its protobuf. -_METADATA_UPDATE_FIELDS = frozenset( - {"uut_instance", "uut", "operator", "test_station", "test_description", "test"} -) - - def _read_mask(fields: Iterable[str]) -> FieldMask | None: - paths = string_list(fields, "read_fields") + # Send the field name of an enum member, not its str() representation. + paths = [ + str(field.value) if isinstance(field, Enum) else field + for field in string_list(fields, "read_fields") + ] return FieldMask(paths=paths) if paths else None -def _require_items( - updates: Mapping[str, Mapping[str, object]], -) -> Mapping[str, Mapping[str, object]]: - if not updates: - raise ValueError("At least one update must be supplied for a batch update.") - return updates - - def _with_force( - ids: Iterable[str], force: bool | Iterable[bool], ids_name: str + resources: Iterable[TestResult | Step | str | UUID], force: bool | Iterable[bool], name: str ) -> list[tuple[str, bool]]: - id_list = string_list(ids, ids_name) + id_list = _resource_ids(resources, name) if isinstance(force, bool): return [(resource_id, force) for resource_id in id_list] force_list = list(force) @@ -1011,34 +1160,99 @@ def _with_force( return list(zip(id_list, force_list)) -def _populate_update( - resource: TestResultProto | StepProto | MeasurementProto, - fields: Mapping[str, object], - allowed: frozenset[str], -) -> FieldMask: - if not fields: - raise ValueError("At least one field must be supplied for an update.") - unknown_fields = fields.keys() - allowed - if unknown_fields: - raise ValueError(f"Fields cannot be updated: {', '.join(sorted(unknown_fields))}.") - - target = cast(Any, resource) - for name, value in fields.items(): - if value is None: - target.ClearField(name) - elif name in {"start_date_time", "end_date_time"}: - getattr(target, name).CopyFrom(hightime_datetime_to_protobuf(cast(ht.datetime, value))) - elif name == "duration": - target.duration.CopyFrom(hightime_timedelta_to_protobuf(cast(ht.timedelta, value))) - elif name == "keywords": - target.keywords[:] = string_list(cast(Iterable[str], value), "keywords") - elif name == "extension": - json_format.ParseDict(dict(cast(Mapping[str, Any], value)), target.extension) - elif name == "error_information": - target.error_information.CopyFrom(cast(ErrorInformation, value).to_protobuf()) - elif name in _METADATA_UPDATE_FIELDS: - getattr(target, name).CopyFrom(cast(Any, value).to_protobuf()) - else: - setattr(target, name, value) +def _update_items( + updates: Mapping[str, Mapping[str, Any]] | Mapping[UUID, Mapping[str, Any]] | Iterable[_T], +) -> list[tuple[_T | str | UUID, Mapping[str, Any]]]: + items: list[tuple[_T | str | UUID, Mapping[str, Any]]] + if isinstance(updates, Mapping): + items = [(resource_id, fields) for resource_id, fields in updates.items()] + else: + items = [(target, {}) for target in updates] + if not items: + raise ValueError("At least one update must be supplied for a batch update.") + return items + + +def _test_result_update( + target: TestResult | TestResultUpdateBuilder | str | UUID, fields: Mapping[str, Any] +) -> UpdateTestResultRequest: + if isinstance(target, TestResultUpdateBuilder): + _reject_builder_fields(fields) + return target.build_update() + if isinstance(target, TestResult) and not fields: + fields = resource_update_fields( + target, + TestResultProto.DESCRIPTOR, + TEST_RESULT_UPDATE_FIELDS, + TEST_RESULT_RELATIONSHIPS, + ) + test_result = TestResultProto(id=_resource_id(target)) + mask = populate_update(test_result, fields, TEST_RESULT_UPDATE_FIELDS) + return UpdateTestResultRequest(test_result=test_result, update_mask=mask) + + +def _step_update( + target: Step | StepUpdateBuilder | str | UUID, fields: Mapping[str, Any] +) -> UpdateStepRequest: + if isinstance(target, StepUpdateBuilder): + _reject_builder_fields(fields) + return target.build_update() + if isinstance(target, Step) and not fields: + fields = resource_update_fields( + target, StepProto.DESCRIPTOR, STEP_UPDATE_FIELDS, STEP_RELATIONSHIPS + ) + step = StepProto(id=_resource_id(target)) + mask = populate_update(step, fields, STEP_UPDATE_FIELDS) + return UpdateStepRequest(step=step, update_mask=mask) + + +def _measurement_update( + target: Measurement | MeasurementUpdateBuilder | str | UUID, fields: Mapping[str, Any] +) -> UpdateMeasurementRequest: + if isinstance(target, MeasurementUpdateBuilder): + _reject_builder_fields(fields) + return target.build_update() + if isinstance(target, Measurement) and not fields: + fields = resource_update_fields( + target, MeasurementProto.DESCRIPTOR, MEASUREMENT_UPDATE_FIELDS, {} + ) + measurement = MeasurementProto(id=_resource_id(target)) + mask = populate_update(measurement, fields, MEASUREMENT_UPDATE_FIELDS) + return UpdateMeasurementRequest(measurement=measurement, update_mask=mask) + + +def _resource_id(target: TestResult | Step | Measurement | str | UUID) -> str: + return id_string(target) if isinstance(target, (str, UUID)) else target.id + + +def _reject_builder_fields(fields: Mapping[str, Any]) -> None: + if fields: + raise TypeError( + "Keyword fields cannot be combined with an update builder. " + "Select the fields on the builder instead." + ) + + +def _resource_ids( + resources: Iterable[TestResult | Step | Measurement | str | UUID], name: str +) -> list[str]: + # A str is itself an iterable of one-character strings. + if isinstance(resources, (str, UUID)): + raise TypeError( + f"{name} must be a list or other iterable of resources or IDs, " + f"not a single {type(resources).__name__}." + ) + return [_resource_id(resource) for resource in resources] + + +def _reject_model_fields(fields: Mapping[Any, Any], model: str, resource_name: str) -> None: + if fields: + raise TypeError( + f"Pass either {model} or the {resource_name} fields as arguments, not both." + ) + - return FieldMask(paths=list(fields)) +def _measurement_from_fields( + name: str, value: MeasurementValue, step_id: str | UUID, **fields: Any +) -> Measurement: + return Measurement(name=name, value=value, step_id=step_id, **fields) diff --git a/src/ni/datastore/data/v2/_grpc_conversion.py b/src/ni/datastore/data/v2/_grpc_conversion.py index 43ea7535..4868559c 100644 --- a/src/ni/datastore/data/v2/_grpc_conversion.py +++ b/src/ni/datastore/data/v2/_grpc_conversion.py @@ -4,13 +4,14 @@ from collections.abc import Iterable, Mapping from typing import Any, cast +from uuid import UUID import hightime as ht import numpy as np from google.protobuf import json_format from google.protobuf.message import Message from google.protobuf.struct_pb2 import Struct -from ni.measurements.data.v2.data_store_pb2 import MeasurementValue +from ni.measurements.data.v2.data_store_pb2 import MeasurementValue as MeasurementValueProto from ni.protobuf.types.precision_duration_conversion import ( hightime_timedelta_from_protobuf, hightime_timedelta_to_protobuf, @@ -54,9 +55,10 @@ from nitypes.xy_data import XYData from ni.datastore.data.v2._types._json_value import JsonValue, json_value_from_protobuf +from ni.datastore.data.v2._types._measurement_value import MeasurementReadValue -def populate_measurement_value(measurement_value: MeasurementValue, value: object) -> None: +def populate_measurement_value(measurement_value: MeasurementValueProto, value: object) -> None: """Convert a supported Python value into a V2 measurement value.""" if isinstance(value, bool): measurement_value.scalar.bool_value = value @@ -129,7 +131,9 @@ def populate_measurement_value(measurement_value: MeasurementValue, value: objec ) -def convert_measurement_value_from_protobuf(measurement_value: MeasurementValue) -> object: +def convert_measurement_value_from_protobuf( + measurement_value: MeasurementValueProto, +) -> MeasurementReadValue: """Convert a V2 measurement value into a useful Python value.""" value_type = measurement_value.WhichOneof("value") if value_type == "scalar": @@ -161,20 +165,20 @@ def convert_measurement_value_from_protobuf(measurement_value: MeasurementValue) raise TypeError(f"Unsupported or missing measurement value type: {value_type}.") -def measurement_value_to_protobuf(value: object) -> MeasurementValue | None: +def measurement_value_to_protobuf(value: object) -> MeasurementValueProto | None: """Convert an optional measurement value, upper limit, or lower limit to protobuf.""" if value is None: return None - measurement_value = MeasurementValue() + measurement_value = MeasurementValueProto() populate_measurement_value(measurement_value, value) return measurement_value -def measurement_value_from_protobuf(message: Message, field: str) -> object: +def measurement_value_from_protobuf(message: Message, field: str) -> MeasurementReadValue | None: """Convert an optional MeasurementValue field, or return None when it has no inline value.""" if not message.HasField(field): return None - measurement_value = cast(MeasurementValue, getattr(message, field)) + measurement_value = cast(MeasurementValueProto, getattr(message, field)) # Non-scalar payloads may be returned only as a data_moniker reference. if measurement_value.WhichOneof("value") in (None, "data_moniker"): return None @@ -226,3 +230,17 @@ def string_list(values: Iterable[str] | None, name: str) -> list[str]: if isinstance(values, str): raise TypeError(f"{name} must be a list or other iterable of strings, not a single str.") return list(values) if values is not None else [] + + +def id_string(value: str | UUID) -> str: + """Convert a resource or metadata ID to its string form.""" + return str(value) if isinstance(value, UUID) else value + + +def id_list(values: Iterable[str | UUID] | None, name: str) -> list[str]: + """Convert an iterable of IDs to a list of strings, rejecting a single ID.""" + if isinstance(values, (str, UUID)): + raise TypeError( + f"{name} must be a list or other iterable of IDs, not a single {type(values).__name__}." + ) + return [id_string(value) for value in values] if values is not None else [] diff --git a/src/ni/datastore/data/v2/_types/_condition.py b/src/ni/datastore/data/v2/_types/_condition.py index 63f2d817..c94a0445 100644 --- a/src/ni/datastore/data/v2/_types/_condition.py +++ b/src/ni/datastore/data/v2/_types/_condition.py @@ -24,7 +24,7 @@ class Condition: __slots__ = ( "name", - "value", + "_value", "condition_type", "value_label", "units", @@ -53,10 +53,24 @@ def __init__( units of a Scalar value. """ self.name = name - self.value = value self.condition_type = condition_type self.value_label = value_label self.units = units + self.value = value + + @property + def value(self) -> object: + """The condition value. + + Setting a Scalar value also sets units to the Scalar units when units is empty. + """ + return self._value + + @value.setter + def value(self, value: object) -> None: + self._value = value + if isinstance(value, Scalar) and not self.units: + self.units = value.units @staticmethod def from_protobuf(condition_proto: ConditionProto) -> "Condition": diff --git a/src/ni/datastore/data/v2/_types/_create.py b/src/ni/datastore/data/v2/_types/_create.py new file mode 100644 index 00000000..1f6c62fb --- /dev/null +++ b/src/ni/datastore/data/v2/_types/_create.py @@ -0,0 +1,91 @@ +"""Typed create fields for the V2 Data Store Client.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from typing import Any, TypedDict +from uuid import UUID + +import hightime as ht + +from ni.datastore.data.v2._types._condition import Condition +from ni.datastore.data.v2._types._error_information import ErrorInformation +from ni.datastore.data.v2._types._measurement import Measurement +from ni.datastore.data.v2._types._outcome import Outcome +from ni.datastore.data.v2._types._status import Status +from ni.datastore.data.v2._types._step import Step +from ni.datastore.metadata import ( + HardwareItem, + Operator, + SoftwareItem, + Test, + TestAdapter, + TestDescription, + TestStation, + Uut, + UutInstance, +) + + +class TestResultCreateFields(TypedDict, total=False): + """The test result fields that create_test_result accepts as keyword arguments. + + Each field has the same meaning and default as the matching TestResult + constructor argument. + """ + + uut_instance_id: str | UUID + uut_id: str | UUID + operator_id: str | UUID + test_station_id: str | UUID + test_description_id: str | UUID + uut_instance: UutInstance | None + uut: Uut | None + operator: Operator | None + test_station: TestStation | None + test_description: TestDescription | None + software_item_ids: Iterable[str | UUID] | None + hardware_item_ids: Iterable[str | UUID] | None + test_adapter_ids: Iterable[str | UUID] | None + software_items: Iterable[SoftwareItem] | None + hardware_items: Iterable[HardwareItem] | None + test_adapters: Iterable[TestAdapter] | None + start_date_time: ht.datetime | None + end_date_time: ht.datetime | None + duration: ht.timedelta | None + outcome: Outcome + status: Status + link: str + keywords: Iterable[str] | None + extension: Mapping[str, Any] | None + schema_id: str + error_information: ErrorInformation | None + steps: Iterable[Step] | None + + +class StepCreateFields(TypedDict, total=False): + """The step fields that create_step accepts as keyword arguments. + + Each field has the same meaning and default as the matching Step + constructor argument. + """ + + parent_step_id: str | UUID + test_result_id: str | UUID + test_id: str | UUID + test: Test | None + step_type: str + notes: str + start_date_time: ht.datetime | None + end_date_time: ht.datetime | None + duration: ht.timedelta | None + outcome: Outcome + status: Status + link: str + keywords: Iterable[str] | None + extension: Mapping[str, Any] | None + schema_id: str + error_information: ErrorInformation | None + conditions: Iterable[Condition] | None + measurements: Iterable[Measurement] | None + children: Iterable[Step] | None diff --git a/src/ni/datastore/data/v2/_types/_measurement.py b/src/ni/datastore/data/v2/_types/_measurement.py index afee5ded..2cb45bb2 100644 --- a/src/ni/datastore/data/v2/_types/_measurement.py +++ b/src/ni/datastore/data/v2/_types/_measurement.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any, Iterable, Mapping, MutableMapping, MutableSequence, Sequence +from uuid import UUID import hightime as ht from ni.measurements.data.v2.data_store_pb2 import Measurement as MeasurementProto @@ -13,14 +14,16 @@ duration_to_protobuf, extension_from_protobuf, extension_to_protobuf, + id_list, + id_string, measurement_value_from_protobuf, measurement_value_to_protobuf, - string_list, timestamp_from_protobuf, timestamp_to_protobuf, ) from ni.datastore.data.v2._types._condition import Condition from ni.datastore.data.v2._types._error_information import ErrorInformation +from ni.datastore.data.v2._types._measurement_value import MeasurementValue from ni.datastore.data.v2._types._outcome import Outcome from ni.datastore.metadata import HardwareItem, SoftwareItem, TestAdapter @@ -38,7 +41,7 @@ class Measurement: "test_result_id", "step_id", "name", - "value", + "_value", "upper_limit", "lower_limit", "value_type", @@ -65,6 +68,8 @@ class Measurement: "updated_by", ) + units: str + @property def extension(self) -> MutableMapping[str, Any]: """Custom JSON data associated with the measurement.""" @@ -105,16 +110,30 @@ def conditions(self) -> Sequence[Condition]: """Read-only output-only conditions copied from the owning step.""" return self._conditions + @property + def value(self) -> MeasurementValue | None: + """The measured value. + + Setting a Scalar value also sets units to the Scalar units when units is empty. + """ + return self._value + + @value.setter + def value(self, value: MeasurementValue | None) -> None: + self._value = value + if isinstance(value, Scalar) and not self.units: + self.units = value.units + def __init__( self, *, - id: str = "", - test_result_id: str = "", - step_id: str = "", + id: str | UUID = "", + test_result_id: str | UUID = "", + step_id: str | UUID = "", name: str = "", - value: object = None, - upper_limit: object = None, - lower_limit: object = None, + value: MeasurementValue | None = None, + upper_limit: MeasurementValue | None = None, + lower_limit: MeasurementValue | None = None, value_type: str = "", units: str = "", notes: str = "", @@ -125,9 +144,9 @@ def __init__( error_information: ErrorInformation | None = None, extension: Mapping[str, Any] | None = None, schema_id: str = "", - software_item_ids: Iterable[str] | None = None, - hardware_item_ids: Iterable[str] | None = None, - test_adapter_ids: Iterable[str] | None = None, + software_item_ids: Iterable[str | UUID] | None = None, + hardware_item_ids: Iterable[str | UUID] | None = None, + test_adapter_ids: Iterable[str | UUID] | None = None, software_items: Iterable[SoftwareItem] | None = None, hardware_items: Iterable[HardwareItem] | None = None, test_adapters: Iterable[TestAdapter] | None = None, @@ -186,15 +205,15 @@ def __init__( created_by (optional): Output only. Who created the measurement. updated_by (optional): Output only. Who last updated the measurement. """ - self.id = id - self.test_result_id = test_result_id - self.step_id = step_id + self.id = id_string(id) + self.test_result_id = id_string(test_result_id) + self.step_id = id_string(step_id) self.name = name + self.units = units self.value = value self.upper_limit = upper_limit self.lower_limit = lower_limit self.value_type = value_type - self.units = units self.notes = notes self.start_date_time = start_date_time self.end_date_time = end_date_time @@ -203,15 +222,13 @@ def __init__( self.error_information = error_information self._extension: MutableMapping[str, Any] = dict(extension) if extension is not None else {} self.schema_id = schema_id - self._software_item_ids: MutableSequence[str] = string_list( + self._software_item_ids: MutableSequence[str] = id_list( software_item_ids, "software_item_ids" ) - self._hardware_item_ids: MutableSequence[str] = string_list( + self._hardware_item_ids: MutableSequence[str] = id_list( hardware_item_ids, "hardware_item_ids" ) - self._test_adapter_ids: MutableSequence[str] = string_list( - test_adapter_ids, "test_adapter_ids" - ) + self._test_adapter_ids: MutableSequence[str] = id_list(test_adapter_ids, "test_adapter_ids") self._software_items: MutableSequence[SoftwareItem] = ( list(software_items) if software_items is not None else [] ) diff --git a/src/ni/datastore/data/v2/_types/_measurement_value.py b/src/ni/datastore/data/v2/_types/_measurement_value.py new file mode 100644 index 00000000..4f1c06d9 --- /dev/null +++ b/src/ni/datastore/data/v2/_types/_measurement_value.py @@ -0,0 +1,45 @@ +"""Measurement value types for the V2 Data Store Client.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from typing import Any, TypeAlias, Union + +from nitypes.scalar import Scalar +from nitypes.vector import Vector +from nitypes.waveform import AnalogWaveform, ComplexWaveform, DigitalWaveform, Spectrum +from nitypes.xy_data import XYData + +from ni.datastore.data.v2._types._json_value import JsonValue + +MeasurementReadValue: TypeAlias = Union[ + Scalar[Any], + Vector[Any], + AnalogWaveform[Any], + ComplexWaveform[Any], + Spectrum[Any], + DigitalWaveform[Any], + XYData[Any], + dict[str, Any], + JsonValue, +] +"""A measurement value or limit read from the data store. + +A single value is always read as a Scalar and a list of values as a Vector. JSON +is read as a dictionary, or as a JsonValue when it has attributes or a schema. +""" + +MeasurementValue: TypeAlias = Union[ + bool, + int, + float, + str, + MeasurementReadValue, + Mapping[str, Any], + Iterable[Union[bool, int, float, str]], +] +"""A measurement value or limit that can be written to the data store. + +Includes everything that can be read, plus plain bool, int, float, and str +values, JSON mappings, and lists of plain values. +""" diff --git a/src/ni/datastore/data/v2/_types/_read_fields.py b/src/ni/datastore/data/v2/_types/_read_fields.py new file mode 100644 index 00000000..1f33c60e --- /dev/null +++ b/src/ni/datastore/data/v2/_types/_read_fields.py @@ -0,0 +1,117 @@ +"""Field names that can be selected when reading V2 data store resources.""" + +from __future__ import annotations + +from enum import Enum + + +class TestResultField(str, Enum): + """A test result field that can be selected with read_fields.""" + + ID = "id" + UUT_INSTANCE_ID = "uut_instance_id" + OPERATOR_ID = "operator_id" + TEST_STATION_ID = "test_station_id" + TEST_DESCRIPTION_ID = "test_description_id" + SOFTWARE_ITEM_IDS = "software_item_ids" + HARDWARE_ITEM_IDS = "hardware_item_ids" + TEST_ADAPTER_IDS = "test_adapter_ids" + NAME = "name" + START_DATE_TIME = "start_date_time" + END_DATE_TIME = "end_date_time" + OUTCOME = "outcome" + STATUS = "status" + ERROR_INFORMATION = "error_information" + LINK = "link" + EXTENSION = "extension" + SCHEMA_ID = "schema_id" + UUT_ID = "uut_id" + KEYWORDS = "keywords" + DURATION = "duration" + CREATED_AT = "created_at" + UPDATED_AT = "updated_at" + CREATED_BY = "created_by" + UPDATED_BY = "updated_by" + STEPS = "steps" + UUT_INSTANCE = "uut_instance" + OPERATOR = "operator" + TEST_STATION = "test_station" + TEST_DESCRIPTION = "test_description" + UUT = "uut" + SOFTWARE_ITEMS = "software_items" + HARDWARE_ITEMS = "hardware_items" + TEST_ADAPTERS = "test_adapters" + + +class StepField(str, Enum): + """A step field that can be selected with read_fields.""" + + ID = "id" + PARENT_STEP_ID = "parent_step_id" + TEST_RESULT_ID = "test_result_id" + TEST_ID = "test_id" + NAME = "name" + STEP_TYPE = "step_type" + NOTES = "notes" + START_DATE_TIME = "start_date_time" + END_DATE_TIME = "end_date_time" + OUTCOME = "outcome" + STATUS = "status" + ERROR_INFORMATION = "error_information" + LINK = "link" + EXTENSION = "extension" + SCHEMA_ID = "schema_id" + KEYWORDS = "keywords" + DURATION = "duration" + PATH = "path" + PATH_IDS = "path_ids" + CREATED_AT = "created_at" + UPDATED_AT = "updated_at" + CREATED_BY = "created_by" + UPDATED_BY = "updated_by" + CONDITIONS = "conditions" + MEASUREMENTS = "measurements" + CHILDREN = "children" + TEST = "test" + + +class MeasurementField(str, Enum): + """A measurement field that can be selected with read_fields.""" + + ID = "id" + TEST_RESULT_ID = "test_result_id" + STEP_ID = "step_id" + NAME = "name" + VALUE_TYPE = "value_type" + VALUE = "value" + UNITS = "units" + UPPER_LIMIT = "upper_limit" + LOWER_LIMIT = "lower_limit" + SOFTWARE_ITEM_IDS = "software_item_ids" + HARDWARE_ITEM_IDS = "hardware_item_ids" + TEST_ADAPTER_IDS = "test_adapter_ids" + NOTES = "notes" + START_DATE_TIME = "start_date_time" + END_DATE_TIME = "end_date_time" + OUTCOME = "outcome" + ERROR_INFORMATION = "error_information" + EXTENSION = "extension" + SCHEMA_ID = "schema_id" + DURATION = "duration" + CONDITIONS = "conditions" + CREATED_AT = "created_at" + UPDATED_AT = "updated_at" + CREATED_BY = "created_by" + UPDATED_BY = "updated_by" + SOFTWARE_ITEMS = "software_items" + HARDWARE_ITEMS = "hardware_items" + TEST_ADAPTERS = "test_adapters" + COMPARISON_TYPE = "comparison_type" + + +class MeasurementValueField(str, Enum): + """A measurement value field that can be selected with read_measurement_values.""" + + VALUE = "value" + UPPER_LIMIT = "upper_limit" + LOWER_LIMIT = "lower_limit" diff --git a/src/ni/datastore/data/v2/_types/_step.py b/src/ni/datastore/data/v2/_types/_step.py index 8166e808..48637c07 100644 --- a/src/ni/datastore/data/v2/_types/_step.py +++ b/src/ni/datastore/data/v2/_types/_step.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any, Iterable, Mapping, MutableMapping, MutableSequence +from uuid import UUID import hightime as ht from ni.measurements.data.v2.data_store_pb2 import Step as StepProto @@ -12,6 +13,7 @@ duration_to_protobuf, extension_from_protobuf, extension_to_protobuf, + id_string, string_list, timestamp_from_protobuf, timestamp_to_protobuf, @@ -101,10 +103,10 @@ def __init__( self, name: str, *, - id: str = "", - parent_step_id: str = "", - test_result_id: str = "", - test_id: str = "", + id: str | UUID = "", + parent_step_id: str | UUID = "", + test_result_id: str | UUID = "", + test_id: str | UUID = "", test: Test | None = None, step_type: str = "", notes: str = "", @@ -162,10 +164,10 @@ def __init__( updated_by (optional): Output only. Who last updated the step. """ self.name = name - self.id = id - self.parent_step_id = parent_step_id - self.test_result_id = test_result_id - self.test_id = test_id + self.id = id_string(id) + self.parent_step_id = id_string(parent_step_id) + self.test_result_id = id_string(test_result_id) + self.test_id = id_string(test_id) self.test = test self.step_type = step_type self.notes = notes diff --git a/src/ni/datastore/data/v2/_types/_test_result.py b/src/ni/datastore/data/v2/_types/_test_result.py index 1b9cbe87..ed71cd6c 100644 --- a/src/ni/datastore/data/v2/_types/_test_result.py +++ b/src/ni/datastore/data/v2/_types/_test_result.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any, Iterable, Mapping, MutableMapping, MutableSequence +from uuid import UUID import hightime as ht from ni.measurements.data.v2.data_store_pb2 import TestResult as TestResultProto @@ -12,6 +13,8 @@ duration_to_protobuf, extension_from_protobuf, extension_to_protobuf, + id_list, + id_string, string_list, timestamp_from_protobuf, timestamp_to_protobuf, @@ -125,20 +128,20 @@ def __init__( self, name: str, *, - id: str = "", - uut_instance_id: str = "", - uut_id: str = "", - operator_id: str = "", - test_station_id: str = "", - test_description_id: str = "", + id: str | UUID = "", + uut_instance_id: str | UUID = "", + uut_id: str | UUID = "", + operator_id: str | UUID = "", + test_station_id: str | UUID = "", + test_description_id: str | UUID = "", uut_instance: UutInstance | None = None, uut: Uut | None = None, operator: Operator | None = None, test_station: TestStation | None = None, test_description: TestDescription | None = None, - software_item_ids: Iterable[str] | None = None, - hardware_item_ids: Iterable[str] | None = None, - test_adapter_ids: Iterable[str] | None = None, + software_item_ids: Iterable[str | UUID] | None = None, + hardware_item_ids: Iterable[str | UUID] | None = None, + test_adapter_ids: Iterable[str | UUID] | None = None, software_items: Iterable[SoftwareItem] | None = None, hardware_items: Iterable[HardwareItem] | None = None, test_adapters: Iterable[TestAdapter] | None = None, @@ -204,26 +207,24 @@ def __init__( updated_by (optional): Output only. Who last updated the test result. """ self.name = name - self.id = id - self.uut_instance_id = uut_instance_id - self.uut_id = uut_id - self.operator_id = operator_id - self.test_station_id = test_station_id - self.test_description_id = test_description_id + self.id = id_string(id) + self.uut_instance_id = id_string(uut_instance_id) + self.uut_id = id_string(uut_id) + self.operator_id = id_string(operator_id) + self.test_station_id = id_string(test_station_id) + self.test_description_id = id_string(test_description_id) self.uut_instance = uut_instance self.uut = uut self.operator = operator self.test_station = test_station self.test_description = test_description - self._software_item_ids: MutableSequence[str] = string_list( + self._software_item_ids: MutableSequence[str] = id_list( software_item_ids, "software_item_ids" ) - self._hardware_item_ids: MutableSequence[str] = string_list( + self._hardware_item_ids: MutableSequence[str] = id_list( hardware_item_ids, "hardware_item_ids" ) - self._test_adapter_ids: MutableSequence[str] = string_list( - test_adapter_ids, "test_adapter_ids" - ) + self._test_adapter_ids: MutableSequence[str] = id_list(test_adapter_ids, "test_adapter_ids") self._software_items: MutableSequence[SoftwareItem] = ( list(software_items) if software_items is not None else [] ) diff --git a/src/ni/datastore/data/v2/_types/_update.py b/src/ni/datastore/data/v2/_types/_update.py new file mode 100644 index 00000000..e2e72b6b --- /dev/null +++ b/src/ni/datastore/data/v2/_types/_update.py @@ -0,0 +1,431 @@ +"""Update builders and typed update fields for the V2 Data Store Client.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from typing import Any, TypedDict +from uuid import UUID + +import hightime as ht +from google.protobuf.field_mask_pb2 import FieldMask +from ni.measurements.data.v2.data_store_pb2 import ( + Measurement as MeasurementProto, + Step as StepProto, + TestResult as TestResultProto, +) +from ni.measurements.data.v2.data_store_service_pb2 import ( + UpdateMeasurementRequest, + UpdateStepRequest, + UpdateTestResultRequest, +) + +from ni.datastore.data.v2._grpc_conversion import id_string, string_list +from ni.datastore.data.v2._types._error_information import ErrorInformation +from ni.datastore.data.v2._types._measurement import Measurement +from ni.datastore.data.v2._types._outcome import Outcome +from ni.datastore.data.v2._types._status import Status +from ni.datastore.data.v2._types._step import Step +from ni.datastore.data.v2._types._test_result import TestResult +from ni.datastore.data.v2._update_conversion import ( + MEASUREMENT_UPDATE_FIELDS, + populate_update, + STEP_UPDATE_FIELDS, + TEST_RESULT_UPDATE_FIELDS, +) +from ni.datastore.metadata import ( + Operator, + Test, + TestDescription, + TestStation, + Uut, + UutInstance, +) + + +class TestResultUpdateFields(TypedDict, total=False): + """The test result fields that update_test_result accepts as keyword arguments. + + A value of None clears the field. uut_id, uut, keywords, and duration are + Stage 2 fields that the service may not support yet. + """ + + uut_instance_id: str | UUID | None + uut_id: str | UUID | None + operator_id: str | UUID | None + test_station_id: str | UUID | None + test_description_id: str | UUID | None + uut_instance: UutInstance | None + uut: Uut | None + operator: Operator | None + test_station: TestStation | None + test_description: TestDescription | None + start_date_time: ht.datetime | None + end_date_time: ht.datetime | None + duration: ht.timedelta | None + outcome: Outcome | None + status: Status | None + error_information: ErrorInformation | None + link: str | None + keywords: Iterable[str] | None + extension: Mapping[str, Any] | None + + +class StepUpdateFields(TypedDict, total=False): + """The step fields that update_step accepts as keyword arguments. + + A value of None clears the field. keywords and duration are Stage 2 fields + that the service may not support yet. + """ + + test_id: str | UUID | None + test: Test | None + notes: str | None + start_date_time: ht.datetime | None + end_date_time: ht.datetime | None + duration: ht.timedelta | None + outcome: Outcome | None + status: Status | None + error_information: ErrorInformation | None + link: str | None + keywords: Iterable[str] | None + extension: Mapping[str, Any] | None + + +class MeasurementUpdateFields(TypedDict, total=False): + """The measurement fields that update_measurement accepts as keyword arguments. + + A value of None clears the field. duration is a Stage 2 field that the + service may not support yet. + """ + + notes: str | None + start_date_time: ht.datetime | None + end_date_time: ht.datetime | None + duration: ht.timedelta | None + outcome: Outcome | None + error_information: ErrorInformation | None + extension: Mapping[str, Any] | None + + +class TestResultUpdateBuilder: + """Builds an update for an existing test result. + + Each with method sets a field and adds it to the update mask. Only the + selected fields change. None clears a selected field. For a relationship + that can be set by ID or by inline metadata, such as operator_id and + operator, the last call wins and the other form is not sent. Values are + copied when each with method is called. + + Example: + update = TestResultUpdateBuilder(test_result).with_outcome(Outcome.PASSED) + updated = data_store_client.update_test_result(update) + """ + + __slots__ = ("_test_result", "_paths") + + def __init__(self, test_result: TestResult | str | UUID) -> None: + """Start an update for a test result without selecting any fields. + + Args: + test_result: The test result to update, or its ID. + + Raises: + ValueError: If the ID is empty. + """ + self._test_result = TestResultProto(id=_target_id(test_result, "test result")) + self._paths: dict[str, None] = {} + + def with_start_date_time(self, start_date_time: ht.datetime | None) -> TestResultUpdateBuilder: + """Set the start date and time, or clear it with None.""" + return self._select("start_date_time", start_date_time) + + def with_end_date_time(self, end_date_time: ht.datetime | None) -> TestResultUpdateBuilder: + """Set the end date and time, or clear it with None.""" + return self._select("end_date_time", end_date_time) + + def with_duration(self, duration: ht.timedelta | None) -> TestResultUpdateBuilder: + """Set the duration, or clear it with None. The service may not support this yet.""" + return self._select("duration", duration) + + def with_status(self, status: Status) -> TestResultUpdateBuilder: + """Set the lifecycle status.""" + return self._select("status", status) + + def with_outcome(self, outcome: Outcome) -> TestResultUpdateBuilder: + """Set the outcome.""" + return self._select("outcome", outcome) + + def with_error_information( + self, error_information: ErrorInformation | None + ) -> TestResultUpdateBuilder: + """Set the error information, or clear it with None.""" + return self._select("error_information", error_information) + + def with_extension(self, extension: Mapping[str, Any] | None) -> TestResultUpdateBuilder: + """Replace the whole extension JSON document, or clear it with None.""" + return self._select("extension", None if extension is None else dict(extension)) + + def with_link(self, link: str | None) -> TestResultUpdateBuilder: + """Set the link, or clear it with None.""" + return self._select("link", link) + + def with_keywords(self, keywords: Iterable[str] | None) -> TestResultUpdateBuilder: + """Replace the keywords, or clear them with None. The service may not support this yet.""" + return self._select( + "keywords", None if keywords is None else string_list(keywords, "keywords") + ) + + def with_uut_instance_id(self, uut_instance_id: str | UUID | None) -> TestResultUpdateBuilder: + """Link a UUT instance by ID or alias, or unlink it with None.""" + return self._select("uut_instance_id", uut_instance_id, "uut_instance") + + def with_uut_instance(self, uut_instance: UutInstance | None) -> TestResultUpdateBuilder: + """Link a UUT instance found or created from inline metadata, or unlink it with None.""" + return self._select("uut_instance", uut_instance, "uut_instance_id") + + def with_uut_id(self, uut_id: str | UUID | None) -> TestResultUpdateBuilder: + """Link a UUT by ID or alias, or unlink it with None. + + The service may not support this yet. + """ + return self._select("uut_id", uut_id, "uut") + + def with_uut(self, uut: Uut | None) -> TestResultUpdateBuilder: + """Link a UUT from inline metadata, or unlink it with None. + + The service may not support this yet. + """ + return self._select("uut", uut, "uut_id") + + def with_operator_id(self, operator_id: str | UUID | None) -> TestResultUpdateBuilder: + """Link an operator by ID or alias, or unlink it with None.""" + return self._select("operator_id", operator_id, "operator") + + def with_operator(self, operator: Operator | None) -> TestResultUpdateBuilder: + """Link an operator found or created from inline metadata, or unlink it with None.""" + return self._select("operator", operator, "operator_id") + + def with_test_station_id(self, test_station_id: str | UUID | None) -> TestResultUpdateBuilder: + """Link a test station by ID or alias, or unlink it with None.""" + return self._select("test_station_id", test_station_id, "test_station") + + def with_test_station(self, test_station: TestStation | None) -> TestResultUpdateBuilder: + """Link a test station found or created from inline metadata, or unlink it with None.""" + return self._select("test_station", test_station, "test_station_id") + + def with_test_description_id( + self, test_description_id: str | UUID | None + ) -> TestResultUpdateBuilder: + """Link a test description by ID or alias, or unlink it with None.""" + return self._select("test_description_id", test_description_id, "test_description") + + def with_test_description( + self, test_description: TestDescription | None + ) -> TestResultUpdateBuilder: + """Link a test description found or created from inline metadata, or unlink it with None.""" + return self._select("test_description", test_description, "test_description_id") + + def build_update(self) -> UpdateTestResultRequest: + """Build the update request. + + Raises: + ValueError: If no fields are selected. + """ + return UpdateTestResultRequest( + test_result=self._test_result, update_mask=_update_mask(self._paths) + ) + + def _select(self, name: str, value: object, other: str = "") -> TestResultUpdateBuilder: + _select(self._test_result, self._paths, TEST_RESULT_UPDATE_FIELDS, name, value, other) + return self + + +class StepUpdateBuilder: + """Builds an update for an existing step. + + Each with method sets a field and adds it to the update mask. Only the + selected fields change. None clears a selected field. For the test, set by + test_id or by inline metadata, the last call wins and the other form is not + sent. Values are copied when each with method is called. + + Example: + update = StepUpdateBuilder(step).with_outcome(Outcome.PASSED).with_notes("Verified") + updated = data_store_client.update_step(update) + """ + + __slots__ = ("_step", "_paths") + + def __init__(self, step: Step | str | UUID) -> None: + """Start an update for a step without selecting any fields. + + Args: + step: The step to update, or its ID. + + Raises: + ValueError: If the ID is empty. + """ + self._step = StepProto(id=_target_id(step, "step")) + self._paths: dict[str, None] = {} + + def with_start_date_time(self, start_date_time: ht.datetime | None) -> StepUpdateBuilder: + """Set the start date and time, or clear it with None.""" + return self._select("start_date_time", start_date_time) + + def with_end_date_time(self, end_date_time: ht.datetime | None) -> StepUpdateBuilder: + """Set the end date and time, or clear it with None.""" + return self._select("end_date_time", end_date_time) + + def with_duration(self, duration: ht.timedelta | None) -> StepUpdateBuilder: + """Set the duration, or clear it with None. The service may not support this yet.""" + return self._select("duration", duration) + + def with_status(self, status: Status) -> StepUpdateBuilder: + """Set the lifecycle status.""" + return self._select("status", status) + + def with_outcome(self, outcome: Outcome) -> StepUpdateBuilder: + """Set the outcome.""" + return self._select("outcome", outcome) + + def with_error_information( + self, error_information: ErrorInformation | None + ) -> StepUpdateBuilder: + """Set the error information, or clear it with None.""" + return self._select("error_information", error_information) + + def with_extension(self, extension: Mapping[str, Any] | None) -> StepUpdateBuilder: + """Replace the whole extension JSON document, or clear it with None.""" + return self._select("extension", None if extension is None else dict(extension)) + + def with_notes(self, notes: str | None) -> StepUpdateBuilder: + """Set the notes, or clear them with None.""" + return self._select("notes", notes) + + def with_link(self, link: str | None) -> StepUpdateBuilder: + """Set the link, or clear it with None.""" + return self._select("link", link) + + def with_keywords(self, keywords: Iterable[str] | None) -> StepUpdateBuilder: + """Replace the keywords, or clear them with None. The service may not support this yet.""" + return self._select( + "keywords", None if keywords is None else string_list(keywords, "keywords") + ) + + def with_test_id(self, test_id: str | UUID | None) -> StepUpdateBuilder: + """Link a test by ID or alias, or unlink it with None.""" + return self._select("test_id", test_id, "test") + + def with_test(self, test: Test | None) -> StepUpdateBuilder: + """Link a test found or created from inline metadata, or unlink it with None.""" + return self._select("test", test, "test_id") + + def build_update(self) -> UpdateStepRequest: + """Build the update request. + + Raises: + ValueError: If no fields are selected. + """ + return UpdateStepRequest(step=self._step, update_mask=_update_mask(self._paths)) + + def _select(self, name: str, value: object, other: str = "") -> StepUpdateBuilder: + _select(self._step, self._paths, STEP_UPDATE_FIELDS, name, value, other) + return self + + +class MeasurementUpdateBuilder: + """Builds an update for an existing measurement. + + Each with method sets a field and adds it to the update mask. Only the + selected fields change. None clears a selected field. Values are copied when + each with method is called. The value, units, and limits cannot be updated. + + Example: + update = MeasurementUpdateBuilder(measurement).with_notes("Rechecked") + updated = data_store_client.update_measurement(update) + """ + + __slots__ = ("_measurement", "_paths") + + def __init__(self, measurement: Measurement | str | UUID) -> None: + """Start an update for a measurement without selecting any fields. + + Args: + measurement: The measurement to update, or its ID. + + Raises: + ValueError: If the ID is empty. + """ + self._measurement = MeasurementProto(id=_target_id(measurement, "measurement")) + self._paths: dict[str, None] = {} + + def with_start_date_time(self, start_date_time: ht.datetime | None) -> MeasurementUpdateBuilder: + """Set the start date and time, or clear it with None.""" + return self._select("start_date_time", start_date_time) + + def with_end_date_time(self, end_date_time: ht.datetime | None) -> MeasurementUpdateBuilder: + """Set the end date and time, or clear it with None.""" + return self._select("end_date_time", end_date_time) + + def with_duration(self, duration: ht.timedelta | None) -> MeasurementUpdateBuilder: + """Set the duration, or clear it with None. The service may not support this yet.""" + return self._select("duration", duration) + + def with_outcome(self, outcome: Outcome) -> MeasurementUpdateBuilder: + """Set the outcome.""" + return self._select("outcome", outcome) + + def with_error_information( + self, error_information: ErrorInformation | None + ) -> MeasurementUpdateBuilder: + """Set the error information, or clear it with None.""" + return self._select("error_information", error_information) + + def with_extension(self, extension: Mapping[str, Any] | None) -> MeasurementUpdateBuilder: + """Replace the whole extension JSON document, or clear it with None.""" + return self._select("extension", None if extension is None else dict(extension)) + + def with_notes(self, notes: str | None) -> MeasurementUpdateBuilder: + """Set the notes, or clear them with None.""" + return self._select("notes", notes) + + def build_update(self) -> UpdateMeasurementRequest: + """Build the update request. + + Raises: + ValueError: If no fields are selected. + """ + return UpdateMeasurementRequest( + measurement=self._measurement, update_mask=_update_mask(self._paths) + ) + + def _select(self, name: str, value: object) -> MeasurementUpdateBuilder: + _select(self._measurement, self._paths, MEASUREMENT_UPDATE_FIELDS, name, value, "") + return self + + +def _target_id(target: TestResult | Step | Measurement | str | UUID, resource_name: str) -> str: + resource_id = id_string(target) if isinstance(target, (str, UUID)) else target.id + if not resource_id.strip(): + raise ValueError(f"The {resource_name} ID must not be empty.") + return resource_id + + +def _select( + resource: TestResultProto | StepProto | MeasurementProto, + paths: dict[str, None], + allowed: frozenset[str], + name: str, + value: object, + other: str, +) -> None: + if other: + resource.ClearField(other) # type: ignore[arg-type] + paths.pop(other, None) + populate_update(resource, {name: value}, allowed) + paths[name] = None + + +def _update_mask(paths: Mapping[str, None]) -> FieldMask: + if not paths: + raise ValueError("At least one field must be selected before building an update.") + return FieldMask(paths=list(paths)) diff --git a/src/ni/datastore/data/v2/_update_conversion.py b/src/ni/datastore/data/v2/_update_conversion.py new file mode 100644 index 00000000..2b7f0de4 --- /dev/null +++ b/src/ni/datastore/data/v2/_update_conversion.py @@ -0,0 +1,158 @@ +"""Conversion of update fields to V2 update requests.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from typing import Any, cast +from uuid import UUID + +import hightime as ht +from google.protobuf import json_format +from google.protobuf.descriptor import Descriptor +from google.protobuf.field_mask_pb2 import FieldMask +from ni.measurements.data.v2.data_store_pb2 import ( + Measurement as MeasurementProto, + Step as StepProto, + TestResult as TestResultProto, +) +from ni.protobuf.types.precision_duration_conversion import hightime_timedelta_to_protobuf +from ni.protobuf.types.precision_timestamp_conversion import hightime_datetime_to_protobuf + +from ni.datastore.data.v2._grpc_conversion import id_string, string_list +from ni.datastore.data.v2._types._error_information import ErrorInformation + +# Mutable update-mask paths from the V2 mutability design, including Stage 2 resource fields. +TEST_RESULT_UPDATE_FIELDS = frozenset( + { + "uut_instance_id", + "uut_id", + "operator_id", + "test_station_id", + "test_description_id", + "uut_instance", + "uut", + "operator", + "test_station", + "test_description", + "start_date_time", + "end_date_time", + "duration", + "outcome", + "status", + "error_information", + "link", + "keywords", + "extension", + } +) +STEP_UPDATE_FIELDS = frozenset( + { + "test_id", + "test", + "notes", + "start_date_time", + "end_date_time", + "duration", + "outcome", + "status", + "error_information", + "link", + "keywords", + "extension", + } +) +MEASUREMENT_UPDATE_FIELDS = frozenset( + { + "notes", + "outcome", + "error_information", + "extension", + "start_date_time", + "end_date_time", + "duration", + } +) + +# Stage 2 fields that the service does not accept in an update mask yet. A whole-resource +# update selects them only when they have a value, so it still works with the current service. +_STAGE_2_UPDATE_FIELDS = frozenset({"uut_id", "uut", "keywords", "duration"}) + +# Relationships that are set by ID or alias, or by inline metadata, but never both. +TEST_RESULT_RELATIONSHIPS = { + "uut_instance_id": "uut_instance", + "uut_id": "uut", + "operator_id": "operator", + "test_station_id": "test_station", + "test_description_id": "test_description", +} +STEP_RELATIONSHIPS = {"test_id": "test"} + +# Update fields that take a metadata model, such as Operator, and are sent as its protobuf. +_METADATA_UPDATE_FIELDS = frozenset( + {"uut_instance", "uut", "operator", "test_station", "test_description", "test"} +) + + +def populate_update( + resource: TestResultProto | StepProto | MeasurementProto, + fields: Mapping[str, object], + allowed: frozenset[str], +) -> FieldMask: + """Copy update field values into a protobuf resource and return the update mask.""" + if not fields: + raise ValueError("At least one field must be supplied for an update.") + unknown_fields = fields.keys() - allowed + if unknown_fields: + raise ValueError(f"Fields cannot be updated: {', '.join(sorted(unknown_fields))}.") + + target = cast(Any, resource) + for name, value in fields.items(): + if value is None: + target.ClearField(name) + elif name in {"start_date_time", "end_date_time"}: + getattr(target, name).CopyFrom(hightime_datetime_to_protobuf(cast(ht.datetime, value))) + elif name == "duration": + target.duration.CopyFrom(hightime_timedelta_to_protobuf(cast(ht.timedelta, value))) + elif name == "keywords": + target.keywords[:] = string_list(cast(Iterable[str], value), "keywords") + elif name == "extension": + json_format.ParseDict(dict(cast(Mapping[str, Any], value)), target.extension) + elif name == "error_information": + target.error_information.CopyFrom(cast(ErrorInformation, value).to_protobuf()) + elif name in _METADATA_UPDATE_FIELDS: + getattr(target, name).CopyFrom(cast(Any, value).to_protobuf()) + elif isinstance(value, UUID): + setattr(target, name, id_string(value)) + else: + setattr(target, name, value) + + return FieldMask(paths=list(fields)) + + +def resource_update_fields( + resource: object, + descriptor: Descriptor, + allowed: frozenset[str], + relationships: Mapping[str, str], +) -> dict[str, object]: + """Select every updatable field of a resource, in protobuf field order. + + Empty values are selected too, so the update clears them. Stage 2 fields are + selected only when they have a value. When a relationship has both an ID and + inline metadata, such as an expanded read, only the ID is sent. + """ + fields: dict[str, object] = {} + for field in descriptor.fields: + if field.name not in allowed: + continue + value = getattr(resource, field.name) + if field.name in _STAGE_2_UPDATE_FIELDS and not value: + continue + if field.name == "extension" and not value: + value = None + fields[field.name] = value + + for id_field, inline_field in relationships.items(): + if fields.get(id_field) and fields.get(inline_field) is not None: + fields[inline_field] = None + return fields diff --git a/tests/unit/data/test_v2_data_store_client.py b/tests/unit/data/test_v2_data_store_client.py index 0e9dd8d2..72d7eed9 100644 --- a/tests/unit/data/test_v2_data_store_client.py +++ b/tests/unit/data/test_v2_data_store_client.py @@ -3,8 +3,9 @@ from __future__ import annotations import datetime as std_datetime +import uuid from collections.abc import Callable -from typing import Any +from typing import Any, cast from unittest.mock import Mock import numpy as np @@ -26,11 +27,19 @@ ErrorInformation, JsonValue, Measurement, + MeasurementField, + MeasurementUpdateBuilder, + MeasurementUpdateFields, + MeasurementValueField, Outcome, QueryResults, Status, Step, + StepField, + StepUpdateBuilder, TestResult, + TestResultField, + TestResultUpdateBuilder, ) UTC = std_datetime.timezone.utc @@ -45,6 +54,7 @@ def service_client(mocker: MockerFixture) -> Any: service_client.update_test_result.return_value = data_store_pb2.TestResult() service_client.update_step.return_value = data_store_pb2.Step() service_client.update_measurement.return_value = data_store_pb2.Measurement() + service_client.create_measurement.return_value = data_store_pb2.Measurement() service_client.remove_test_result_metadata.return_value = ( data_store_service_pb2.RemoveTestResultMetadataResponse() ) @@ -73,10 +83,12 @@ def _read_response(service_client: Any, value: data_store_pb2.MeasurementValue) # ---------------------------------------------------------------- create -def test___create_test_result___sends_model_and_returns_id( +def test___create_test_result___sends_model_and_returns_created_test_result( client: DataStoreClient, service_client: Any ) -> None: - service_client.create_test_result.return_value = data_store_pb2.TestResult(id="tr-id") + service_client.create_test_result.return_value = data_store_pb2.TestResult( + id="tr-id", name="Power test" + ) test_result = TestResult( "Power test", uut_instance_id="uut-instance-id", @@ -87,18 +99,20 @@ def test___create_test_result___sends_model_and_returns_id( keywords=["smoke"], ) - test_result_id = client.create_test_result(test_result) + created = client.create_test_result(test_result) request = _sent(service_client.create_test_result) assert isinstance(request, data_store_service_pb2.CreateTestResultRequest) assert request.test_result == test_result.to_protobuf() - assert test_result_id == "tr-id" + assert created == TestResult("Power test", id="tr-id") -def test___create_step___sends_model_with_conditions_and_returns_id( +def test___create_step___sends_model_with_conditions_and_returns_created_step( client: DataStoreClient, service_client: Any ) -> None: - service_client.create_step.return_value = data_store_pb2.Step(id="step-id") + service_client.create_step.return_value = data_store_pb2.Step( + id="step-id", name="Measure voltage" + ) step = Step( "Measure voltage", test_result_id="tr-id", @@ -106,13 +120,91 @@ def test___create_step___sends_model_with_conditions_and_returns_id( conditions=[Condition("temperature", 25.0)], ) - step_id = client.create_step(step) + created = client.create_step(step) request = _sent(service_client.create_step) assert isinstance(request, data_store_service_pb2.CreateStepRequest) assert request.step == step.to_protobuf() assert request.step.conditions[0].value.scalar.double_value == 25.0 - assert step_id == "step-id" + assert created == Step("Measure voltage", id="step-id") + + +def test___create_test_result___name_and_fields___sends_same_request_as_model( + client: DataStoreClient, service_client: Any +) -> None: + service_client.create_test_result.return_value = data_store_pb2.TestResult(id="tr-id") + + created = client.create_test_result( + "Power test", operator_id="operator-id", outcome=Outcome.PASSED, keywords=["smoke"] + ) + + expected = TestResult( + "Power test", operator_id="operator-id", outcome=Outcome.PASSED, keywords=["smoke"] + ) + assert _sent(service_client.create_test_result).test_result == expected.to_protobuf() + assert created.id == "tr-id" + + +def test___create_step___name_and_fields___sends_same_request_as_model( + client: DataStoreClient, service_client: Any +) -> None: + service_client.create_step.return_value = data_store_pb2.Step(id="step-id") + + created = client.create_step( + "Measure voltage", test_result_id="tr-id", conditions=[Condition("temperature", 25.0)] + ) + + expected = Step( + "Measure voltage", test_result_id="tr-id", conditions=[Condition("temperature", 25.0)] + ) + assert _sent(service_client.create_step).step == expected.to_protobuf() + assert created.id == "step-id" + + +def test___create_measurement___model___sends_same_request_as_fields( + client: DataStoreClient, service_client: Any +) -> None: + service_client.create_measurement.return_value = data_store_pb2.Measurement(id="m-id") + measurement = Measurement( + name="voltage", value=Scalar(5.0, "V"), step_id="step-id", outcome=Outcome.PASSED + ) + + created = client.create_measurement(measurement) + + from_fields = client.create_measurement( + "voltage", Scalar(5.0, "V"), "step-id", outcome=Outcome.PASSED + ) + first, second = [call.args[0] for call in service_client.create_measurement.call_args_list] + assert first.measurement == measurement.to_protobuf() + assert first == second + assert created.id == from_fields.id == "m-id" + + +@pytest.mark.parametrize( + "call", + [ + pytest.param( + lambda client: client.create_test_result(TestResult("t"), link="x"), + id="create_test_result", + ), + pytest.param(lambda client: client.create_step(Step("s"), notes="x"), id="create_step"), + pytest.param( + lambda client: client.create_measurement(Measurement(name="v"), 1.0), + id="create_measurement_positional", + ), + pytest.param( + lambda client: client.create_measurement(Measurement(name="v"), units="V"), + id="create_measurement_keyword", + ), + ], +) +def test___create___model_with_fields___raises_type_error_before_rpc( + client: DataStoreClient, service_client: Any, call: Callable[[DataStoreClient], object] +) -> None: + with pytest.raises(TypeError, match="not both"): + call(client) + + assert service_client.method_calls == [] def test___add_condition___sends_condition_and_returns_step( @@ -144,9 +236,11 @@ def test___add_condition___rejects_unsupported_value_before_rpc( def test___create_measurement___converts_value_and_metadata( client: DataStoreClient, service_client: Any ) -> None: - service_client.create_measurement.return_value = data_store_pb2.Measurement(id="m-id") + response = data_store_pb2.Measurement(id="m-id", name="voltage", step_id="step-id", units="V") + response.value.scalar.double_value = 5.2 + service_client.create_measurement.return_value = response - measurement_id = client.create_measurement( + created = client.create_measurement( "voltage", 5.2, "step-id", @@ -160,7 +254,9 @@ def test___create_measurement___converts_value_and_metadata( request = _sent(service_client.create_measurement) assert isinstance(request, data_store_service_pb2.CreateMeasurementRequest) - assert measurement_id == "m-id" + assert created == Measurement( + id="m-id", name="voltage", step_id="step-id", units="V", value=Scalar(5.2) + ) assert (request.measurement.name, request.measurement.step_id) == ("voltage", "step-id") assert request.measurement.units == "V" assert request.measurement.value.scalar.double_value == 5.2 @@ -246,7 +342,7 @@ def test___create_measurement___rejects_unsupported_value_before_rpc( client: DataStoreClient, service_client: Any ) -> None: with pytest.raises(TypeError, match="Unsupported measurement value type"): - client.create_measurement("measurement", object(), "step-id") + client.create_measurement("measurement", object(), "step-id") # type: ignore[call-overload] service_client.create_measurement.assert_not_called() @@ -254,14 +350,21 @@ def test___create_measurement___rejects_unsupported_value_before_rpc( # ---------------------------------------------------------------- read value +@pytest.mark.parametrize( + "read_source", + [ + pytest.param(Measurement(id="m-id"), id="measurement"), + pytest.param("m-id", id="measurement_id"), + ], +) def test___read_measurement_value___requests_value_of_measurement_id( - client: DataStoreClient, service_client: Any + client: DataStoreClient, service_client: Any, read_source: Measurement | str ) -> None: value = data_store_pb2.MeasurementValue() value.scalar.bool_value = True _read_response(service_client, value) - read_value = client.read_measurement_value(Measurement(id="m-id")) + read_value = client.read_measurement_value(read_source) request = _sent(service_client.read_measurement_values) assert request.measurement_id == "m-id" @@ -551,7 +654,7 @@ def test___update_measurement___converts_error_information_and_extension( def test___update_measurement___converts_timestamp( client: DataStoreClient, service_client: Any, field: str ) -> None: - client.update_measurement("m-id", **{field: TIMESTAMP}) + client.update_measurement("m-id", **cast(MeasurementUpdateFields, {field: TIMESTAMP})) request = _sent(service_client.update_measurement) assert getattr(request.measurement, field) == hightime_datetime_to_protobuf(TIMESTAMP) @@ -629,6 +732,193 @@ def test___update___rejects_immutable_field_before_rpc( getattr(service_client, method).assert_not_called() +def test___update_step___builder___sends_builder_request( + client: DataStoreClient, service_client: Any +) -> None: + service_client.update_step.return_value = data_store_pb2.Step(id="step-id", notes="ok") + + updated = client.update_step(StepUpdateBuilder("step-id").with_notes("ok")) + + request = _sent(service_client.update_step) + assert request.step.id == "step-id" + assert request.step.notes == "ok" + assert list(request.update_mask.paths) == ["notes"] + assert updated.notes == "ok" + + +@pytest.mark.parametrize( + ("method", "builder"), + [ + ("update_test_result", TestResultUpdateBuilder("tr-id").with_link("x")), + ("update_step", StepUpdateBuilder("step-id").with_notes("x")), + ("update_measurement", MeasurementUpdateBuilder("m-id").with_notes("x")), + ], +) +def test___update___builder_with_keyword_fields___raises_type_error( + client: DataStoreClient, service_client: Any, method: str, builder: object +) -> None: + with pytest.raises(TypeError, match="builder"): + getattr(client, method)(builder, outcome=Outcome.PASSED) + + getattr(service_client, method).assert_not_called() + + +def test___update_step___resource_with_keyword_fields___updates_only_given_fields( + client: DataStoreClient, service_client: Any +) -> None: + step = Step(name="s", id="step-id", notes="old", link="https://example.com") + + client.update_step(step, outcome=Outcome.PASSED) + + request = _sent(service_client.update_step) + assert request.step.id == "step-id" + assert request.step.notes == "" + assert list(request.update_mask.paths) == ["outcome"] + + +def test___update_test_result___resource_only___selects_every_supported_field( + client: DataStoreClient, service_client: Any +) -> None: + test_result = TestResult( + name="ignored", + id="tr-id", + operator_id="operator-id", + link="https://example.com", + outcome=Outcome.PASSED, + schema_id="ignored-schema", + software_item_ids=["sw-id"], + ) + + client.update_test_result(test_result) + + request = _sent(service_client.update_test_result) + assert list(request.update_mask.paths) == [ + "uut_instance_id", + "operator_id", + "test_station_id", + "test_description_id", + "start_date_time", + "end_date_time", + "outcome", + "status", + "error_information", + "link", + "extension", + "uut_instance", + "operator", + "test_station", + "test_description", + ] + sent = request.test_result + assert sent.id == "tr-id" + assert sent.operator_id == "operator-id" + assert sent.link == "https://example.com" + assert sent.outcome == data_store_pb2.OUTCOME_PASSED + assert not sent.HasField("extension") + # Immutable and dedicated-RPC fields are not sent. + assert sent.name == "" + assert sent.schema_id == "" + assert list(sent.software_item_ids) == [] + + +def test___update_test_result___resource_only_with_stage_2_values___selects_them( + client: DataStoreClient, service_client: Any +) -> None: + test_result = TestResult( + name="t", id="tr-id", uut_id="uut-id", keywords=["smoke"], duration=timedelta(seconds=1) + ) + + client.update_test_result(test_result) + + paths = list(_sent(service_client.update_test_result).update_mask.paths) + assert {"uut_id", "keywords", "duration"} <= set(paths) + assert "uut" not in paths + + +def test___update_step___resource_only_with_id_and_inline___sends_id_only( + client: DataStoreClient, service_client: Any +) -> None: + from ni.datastore.metadata import Test + + step = Step(name="s", id="step-id", test_id="test-id", test=Test(name="expanded")) + + client.update_step(step) + + request = _sent(service_client.update_step) + assert request.step.test_id == "test-id" + assert not request.step.HasField("test") + assert {"test_id", "test"} <= set(request.update_mask.paths) + + +def test___update_measurement___resource_only___does_not_send_value( + client: DataStoreClient, service_client: Any +) -> None: + measurement = Measurement( + id="m-id", name="v", value=Vector([1.0, 2.0]), notes="checked", outcome=Outcome.PASSED + ) + + client.update_measurement(measurement) + + request = _sent(service_client.update_measurement) + assert list(request.update_mask.paths) == [ + "notes", + "start_date_time", + "end_date_time", + "outcome", + "error_information", + "extension", + ] + assert request.measurement.notes == "checked" + assert not request.measurement.HasField("value") + + +def test___batch_update_steps___builders_and_resources___sends_each_request( + client: DataStoreClient, service_client: Any +) -> None: + service_client.batch_update_steps.return_value = ( + data_store_service_pb2.BatchUpdateStepsResponse() + ) + + client.batch_update_steps( + [StepUpdateBuilder("a").with_notes("n"), Step(name="b", id="b", notes="m")] + ) + + first, second = _sent(service_client.batch_update_steps).requests + assert first.step.id == "a" + assert list(first.update_mask.paths) == ["notes"] + assert second.step.id == "b" + assert second.step.notes == "m" + assert "notes" in second.update_mask.paths + + +@pytest.mark.parametrize( + ("method", "updates"), + [ + ("batch_update_test_results", [TestResultUpdateBuilder("a").with_link("x")]), + ("batch_update_measurements", [MeasurementUpdateBuilder("a").with_notes("x")]), + ], +) +def test___batch_update___builders___sends_builder_requests( + client: DataStoreClient, service_client: Any, method: str, updates: list[Any] +) -> None: + client_method = getattr(service_client, method) + client_method.return_value = Mock(test_results=[], measurements=[]) + + getattr(client, method)(updates) + + [request] = _sent(client_method).requests + assert len(request.update_mask.paths) == 1 + + +def test___batch_update___empty_iterable___raises_value_error( + client: DataStoreClient, service_client: Any +) -> None: + with pytest.raises(ValueError, match="At least one update"): + client.batch_update_steps([]) + + service_client.batch_update_steps.assert_not_called() + + # ---------------------------------------------------------------- delete @@ -670,6 +960,33 @@ def test___delete___defaults_to_no_force(client: DataStoreClient, service_client assert _sent(service_client.delete_step).force is False +def test___delete___resources___sends_their_ids( + client: DataStoreClient, service_client: Any +) -> None: + client.delete_test_result(TestResult("t", id="tr-id"), force=True) + client.delete_step(Step("s", id="step-id")) + client.delete_measurement(Measurement(id="m-id")) + + assert _sent(service_client.delete_test_result).test_result_id == "tr-id" + assert _sent(service_client.delete_test_result).force is True + assert _sent(service_client.delete_step).step_id == "step-id" + assert _sent(service_client.delete_measurement).measurement_id == "m-id" + + +def test___batch_delete___resources_and_ids___sends_ids_in_order( + client: DataStoreClient, service_client: Any +) -> None: + client.batch_delete_test_results([TestResult("t", id="a"), "b"], force=[True, False]) + client.batch_delete_steps([Step("s", id="c"), "d"]) + client.batch_delete_measurements(["e", Measurement(id="f")]) + + test_results = _sent(service_client.batch_delete_test_results).requests + assert [(r.test_result_id, r.force) for r in test_results] == [("a", True), ("b", False)] + assert [r.step_id for r in _sent(service_client.batch_delete_steps).requests] == ["c", "d"] + measurements = _sent(service_client.batch_delete_measurements).requests + assert [r.measurement_id for r in measurements] == ["e", "f"] + + # ---------------------------------------------------------------- conditions and metadata links @@ -798,15 +1115,22 @@ def test___create_measurement___omits_limits_when_not_given( assert not sent.HasField("lower_limit") +@pytest.mark.parametrize( + "read_source", + [ + pytest.param(Measurement(id="m-id"), id="measurement"), + pytest.param("m-id", id="measurement_id"), + ], +) def test___read_measurement_values___returns_value_and_limits( - client: DataStoreClient, service_client: Any + client: DataStoreClient, service_client: Any, read_source: Measurement | str ) -> None: response = data_store_service_pb2.ReadMeasurementValuesResponse() response.value.scalar.double_value = 5.0 response.upper_limit.scalar.double_value = 5.5 service_client.read_measurement_values.return_value = response - result = client.read_measurement_values(Measurement(id="m-id")) + result = client.read_measurement_values(read_source) assert _sent(service_client.read_measurement_values) == ( data_store_service_pb2.ReadMeasurementValuesRequest(measurement_id="m-id") @@ -826,56 +1150,101 @@ def test___read_measurement_values___sends_read_fields( data_store_service_pb2.ReadMeasurementValuesResponse() ) - client.read_measurement_values(Measurement(id="m-id"), read_fields=["upper_limit"]) + client.read_measurement_values( + Measurement(id="m-id"), read_fields=[MeasurementValueField.UPPER_LIMIT] + ) assert list(_sent(service_client.read_measurement_values).read_mask.paths) == ["upper_limit"] +def test___get_test_result___sends_read_field_enums_as_field_names( + client: DataStoreClient, service_client: Any +) -> None: + service_client.get_test_result.return_value = data_store_pb2.TestResult(id="tr-id") + + client.get_test_result("tr-id", read_fields=[TestResultField.NAME, TestResultField.OUTCOME]) + + assert list(_sent(service_client.get_test_result).read_mask.paths) == ["name", "outcome"] + + +def test___get_step___sends_read_field_enums_as_field_names( + client: DataStoreClient, service_client: Any +) -> None: + service_client.get_step.return_value = data_store_pb2.Step(id="step-id") + + client.get_step("step-id", read_fields=[StepField.NAME, StepField.CONDITIONS]) + + assert list(_sent(service_client.get_step).read_mask.paths) == ["name", "conditions"] + + +def test___query_measurements___accepts_read_field_enums_and_plain_names( + client: DataStoreClient, service_client: Any +) -> None: + service_client.query_measurements.return_value = ( + data_store_service_pb2.QueryMeasurementsResponse() + ) + + client.query_measurements(read_fields=[MeasurementField.NAME, "conditions"]) + + assert list(_sent(service_client.query_measurements).read_mask.paths) == [ + "name", + "conditions", + ] + + # ---------------------------------------------------------------- batch create -def test___batch_create_test_results___sends_models_and_returns_ids( +def test___batch_create_test_results___sends_models_and_returns_created_test_results( client: DataStoreClient, service_client: Any ) -> None: service_client.batch_create_test_results.return_value = ( data_store_service_pb2.BatchCreateTestResultsResponse( - test_results=[data_store_pb2.TestResult(id="a"), data_store_pb2.TestResult(id="b")] + test_results=[ + data_store_pb2.TestResult(id="a", name="tr-1"), + data_store_pb2.TestResult(id="b", name="tr-2"), + ] ) ) nested = TestResult("tr-1", steps=[Step("s", measurements=[Measurement(name="m", value=1.0)])]) - ids = client.batch_create_test_results([nested, TestResult("tr-2")]) + created = client.batch_create_test_results([nested, TestResult("tr-2")]) requests = _sent(service_client.batch_create_test_results).requests assert [r.test_result.name for r in requests] == ["tr-1", "tr-2"] assert requests[0].test_result.steps[0].measurements[0].value.scalar.double_value == 1.0 - assert ids == ["a", "b"] + assert created == [TestResult("tr-1", id="a"), TestResult("tr-2", id="b")] -def test___batch_create_steps___sends_models_and_returns_ids( +def test___batch_create_steps___sends_models_and_returns_created_steps( client: DataStoreClient, service_client: Any ) -> None: service_client.batch_create_steps.return_value = ( - data_store_service_pb2.BatchCreateStepsResponse(steps=[data_store_pb2.Step(id="a")]) + data_store_service_pb2.BatchCreateStepsResponse( + steps=[data_store_pb2.Step(id="a", name="parent")] + ) ) - ids = client.batch_create_steps([Step("parent", children=[Step("child")])]) + created = client.batch_create_steps([Step("parent", children=[Step("child")])]) [request] = _sent(service_client.batch_create_steps).requests assert request.step.children[0].name == "child" - assert ids == ["a"] + assert created == [Step("parent", id="a")] -def test___batch_create_measurements___sends_values_and_returns_ids( +def test___batch_create_measurements___sends_values_and_returns_created_measurements( client: DataStoreClient, service_client: Any ) -> None: service_client.batch_create_measurements.return_value = ( data_store_service_pb2.BatchCreateMeasurementsResponse( - measurements=[data_store_pb2.Measurement(id="a"), data_store_pb2.Measurement(id="b")] + measurements=[ + data_store_pb2.Measurement(id="a", name="m1"), + data_store_pb2.Measurement(id="b", name="m2"), + ] ) ) - ids = client.batch_create_measurements( + created = client.batch_create_measurements( [ Measurement(name="m1", step_id="s1", value=1.0, upper_limit=2.0), Measurement(name="m2", step_id="s2", value="PASS"), @@ -885,7 +1254,7 @@ def test___batch_create_measurements___sends_values_and_returns_ids( requests = _sent(service_client.batch_create_measurements).requests assert requests[0].measurement.upper_limit.scalar.double_value == 2.0 assert requests[1].measurement.value.scalar.string_value == "PASS" - assert ids == ["a", "b"] + assert created == [Measurement(id="a", name="m1"), Measurement(id="b", name="m2")] # ---------------------------------------------------------------- batch update @@ -1108,22 +1477,22 @@ def test___batch_delete_measurements___sends_ids( pytest.param( "read_fields", lambda client: client.read_measurement_values( - Measurement(id="m-id"), read_fields="value" + Measurement(id="m-id"), read_fields=MeasurementValueField.VALUE ), id="read_measurement_values", ), pytest.param( - "test_result_ids", + "test_results", lambda client: client.batch_delete_test_results("tr-id"), id="batch_delete_test_results", ), pytest.param( - "step_ids", + "steps", lambda client: client.batch_delete_steps("step-id"), id="batch_delete_steps", ), pytest.param( - "measurement_ids", + "measurements", lambda client: client.batch_delete_measurements("m-id"), id="batch_delete_measurements", ), @@ -1141,6 +1510,148 @@ def test___string_list_argument___single_string___raises_before_rpc( assert service_client.method_calls == [] +# ---------------------------------------------------------------- UUID IDs + +ID = uuid.UUID("3f2b8c1e-5a7d-4e2b-9c1f-2d4e6a8b0c3d") +ID_TEXT = str(ID) + + +def test___get_methods___uuid___send_string_ids( + client: DataStoreClient, service_client: Any +) -> None: + service_client.get_test_result.return_value = data_store_pb2.TestResult() + service_client.get_step.return_value = data_store_pb2.Step() + service_client.get_measurement.return_value = data_store_pb2.Measurement() + + client.get_test_result(ID) + client.get_step(ID) + client.get_measurement(ID) + + assert _sent(service_client.get_test_result).test_result_id == ID_TEXT + assert _sent(service_client.get_step).step_id == ID_TEXT + assert _sent(service_client.get_measurement).measurement_id == ID_TEXT + + +def test___read_measurement_value___uuid___sends_string_id( + client: DataStoreClient, service_client: Any +) -> None: + client.create_measurement("v", 1.0, "step-id") + _read_response(service_client, _sent(service_client.create_measurement).measurement.value) + + client.read_measurement_value(ID) + assert _sent(service_client.read_measurement_values).measurement_id == ID_TEXT + client.read_measurement_values(uuid.UUID(int=1)) + assert _sent(service_client.read_measurement_values).measurement_id == str(uuid.UUID(int=1)) + + +def test___create_measurement___uuid_ids___sends_string_ids( + client: DataStoreClient, service_client: Any +) -> None: + client.create_measurement("v", 1.0, ID, software_item_ids=[ID]) + + sent = _sent(service_client.create_measurement).measurement + assert sent.step_id == ID_TEXT + assert list(sent.software_item_ids) == [ID_TEXT] + + +def test___create_step___uuid_fields___sends_string_ids( + client: DataStoreClient, service_client: Any +) -> None: + service_client.create_step.return_value = data_store_pb2.Step() + + client.create_step("s", test_result_id=ID, test_id=ID) + + sent = _sent(service_client.create_step).step + assert (sent.test_result_id, sent.test_id) == (ID_TEXT, ID_TEXT) + + +def test___conditions___uuid_step_id___sends_string_id( + client: DataStoreClient, service_client: Any +) -> None: + service_client.add_condition.return_value = data_store_service_pb2.AddConditionResponse() + service_client.remove_condition.return_value = data_store_service_pb2.RemoveConditionResponse() + + client.add_condition(ID, Condition("c", 1.0)) + client.remove_condition(ID, "c") + + assert _sent(service_client.add_condition).step_id == ID_TEXT + assert _sent(service_client.remove_condition).step_id == ID_TEXT + + +def test___update___uuid_target_and_id_fields___sends_string_ids( + client: DataStoreClient, service_client: Any +) -> None: + client.update_test_result(ID, operator_id=ID) + client.update_step(StepUpdateBuilder(ID).with_test_id(ID)) + + test_result = _sent(service_client.update_test_result).test_result + assert (test_result.id, test_result.operator_id) == (ID_TEXT, ID_TEXT) + step = _sent(service_client.update_step).step + assert (step.id, step.test_id) == (ID_TEXT, ID_TEXT) + + +def test___batch_update___uuid_keys___sends_string_ids( + client: DataStoreClient, service_client: Any +) -> None: + service_client.batch_update_measurements.return_value = ( + data_store_service_pb2.BatchUpdateMeasurementsResponse() + ) + + client.batch_update_measurements({ID: {"notes": "n"}}) + + assert _sent(service_client.batch_update_measurements).requests[0].measurement.id == ID_TEXT + + +def test___metadata_links___uuid_ids___sends_string_ids( + client: DataStoreClient, service_client: Any +) -> None: + service_client.add_test_result_metadata.return_value = ( + data_store_service_pb2.AddTestResultMetadataResponse() + ) + service_client.add_measurement_metadata.return_value = ( + data_store_service_pb2.AddMeasurementMetadataResponse() + ) + + client.add_test_result_metadata(ID, software_item_ids=[ID], test_adapter_ids=["alias"]) + client.add_measurement_metadata(ID, hardware_item_ids=[ID]) + + test_result_request = _sent(service_client.add_test_result_metadata) + assert test_result_request.test_result_id == ID_TEXT + assert list(test_result_request.software_item_ids) == [ID_TEXT] + assert list(test_result_request.test_adapter_ids) == ["alias"] + measurement_request = _sent(service_client.add_measurement_metadata) + assert measurement_request.measurement_id == ID_TEXT + assert list(measurement_request.hardware_item_ids) == [ID_TEXT] + + +def test___deletes___uuid_ids___send_string_ids( + client: DataStoreClient, service_client: Any +) -> None: + client.delete_test_result(ID) + client.delete_step(ID) + client.delete_measurement(ID) + client.batch_delete_steps([ID, "b"]) + + assert _sent(service_client.delete_test_result).test_result_id == ID_TEXT + assert _sent(service_client.delete_step).step_id == ID_TEXT + assert _sent(service_client.delete_measurement).measurement_id == ID_TEXT + assert [r.step_id for r in _sent(service_client.batch_delete_steps).requests] == [ + ID_TEXT, + "b", + ] + + +def test___id_list___single_uuid___raises_type_error_before_rpc( + client: DataStoreClient, service_client: Any +) -> None: + with pytest.raises(TypeError, match="^steps must be"): + client.batch_delete_steps(ID) # type: ignore[arg-type] + with pytest.raises(TypeError, match="^software_item_ids must be"): + client.add_test_result_metadata("tr-id", software_item_ids=ID) # type: ignore[arg-type] + + assert service_client.method_calls == [] + + # ---------------------------------------------------------------- lifecycle diff --git a/tests/unit/data/test_v2_types.py b/tests/unit/data/test_v2_types.py index 9a4abcc0..28577f32 100644 --- a/tests/unit/data/test_v2_types.py +++ b/tests/unit/data/test_v2_types.py @@ -3,13 +3,16 @@ from __future__ import annotations import datetime as std_datetime +import inspect +import uuid from collections.abc import Callable +from enum import Enum from typing import Any import pytest from google.protobuf import json_format from hightime import datetime, timedelta -from ni.measurements.data.v2 import data_store_pb2 +from ni.measurements.data.v2 import data_store_pb2, data_store_service_pb2 from ni.protobuf.types.precision_duration_conversion import hightime_timedelta_to_protobuf from ni.protobuf.types.precision_timestamp_conversion import hightime_datetime_to_protobuf from nitypes.scalar import Scalar @@ -20,10 +23,22 @@ ErrorInformation, JsonValue, Measurement, + MeasurementField, + MeasurementValueField, Outcome, Status, Step, + StepCreateFields, + StepField, + StepUpdateFields, TestResult, + TestResultCreateFields, + TestResultField, + TestResultUpdateFields, +) +from ni.datastore.data.v2._update_conversion import ( + STEP_UPDATE_FIELDS, + TEST_RESULT_UPDATE_FIELDS, ) from ni.datastore.metadata import ( HardwareItem, @@ -217,6 +232,48 @@ def test___condition___uses_scalar_units_when_units_not_given() -> None: assert proto.value.scalar.double_value == 3.3 +@pytest.mark.parametrize("model", ["condition", "measurement"]) +def test___scalar_value_on_construction___sets_units(model: str) -> None: + value = Scalar(3.4, "V") + item = Condition("Vcc", value) if model == "condition" else Measurement(name="Vcc", value=value) + + assert item.units == "V" + + +@pytest.mark.parametrize("model", ["condition", "measurement"]) +def test___scalar_value_with_given_units___keeps_given_units(model: str) -> None: + value = Scalar(3.4, "V") + item = ( + Condition("Vcc", value, units="mV") + if model == "condition" + else Measurement(name="Vcc", value=value, units="mV") + ) + + assert item.units == "mV" + + +@pytest.mark.parametrize("model", ["condition", "measurement"]) +def test___scalar_value_set_later___sets_empty_units(model: str) -> None: + item = Condition("Vcc", 3.4) if model == "condition" else Measurement(name="Vcc", value=3.4) + + item.value = Scalar(3.4, "V") + + assert item.units == "V" + + +@pytest.mark.parametrize("model", ["condition", "measurement"]) +def test___scalar_value_set_later___keeps_existing_units(model: str) -> None: + item = ( + Condition("Vcc", 3.4, units="mV") + if model == "condition" + else Measurement(name="Vcc", value=3.4, units="mV") + ) + + item.value = Scalar(3.4, "V") + + assert item.units == "mV" + + def test___condition___scalar_with_only_units_reads_as_plain_value() -> None: proto = Condition("voltage", Scalar(3.3, "V")).to_protobuf() @@ -264,6 +321,23 @@ def test___enums___reject_unknown_protobuf_values(enum_type: type[Outcome] | typ enum_type.from_protobuf(999) # type: ignore[arg-type] +@pytest.mark.parametrize( + ("field_type", "message_type"), + [ + (TestResultField, data_store_pb2.TestResult), + (StepField, data_store_pb2.Step), + (MeasurementField, data_store_pb2.Measurement), + (MeasurementValueField, data_store_service_pb2.ReadMeasurementValuesResponse), + ], +) +def test___read_field_enums___match_protobuf_fields( + field_type: type[Enum], message_type: Any +) -> None: + field_names = sorted(field.value for field in field_type) + + assert field_names == sorted(field.name for field in message_type.DESCRIPTOR.fields) + + @pytest.mark.parametrize( ("argument", "create_model"), [ @@ -460,3 +534,85 @@ def test___client___wraps_every_low_level_method() -> None: } assert sorted(low_level_methods - set(dir(DataStoreClient))) == [] + + +_OUTPUT_ONLY_INIT_PARAMETERS = {"id", "created_at", "updated_at", "created_by", "updated_by"} + + +@pytest.mark.parametrize( + ("model", "create_fields", "excluded"), + [ + (TestResult, TestResultCreateFields, {"name"}), + (Step, StepCreateFields, {"name", "path", "path_ids"}), + ], +) +def test___create_fields___match_model_constructor( + model: type, create_fields: type, excluded: set[str] +) -> None: + parameters = set(inspect.signature(model).parameters) + + expected = parameters - excluded - _OUTPUT_ONLY_INIT_PARAMETERS + assert set(create_fields.__annotations__) == expected + + +@pytest.mark.parametrize( + ("update_fields", "allowed"), + [ + (TestResultUpdateFields, TEST_RESULT_UPDATE_FIELDS), + (StepUpdateFields, STEP_UPDATE_FIELDS), + ], +) +def test___update_fields___match_updatable_fields( + update_fields: type, allowed: frozenset[str] +) -> None: + assert set(update_fields.__annotations__) == allowed + + +_UUID = uuid.UUID("3f2b8c1e-5a7d-4e2b-9c1f-2d4e6a8b0c3d") + + +def test___test_result___uuid_ids___stored_as_strings() -> None: + test_result = TestResult( + "t", + id=_UUID, + uut_instance_id=_UUID, + uut_id=_UUID, + operator_id=_UUID, + test_station_id=_UUID, + test_description_id=_UUID, + software_item_ids=[_UUID, "alias"], + hardware_item_ids=[_UUID], + test_adapter_ids=[_UUID], + ) + + proto = test_result.to_protobuf() + assert test_result.id == str(_UUID) + assert test_result.operator_id == str(_UUID) + assert list(test_result.software_item_ids) == [str(_UUID), "alias"] + assert proto.uut_instance_id == proto.test_description_id == str(_UUID) + assert list(proto.hardware_item_ids) == list(proto.test_adapter_ids) == [str(_UUID)] + + +def test___step___uuid_ids___stored_as_strings() -> None: + step = Step("s", id=_UUID, parent_step_id=_UUID, test_result_id=_UUID, test_id=_UUID) + + assert (step.id, step.parent_step_id, step.test_result_id, step.test_id) == (str(_UUID),) * 4 + + +def test___measurement___uuid_ids___stored_as_strings() -> None: + measurement = Measurement( + id=_UUID, + test_result_id=_UUID, + step_id=_UUID, + software_item_ids=[_UUID], + hardware_item_ids=[_UUID], + test_adapter_ids=[_UUID], + ) + + assert (measurement.id, measurement.test_result_id, measurement.step_id) == (str(_UUID),) * 3 + assert list(measurement.to_protobuf().software_item_ids) == [str(_UUID)] + + +def test___model___single_uuid_for_id_list___raises_type_error() -> None: + with pytest.raises(TypeError, match="^software_item_ids must be"): + TestResult("t", software_item_ids=_UUID) # type: ignore[arg-type] diff --git a/tests/unit/data/test_v2_update_builders.py b/tests/unit/data/test_v2_update_builders.py new file mode 100644 index 00000000..b4b7690f --- /dev/null +++ b/tests/unit/data/test_v2_update_builders.py @@ -0,0 +1,201 @@ +"""Tests for the V2 update builders.""" + +from __future__ import annotations + +import datetime as std_datetime + +import pytest +from hightime import datetime, timedelta +from ni.measurements.data.v2 import data_store_pb2 +from ni.protobuf.types.precision_duration_conversion import hightime_timedelta_to_protobuf +from ni.protobuf.types.precision_timestamp_conversion import hightime_datetime_to_protobuf + +from ni.datastore.data.v2 import ( + ErrorInformation, + Measurement, + MeasurementUpdateBuilder, + Outcome, + Status, + Step, + StepUpdateBuilder, + TestResult, + TestResultUpdateBuilder, +) +from ni.datastore.metadata import Operator, Test, Uut + +TIMESTAMP = datetime(2026, 5, 6, 7, 8, 9, tzinfo=std_datetime.timezone.utc) + + +def test___step_builder___build_update___sets_values_and_selects_fields_in_call_order() -> None: + request = ( + StepUpdateBuilder("step-id") + .with_outcome(Outcome.PASSED) + .with_notes("Verified") + .with_status(Status.DONE) + .build_update() + ) + + assert request.step.id == "step-id" + assert request.step.outcome == data_store_pb2.OUTCOME_PASSED + assert request.step.notes == "Verified" + assert request.step.status == data_store_pb2.STATUS_DONE + assert list(request.update_mask.paths) == ["outcome", "notes", "status"] + + +def test___test_result_builder___all_fields___converts_values() -> None: + request = ( + TestResultUpdateBuilder("tr-id") + .with_start_date_time(TIMESTAMP) + .with_end_date_time(TIMESTAMP) + .with_duration(timedelta(seconds=3)) + .with_status(Status.DONE) + .with_outcome(Outcome.FAILED) + .with_error_information(ErrorInformation(error_code=2, message="boom")) + .with_extension({"retest": True}) + .with_link("https://example.com") + .with_keywords(["smoke"]) + .with_uut_instance_id("uut-instance-id") + .with_uut_id("uut-id") + .with_operator_id("operator-id") + .with_test_station_id("station-id") + .with_test_description_id("description-id") + .build_update() + ) + + test_result = request.test_result + assert test_result.start_date_time == hightime_datetime_to_protobuf(TIMESTAMP) + assert test_result.end_date_time == hightime_datetime_to_protobuf(TIMESTAMP) + assert test_result.duration == hightime_timedelta_to_protobuf(timedelta(seconds=3)) + assert test_result.status == data_store_pb2.STATUS_DONE + assert test_result.outcome == data_store_pb2.OUTCOME_FAILED + assert test_result.error_information.message == "boom" + assert test_result.extension["retest"] is True + assert test_result.link == "https://example.com" + assert list(test_result.keywords) == ["smoke"] + assert test_result.uut_instance_id == "uut-instance-id" + assert test_result.uut_id == "uut-id" + assert test_result.operator_id == "operator-id" + assert test_result.test_station_id == "station-id" + assert test_result.test_description_id == "description-id" + assert len(request.update_mask.paths) == 14 + + +def test___measurement_builder___build_update___sets_values_and_selects_fields() -> None: + request = ( + MeasurementUpdateBuilder("m-id") + .with_notes("Rechecked") + .with_outcome(Outcome.PASSED) + .with_extension({"probe": "10x"}) + .build_update() + ) + + assert request.measurement.id == "m-id" + assert request.measurement.notes == "Rechecked" + assert request.measurement.outcome == data_store_pb2.OUTCOME_PASSED + assert request.measurement.extension["probe"] == "10x" + assert list(request.update_mask.paths) == ["notes", "outcome", "extension"] + + +def test___builder___none___selects_field_with_empty_value() -> None: + request = StepUpdateBuilder("step-id").with_link(None).with_notes(None).build_update() + + assert request.step.link == "" + assert request.step.notes == "" + assert list(request.update_mask.paths) == ["link", "notes"] + + +def test___builder___same_field_twice___last_value_wins() -> None: + request = StepUpdateBuilder("step-id").with_notes("first").with_notes("second").build_update() + + assert request.step.notes == "second" + assert list(request.update_mask.paths) == ["notes"] + + +def test___test_result_builder___id_after_inline___discards_inline() -> None: + request = ( + TestResultUpdateBuilder("tr-id") + .with_operator(Operator(name="Ada")) + .with_operator_id("operator-id") + .build_update() + ) + + assert request.test_result.operator_id == "operator-id" + assert not request.test_result.HasField("operator") + assert list(request.update_mask.paths) == ["operator_id"] + + +def test___test_result_builder___inline_after_id___discards_id() -> None: + request = ( + TestResultUpdateBuilder("tr-id") + .with_uut_id("uut-id") + .with_uut(Uut(model_name="model")) + .build_update() + ) + + assert request.test_result.uut_id == "" + assert request.test_result.uut.model_name == "model" + assert list(request.update_mask.paths) == ["uut"] + + +def test___step_builder___inline_test___sends_test_and_discards_id() -> None: + request = ( + StepUpdateBuilder("step-id") + .with_test_id("test-id") + .with_test(Test(name="t")) + .build_update() + ) + + assert request.step.test_id == "" + assert request.step.test.name == "t" + assert list(request.update_mask.paths) == ["test"] + + +def test___test_result_builder___from_resource___uses_resource_id() -> None: + request = ( + TestResultUpdateBuilder(TestResult(name="t", id="tr-id")) + .with_outcome(Outcome.PASSED) + .build_update() + ) + + assert request.test_result.id == "tr-id" + + +def test___step_builder___from_resource___uses_resource_id() -> None: + request = StepUpdateBuilder(Step(name="s", id="step-id")).with_notes("n").build_update() + + assert request.step.id == "step-id" + + +def test___measurement_builder___from_resource___uses_resource_id() -> None: + request = MeasurementUpdateBuilder(Measurement(id="m-id")).with_notes("n").build_update() + + assert request.measurement.id == "m-id" + + +@pytest.mark.parametrize("target", ["", " ", Step(name="unsaved")]) +def test___builder___blank_id___raises_value_error(target: Step | str) -> None: + with pytest.raises(ValueError, match="ID must not be empty"): + StepUpdateBuilder(target) + + +def test___builder___no_fields_selected___build_update_raises_value_error() -> None: + with pytest.raises(ValueError, match="At least one field"): + MeasurementUpdateBuilder("m-id").build_update() + + +def test___builder___single_string_keywords___raises_type_error() -> None: + with pytest.raises(TypeError, match="keywords must be a list"): + StepUpdateBuilder("step-id").with_keywords("smoke") + + +def test___builder___input_changed_after_with___request_uses_original_value() -> None: + extension = {"probe": "10x"} + keywords = ["smoke"] + builder = StepUpdateBuilder("step-id").with_extension(extension).with_keywords(keywords) + + extension["probe"] = "1x" + keywords.append("regression") + request = builder.build_update() + + assert request.step.extension["probe"] == "10x" + assert list(request.step.keywords) == ["smoke"]