@uns-kit/cli 0.0.36 → 0.0.37
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.
- package/LICENSE +21 -21
- package/README.md +128 -128
- package/dist/index.js +11 -5
- package/package.json +8 -2
- package/templates/api/src/examples/api-example.ts +62 -62
- package/templates/azure-pipelines.yml +21 -21
- package/templates/codegen/codegen.ts +15 -15
- package/templates/codegen/src/uns/uns-tags.ts +1 -1
- package/templates/codegen/src/uns/uns-topics.ts +1 -1
- package/templates/config-files/config-docker.json +26 -26
- package/templates/config-files/config-localhost.json +26 -26
- package/templates/cron/src/examples/cron-example.ts +46 -46
- package/templates/default/README.md +32 -30
- package/templates/default/config.json +27 -27
- package/templates/default/gitignore +51 -51
- package/templates/default/package.json +38 -19
- package/templates/default/src/config/project.config.extension.example +23 -23
- package/templates/default/src/config/project.config.extension.ts +6 -6
- package/templates/default/src/examples/data-example.ts +68 -68
- package/templates/default/src/examples/load-test-data.ts +108 -108
- package/templates/default/src/examples/table-example.ts +66 -66
- package/templates/default/src/examples/uns-gateway-cli.ts +7 -7
- package/templates/default/src/index.ts +15 -15
- package/templates/default/src/uns/uns-tags.ts +2 -2
- package/templates/default/src/uns/uns-topics.ts +2 -2
- package/templates/default/tsconfig.json +29 -16
- package/templates/python/app/README.md +8 -8
- package/templates/python/examples/README.md +134 -134
- package/templates/python/examples/api_handler.py +28 -28
- package/templates/python/examples/data_publish.py +11 -11
- package/templates/python/examples/data_subscribe.py +8 -8
- package/templates/python/examples/data_transformer.py +17 -17
- package/templates/python/examples/table_transformer.py +15 -15
- package/templates/python/gateway/cli.py +75 -75
- package/templates/python/gateway/client.py +155 -155
- package/templates/python/gateway/manager.py +97 -97
- package/templates/python/proto/uns-gateway.proto +102 -102
- package/templates/python/pyproject.toml +4 -4
- package/templates/python/scripts/setup.sh +87 -87
- package/templates/temporal/src/examples/temporal-example.ts +35 -35
- package/templates/vscode/.vscode/launch.json +164 -164
- package/templates/vscode/.vscode/settings.json +9 -9
- package/templates/vscode/uns-kit.code-workspace +13 -13
- package/templates/python/gen/__init__.py +0 -1
- package/templates/python/gen/uns_gateway_pb2.py +0 -70
- package/templates/python/gen/uns_gateway_pb2_grpc.py +0 -312
|
@@ -1,155 +1,155 @@
|
|
|
1
|
-
from typing import Iterable, Dict, Callable, List
|
|
2
|
-
import queue
|
|
3
|
-
import json
|
|
4
|
-
import threading
|
|
5
|
-
from datetime import datetime, timezone
|
|
6
|
-
import grpc
|
|
7
|
-
from .manager import GatewayManager
|
|
8
|
-
|
|
9
|
-
from gen import uns_gateway_pb2 as pb2
|
|
10
|
-
from gen import uns_gateway_pb2_grpc as gw
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def iso_now() -> str:
|
|
14
|
-
"""Return current UTC time in ISO format with milliseconds."""
|
|
15
|
-
dt = datetime.now(timezone.utc)
|
|
16
|
-
return dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def make_channel(addr: str) -> grpc.Channel:
|
|
20
|
-
"""Create a gRPC insecure channel."""
|
|
21
|
-
return grpc.insecure_channel(addr)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
class Client:
|
|
25
|
-
"""
|
|
26
|
-
High-level client for UNS gateway.
|
|
27
|
-
Automatically manages gateway lifecycle and keeps a persistent stub.
|
|
28
|
-
"""
|
|
29
|
-
|
|
30
|
-
def __init__(self, addr: str | None = None, auto: bool = True, timeout_s: int = 20):
|
|
31
|
-
self.manager = GatewayManager(addr=addr, auto=auto, timeout_s=timeout_s)
|
|
32
|
-
self.addr = self.manager.ensure_running()
|
|
33
|
-
self._ch = make_channel(self.addr)
|
|
34
|
-
self._stub = gw.UnsGatewayStub(self._ch)
|
|
35
|
-
|
|
36
|
-
# --- Publish ---
|
|
37
|
-
def publish_data(self, topic: str, attribute: str, value: float,
|
|
38
|
-
time_iso: str | None = None, uom: str = "",
|
|
39
|
-
data_group: str = "", cumulative: bool = False):
|
|
40
|
-
time_iso = time_iso or iso_now()
|
|
41
|
-
req = pb2.PublishRequest(
|
|
42
|
-
topic=topic,
|
|
43
|
-
attribute=attribute,
|
|
44
|
-
data=pb2.Data(
|
|
45
|
-
time=time_iso,
|
|
46
|
-
value_number=value,
|
|
47
|
-
uom=uom,
|
|
48
|
-
data_group=data_group
|
|
49
|
-
),
|
|
50
|
-
value_is_cumulative=cumulative
|
|
51
|
-
)
|
|
52
|
-
res = self._stub.Publish(req)
|
|
53
|
-
if not res.ok:
|
|
54
|
-
raise RuntimeError(res.error)
|
|
55
|
-
|
|
56
|
-
def publish_table(self, topic: str, attribute: str, obj: Dict[str, any],
|
|
57
|
-
time_iso: str | None = None, data_group: str = "default"):
|
|
58
|
-
"""
|
|
59
|
-
Publish a dictionary as a Table.
|
|
60
|
-
Numeric values -> value_number
|
|
61
|
-
None -> empty
|
|
62
|
-
Other -> value_string
|
|
63
|
-
"""
|
|
64
|
-
time_iso = time_iso or iso_now()
|
|
65
|
-
tv_list = []
|
|
66
|
-
for k, v in obj.items():
|
|
67
|
-
if isinstance(v, (int, float)):
|
|
68
|
-
tv_list.append(pb2.TableValue(key=k, value_number=float(v)))
|
|
69
|
-
elif v is None:
|
|
70
|
-
tv_list.append(pb2.TableValue(key=k))
|
|
71
|
-
else:
|
|
72
|
-
tv_list.append(pb2.TableValue(key=k, value_string=str(v)))
|
|
73
|
-
|
|
74
|
-
req = pb2.PublishRequest(
|
|
75
|
-
topic=topic,
|
|
76
|
-
attribute=attribute,
|
|
77
|
-
table=pb2.Table(time=time_iso, values=tv_list, data_group=data_group)
|
|
78
|
-
)
|
|
79
|
-
res = self._stub.Publish(req)
|
|
80
|
-
if not res.ok:
|
|
81
|
-
raise RuntimeError(res.error)
|
|
82
|
-
# --- Subscribe ---
|
|
83
|
-
def subscribe(self, topics: Iterable[str], callback: Callable[[any], None] | None = None):
|
|
84
|
-
"""
|
|
85
|
-
Subscribe to topics and call the provided callback on each message.
|
|
86
|
-
"""
|
|
87
|
-
stub = self._stub
|
|
88
|
-
try:
|
|
89
|
-
stub.Ready(pb2.ReadyRequest(timeout_ms=15000, wait_input=True))
|
|
90
|
-
except Exception:
|
|
91
|
-
pass
|
|
92
|
-
|
|
93
|
-
stream = stub.Subscribe(pb2.SubscribeRequest(topics=list(topics)))
|
|
94
|
-
try:
|
|
95
|
-
for msg in stream:
|
|
96
|
-
if callback:
|
|
97
|
-
callback(msg)
|
|
98
|
-
else:
|
|
99
|
-
print(f"{msg.topic}: {msg.payload}")
|
|
100
|
-
except KeyboardInterrupt:
|
|
101
|
-
pass
|
|
102
|
-
|
|
103
|
-
# --- API Register ---
|
|
104
|
-
def register_api(self, topic: str, attribute: str, desc: str = "",
|
|
105
|
-
tags: list[str] | None = None,
|
|
106
|
-
query_params: List[pb2.ApiQueryParam] | None = None):
|
|
107
|
-
tags = tags or []
|
|
108
|
-
query_params = query_params or []
|
|
109
|
-
|
|
110
|
-
res = self._stub.RegisterApiGet(
|
|
111
|
-
pb2.RegisterApiGetRequest(
|
|
112
|
-
topic=topic,
|
|
113
|
-
attribute=attribute,
|
|
114
|
-
api_description=desc,
|
|
115
|
-
tags=tags,
|
|
116
|
-
query_params=query_params
|
|
117
|
-
)
|
|
118
|
-
)
|
|
119
|
-
if not res.ok:
|
|
120
|
-
raise RuntimeError(f"Register failed: {res.error}")
|
|
121
|
-
|
|
122
|
-
def unregister_api(self, topic: str, attribute: str):
|
|
123
|
-
res = self._stub.UnregisterApiGet(
|
|
124
|
-
pb2.UnregisterApiGetRequest(topic=topic, attribute=attribute)
|
|
125
|
-
)
|
|
126
|
-
if not res.ok:
|
|
127
|
-
raise RuntimeError(f"Unregister failed: {res.error}")
|
|
128
|
-
|
|
129
|
-
# --- API Stream ---
|
|
130
|
-
def api_stream(self, echo: bool = False, handle_event: Callable | None = None):
|
|
131
|
-
q: "queue.Queue[pb2.ApiEventResponse|None]" = queue.Queue()
|
|
132
|
-
|
|
133
|
-
def req_iter():
|
|
134
|
-
while True:
|
|
135
|
-
item = q.get()
|
|
136
|
-
if item is None:
|
|
137
|
-
break
|
|
138
|
-
yield item
|
|
139
|
-
|
|
140
|
-
stream = self._stub.ApiEventStream(req_iter())
|
|
141
|
-
try:
|
|
142
|
-
for ev in stream:
|
|
143
|
-
if handle_event:
|
|
144
|
-
resp = handle_event(ev)
|
|
145
|
-
if resp:
|
|
146
|
-
q.put(resp)
|
|
147
|
-
continue
|
|
148
|
-
if echo:
|
|
149
|
-
q.put(pb2.ApiEventResponse(id=ev.id, status=200, body="OK"))
|
|
150
|
-
continue
|
|
151
|
-
body = {"status": "OK", "endpoint": ev.path, "query": dict(ev.query)}
|
|
152
|
-
q.put(pb2.ApiEventResponse(id=ev.id, status=200, headers={"Content-Type": "application/json"},
|
|
153
|
-
body=json.dumps(body)))
|
|
154
|
-
except KeyboardInterrupt:
|
|
155
|
-
q.put(None)
|
|
1
|
+
from typing import Iterable, Dict, Callable, List
|
|
2
|
+
import queue
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
import grpc
|
|
7
|
+
from .manager import GatewayManager
|
|
8
|
+
|
|
9
|
+
from gen import uns_gateway_pb2 as pb2
|
|
10
|
+
from gen import uns_gateway_pb2_grpc as gw
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def iso_now() -> str:
|
|
14
|
+
"""Return current UTC time in ISO format with milliseconds."""
|
|
15
|
+
dt = datetime.now(timezone.utc)
|
|
16
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make_channel(addr: str) -> grpc.Channel:
|
|
20
|
+
"""Create a gRPC insecure channel."""
|
|
21
|
+
return grpc.insecure_channel(addr)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Client:
|
|
25
|
+
"""
|
|
26
|
+
High-level client for UNS gateway.
|
|
27
|
+
Automatically manages gateway lifecycle and keeps a persistent stub.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, addr: str | None = None, auto: bool = True, timeout_s: int = 20):
|
|
31
|
+
self.manager = GatewayManager(addr=addr, auto=auto, timeout_s=timeout_s)
|
|
32
|
+
self.addr = self.manager.ensure_running()
|
|
33
|
+
self._ch = make_channel(self.addr)
|
|
34
|
+
self._stub = gw.UnsGatewayStub(self._ch)
|
|
35
|
+
|
|
36
|
+
# --- Publish ---
|
|
37
|
+
def publish_data(self, topic: str, attribute: str, value: float,
|
|
38
|
+
time_iso: str | None = None, uom: str = "",
|
|
39
|
+
data_group: str = "", cumulative: bool = False):
|
|
40
|
+
time_iso = time_iso or iso_now()
|
|
41
|
+
req = pb2.PublishRequest(
|
|
42
|
+
topic=topic,
|
|
43
|
+
attribute=attribute,
|
|
44
|
+
data=pb2.Data(
|
|
45
|
+
time=time_iso,
|
|
46
|
+
value_number=value,
|
|
47
|
+
uom=uom,
|
|
48
|
+
data_group=data_group
|
|
49
|
+
),
|
|
50
|
+
value_is_cumulative=cumulative
|
|
51
|
+
)
|
|
52
|
+
res = self._stub.Publish(req)
|
|
53
|
+
if not res.ok:
|
|
54
|
+
raise RuntimeError(res.error)
|
|
55
|
+
|
|
56
|
+
def publish_table(self, topic: str, attribute: str, obj: Dict[str, any],
|
|
57
|
+
time_iso: str | None = None, data_group: str = "default"):
|
|
58
|
+
"""
|
|
59
|
+
Publish a dictionary as a Table.
|
|
60
|
+
Numeric values -> value_number
|
|
61
|
+
None -> empty
|
|
62
|
+
Other -> value_string
|
|
63
|
+
"""
|
|
64
|
+
time_iso = time_iso or iso_now()
|
|
65
|
+
tv_list = []
|
|
66
|
+
for k, v in obj.items():
|
|
67
|
+
if isinstance(v, (int, float)):
|
|
68
|
+
tv_list.append(pb2.TableValue(key=k, value_number=float(v)))
|
|
69
|
+
elif v is None:
|
|
70
|
+
tv_list.append(pb2.TableValue(key=k))
|
|
71
|
+
else:
|
|
72
|
+
tv_list.append(pb2.TableValue(key=k, value_string=str(v)))
|
|
73
|
+
|
|
74
|
+
req = pb2.PublishRequest(
|
|
75
|
+
topic=topic,
|
|
76
|
+
attribute=attribute,
|
|
77
|
+
table=pb2.Table(time=time_iso, values=tv_list, data_group=data_group)
|
|
78
|
+
)
|
|
79
|
+
res = self._stub.Publish(req)
|
|
80
|
+
if not res.ok:
|
|
81
|
+
raise RuntimeError(res.error)
|
|
82
|
+
# --- Subscribe ---
|
|
83
|
+
def subscribe(self, topics: Iterable[str], callback: Callable[[any], None] | None = None):
|
|
84
|
+
"""
|
|
85
|
+
Subscribe to topics and call the provided callback on each message.
|
|
86
|
+
"""
|
|
87
|
+
stub = self._stub
|
|
88
|
+
try:
|
|
89
|
+
stub.Ready(pb2.ReadyRequest(timeout_ms=15000, wait_input=True))
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
stream = stub.Subscribe(pb2.SubscribeRequest(topics=list(topics)))
|
|
94
|
+
try:
|
|
95
|
+
for msg in stream:
|
|
96
|
+
if callback:
|
|
97
|
+
callback(msg)
|
|
98
|
+
else:
|
|
99
|
+
print(f"{msg.topic}: {msg.payload}")
|
|
100
|
+
except KeyboardInterrupt:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
# --- API Register ---
|
|
104
|
+
def register_api(self, topic: str, attribute: str, desc: str = "",
|
|
105
|
+
tags: list[str] | None = None,
|
|
106
|
+
query_params: List[pb2.ApiQueryParam] | None = None):
|
|
107
|
+
tags = tags or []
|
|
108
|
+
query_params = query_params or []
|
|
109
|
+
|
|
110
|
+
res = self._stub.RegisterApiGet(
|
|
111
|
+
pb2.RegisterApiGetRequest(
|
|
112
|
+
topic=topic,
|
|
113
|
+
attribute=attribute,
|
|
114
|
+
api_description=desc,
|
|
115
|
+
tags=tags,
|
|
116
|
+
query_params=query_params
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
if not res.ok:
|
|
120
|
+
raise RuntimeError(f"Register failed: {res.error}")
|
|
121
|
+
|
|
122
|
+
def unregister_api(self, topic: str, attribute: str):
|
|
123
|
+
res = self._stub.UnregisterApiGet(
|
|
124
|
+
pb2.UnregisterApiGetRequest(topic=topic, attribute=attribute)
|
|
125
|
+
)
|
|
126
|
+
if not res.ok:
|
|
127
|
+
raise RuntimeError(f"Unregister failed: {res.error}")
|
|
128
|
+
|
|
129
|
+
# --- API Stream ---
|
|
130
|
+
def api_stream(self, echo: bool = False, handle_event: Callable | None = None):
|
|
131
|
+
q: "queue.Queue[pb2.ApiEventResponse|None]" = queue.Queue()
|
|
132
|
+
|
|
133
|
+
def req_iter():
|
|
134
|
+
while True:
|
|
135
|
+
item = q.get()
|
|
136
|
+
if item is None:
|
|
137
|
+
break
|
|
138
|
+
yield item
|
|
139
|
+
|
|
140
|
+
stream = self._stub.ApiEventStream(req_iter())
|
|
141
|
+
try:
|
|
142
|
+
for ev in stream:
|
|
143
|
+
if handle_event:
|
|
144
|
+
resp = handle_event(ev)
|
|
145
|
+
if resp:
|
|
146
|
+
q.put(resp)
|
|
147
|
+
continue
|
|
148
|
+
if echo:
|
|
149
|
+
q.put(pb2.ApiEventResponse(id=ev.id, status=200, body="OK"))
|
|
150
|
+
continue
|
|
151
|
+
body = {"status": "OK", "endpoint": ev.path, "query": dict(ev.query)}
|
|
152
|
+
q.put(pb2.ApiEventResponse(id=ev.id, status=200, headers={"Content-Type": "application/json"},
|
|
153
|
+
body=json.dumps(body)))
|
|
154
|
+
except KeyboardInterrupt:
|
|
155
|
+
q.put(None)
|
|
@@ -1,97 +1,97 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import sys
|
|
3
|
-
import time
|
|
4
|
-
import socket
|
|
5
|
-
import subprocess
|
|
6
|
-
import atexit
|
|
7
|
-
import signal
|
|
8
|
-
import grpc
|
|
9
|
-
|
|
10
|
-
class GatewayManager:
|
|
11
|
-
"""
|
|
12
|
-
Ensures a Node.js UNS gateway is running.
|
|
13
|
-
Can start it automatically if needed.
|
|
14
|
-
"""
|
|
15
|
-
def __init__(self, addr: str | None = None, auto: bool = True, timeout_s: int = 20):
|
|
16
|
-
self.addr = addr
|
|
17
|
-
self.auto = auto
|
|
18
|
-
self.timeout_s = timeout_s
|
|
19
|
-
self._proc: subprocess.Popen | None = None
|
|
20
|
-
|
|
21
|
-
def _default_addr(self) -> str:
|
|
22
|
-
if os.name == "nt":
|
|
23
|
-
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
24
|
-
s.bind(("127.0.0.1", 0))
|
|
25
|
-
port = s.getsockname()[1]
|
|
26
|
-
return f"127.0.0.1:{port}"
|
|
27
|
-
else:
|
|
28
|
-
script = os.path.basename(sys.argv[0]).replace(".py", "")
|
|
29
|
-
return f"unix:/tmp/uns-gateway-{script}-{os.getpid()}.sock"
|
|
30
|
-
|
|
31
|
-
def _cleanup(self):
|
|
32
|
-
if not self._proc:
|
|
33
|
-
return
|
|
34
|
-
try:
|
|
35
|
-
if self._proc.poll() is None:
|
|
36
|
-
if os.name == "nt":
|
|
37
|
-
self._proc.terminate()
|
|
38
|
-
else:
|
|
39
|
-
os.killpg(os.getpgid(self._proc.pid), signal.SIGTERM)
|
|
40
|
-
try:
|
|
41
|
-
self._proc.wait(timeout=3)
|
|
42
|
-
except Exception:
|
|
43
|
-
if os.name == "nt":
|
|
44
|
-
self._proc.kill()
|
|
45
|
-
else:
|
|
46
|
-
os.killpg(os.getpgid(self._proc.pid), signal.SIGKILL)
|
|
47
|
-
except Exception:
|
|
48
|
-
pass
|
|
49
|
-
self._proc = None
|
|
50
|
-
|
|
51
|
-
def ensure_running(self) -> str:
|
|
52
|
-
addr = self.addr or self._default_addr()
|
|
53
|
-
ch = grpc.insecure_channel(addr)
|
|
54
|
-
try:
|
|
55
|
-
grpc.channel_ready_future(ch).result(timeout=2)
|
|
56
|
-
ch.close()
|
|
57
|
-
return addr
|
|
58
|
-
except Exception:
|
|
59
|
-
ch.close()
|
|
60
|
-
if not self.auto:
|
|
61
|
-
return addr
|
|
62
|
-
|
|
63
|
-
# Spawn Node.js gateway
|
|
64
|
-
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
65
|
-
cli_path = os.path.join(repo_root, "node_modules", "@uns-kit", "core", "dist", "uns-grpc", "uns-gateway-cli")
|
|
66
|
-
popen_kwargs = {}
|
|
67
|
-
creationflags = 0
|
|
68
|
-
if os.name != "nt":
|
|
69
|
-
popen_kwargs["preexec_fn"] = os.setsid
|
|
70
|
-
else:
|
|
71
|
-
creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
72
|
-
|
|
73
|
-
suffix = f"py-{os.path.basename(sys.argv[0]).replace('.py','')}-{os.getpid()}"
|
|
74
|
-
self._proc = subprocess.Popen(
|
|
75
|
-
["node", cli_path, "--addr", addr, "--instanceSuffix", suffix, "--instanceMode", "force"],
|
|
76
|
-
cwd=repo_root,
|
|
77
|
-
creationflags=creationflags,
|
|
78
|
-
**popen_kwargs,
|
|
79
|
-
)
|
|
80
|
-
atexit.register(self._cleanup)
|
|
81
|
-
|
|
82
|
-
# Wait for channel ready
|
|
83
|
-
start = time.time()
|
|
84
|
-
while time.time() - start < self.timeout_s:
|
|
85
|
-
ch2 = grpc.insecure_channel(addr)
|
|
86
|
-
try:
|
|
87
|
-
grpc.channel_ready_future(ch2).result(timeout=2)
|
|
88
|
-
ch2.close()
|
|
89
|
-
wait_s = int(os.environ.get("UNS_GATEWAY_HANDOVER_WAIT", "11"))
|
|
90
|
-
if wait_s > 0:
|
|
91
|
-
time.sleep(wait_s)
|
|
92
|
-
return addr
|
|
93
|
-
except Exception:
|
|
94
|
-
ch2.close()
|
|
95
|
-
time.sleep(0.5)
|
|
96
|
-
|
|
97
|
-
raise RuntimeError("Gateway did not become ready in time")
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
import socket
|
|
5
|
+
import subprocess
|
|
6
|
+
import atexit
|
|
7
|
+
import signal
|
|
8
|
+
import grpc
|
|
9
|
+
|
|
10
|
+
class GatewayManager:
|
|
11
|
+
"""
|
|
12
|
+
Ensures a Node.js UNS gateway is running.
|
|
13
|
+
Can start it automatically if needed.
|
|
14
|
+
"""
|
|
15
|
+
def __init__(self, addr: str | None = None, auto: bool = True, timeout_s: int = 20):
|
|
16
|
+
self.addr = addr
|
|
17
|
+
self.auto = auto
|
|
18
|
+
self.timeout_s = timeout_s
|
|
19
|
+
self._proc: subprocess.Popen | None = None
|
|
20
|
+
|
|
21
|
+
def _default_addr(self) -> str:
|
|
22
|
+
if os.name == "nt":
|
|
23
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
24
|
+
s.bind(("127.0.0.1", 0))
|
|
25
|
+
port = s.getsockname()[1]
|
|
26
|
+
return f"127.0.0.1:{port}"
|
|
27
|
+
else:
|
|
28
|
+
script = os.path.basename(sys.argv[0]).replace(".py", "")
|
|
29
|
+
return f"unix:/tmp/uns-gateway-{script}-{os.getpid()}.sock"
|
|
30
|
+
|
|
31
|
+
def _cleanup(self):
|
|
32
|
+
if not self._proc:
|
|
33
|
+
return
|
|
34
|
+
try:
|
|
35
|
+
if self._proc.poll() is None:
|
|
36
|
+
if os.name == "nt":
|
|
37
|
+
self._proc.terminate()
|
|
38
|
+
else:
|
|
39
|
+
os.killpg(os.getpgid(self._proc.pid), signal.SIGTERM)
|
|
40
|
+
try:
|
|
41
|
+
self._proc.wait(timeout=3)
|
|
42
|
+
except Exception:
|
|
43
|
+
if os.name == "nt":
|
|
44
|
+
self._proc.kill()
|
|
45
|
+
else:
|
|
46
|
+
os.killpg(os.getpgid(self._proc.pid), signal.SIGKILL)
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
self._proc = None
|
|
50
|
+
|
|
51
|
+
def ensure_running(self) -> str:
|
|
52
|
+
addr = self.addr or self._default_addr()
|
|
53
|
+
ch = grpc.insecure_channel(addr)
|
|
54
|
+
try:
|
|
55
|
+
grpc.channel_ready_future(ch).result(timeout=2)
|
|
56
|
+
ch.close()
|
|
57
|
+
return addr
|
|
58
|
+
except Exception:
|
|
59
|
+
ch.close()
|
|
60
|
+
if not self.auto:
|
|
61
|
+
return addr
|
|
62
|
+
|
|
63
|
+
# Spawn Node.js gateway
|
|
64
|
+
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
65
|
+
cli_path = os.path.join(repo_root, "node_modules", "@uns-kit", "core", "dist", "uns-grpc", "uns-gateway-cli")
|
|
66
|
+
popen_kwargs = {}
|
|
67
|
+
creationflags = 0
|
|
68
|
+
if os.name != "nt":
|
|
69
|
+
popen_kwargs["preexec_fn"] = os.setsid
|
|
70
|
+
else:
|
|
71
|
+
creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
72
|
+
|
|
73
|
+
suffix = f"py-{os.path.basename(sys.argv[0]).replace('.py','')}-{os.getpid()}"
|
|
74
|
+
self._proc = subprocess.Popen(
|
|
75
|
+
["node", cli_path, "--addr", addr, "--instanceSuffix", suffix, "--instanceMode", "force"],
|
|
76
|
+
cwd=repo_root,
|
|
77
|
+
creationflags=creationflags,
|
|
78
|
+
**popen_kwargs,
|
|
79
|
+
)
|
|
80
|
+
atexit.register(self._cleanup)
|
|
81
|
+
|
|
82
|
+
# Wait for channel ready
|
|
83
|
+
start = time.time()
|
|
84
|
+
while time.time() - start < self.timeout_s:
|
|
85
|
+
ch2 = grpc.insecure_channel(addr)
|
|
86
|
+
try:
|
|
87
|
+
grpc.channel_ready_future(ch2).result(timeout=2)
|
|
88
|
+
ch2.close()
|
|
89
|
+
wait_s = int(os.environ.get("UNS_GATEWAY_HANDOVER_WAIT", "11"))
|
|
90
|
+
if wait_s > 0:
|
|
91
|
+
time.sleep(wait_s)
|
|
92
|
+
return addr
|
|
93
|
+
except Exception:
|
|
94
|
+
ch2.close()
|
|
95
|
+
time.sleep(0.5)
|
|
96
|
+
|
|
97
|
+
raise RuntimeError("Gateway did not become ready in time")
|