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.
- ni_measurementlink_discovery_v1_client-0.1.0.dev0/PKG-INFO +59 -0
- ni_measurementlink_discovery_v1_client-0.1.0.dev0/README.md +27 -0
- ni_measurementlink_discovery_v1_client-0.1.0.dev0/pyproject.toml +99 -0
- ni_measurementlink_discovery_v1_client-0.1.0.dev0/src/ni/measurementlink/discovery/v1/client/__init__.py +10 -0
- ni_measurementlink_discovery_v1_client-0.1.0.dev0/src/ni/measurementlink/discovery/v1/client/_client.py +277 -0
- ni_measurementlink_discovery_v1_client-0.1.0.dev0/src/ni/measurementlink/discovery/v1/client/_support.py +192 -0
- ni_measurementlink_discovery_v1_client-0.1.0.dev0/src/ni/measurementlink/discovery/v1/client/_types.py +111 -0
- ni_measurementlink_discovery_v1_client-0.1.0.dev0/src/ni/measurementlink/discovery/v1/client/py.typed +0 -0
|
@@ -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
|
+
)
|
|
File without changes
|