msdev 0.9.0__py3-none-any.whl

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,210 @@
1
+ """Node registry and SSH lifecycle application service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from ..resources import Node, parse_variable_assignment
10
+ from ..transport import RpcError
11
+ from .context import ServiceContext
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class NodeAddRequest:
16
+ name: str
17
+ ssh_host: str | None = None
18
+ remote_bin: str = "~/.local/bin/msdevd"
19
+ auto_bootstrap: bool = True
20
+ force: bool = False
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class NodeListResult:
25
+ nodes: tuple[Node, ...]
26
+
27
+ def to_dict(self) -> dict[str, Any]:
28
+ return {"nodes": [asdict(node) for node in self.nodes]}
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class NodeConnectionResult:
33
+ node: str
34
+ ssh_host: str
35
+ state: str | None = None
36
+ persist: str | None = None
37
+ error: str | None = None
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class NodeConnectionBatch:
42
+ action: str
43
+ items: tuple[NodeConnectionResult, ...]
44
+ exit_code: int
45
+
46
+
47
+ class NodeService:
48
+ def __init__(self, context: ServiceContext):
49
+ self.context = context
50
+
51
+ @staticmethod
52
+ def _required(value: str | None, label: str) -> str:
53
+ if not isinstance(value, str) or not value.strip():
54
+ raise ValueError(f"{label} must be a non-empty string")
55
+ return value
56
+
57
+ @staticmethod
58
+ def _boolean(value: Any, label: str) -> bool:
59
+ if type(value) is not bool:
60
+ raise ValueError(f"{label} must be a boolean")
61
+ return value
62
+
63
+ def add(self, request: NodeAddRequest) -> Node:
64
+ name = self._required(request.name, "node name")
65
+ node = Node(
66
+ name=name,
67
+ ssh_host=self._required(request.ssh_host or name, "SSH host"),
68
+ remote_bin=self._required(request.remote_bin, "remote binary"),
69
+ auto_bootstrap=self._boolean(
70
+ request.auto_bootstrap,
71
+ "auto_bootstrap",
72
+ ),
73
+ )
74
+ force = self._boolean(request.force, "force")
75
+ transport = self.context.create_transport(node)
76
+ validate = getattr(transport, "validate", None)
77
+ if validate is None:
78
+ raise TypeError("node transport does not support SSH validation")
79
+ validate()
80
+ self.context.node_store.add(
81
+ node,
82
+ force=force,
83
+ create_host_environment=True,
84
+ )
85
+ return node
86
+
87
+ def list(self) -> NodeListResult:
88
+ return NodeListResult(tuple(self.context.node_store.list()))
89
+
90
+ def update_variables(
91
+ self,
92
+ name: str,
93
+ *,
94
+ set_values: tuple[str, ...] = (),
95
+ unset_keys: tuple[str, ...] = (),
96
+ ) -> Node:
97
+ node_name = self._required(name, "node name")
98
+ updates: dict[str, str] = {}
99
+ for assignment in set_values:
100
+ key, value = parse_variable_assignment(assignment)
101
+ updates[key] = value
102
+ for key in unset_keys:
103
+ parsed_key, _ = parse_variable_assignment(f"{key}=")
104
+ if parsed_key in updates:
105
+ raise ValueError(
106
+ f"environment variable cannot be set and unset: {key}"
107
+ )
108
+ return self.context.node_store.update_variables(
109
+ node_name,
110
+ set_values=updates,
111
+ unset_keys=unset_keys,
112
+ )
113
+
114
+ def remove(self, name: str, *, cascade: bool = False) -> tuple[str, ...]:
115
+ node_name = self._required(name, "node name")
116
+ self._boolean(cascade, "cascade")
117
+ return tuple(self.context.node_store.remove(node_name, cascade=cascade))
118
+
119
+ def status(self, name: str) -> dict[str, Any]:
120
+ node_name = self._required(name, "node name")
121
+ result = self.context.transport_for_node(node_name).call("node.info")
122
+ if not isinstance(result, dict):
123
+ raise RuntimeError("node.info returned a non-object result")
124
+ return result
125
+
126
+ def bootstrap(
127
+ self,
128
+ name: str,
129
+ package_path: Path | None = None,
130
+ ) -> dict[str, Any]:
131
+ node = self.context.node_store.get(self._required(name, "node name"))
132
+ return self.context.bootstrap(node, package_path)
133
+
134
+ def _connection_nodes(
135
+ self,
136
+ name: str | None,
137
+ all_nodes: bool,
138
+ ) -> list[Node]:
139
+ self._boolean(all_nodes, "all_nodes")
140
+ if all_nodes and name:
141
+ raise ValueError("node name and --all cannot be used together")
142
+ if all_nodes:
143
+ nodes = self.context.node_store.list()
144
+ if not nodes:
145
+ raise ValueError("no SSH nodes registered")
146
+ return nodes
147
+ if not name:
148
+ raise ValueError("node name or --all is required")
149
+ return [self.context.node_store.get(self._required(name, "node name"))]
150
+
151
+ def _connection(
152
+ self,
153
+ action: str,
154
+ name: str | None,
155
+ all_nodes: bool,
156
+ ) -> NodeConnectionBatch:
157
+ items: list[NodeConnectionResult] = []
158
+ failed = False
159
+ for node in self._connection_nodes(name, all_nodes):
160
+ operation = getattr(self.context.create_transport(node), action)
161
+ try:
162
+ result = operation()
163
+ if action == "connect":
164
+ state = (
165
+ "already connected"
166
+ if result["already_connected"]
167
+ else "connected"
168
+ )
169
+ persist = str(result["persist"])
170
+ else:
171
+ state = (
172
+ "already disconnected"
173
+ if result["already_disconnected"]
174
+ else "disconnected"
175
+ )
176
+ persist = None
177
+ items.append(
178
+ NodeConnectionResult(
179
+ node.name,
180
+ node.ssh_host,
181
+ state,
182
+ persist,
183
+ )
184
+ )
185
+ except (RpcError, OSError) as exc:
186
+ failed = True
187
+ items.append(
188
+ NodeConnectionResult(
189
+ node.name,
190
+ node.ssh_host,
191
+ error=str(exc),
192
+ )
193
+ )
194
+ return NodeConnectionBatch(action, tuple(items), 1 if failed else 0)
195
+
196
+ def connect(
197
+ self,
198
+ name: str | None = None,
199
+ *,
200
+ all_nodes: bool = False,
201
+ ) -> NodeConnectionBatch:
202
+ return self._connection("connect", name, all_nodes)
203
+
204
+ def disconnect(
205
+ self,
206
+ name: str | None = None,
207
+ *,
208
+ all_nodes: bool = False,
209
+ ) -> NodeConnectionBatch:
210
+ return self._connection("disconnect", name, all_nodes)
@@ -0,0 +1,116 @@
1
+ """NPU discovery application service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from ..transport import RpcError
9
+ from .context import ServiceContext
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class NpuNodeResult:
14
+ node: str
15
+ ssh_host: str | None
16
+ result: dict[str, Any] | None = None
17
+ error: str | None = None
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class NpuListResult:
22
+ items: tuple[NpuNodeResult, ...]
23
+ all_nodes: bool
24
+ exit_code: int
25
+
26
+ def to_dict(self) -> dict[str, Any]:
27
+ if not self.all_nodes:
28
+ item = self.items[0]
29
+ return {
30
+ "node": item.node,
31
+ **(item.result or {}),
32
+ "exit_code": self.exit_code,
33
+ }
34
+ nodes: dict[str, Any] = {}
35
+ for item in self.items:
36
+ value: dict[str, Any] = {"ssh_host": item.ssh_host}
37
+ if item.error is not None:
38
+ value["error"] = item.error
39
+ else:
40
+ value["result"] = item.result
41
+ nodes[item.node] = value
42
+ return {"nodes": nodes, "exit_code": self.exit_code}
43
+
44
+
45
+ class NpuService:
46
+ def __init__(self, context: ServiceContext):
47
+ self.context = context
48
+
49
+ def list(
50
+ self,
51
+ *,
52
+ node: str | None = None,
53
+ all_nodes: bool = False,
54
+ ) -> NpuListResult:
55
+ if type(all_nodes) is not bool:
56
+ raise ValueError("all_nodes must be a boolean")
57
+ if node is not None and not isinstance(node, str):
58
+ raise ValueError("node must be a non-empty string")
59
+ node_name = node if isinstance(node, str) and node.strip() else ""
60
+ if all_nodes and node_name:
61
+ raise ValueError("node and all_nodes cannot be used together")
62
+ if all_nodes:
63
+ configured = self.context.node_store.list()
64
+ if not configured:
65
+ raise ValueError("no SSH nodes registered")
66
+ items: list[NpuNodeResult] = []
67
+ failed = False
68
+ for configured_node in configured:
69
+ try:
70
+ result = self.context.create_transport(
71
+ configured_node
72
+ ).call("npu.list")
73
+ if not isinstance(result, dict):
74
+ raise RuntimeError(
75
+ "npu.list returned a non-object result"
76
+ )
77
+ items.append(
78
+ NpuNodeResult(
79
+ node=configured_node.name,
80
+ ssh_host=configured_node.ssh_host,
81
+ result=result,
82
+ )
83
+ )
84
+ if not result.get("available"):
85
+ failed = True
86
+ except (RpcError, OSError) as exc:
87
+ failed = True
88
+ items.append(
89
+ NpuNodeResult(
90
+ node=configured_node.name,
91
+ ssh_host=configured_node.ssh_host,
92
+ error=str(exc),
93
+ )
94
+ )
95
+ return NpuListResult(
96
+ items=tuple(items),
97
+ all_nodes=True,
98
+ exit_code=1 if failed else 0,
99
+ )
100
+
101
+ if not node_name:
102
+ raise ValueError("node is required unless all_nodes is true")
103
+ result = self.context.transport_for_node(node_name).call("npu.list")
104
+ if not isinstance(result, dict):
105
+ raise RuntimeError("npu.list returned a non-object result")
106
+ return NpuListResult(
107
+ items=(
108
+ NpuNodeResult(
109
+ node=node_name,
110
+ ssh_host=None,
111
+ result=result,
112
+ ),
113
+ ),
114
+ all_nodes=False,
115
+ exit_code=0 if result.get("available") else 1,
116
+ )