ni.measurementlink.discovery.v1.client 0.1.0.dev0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.3
2
+ Name: ni.measurementlink.discovery.v1.client
3
+ Version: 0.1.0.dev0
4
+ Summary: gRPC Client for NI Discovery Service
5
+ License: MIT
6
+ Keywords: discovery,client
7
+ Author: NI
8
+ Author-email: opensource@ni.com
9
+ Maintainer: Brad Keryan
10
+ Maintainer-email: brad.keryan@emerson.com
11
+ Requires-Python: >=3.9,<4.0
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Manufacturing
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: Microsoft :: Windows
18
+ Classifier: Operating System :: POSIX
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: Implementation :: CPython
26
+ Requires-Dist: grpcio (>=1.49.0,<2.0)
27
+ Requires-Dist: ni-grpc-extensions (>=0.1.0.dev0)
28
+ Requires-Dist: ni-measurementlink-discovery-v1-proto (>=0.1.0.dev0)
29
+ Requires-Dist: pywin32 (>=303) ; sys_platform == "win32"
30
+ Project-URL: Repository, https://github.com/ni/ni-apis-python
31
+ Description-Content-Type: text/markdown
32
+
33
+ # Table of Contents
34
+
35
+ - [Table of Contents](#table-of-contents)
36
+ - [About](#about)
37
+ - [Operating System Support](#operating-system-support)
38
+ - [Python Version Support](#python-version-support)
39
+
40
+ # About
41
+
42
+ `ni.measurementlink.discovery.v1.client` is a Python package that provides a gRPC client for the
43
+ NI Discovery Service, version 1. The corresponding service is defined in the
44
+ [ni.measurementlink.discovery.v1.proto package](https://github.com/ni/ni-apis/tree/main/ni/measurementlink/discovery/v1).
45
+
46
+ NI created and supports this package.
47
+
48
+ ## Operating System Support
49
+
50
+ `ni.measurementlink.discovery.v1.client` supports Windows operating systems.
51
+
52
+ ## Python Version Support
53
+
54
+ `ni.measurementlink.discovery.v1.client` supports CPython 3.9+.
55
+
56
+ ## Installation
57
+
58
+ You can directly install the `ni.measurementlink.discovery.v1.client` package using `pip` or by listing it as a
59
+ dependency in your project's `pyproject.toml` file.
@@ -0,0 +1,27 @@
1
+ # Table of Contents
2
+
3
+ - [Table of Contents](#table-of-contents)
4
+ - [About](#about)
5
+ - [Operating System Support](#operating-system-support)
6
+ - [Python Version Support](#python-version-support)
7
+
8
+ # About
9
+
10
+ `ni.measurementlink.discovery.v1.client` is a Python package that provides a gRPC client for the
11
+ NI Discovery Service, version 1. The corresponding service is defined in the
12
+ [ni.measurementlink.discovery.v1.proto package](https://github.com/ni/ni-apis/tree/main/ni/measurementlink/discovery/v1).
13
+
14
+ NI created and supports this package.
15
+
16
+ ## Operating System Support
17
+
18
+ `ni.measurementlink.discovery.v1.client` supports Windows operating systems.
19
+
20
+ ## Python Version Support
21
+
22
+ `ni.measurementlink.discovery.v1.client` supports CPython 3.9+.
23
+
24
+ ## Installation
25
+
26
+ You can directly install the `ni.measurementlink.discovery.v1.client` package using `pip` or by listing it as a
27
+ dependency in your project's `pyproject.toml` file.
@@ -0,0 +1,99 @@
1
+ [tool.poetry]
2
+ name = "ni.measurementlink.discovery.v1.client"
3
+ version = "0.1.0-dev0"
4
+ license = "MIT"
5
+ description = "gRPC Client for NI Discovery Service"
6
+ authors = ["NI <opensource@ni.com>"]
7
+ maintainers = [
8
+ "Brad Keryan <brad.keryan@emerson.com>",
9
+ "Joe Friedrichsen <joe.friedrichsen@emerson.com>",
10
+ ]
11
+ readme = "README.md"
12
+ repository = "https://github.com/ni/ni-apis-python"
13
+ keywords = ["discovery", "client"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: Manufacturing",
18
+ "Intended Audience :: Science/Research",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: Microsoft :: Windows",
21
+ "Operating System :: POSIX",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.9",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Programming Language :: Python :: Implementation :: CPython",
29
+ ]
30
+ packages = [{include = "ni", from = "src"}]
31
+
32
+ [tool.poetry.dependencies]
33
+ python = "^3.9"
34
+ grpcio = {version=">=1.49.0,<2.0"}
35
+ ni-grpc-extensions = { version = ">=0.1.0.dev0", allow-prereleases = true }
36
+ ni-measurementlink-discovery-v1-proto = { version = ">=0.1.0.dev0", allow-prereleases = true }
37
+ pywin32 = { version = ">=303", platform = "win32" }
38
+
39
+ [tool.poetry.group.dev.dependencies]
40
+ types-grpcio = ">=1.0"
41
+ types-pywin32 = ">=304"
42
+
43
+ [tool.poetry.group.lint.dependencies]
44
+ bandit = { version = ">=1.7", extras = ["toml"] }
45
+ ni-python-styleguide = ">=0.4.1"
46
+ mypy = ">=1.0"
47
+ pyright = { version = ">=1.1.400", extras = ["nodejs"] }
48
+
49
+ [tool.poetry.group.test.dependencies]
50
+ pytest = ">=7.2"
51
+ pytest-cov = ">=4.0"
52
+ pytest-doctestplus = ">=1.4"
53
+ pytest-mock = ">=3.0"
54
+
55
+ [tool.poetry.group.docs]
56
+ optional = true
57
+
58
+ [tool.poetry.group.docs.dependencies]
59
+ # The latest Sphinx requires a recent Python version.
60
+ Sphinx = { version = ">=8.2", python = "^3.11" }
61
+ sphinx-rtd-theme = ">=1.0.0"
62
+ sphinx-autoapi = ">=1.8.4"
63
+ m2r2 = ">=0.3.2"
64
+ toml = ">=0.10.2"
65
+
66
+ [build-system]
67
+ requires = ["poetry-core>=1.8"]
68
+ build-backend = "poetry.core.masonry.api"
69
+
70
+
71
+ [tool.bandit]
72
+ skips = [
73
+ "B101", # assert_used
74
+ ]
75
+
76
+ [tool.ni-python-styleguide]
77
+ extend_exclude = "docs"
78
+
79
+ [tool.black]
80
+ extend-exclude = 'docs/'
81
+ line-length = 100
82
+
83
+ [tool.mypy]
84
+ mypy_path = "src"
85
+ files = "."
86
+ namespace_packages = true
87
+ strict = true
88
+ explicit_package_bases = true
89
+ exclude = ["docs"]
90
+
91
+ [tool.pyright]
92
+ include = ["src/", "tests/"]
93
+ # Turn off this check to avoid pyright warnings for pywin32 and pywin32-types
94
+ # when pyright runs for the win32 platform on a Linux build agent.
95
+ reportMissingModuleSource = false
96
+
97
+ [tool.pytest.ini_options]
98
+ addopts = "--doctest-modules --doctest-plus --strict-markers"
99
+ testpaths = ["tests"]
@@ -0,0 +1,10 @@
1
+ """Public API for accessing the NI Discovery Service."""
2
+
3
+ from ni.measurementlink.discovery.v1.client._client import DiscoveryClient
4
+ from ni.measurementlink.discovery.v1.client._types import (
5
+ ComputeNodeDescriptor,
6
+ ServiceInfo,
7
+ ServiceLocation,
8
+ )
9
+
10
+ __all__ = ["DiscoveryClient", "ServiceLocation", "ComputeNodeDescriptor", "ServiceInfo"]
@@ -0,0 +1,277 @@
1
+ """Client for accessing the NI Discovery Service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ from collections.abc import Sequence
8
+
9
+ import grpc
10
+ from ni.measurementlink.discovery.v1 import discovery_service_pb2, discovery_service_pb2_grpc
11
+ from ni_grpc_extensions.channelpool import GrpcChannelPool # type: ignore[import-untyped]
12
+
13
+ from ni.measurementlink.discovery.v1.client._support import (
14
+ _get_discovery_service_address,
15
+ )
16
+ from ni.measurementlink.discovery.v1.client._types import (
17
+ ComputeNodeDescriptor,
18
+ ServiceInfo,
19
+ ServiceLocation,
20
+ )
21
+
22
+ SERVICE_PROGRAMMINGLANGUAGE_KEY = "ni/service.programminglanguage"
23
+ _logger = logging.getLogger(__name__)
24
+
25
+
26
+ class DiscoveryClient:
27
+ """Client for accessing the NI Discovery Service."""
28
+
29
+ __slots__ = [
30
+ "_initialization_lock",
31
+ "_grpc_channel_pool",
32
+ "_stub",
33
+ "__weakref__",
34
+ ]
35
+
36
+ _initialization_lock: threading.Lock
37
+ _grpc_channel_pool: GrpcChannelPool | None
38
+ _stub: discovery_service_pb2_grpc.DiscoveryServiceStub | None
39
+
40
+ def __init__(
41
+ self,
42
+ stub: discovery_service_pb2_grpc.DiscoveryServiceStub | None = None,
43
+ *,
44
+ grpc_channel_pool: GrpcChannelPool | None = None,
45
+ ) -> None:
46
+ """Initialize the discovery client.
47
+
48
+ Args:
49
+ stub: An optional discovery service gRPC stub for unit testing.
50
+
51
+ grpc_channel_pool: An optional gRPC channel pool (recommended).
52
+ """
53
+ self._initialization_lock = threading.Lock()
54
+ self._grpc_channel_pool = grpc_channel_pool
55
+ self._stub = stub
56
+
57
+ def _get_stub(self) -> discovery_service_pb2_grpc.DiscoveryServiceStub:
58
+ if self._stub is None:
59
+ with self._initialization_lock:
60
+ if self._grpc_channel_pool is None:
61
+ _logger.debug("Creating unshared GrpcChannelPool.")
62
+ self._grpc_channel_pool = GrpcChannelPool()
63
+ if self._stub is None:
64
+ address = _get_discovery_service_address()
65
+ channel = self._grpc_channel_pool.get_channel(address)
66
+ self._stub = discovery_service_pb2_grpc.DiscoveryServiceStub(channel)
67
+ return self._stub
68
+
69
+ def register_service(self, service_info: ServiceInfo, service_location: ServiceLocation) -> str:
70
+ """Register the specified service with the discovery service.
71
+
72
+ Args:
73
+ service_info: Information describing the service.
74
+
75
+ service_location: The location of the service on the network.
76
+
77
+ Returns:
78
+ ID that can be used to unregister the service.
79
+ """
80
+ annotations = service_info.annotations.copy()
81
+ annotations[SERVICE_PROGRAMMINGLANGUAGE_KEY] = "Python"
82
+ try:
83
+ grpc_service_description = discovery_service_pb2.ServiceDescriptor(
84
+ display_name=service_info.display_name,
85
+ description_url=service_info.description_url,
86
+ provided_interfaces=service_info.provided_interfaces,
87
+ service_class=service_info.service_class,
88
+ annotations=annotations,
89
+ versions=service_info.versions,
90
+ )
91
+
92
+ grpc_service_location = discovery_service_pb2.ServiceLocation(
93
+ location=service_location.location,
94
+ insecure_port=service_location.insecure_port,
95
+ ssl_authenticated_port=service_location.ssl_authenticated_port,
96
+ )
97
+
98
+ request = discovery_service_pb2.RegisterServiceRequest(
99
+ service_description=grpc_service_description,
100
+ location=grpc_service_location,
101
+ )
102
+
103
+ response = self._get_stub().RegisterService(request)
104
+ _logger.info("Successfully registered with discovery service.")
105
+ return response.registration_id
106
+ except grpc.RpcError as e:
107
+ if e.code() == grpc.StatusCode.UNAVAILABLE:
108
+ _logger.error(
109
+ "Unable to register with discovery service. Possible reason: discovery service not available."
110
+ )
111
+ else:
112
+ _logger.exception("Error in registering with discovery service.")
113
+ raise
114
+ except FileNotFoundError:
115
+ _logger.error(
116
+ "Unable to register with discovery service. Possible reason: discovery service not running."
117
+ )
118
+ raise
119
+ except Exception:
120
+ _logger.exception("Error in registering with discovery service.")
121
+ raise
122
+
123
+ def unregister_service(self, registration_id: str) -> bool:
124
+ """Unregisters the specified service from the discovery service.
125
+
126
+ This method should be called before the service exits.
127
+
128
+ Args:
129
+ registration_id: The registration ID returned from register_service.
130
+ This argument should be omitted after calling the deprecated
131
+ register_measurement_service method.
132
+
133
+ Returns:
134
+ Boolean indicating whether the service was unregistered.
135
+ """
136
+ try:
137
+ if not registration_id:
138
+ _logger.info("Not registered with discovery service.")
139
+ return False
140
+
141
+ request = discovery_service_pb2.UnregisterServiceRequest(
142
+ registration_id=registration_id
143
+ )
144
+
145
+ _ = self._get_stub().UnregisterService(request)
146
+ _logger.info("Successfully unregistered with discovery service.")
147
+
148
+ return True
149
+ except grpc.RpcError as e:
150
+ if e.code() == grpc.StatusCode.UNAVAILABLE:
151
+ _logger.error(
152
+ "Unable to unregister with discovery service. Possible reason: discovery service not available."
153
+ )
154
+ else:
155
+ _logger.exception("Error in unregistering with discovery service.")
156
+ raise
157
+ except FileNotFoundError:
158
+ _logger.error(
159
+ "Unable to unregister with discovery service. Possible reason: discovery service not running."
160
+ )
161
+ raise
162
+ except Exception:
163
+ _logger.exception("Error in unregistering with discovery service.")
164
+ raise
165
+
166
+ def resolve_service(
167
+ self,
168
+ provided_interface: str,
169
+ service_class: str = "",
170
+ deployment_target: str = "",
171
+ version: str = "",
172
+ ) -> ServiceLocation:
173
+ """Resolve the location of a service.
174
+
175
+ Given a description of a service, returns information that can be used to establish
176
+ communication with that service. If necessary, the service will be started by the
177
+ discovery service if it has not already been started.
178
+
179
+ Args:
180
+ provided_interface: The gRPC full name of the service.
181
+ service_class: The service "class" that should be matched. If the value is not
182
+ specified and there is more than one matching service registered, an error
183
+ is returned.
184
+ deployment_target: The deployment target from which the service should be resolved.
185
+ Callers should pass either the url string from a :class:`ComputeNodeSpecifier`
186
+ or a blank string for local execution.
187
+ version: The version of the service to resolve. If not specified, the latest version
188
+ will be resolved.
189
+
190
+ Returns:
191
+ The service location.
192
+ """
193
+ request = discovery_service_pb2.ResolveServiceRequest(
194
+ provided_interface=provided_interface,
195
+ service_class=service_class,
196
+ deployment_target=deployment_target,
197
+ version=version,
198
+ )
199
+
200
+ response = self._get_stub().ResolveService(request)
201
+
202
+ return ServiceLocation._from_grpc(response)
203
+
204
+ def resolve_service_with_information(
205
+ self,
206
+ provided_interface: str,
207
+ service_class: str = "",
208
+ deployment_target: str = "",
209
+ version: str = "",
210
+ ) -> tuple[ServiceLocation, ServiceInfo]:
211
+ """Resolve the location of a service along with its information.
212
+
213
+ Given a description of a service, returns information for the service in addition to
214
+ the location of the service. If necessary, the service will be started by the discovery
215
+ service if it has not already been started.
216
+
217
+ Args:
218
+ provided_interface: The gRPC full name of the service.
219
+ service_class: The service "class" that should be matched. If the value is not
220
+ specified and there is more than one matching service registered, an error
221
+ is returned.
222
+ deployment_target: The deployment target from which the service should be resolved.
223
+ Callers should pass either the url string from a :class:`ComputeNodeSpecifier`
224
+ or a blank string for local execution.
225
+ version: The version of the service to resolve. If not specified, the latest version
226
+ will be resolved.
227
+
228
+ Returns:
229
+ A tuple containing the service location and service information.
230
+ """
231
+ request = discovery_service_pb2.ResolveServiceWithInformationRequest(
232
+ provided_interface=provided_interface,
233
+ service_class=service_class,
234
+ deployment_target=deployment_target,
235
+ version=version,
236
+ )
237
+
238
+ response = self._get_stub().ResolveServiceWithInformation(request)
239
+
240
+ return (
241
+ ServiceLocation._from_grpc(response.service_location),
242
+ ServiceInfo._from_grpc(response.service_descriptor),
243
+ )
244
+
245
+ def enumerate_services(self, provided_interface: str) -> Sequence[ServiceInfo]:
246
+ """Enumerates all the services for the provided interface.
247
+
248
+ Args:
249
+ provided_interface: The gRPC full name of the services.
250
+
251
+ Returns:
252
+ The list of information describing the services.
253
+ """
254
+ request = discovery_service_pb2.EnumerateServicesRequest(
255
+ provided_interface=provided_interface
256
+ )
257
+
258
+ response = self._get_stub().EnumerateServices(request)
259
+
260
+ return [ServiceInfo._from_grpc(service) for service in response.available_services]
261
+
262
+ def enumerate_compute_nodes(self) -> Sequence[ComputeNodeDescriptor]:
263
+ """Enumerates all the compute nodes registered with the discovery service.
264
+
265
+ Returns:
266
+ The list of information describing the compute nodes.
267
+ """
268
+ request = discovery_service_pb2.EnumerateComputeNodesRequest()
269
+
270
+ try:
271
+ response = self._get_stub().EnumerateComputeNodes(request)
272
+ except grpc.RpcError as e:
273
+ if e.code() == grpc.StatusCode.UNIMPLEMENTED:
274
+ return []
275
+ raise
276
+
277
+ return [ComputeNodeDescriptor._from_grpc(node) for node in response.compute_nodes]
@@ -0,0 +1,192 @@
1
+ """Support functions for the NI Discovery Service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import pathlib
9
+ import subprocess # nosec: B404
10
+ import sys
11
+ import time
12
+ import typing
13
+ from typing import Any
14
+
15
+ from ni.measurementlink.discovery.v1 import (
16
+ discovery_service_pb2,
17
+ )
18
+
19
+ if sys.platform == "win32":
20
+ import msvcrt
21
+ import winreg
22
+
23
+ import win32con
24
+ import win32file
25
+
26
+ _logger = logging.getLogger(__name__)
27
+ # Save Popen object to avoid "ResourceWarning: subprocess N is still running"
28
+ _discovery_service_subprocess: subprocess.Popen[Any] | None = None
29
+
30
+ _START_SERVICE_TIMEOUT = 30.0
31
+ _START_SERVICE_POLLING_INTERVAL = 100e-3
32
+
33
+
34
+ def _get_discovery_service_address() -> str:
35
+ key_file_path = _get_key_file_path()
36
+ _ensure_discovery_service_started(key_file_path)
37
+ _logger.debug("Discovery service key file path: %s", key_file_path)
38
+ with _open_key_file(str(key_file_path)) as key_file:
39
+ key_json = json.load(key_file)
40
+ return "localhost:" + str(key_json["InsecurePort"])
41
+
42
+
43
+ def _ensure_discovery_service_started(key_file_path: pathlib.Path) -> None:
44
+ """Check whether discovery service already running, if not start the discovery service."""
45
+ if _service_already_running(key_file_path):
46
+ return
47
+
48
+ exe_file_path = _get_discovery_service_location()
49
+ global _discovery_service_subprocess # save Popen object to avoid ResourceWarning
50
+ _discovery_service_subprocess = _start_service(exe_file_path, key_file_path)
51
+
52
+
53
+ def _get_discovery_service_location() -> pathlib.PurePath:
54
+ """Gets the location of the discovery service process executable."""
55
+ registration_json_path = _get_registration_json_file_path()
56
+ registration_json_obj = json.loads(registration_json_path.read_text())
57
+ return registration_json_path.parent / str(registration_json_obj["discovery"]["path"])
58
+
59
+
60
+ def _get_registration_json_file_path() -> pathlib.Path:
61
+ if sys.platform == "win32":
62
+ json_path = _get_nipath("NISHAREDDIR64") / "Services" / "NIServices.json"
63
+ if not json_path.exists():
64
+ json_path = (
65
+ _get_nipath("NISHAREDDIR64") / "MeasurementLink" / "MeasurementLinkServices.json"
66
+ )
67
+ return json_path
68
+ else:
69
+ raise NotImplementedError(
70
+ f"Platform not supported: {sys.platform}. Supported platforms: win32."
71
+ )
72
+
73
+
74
+ def _key_file_exists(key_file_path: pathlib.Path) -> bool:
75
+ return key_file_path.is_file() and key_file_path.stat().st_size > 0
76
+
77
+
78
+ def _start_service(
79
+ exe_file_path: pathlib.PurePath, key_file_path: pathlib.Path
80
+ ) -> subprocess.Popen[Any]:
81
+ """Starts the service at the specified path and wait for the service to get up and running."""
82
+ kwargs: dict[str, Any] = {}
83
+ if sys.platform == "win32":
84
+ # Terminating the measurement service should not terminate the discovery service.
85
+ kwargs["creationflags"] = subprocess.CREATE_BREAKAWAY_FROM_JOB | subprocess.DETACHED_PROCESS
86
+ # The EXE file path comes from the registration JSON, which is installed in
87
+ # a trusted location (e.g. NISHAREDDIR64).
88
+ discovery_service_subprocess = subprocess.Popen( # nosec: B603
89
+ [exe_file_path],
90
+ cwd=exe_file_path.parent,
91
+ stdin=subprocess.DEVNULL,
92
+ stdout=subprocess.DEVNULL,
93
+ stderr=subprocess.DEVNULL,
94
+ **kwargs,
95
+ )
96
+
97
+ # After the execution of process, check for key file existence in the path
98
+ # stop checking after 30 seconds have elapsed and throw error
99
+ timeout_time = time.time() + _START_SERVICE_TIMEOUT
100
+ while True:
101
+ try:
102
+ with _open_key_file(str(key_file_path)) as _:
103
+ return discovery_service_subprocess # pyright: ignore[reportReturnType]
104
+ except OSError:
105
+ pass
106
+ if time.time() >= timeout_time:
107
+ raise TimeoutError("Timed out waiting for discovery service to start")
108
+ time.sleep(_START_SERVICE_POLLING_INTERVAL)
109
+
110
+
111
+ def _service_already_running(key_file_path: pathlib.Path) -> bool:
112
+ try:
113
+ _delete_existing_key_file(key_file_path)
114
+ except OSError:
115
+ return True
116
+ return False
117
+
118
+
119
+ def _delete_existing_key_file(key_file_path: pathlib.Path) -> None:
120
+ if _key_file_exists(key_file_path):
121
+ with key_file_path.open("w") as _:
122
+ pass
123
+ key_file_path.unlink()
124
+
125
+
126
+ def _get_key_file_path(cluster_id: str | None = None) -> pathlib.Path:
127
+ if cluster_id is not None:
128
+ return _get_key_file_directory() / f"DiscoveryService_{cluster_id}.json"
129
+ return _get_key_file_directory() / "DiscoveryService.json"
130
+
131
+
132
+ def _get_key_file_directory() -> pathlib.Path:
133
+ package = typing.cast(str, discovery_service_pb2.DESCRIPTOR.package)
134
+ version = package.split(".")[-1]
135
+ if sys.platform == "win32":
136
+ return _get_nipath("NIPUBAPPDATADIR") / "MeasurementLink" / "Discovery" / version
137
+ else:
138
+ raise NotImplementedError(
139
+ f"Platform not supported: {sys.platform}. Supported platforms: win32."
140
+ )
141
+
142
+
143
+ def _open_key_file(path: str) -> typing.TextIO:
144
+ if sys.platform == "win32":
145
+ # Use the Win32 API to specify the share mode. Otherwise, opening the file throws
146
+ # PermissionError due to a sharing violation. This is a workaround for
147
+ # https://github.com/python/cpython/issues/59449
148
+ # (Support for opening files with FILE_SHARE_DELETE on Windows).
149
+ try:
150
+ win32_file_handle = win32file.CreateFile(
151
+ path,
152
+ win32file.GENERIC_READ,
153
+ win32file.FILE_SHARE_READ
154
+ | win32file.FILE_SHARE_WRITE
155
+ | win32file.FILE_SHARE_DELETE,
156
+ None,
157
+ win32con.OPEN_EXISTING,
158
+ 0,
159
+ None,
160
+ )
161
+ except win32file.error as e:
162
+ raise OSError(None, e.strerror, path, e.winerror) from e
163
+
164
+ # The CRT file descriptor takes ownership of the Win32 file handle.
165
+ # os.O_TEXT is unnecessary because Python handles newline conversion.
166
+ crt_file_descriptor = msvcrt.open_osfhandle(win32_file_handle.handle, os.O_RDONLY)
167
+ win32_file_handle.Detach()
168
+
169
+ # The Python file object takes ownership of the CRT file descriptor. Closing the Python
170
+ # file object closes the underlying Win32 file handle.
171
+ return os.fdopen(crt_file_descriptor, "r", encoding="utf-8-sig")
172
+ else:
173
+ return open(path)
174
+
175
+
176
+ def _get_nipath(name: str) -> pathlib.Path:
177
+ if sys.platform == "win32":
178
+ access: int = winreg.KEY_READ
179
+ if "64" in name:
180
+ access |= winreg.KEY_WOW64_64KEY
181
+ with winreg.OpenKey(
182
+ winreg.HKEY_LOCAL_MACHINE,
183
+ r"SOFTWARE\National Instruments\Common\Installer",
184
+ access=access,
185
+ ) as key:
186
+ value, type = winreg.QueryValueEx(key, name)
187
+ assert type == winreg.REG_SZ
188
+ return pathlib.Path(value)
189
+ else:
190
+ raise NotImplementedError(
191
+ f"Platform not supported: {sys.platform}. Supported platforms: win32."
192
+ )
@@ -0,0 +1,111 @@
1
+ """Data types for the NI Discovery Service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typing
6
+ from typing import NamedTuple
7
+
8
+ from ni.measurementlink.discovery.v1 import (
9
+ discovery_service_pb2,
10
+ )
11
+
12
+
13
+ class ServiceInfo(NamedTuple):
14
+ """A named tuple providing information about a registered service.
15
+
16
+ This class is used with the NI Discovery Service when registering and enumerating services.
17
+ """
18
+
19
+ service_class: str
20
+ """"The "class" of a service. The value of this field should be unique for all services.
21
+ In effect, the ``.proto`` service declaration defines the interface, and this field
22
+ defines a class or concrete type of the interface."""
23
+
24
+ description_url: str
25
+ """The URL of a web page that provides a description of the service."""
26
+
27
+ provided_interfaces: list[str] = ["ni.measurementlink.measurement.v1.MeasurementService"]
28
+ """The service interfaces provided by the service. These are gRPC full names for the service."""
29
+
30
+ annotations: dict[str, str] = {}
31
+ """Represents a set of annotations on the service.
32
+
33
+ Well-known annotations:
34
+
35
+ - Description
36
+ - Key: "ni/service.description"
37
+ - Expected format: string
38
+ - Example: "Measure inrush current with a shorted load and validate results against
39
+ configured limits."
40
+ - Collection
41
+ - Key: "ni/service.collection"
42
+ - Expected format: "." delimited namespace/hierarchy case-insensitive string
43
+ - Example: "CurrentTests.Inrush"
44
+ - Tags
45
+ - Key: "ni/service.tags"
46
+ - Expected format: serialized JSON string of an array of strings
47
+ - Example: "[\"powerup\", \"current\"]"
48
+ """
49
+
50
+ display_name: str = ""
51
+ """The service display name for clients to display to users."""
52
+
53
+ versions: list[str] = []
54
+ """The list of versions associated with this service in
55
+ the form major.minor.build[.revision] (e.g. 1.0.0)."""
56
+
57
+ @classmethod
58
+ def _from_grpc(cls, other: discovery_service_pb2.ServiceDescriptor) -> ServiceInfo:
59
+ return ServiceInfo(
60
+ service_class=other.service_class,
61
+ description_url=other.description_url,
62
+ provided_interfaces=list(other.provided_interfaces),
63
+ annotations=dict(other.annotations),
64
+ display_name=other.display_name,
65
+ versions=list(other.versions),
66
+ )
67
+
68
+
69
+ class ServiceLocation(typing.NamedTuple):
70
+ """Represents the location of a service."""
71
+
72
+ location: str
73
+ insecure_port: str
74
+ ssl_authenticated_port: str
75
+
76
+ @property
77
+ def insecure_address(self) -> str:
78
+ """Get the service's insecure address in the format host:port."""
79
+ return f"{self.location}:{self.insecure_port}"
80
+
81
+ @property
82
+ def ssl_authenticated_address(self) -> str:
83
+ """Get the service's SSL-authenticated address in the format host:port."""
84
+ return f"{self.location}:{self.ssl_authenticated_port}"
85
+
86
+ @classmethod
87
+ def _from_grpc(cls, other: discovery_service_pb2.ServiceLocation) -> ServiceLocation:
88
+ return ServiceLocation(
89
+ location=other.location,
90
+ insecure_port=other.insecure_port,
91
+ ssl_authenticated_port=other.ssl_authenticated_port,
92
+ )
93
+
94
+
95
+ class ComputeNodeDescriptor(typing.NamedTuple):
96
+ """Represents a compute node."""
97
+
98
+ url: str
99
+ """The resolvable name (URL) of the compute node."""
100
+
101
+ is_local: bool
102
+ """Indicates whether the compute node is local node."""
103
+
104
+ @classmethod
105
+ def _from_grpc(
106
+ cls, other: discovery_service_pb2.ComputeNodeDescriptor
107
+ ) -> ComputeNodeDescriptor:
108
+ return ComputeNodeDescriptor(
109
+ url=other.url,
110
+ is_local=other.is_local,
111
+ )