From 1aa5cb6aab89aa9a1d701e75e8d634acbbd5dad1 Mon Sep 17 00:00:00 2001 From: Alex Dubois Date: Thu, 10 Sep 2026 12:02:02 -0500 Subject: [PATCH 1/9] Commit template as well --- src/codegen/templates/_grpc_interpreter.py.mako | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/codegen/templates/_grpc_interpreter.py.mako b/src/codegen/templates/_grpc_interpreter.py.mako index a348eeeee..e1c93db16 100644 --- a/src/codegen/templates/_grpc_interpreter.py.mako +++ b/src/codegen/templates/_grpc_interpreter.py.mako @@ -29,6 +29,7 @@ from collections.abc import Callable, Sequence import google.protobuf.message import grpc +import nitlsconfig import numpy from . import errors as errors @@ -118,14 +119,20 @@ class GrpcStubInterpreter(BaseInterpreter): def __init__(self, grpc_options): self._grpc_options = grpc_options self._client = nidaqmx_grpc.NiDAQmxStub(grpc_options.grpc_channel) + # Querying the driver version is the first contact with the server, so it decides + # whether this interpreter connected. + connected = False try: major_version = self.get_system_info_attribute_uint32(0x1272) minor_version = self.get_system_info_attribute_uint32(0x1923) update_version = self.get_system_info_attribute_uint32(0x2f22) + connected = True except Exception: major_version = 0 minor_version = 0 update_version = 0 + finally: + nitlsconfig.audit_session_connect('NI-DAQmx', grpc_options.grpc_channel, connected) self._driver_version = DriverVersion(major_version, minor_version, update_version) def _invoke(self, func, request, metadata=None): @@ -158,7 +165,11 @@ class GrpcStubInterpreter(BaseInterpreter): error_message += f'\nSamples per channel written: {entry.value}' grpc_error = rpc_error.code() if grpc_error == grpc.StatusCode.UNAVAILABLE: - error_message = 'Failed to connect to server' + # gRPC reports a rejected TLS handshake and an unreachable server with the + # same code, so ask NI-TLS whether it built this channel and can say more. + error_message = nitlsconfig.get_tls_connection_error_elaboration( + self._grpc_options.grpc_channel + ) or 'Failed to connect to server' elif grpc_error == grpc.StatusCode.UNIMPLEMENTED: error_message = ( 'This operation is not supported by the NI gRPC Device Server being used. Upgrade NI gRPC Device Server.' From 71ca05a8dfb5b48cbb97faf6fa1e248ff1ea5ee3 Mon Sep 17 00:00:00 2001 From: Alex Dubois Date: Thu, 10 Sep 2026 12:08:05 -0500 Subject: [PATCH 2/9] Add the newly generated code as well --- generated/nidaqmx/_grpc_interpreter.py | 51 ++++++++++++++++---------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/generated/nidaqmx/_grpc_interpreter.py b/generated/nidaqmx/_grpc_interpreter.py index 38782fe17..aac67404e 100644 --- a/generated/nidaqmx/_grpc_interpreter.py +++ b/generated/nidaqmx/_grpc_interpreter.py @@ -12,6 +12,7 @@ import google.protobuf.message import grpc +import nitlsconfig import numpy from . import errors as errors @@ -101,14 +102,20 @@ class GrpcStubInterpreter(BaseInterpreter): def __init__(self, grpc_options): self._grpc_options = grpc_options self._client = nidaqmx_grpc.NiDAQmxStub(grpc_options.grpc_channel) + # Querying the driver version is the first contact with the server, so it decides + # whether this interpreter connected. + connected = False try: major_version = self.get_system_info_attribute_uint32(0x1272) minor_version = self.get_system_info_attribute_uint32(0x1923) update_version = self.get_system_info_attribute_uint32(0x2f22) + connected = True except Exception: major_version = 0 minor_version = 0 update_version = 0 + finally: + nitlsconfig.audit_session_connect('NI-DAQmx', grpc_options.grpc_channel, connected) self._driver_version = DriverVersion(major_version, minor_version, update_version) def _invoke(self, func, request, metadata=None): @@ -141,7 +148,11 @@ def _handle_rpc_error(self, rpc_error): error_message += f'\nSamples per channel written: {entry.value}' grpc_error = rpc_error.code() if grpc_error == grpc.StatusCode.UNAVAILABLE: - error_message = 'Failed to connect to server' + # gRPC reports a rejected TLS handshake and an unreachable server with the + # same code, so ask NI-TLS whether it built this channel and can say more. + error_message = nitlsconfig.get_tls_connection_error_elaboration( + self._grpc_options.grpc_channel + ) or 'Failed to connect to server' elif grpc_error == grpc.StatusCode.UNIMPLEMENTED: error_message = ( 'This operation is not supported by the NI gRPC Device Server being used. Upgrade NI gRPC Device Server.' @@ -2427,7 +2438,7 @@ def read_analog_f64( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_samps=read_array.size)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read @@ -2447,7 +2458,7 @@ def read_binary_i16( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_samps=read_array.size)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read @@ -2461,7 +2472,7 @@ def read_binary_i32( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_samps=read_array.size)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read @@ -2475,7 +2486,7 @@ def read_binary_u16( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_samps=read_array.size)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read @@ -2489,7 +2500,7 @@ def read_binary_u32( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_samps=read_array.size)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read @@ -2501,7 +2512,7 @@ def read_counter_f64(self, task, num_samps_per_chan, timeout, read_array): grpc_types.ReadCounterF64Request( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, array_size_in_samps=read_array.size)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read @@ -2515,7 +2526,7 @@ def read_counter_f64_ex( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_samps=read_array.size)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read @@ -2539,7 +2550,7 @@ def read_counter_u32(self, task, num_samps_per_chan, timeout, read_array): grpc_types.ReadCounterU32Request( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, array_size_in_samps=read_array.size)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read @@ -2553,7 +2564,7 @@ def read_counter_u32_ex( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_samps=read_array.size)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read @@ -2569,7 +2580,7 @@ def read_ctr_freq( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, interleaved_raw=interleaved, array_size_in_samps=read_array_frequency.size)) - + _assign_numpy_array(read_array_frequency, response.read_array_frequency) _assign_numpy_array(read_array_duty_cycle, response.read_array_duty_cycle) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) @@ -2592,7 +2603,7 @@ def read_ctr_ticks( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, interleaved_raw=interleaved, array_size_in_samps=read_array_high_ticks.size)) - + _assign_numpy_array(read_array_high_ticks, response.read_array_high_ticks) _assign_numpy_array(read_array_low_ticks, response.read_array_low_ticks) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) @@ -2615,7 +2626,7 @@ def read_ctr_time( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, interleaved_raw=interleaved, array_size_in_samps=read_array_high_time.size)) - + _assign_numpy_array(read_array_high_time, response.read_array_high_time) _assign_numpy_array(read_array_low_time, response.read_array_low_time) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) @@ -2636,7 +2647,7 @@ def read_digital_lines( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_bytes=read_array.nbytes)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read, response.num_bytes_per_samp @@ -2656,7 +2667,7 @@ def read_digital_u16( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_samps=read_array.size)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read @@ -2670,7 +2681,7 @@ def read_digital_u32( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_samps=read_array.size)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read @@ -2684,7 +2695,7 @@ def read_digital_u8( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_samps=read_array.nbytes)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read @@ -2700,7 +2711,7 @@ def read_power_binary_i16( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_samps=read_array_voltage.size)) - + _assign_numpy_array(read_array_voltage, response.read_array_voltage) _assign_numpy_array(read_array_current, response.read_array_current) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) @@ -2717,7 +2728,7 @@ def read_power_f64( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_samps=read_array_voltage.size)) - + _assign_numpy_array(read_array_voltage, response.read_array_voltage) _assign_numpy_array(read_array_current, response.read_array_current) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) @@ -2736,7 +2747,7 @@ def read_raw(self, task, num_samps_per_chan, timeout, read_array): grpc_types.ReadRawRequest( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, array_size_in_bytes=read_array.nbytes)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_read) return read_array, response.samps_read, response.num_bytes_per_samp From 38ec344c522c1b314f99faa5cfb187d29329e8a8 Mon Sep 17 00:00:00 2001 From: Alex Dubois Date: Thu, 10 Sep 2026 13:48:42 -0500 Subject: [PATCH 3/9] Consume latest nitlsconfig and run poetry lock as well --- poetry.lock | 56 ++++++++++++++++++++++++++++++++++++++++++++++++-- pyproject.toml | 2 +- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 9441a7fb0..54802a2eb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2998,6 +2998,26 @@ pycodestyle = [ ] toml = ">=0.10.1" +[[package]] +name = "nitlsconfig" +version = "1.0.0a4" +description = "Python API for reading nitlsconfig configurations and creating NI gRPC Device client channels from them" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"grpc\"" +files = [ + {file = "nitlsconfig-1.0.0a4-py3-none-any.whl", hash = "sha256:674aaa36ae47bf862d545e9b21486dc14184f75a157d8b25366976149e01e6f0"}, + {file = "nitlsconfig-1.0.0a4.tar.gz", hash = "sha256:9fcd034424e3a80e3d0c560bcaf4eaae44b2a03a98bfc1af7892bf53b26d8fe9"}, +] + +[package.dependencies] +grpcio = {version = ">=1.49.0,<2.0", optional = true, markers = "extra == \"grpc\""} +pywin32 = {version = ">=306", optional = true, markers = "sys_platform == \"win32\" and extra == \"grpc\""} + +[package.extras] +grpc = ["grpcio (>=1.49.0,<2.0)", "pywin32 (>=306) ; sys_platform == \"win32\""] + [[package]] name = "nitypes" version = "1.1.0" @@ -4067,6 +4087,38 @@ files = [ [package.extras] dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] +[[package]] +name = "pywin32" +version = "312" +description = "Python for Windows Extensions" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"grpc\" and 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 = "pyyaml" version = "6.0.3" @@ -4924,9 +4976,9 @@ test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_it type = ["pytest-mypy"] [extras] -grpc = ["grpcio", "ni-grpcdevice-v1-proto", "ni-protobuf-types", "protobuf"] +grpc = ["grpcio", "ni-grpcdevice-v1-proto", "ni-protobuf-types", "nitlsconfig", "protobuf"] [metadata] lock-version = "2.1" python-versions = ">=3.9,<4.0" -content-hash = "37345c5a039da001b927e4df3967f6e40b7789fae867146c87fb1f95ddae117d" +content-hash = "b9c8390793367c2cdc2301bba6bf7d42795c57284aaf635d76a0b938afce2e0a" diff --git a/pyproject.toml b/pyproject.toml index f8db0c3de..cf66d64bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ documentation = "https://nidaqmx-python.readthedocs.io" nidaqmx = 'nidaqmx.__main__:main' [project.optional-dependencies] -grpc = ['grpcio (>=1.49.0,<2.0)', 'protobuf (>=4.21)', 'ni-grpcdevice-v1-proto (>=1.0.0)', 'ni-protobuf-types (>=1.0.0)'] +grpc = ['grpcio (>=1.49.0,<2.0)', 'protobuf (>=4.21)', 'ni-grpcdevice-v1-proto (>=1.0.0)', 'ni-protobuf-types (>=1.0.0)', 'nitlsconfig[grpc] (>=1.0.0a4)'] [tool.poetry] From a11d22228d94b34f13a7a28b9f456d19584efb0c Mon Sep 17 00:00:00 2001 From: Alex Dubois Date: Thu, 10 Sep 2026 16:09:11 -0500 Subject: [PATCH 4/9] Add the missing poetry.toml and unit tests --- poetry.toml | 1 + .../unit/test_nitlsconfig_grpc_integration.py | 120 ++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 tests/unit/test_nitlsconfig_grpc_integration.py diff --git a/poetry.toml b/poetry.toml index aa4a8a1fb..7a70eef7e 100644 --- a/poetry.toml +++ b/poetry.toml @@ -9,5 +9,6 @@ min-release-age-exclude = [ "ni-grpcdevice-v1-proto", "ni-protobuf-types", "ni-python-styleguide", + "nitlsconfig", "nitypes", ] diff --git a/tests/unit/test_nitlsconfig_grpc_integration.py b/tests/unit/test_nitlsconfig_grpc_integration.py new file mode 100644 index 000000000..76bdedbc9 --- /dev/null +++ b/tests/unit/test_nitlsconfig_grpc_integration.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from unittest.mock import Mock + +import pytest +from pytest_mock import MockerFixture + +import nidaqmx +from nidaqmx import Task +from tests.unit._grpc_utils import create_grpc_options +from tests.unit._task_utils import expect_create_task, expect_get_task_name + +try: + import grpc + import nitlsconfig + + from nidaqmx._grpc_interpreter import GrpcStubInterpreter +except ImportError: + pass + + +class _FakeRpcError(grpc.RpcError): + def __init__(self, code): + self._code = code + + def code(self): + return self._code + + def details(self): + return "original details" + + def trailing_metadata(self): + return [] + + +@pytest.fixture +def nitls_tagged_channel(): + """A real gRPC channel tagged the way nitlsconfig.create_grpc_device_channel tags one. + + create_grpc_device_channel needs the nitlsconfig CLI installed on the system, so tagging a + plain channel is the closest we can get to one in a unit test. Nothing connects over it. + """ + target = "localhost:31763" + with grpc.insecure_channel(target) as channel: + nitlsconfig.channel_tag.tag_channel_target(channel, target) + assert nitlsconfig.channel_tag.is_nitls_channel( + channel + ), "nitlsconfig no longer recognizes a channel it tagged" + yield channel + + +def _create_interpreter(mocker: MockerFixture, grpc_options, version_error=None): + mocker.patch("nidaqmx._grpc_interpreter.nidaqmx_grpc.NiDAQmxStub", autospec=True) + mocker.patch.object( + GrpcStubInterpreter, + "get_system_info_attribute_uint32", + side_effect=version_error, + return_value=None if version_error else 1, + ) + return GrpcStubInterpreter(grpc_options) + + +def test___untagged_channel___handle_unavailable___raises_failed_to_connect( + mocker: MockerFixture, +): + interpreter = _create_interpreter(mocker, create_grpc_options(mocker)) + + with pytest.raises(nidaqmx.errors.RpcError) as exc_info: + interpreter._handle_rpc_error(_FakeRpcError(grpc.StatusCode.UNAVAILABLE)) + + assert exc_info.value.rpc_code == grpc.StatusCode.UNAVAILABLE + assert exc_info.value.description == "Failed to connect to server" + + +def test___nitls_tagged_channel___handle_unavailable___raises_tls_elaboration( + mocker: MockerFixture, nitls_tagged_channel +): + # The real elaboration, so this fails if nitlsconfig stops recognizing our channel. + expected_message = nitlsconfig.get_tls_connection_error_elaboration(nitls_tagged_channel) + assert expected_message is not None + assert expected_message != "Failed to connect to server" + grpc_options = nidaqmx.GrpcSessionOptions(nitls_tagged_channel, "") + interpreter = _create_interpreter(mocker, grpc_options) + + with pytest.raises(nidaqmx.errors.RpcError) as exc_info: + interpreter._handle_rpc_error(_FakeRpcError(grpc.StatusCode.UNAVAILABLE)) + + assert exc_info.value.rpc_code == grpc.StatusCode.UNAVAILABLE + assert exc_info.value.description == expected_message + + +def test___server_reachable___create_interpreter___audits_connected(mocker: MockerFixture): + patched_audit = mocker.patch("nitlsconfig.audit_session_connect", autospec=True) + grpc_options = create_grpc_options(mocker) + + _create_interpreter(mocker, grpc_options) + + patched_audit.assert_called_once_with("NI-DAQmx", grpc_options.grpc_channel, True) + + +def test___server_unreachable___create_interpreter___audits_not_connected(mocker: MockerFixture): + patched_audit = mocker.patch("nitlsconfig.audit_session_connect", autospec=True) + grpc_options = create_grpc_options(mocker) + + _create_interpreter(mocker, grpc_options, version_error=Exception("unreachable")) + + patched_audit.assert_called_once_with("NI-DAQmx", grpc_options.grpc_channel, False) + + +def test___no_grpc_options___create_task___does_not_audit( + interpreter: Mock, mocker: MockerFixture +): + patched_audit = mocker.patch("nitlsconfig.audit_session_connect", autospec=True) + expect_create_task(interpreter) + expect_get_task_name(interpreter, "MyTask") + + with Task("MyTask"): + pass + + patched_audit.assert_not_called() From c2e3b6b5aaaf918bbf02f53e805b41ef34de6682 Mon Sep 17 00:00:00 2001 From: Alex Dubois Date: Thu, 10 Sep 2026 17:56:58 -0500 Subject: [PATCH 5/9] Fix and flesh out unit tests --- .../unit/test_nitlsconfig_grpc_integration.py | 53 ++++++++++++------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/tests/unit/test_nitlsconfig_grpc_integration.py b/tests/unit/test_nitlsconfig_grpc_integration.py index 76bdedbc9..f469d27d7 100644 --- a/tests/unit/test_nitlsconfig_grpc_integration.py +++ b/tests/unit/test_nitlsconfig_grpc_integration.py @@ -10,42 +10,43 @@ from tests.unit._grpc_utils import create_grpc_options from tests.unit._task_utils import expect_create_task, expect_get_task_name +# _FakeRpcError subclasses grpc.RpcError, so it must be defined here or importing this +# module raises NameError when the grpc extra isn't installed. try: import grpc import nitlsconfig from nidaqmx._grpc_interpreter import GrpcStubInterpreter -except ImportError: - pass + class _FakeRpcError(grpc.RpcError): + def __init__(self, code): + self._code = code -class _FakeRpcError(grpc.RpcError): - def __init__(self, code): - self._code = code + def code(self): + return self._code - def code(self): - return self._code + def details(self): + return "original details" - def details(self): - return "original details" + def trailing_metadata(self): + return [] - def trailing_metadata(self): - return [] +except ImportError: + grpc = None # type: ignore @pytest.fixture def nitls_tagged_channel(): - """A real gRPC channel tagged the way nitlsconfig.create_grpc_device_channel tags one. + """A gRPC channel tagged the way nitlsconfig.create_grpc_device_channel tags one. create_grpc_device_channel needs the nitlsconfig CLI installed on the system, so tagging a - plain channel is the closest we can get to one in a unit test. Nothing connects over it. + plain channel is the closest we can get to one here. Nothing connects over it. """ + if grpc is None: + pytest.skip("The grpc module is not available.") target = "localhost:31763" with grpc.insecure_channel(target) as channel: nitlsconfig.channel_tag.tag_channel_target(channel, target) - assert nitlsconfig.channel_tag.is_nitls_channel( - channel - ), "nitlsconfig no longer recognizes a channel it tagged" yield channel @@ -75,7 +76,7 @@ def test___untagged_channel___handle_unavailable___raises_failed_to_connect( def test___nitls_tagged_channel___handle_unavailable___raises_tls_elaboration( mocker: MockerFixture, nitls_tagged_channel ): - # The real elaboration, so this fails if nitlsconfig stops recognizing our channel. + # Derived from nitlsconfig itself, so this fails if it stops recognizing our channel. expected_message = nitlsconfig.get_tls_connection_error_elaboration(nitls_tagged_channel) assert expected_message is not None assert expected_message != "Failed to connect to server" @@ -89,6 +90,20 @@ def test___nitls_tagged_channel___handle_unavailable___raises_tls_elaboration( assert exc_info.value.description == expected_message +def test___nitls_tagged_channel___handle_other_status_code___preserves_original_details( + mocker: MockerFixture, nitls_tagged_channel +): + # Tagged, so an elaboration is available: this fails if we stop limiting it to UNAVAILABLE. + grpc_options = nidaqmx.GrpcSessionOptions(nitls_tagged_channel, "") + interpreter = _create_interpreter(mocker, grpc_options) + + with pytest.raises(nidaqmx.errors.RpcError) as exc_info: + interpreter._handle_rpc_error(_FakeRpcError(grpc.StatusCode.INTERNAL)) + + assert exc_info.value.rpc_code == grpc.StatusCode.INTERNAL + assert exc_info.value.description == "original details" + + def test___server_reachable___create_interpreter___audits_connected(mocker: MockerFixture): patched_audit = mocker.patch("nitlsconfig.audit_session_connect", autospec=True) grpc_options = create_grpc_options(mocker) @@ -107,9 +122,7 @@ def test___server_unreachable___create_interpreter___audits_not_connected(mocker patched_audit.assert_called_once_with("NI-DAQmx", grpc_options.grpc_channel, False) -def test___no_grpc_options___create_task___does_not_audit( - interpreter: Mock, mocker: MockerFixture -): +def test___no_grpc_options___create_task___does_not_audit(interpreter: Mock, mocker: MockerFixture): patched_audit = mocker.patch("nitlsconfig.audit_session_connect", autospec=True) expect_create_task(interpreter) expect_get_task_name(interpreter, "MyTask") From b44a81712a117c47454ed392c0f9c3dcbb2342c4 Mon Sep 17 00:00:00 2001 From: Alex Dubois Date: Thu, 10 Sep 2026 18:00:26 -0500 Subject: [PATCH 6/9] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aad341c7a..a99559ad4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ All notable changes to this project will be documented in this file. * ### Major Changes * Add support for Analog Path Delay + * Added `nitlsconfig[grpc]>=1.0.0a4` to the optional gRPC dependencies. * ### Known Issues * ... From f51dcd1b053e6fa7d46464ebf2eace397a289d92 Mon Sep 17 00:00:00 2001 From: Alex Dubois Date: Fri, 11 Sep 2026 09:22:45 -0500 Subject: [PATCH 7/9] Addback whitespaces, add nitlsconfig to CSpell exception and change tests accordingly --- .config/cspell/project-software-terms.txt | 1 + CHANGELOG.md | 2 +- generated/nidaqmx/_grpc_interpreter.py | 40 +++++++++---------- .../templates/_grpc_interpreter.py.mako | 2 +- .../unit/test_nitlsconfig_grpc_integration.py | 16 ++++---- 5 files changed, 31 insertions(+), 30 deletions(-) diff --git a/.config/cspell/project-software-terms.txt b/.config/cspell/project-software-terms.txt index 0613a25ef..f90a3667e 100644 --- a/.config/cspell/project-software-terms.txt +++ b/.config/cspell/project-software-terms.txt @@ -37,6 +37,7 @@ NISHAREDDIR # NI Linux install directory environment variable nicai # NI-DAQmx C library filename (Windows DLL) nicaiu # NI-DAQmx C library filename (Windows DLL, Unicode) nidevice # NI package name; ni-device-grpc +nitlsconfig # Dependency: nitlsconfig nitypes # Dependency: nitypes nptdms # Dependency: nptdms numpy # Dependency: numpy diff --git a/CHANGELOG.md b/CHANGELOG.md index a99559ad4..3b78e5798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ All notable changes to this project will be documented in this file. * ### Major Changes * Add support for Analog Path Delay - * Added `nitlsconfig[grpc]>=1.0.0a4` to the optional gRPC dependencies. + * Added `nitlsconfig[grpc]` to the `grpc` extra to enable using NI TLS to create TLS-encrypted gRPC channels. * ### Known Issues * ... diff --git a/generated/nidaqmx/_grpc_interpreter.py b/generated/nidaqmx/_grpc_interpreter.py index aac67404e..fc5d3c7b0 100644 --- a/generated/nidaqmx/_grpc_interpreter.py +++ b/generated/nidaqmx/_grpc_interpreter.py @@ -149,7 +149,7 @@ def _handle_rpc_error(self, rpc_error): grpc_error = rpc_error.code() if grpc_error == grpc.StatusCode.UNAVAILABLE: # gRPC reports a rejected TLS handshake and an unreachable server with the - # same code, so ask NI-TLS whether it built this channel and can say more. + # same code, so ask nitlsconfig whether it built this channel and can say more. error_message = nitlsconfig.get_tls_connection_error_elaboration( self._grpc_options.grpc_channel ) or 'Failed to connect to server' @@ -2438,7 +2438,7 @@ def read_analog_f64( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_samps=read_array.size)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read @@ -2458,7 +2458,7 @@ def read_binary_i16( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_samps=read_array.size)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read @@ -2472,7 +2472,7 @@ def read_binary_i32( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_samps=read_array.size)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read @@ -2486,7 +2486,7 @@ def read_binary_u16( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_samps=read_array.size)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read @@ -2500,7 +2500,7 @@ def read_binary_u32( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_samps=read_array.size)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read @@ -2512,7 +2512,7 @@ def read_counter_f64(self, task, num_samps_per_chan, timeout, read_array): grpc_types.ReadCounterF64Request( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, array_size_in_samps=read_array.size)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read @@ -2526,7 +2526,7 @@ def read_counter_f64_ex( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_samps=read_array.size)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read @@ -2550,7 +2550,7 @@ def read_counter_u32(self, task, num_samps_per_chan, timeout, read_array): grpc_types.ReadCounterU32Request( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, array_size_in_samps=read_array.size)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read @@ -2564,7 +2564,7 @@ def read_counter_u32_ex( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_samps=read_array.size)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read @@ -2580,7 +2580,7 @@ def read_ctr_freq( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, interleaved_raw=interleaved, array_size_in_samps=read_array_frequency.size)) - + _assign_numpy_array(read_array_frequency, response.read_array_frequency) _assign_numpy_array(read_array_duty_cycle, response.read_array_duty_cycle) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) @@ -2603,7 +2603,7 @@ def read_ctr_ticks( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, interleaved_raw=interleaved, array_size_in_samps=read_array_high_ticks.size)) - + _assign_numpy_array(read_array_high_ticks, response.read_array_high_ticks) _assign_numpy_array(read_array_low_ticks, response.read_array_low_ticks) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) @@ -2626,7 +2626,7 @@ def read_ctr_time( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, interleaved_raw=interleaved, array_size_in_samps=read_array_high_time.size)) - + _assign_numpy_array(read_array_high_time, response.read_array_high_time) _assign_numpy_array(read_array_low_time, response.read_array_low_time) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) @@ -2647,7 +2647,7 @@ def read_digital_lines( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_bytes=read_array.nbytes)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read, response.num_bytes_per_samp @@ -2667,7 +2667,7 @@ def read_digital_u16( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_samps=read_array.size)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read @@ -2681,7 +2681,7 @@ def read_digital_u32( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_samps=read_array.size)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read @@ -2695,7 +2695,7 @@ def read_digital_u8( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_samps=read_array.nbytes)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) return read_array, response.samps_per_chan_read @@ -2711,7 +2711,7 @@ def read_power_binary_i16( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_samps=read_array_voltage.size)) - + _assign_numpy_array(read_array_voltage, response.read_array_voltage) _assign_numpy_array(read_array_current, response.read_array_current) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) @@ -2728,7 +2728,7 @@ def read_power_f64( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, fill_mode_raw=fill_mode, array_size_in_samps=read_array_voltage.size)) - + _assign_numpy_array(read_array_voltage, response.read_array_voltage) _assign_numpy_array(read_array_current, response.read_array_current) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_per_chan_read) @@ -2747,7 +2747,7 @@ def read_raw(self, task, num_samps_per_chan, timeout, read_array): grpc_types.ReadRawRequest( task=task, num_samps_per_chan=num_samps_per_chan, timeout=timeout, array_size_in_bytes=read_array.nbytes)) - + _assign_numpy_array(read_array, response.read_array) self._check_for_error_from_response(response.status, samps_per_chan_read=response.samps_read) return read_array, response.samps_read, response.num_bytes_per_samp diff --git a/src/codegen/templates/_grpc_interpreter.py.mako b/src/codegen/templates/_grpc_interpreter.py.mako index e1c93db16..3f5b8713d 100644 --- a/src/codegen/templates/_grpc_interpreter.py.mako +++ b/src/codegen/templates/_grpc_interpreter.py.mako @@ -166,7 +166,7 @@ class GrpcStubInterpreter(BaseInterpreter): grpc_error = rpc_error.code() if grpc_error == grpc.StatusCode.UNAVAILABLE: # gRPC reports a rejected TLS handshake and an unreachable server with the - # same code, so ask NI-TLS whether it built this channel and can say more. + # same code, so ask nitlsconfig whether it built this channel and can say more. error_message = nitlsconfig.get_tls_connection_error_elaboration( self._grpc_options.grpc_channel ) or 'Failed to connect to server' diff --git a/tests/unit/test_nitlsconfig_grpc_integration.py b/tests/unit/test_nitlsconfig_grpc_integration.py index f469d27d7..2d9c39c40 100644 --- a/tests/unit/test_nitlsconfig_grpc_integration.py +++ b/tests/unit/test_nitlsconfig_grpc_integration.py @@ -36,7 +36,7 @@ def trailing_metadata(self): @pytest.fixture -def nitls_tagged_channel(): +def nitlsconfig_tagged_channel(): """A gRPC channel tagged the way nitlsconfig.create_grpc_device_channel tags one. create_grpc_device_channel needs the nitlsconfig CLI installed on the system, so tagging a @@ -73,14 +73,14 @@ def test___untagged_channel___handle_unavailable___raises_failed_to_connect( assert exc_info.value.description == "Failed to connect to server" -def test___nitls_tagged_channel___handle_unavailable___raises_tls_elaboration( - mocker: MockerFixture, nitls_tagged_channel +def test___nitlsconfig_tagged_channel___handle_unavailable___raises_tls_elaboration( + mocker: MockerFixture, nitlsconfig_tagged_channel ): # Derived from nitlsconfig itself, so this fails if it stops recognizing our channel. - expected_message = nitlsconfig.get_tls_connection_error_elaboration(nitls_tagged_channel) + expected_message = nitlsconfig.get_tls_connection_error_elaboration(nitlsconfig_tagged_channel) assert expected_message is not None assert expected_message != "Failed to connect to server" - grpc_options = nidaqmx.GrpcSessionOptions(nitls_tagged_channel, "") + grpc_options = nidaqmx.GrpcSessionOptions(nitlsconfig_tagged_channel, "") interpreter = _create_interpreter(mocker, grpc_options) with pytest.raises(nidaqmx.errors.RpcError) as exc_info: @@ -90,11 +90,11 @@ def test___nitls_tagged_channel___handle_unavailable___raises_tls_elaboration( assert exc_info.value.description == expected_message -def test___nitls_tagged_channel___handle_other_status_code___preserves_original_details( - mocker: MockerFixture, nitls_tagged_channel +def test___nitlsconfig_tagged_channel___handle_other_status_code___preserves_original_details( + mocker: MockerFixture, nitlsconfig_tagged_channel ): # Tagged, so an elaboration is available: this fails if we stop limiting it to UNAVAILABLE. - grpc_options = nidaqmx.GrpcSessionOptions(nitls_tagged_channel, "") + grpc_options = nidaqmx.GrpcSessionOptions(nitlsconfig_tagged_channel, "") interpreter = _create_interpreter(mocker, grpc_options) with pytest.raises(nidaqmx.errors.RpcError) as exc_info: From 7f16f82db96aae231ae0047dcbc47b886e7f5612 Mon Sep 17 00:00:00 2001 From: Alex Dubois Date: Fri, 11 Sep 2026 09:26:31 -0500 Subject: [PATCH 8/9] Add a brief mention of what nitlsconfig is as well --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b78e5798..c82904df2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ All notable changes to this project will be documented in this file. * ### Major Changes * Add support for Analog Path Delay - * Added `nitlsconfig[grpc]` to the `grpc` extra to enable using NI TLS to create TLS-encrypted gRPC channels. + * Added `nitlsconfig[grpc]` to the `grpc` extra to enable using NI TLS to create TLS-encrypted gRPC channels. `nitlsconfig` is the Python API for reading the NI TLS configuration installed on the system and creating NI gRPC Device client channels from it. * ### Known Issues * ... From bdf3b890a9eae2e3920d17d2088825b07a0de20a Mon Sep 17 00:00:00 2001 From: Alex Dubois Date: Fri, 11 Sep 2026 10:00:40 -0500 Subject: [PATCH 9/9] Add a comment as well to clarify --- generated/nidaqmx/_grpc_interpreter.py | 1 + src/codegen/templates/_grpc_interpreter.py.mako | 1 + 2 files changed, 2 insertions(+) diff --git a/generated/nidaqmx/_grpc_interpreter.py b/generated/nidaqmx/_grpc_interpreter.py index fc5d3c7b0..48ea86073 100644 --- a/generated/nidaqmx/_grpc_interpreter.py +++ b/generated/nidaqmx/_grpc_interpreter.py @@ -115,6 +115,7 @@ def __init__(self, grpc_options): minor_version = 0 update_version = 0 finally: + # Record the outcome of a driver's gRPC session initialize RPC. nitlsconfig.audit_session_connect('NI-DAQmx', grpc_options.grpc_channel, connected) self._driver_version = DriverVersion(major_version, minor_version, update_version) diff --git a/src/codegen/templates/_grpc_interpreter.py.mako b/src/codegen/templates/_grpc_interpreter.py.mako index 3f5b8713d..dd6c0d1f3 100644 --- a/src/codegen/templates/_grpc_interpreter.py.mako +++ b/src/codegen/templates/_grpc_interpreter.py.mako @@ -132,6 +132,7 @@ class GrpcStubInterpreter(BaseInterpreter): minor_version = 0 update_version = 0 finally: + # Record the outcome of a driver's gRPC session initialize RPC. nitlsconfig.audit_session_connect('NI-DAQmx', grpc_options.grpc_channel, connected) self._driver_version = DriverVersion(major_version, minor_version, update_version)