hypershell 2.6.4__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.
- hypershell/__init__.py +115 -0
- hypershell/client.py +1255 -0
- hypershell/cluster/__init__.py +378 -0
- hypershell/cluster/local.py +210 -0
- hypershell/cluster/remote.py +721 -0
- hypershell/cluster/ssh.py +352 -0
- hypershell/config.py +440 -0
- hypershell/core/__init__.py +4 -0
- hypershell/core/config.py +359 -0
- hypershell/core/exceptions.py +120 -0
- hypershell/core/fsm.py +82 -0
- hypershell/core/heartbeat.py +70 -0
- hypershell/core/logging.py +201 -0
- hypershell/core/platform.py +121 -0
- hypershell/core/queue.py +155 -0
- hypershell/core/remote.py +192 -0
- hypershell/core/signal.py +79 -0
- hypershell/core/sys.py +39 -0
- hypershell/core/tag.py +47 -0
- hypershell/core/template.py +201 -0
- hypershell/core/thread.py +56 -0
- hypershell/core/types.py +32 -0
- hypershell/data/__init__.py +137 -0
- hypershell/data/core.py +232 -0
- hypershell/data/model.py +674 -0
- hypershell/server.py +1207 -0
- hypershell/submit.py +815 -0
- hypershell/task.py +1141 -0
- hypershell-2.6.4.dist-info/LICENSE +201 -0
- hypershell-2.6.4.dist-info/METADATA +117 -0
- hypershell-2.6.4.dist-info/RECORD +33 -0
- hypershell-2.6.4.dist-info/WHEEL +4 -0
- hypershell-2.6.4.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025 Geoffrey Lentner
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""SSH-based cluster implementation."""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# type annotations
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from typing import Type, List, Iterable, Tuple, IO, Final
|
|
10
|
+
|
|
11
|
+
# standard libs
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
import shlex
|
|
16
|
+
import secrets
|
|
17
|
+
from subprocess import Popen
|
|
18
|
+
|
|
19
|
+
# external libs
|
|
20
|
+
from cmdkit.config import ConfigurationError, Namespace
|
|
21
|
+
|
|
22
|
+
# internal libs
|
|
23
|
+
from hypershell.core.config import config, blame
|
|
24
|
+
from hypershell.core.thread import Thread
|
|
25
|
+
from hypershell.core.logging import Logger, HOSTNAME
|
|
26
|
+
from hypershell.core.template import DEFAULT_TEMPLATE
|
|
27
|
+
from hypershell.client import DEFAULT_DELAY, DEFAULT_SIGNALWAIT
|
|
28
|
+
from hypershell.submit import DEFAULT_BUNDLEWAIT
|
|
29
|
+
from hypershell.server import ServerThread, DEFAULT_PORT, DEFAULT_BUNDLESIZE, DEFAULT_ATTEMPTS
|
|
30
|
+
|
|
31
|
+
# public interface
|
|
32
|
+
__all__ = ['run_ssh', 'SSHCluster', 'NodeList', 'DEFAULT_REMOTE_EXE']
|
|
33
|
+
|
|
34
|
+
# initialize logger
|
|
35
|
+
log = Logger.with_name('hypershell.cluster')
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# NOTE: retain old name for remote executable (for now)
|
|
39
|
+
DEFAULT_REMOTE_EXE: Final[str] = 'hyper-shell'
|
|
40
|
+
"""Default remote executable name."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def run_ssh(**options) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Run cluster with remote clients via SSH until completion.
|
|
46
|
+
|
|
47
|
+
All function arguments are forwarded directly into the
|
|
48
|
+
:class:`~hypershell.cluster.ssh.SSHCluster` thread.
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
>>> from hypershell.cluster import run_ssh
|
|
52
|
+
>>> run_ssh(
|
|
53
|
+
... nodelist=['a00.cluster', 'a01.cluster', 'a02.cluster'],
|
|
54
|
+
... restart_mode=True, max_retries=2, eager=True,
|
|
55
|
+
... client_timeout=600, task_timeout=300, capture=True
|
|
56
|
+
... )
|
|
57
|
+
|
|
58
|
+
See Also:
|
|
59
|
+
- :class:`~hypershell.cluster.ssh.SSHCluster`
|
|
60
|
+
"""
|
|
61
|
+
thread = SSHCluster.new(**options)
|
|
62
|
+
try:
|
|
63
|
+
thread.join()
|
|
64
|
+
except Exception:
|
|
65
|
+
thread.stop()
|
|
66
|
+
raise
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SSHCluster(Thread):
|
|
70
|
+
"""
|
|
71
|
+
Run server with individual external ssh clients.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
source (Iterable[str], optional):
|
|
75
|
+
Any iterable of command-line tasks.
|
|
76
|
+
A new `source` results in a :class:`~hypershell.submit.SubmitThread` populating
|
|
77
|
+
either the database or the queue directly depending on `in_memory`.
|
|
78
|
+
|
|
79
|
+
nodelist (List[str], required):
|
|
80
|
+
List of hostnames for launching clients.
|
|
81
|
+
See also: :class:`~hypershell.cluster.ssh.NodeList`.
|
|
82
|
+
|
|
83
|
+
num_tasks (int, optional):
|
|
84
|
+
Number of parallel task executor threads.
|
|
85
|
+
See :const:`~hypershell.client.DEFAULT_NUM_TASKS`.
|
|
86
|
+
|
|
87
|
+
template (str, optional):
|
|
88
|
+
Template command pattern.
|
|
89
|
+
See :const:`~hypershell.client.DEFAULT_TEMPLATE`.
|
|
90
|
+
|
|
91
|
+
bundlesize (int optional):
|
|
92
|
+
Size of task bundles returned to server.
|
|
93
|
+
See :const:`~hypershell.server.DEFAULT_BUNDLESIZE`.
|
|
94
|
+
|
|
95
|
+
bundlewait (int optional):
|
|
96
|
+
Waiting period in seconds before forcing return of task bundle to server.
|
|
97
|
+
See :const:`~hypershell.submit.DEFAULT_BUNDLEWAIT`.
|
|
98
|
+
|
|
99
|
+
bind (tuple, optional):
|
|
100
|
+
Bind address for server with port number (default: 0.0.0.0).
|
|
101
|
+
See :const:`~hypershell.server.DEFAULT_PORT`.
|
|
102
|
+
|
|
103
|
+
launcher (str, optional):
|
|
104
|
+
Launcher program used to bring up clients on other hosts.
|
|
105
|
+
Defaults to 'ssh'. Use `launcher_args` to provide command options.
|
|
106
|
+
|
|
107
|
+
launcher_args (List[str], optional):
|
|
108
|
+
Additional command-line arguments for launcher program.
|
|
109
|
+
|
|
110
|
+
remote_exe (str, optional):
|
|
111
|
+
Program name or path for remote executable.
|
|
112
|
+
See :const:`~hypershell.cluster.ssh.DEFAULT_REMOTE_EXE`.
|
|
113
|
+
|
|
114
|
+
export_env (bool, optional):
|
|
115
|
+
If enabled, embed local configuration as environment variables
|
|
116
|
+
and forward with client launch command to remote hosts.
|
|
117
|
+
|
|
118
|
+
in_memory (bool, optional):
|
|
119
|
+
If True, revert to basic in-memory queue.
|
|
120
|
+
|
|
121
|
+
no_confirm (bool, optional):
|
|
122
|
+
Disable client confirmation of tasks received.
|
|
123
|
+
|
|
124
|
+
forever_mode (bool, optional):
|
|
125
|
+
Regardless of `source`, if enabled schedule forever.
|
|
126
|
+
Conflicts with `restart_mode` and `in_memory`. Default is `False`.
|
|
127
|
+
|
|
128
|
+
restart_mode (bool, optional):
|
|
129
|
+
If `source` is empty, this option allows for the server to continue
|
|
130
|
+
with scheduling from the database until complete.
|
|
131
|
+
Conflicts with `in_memory`. Default is `False`.
|
|
132
|
+
|
|
133
|
+
max_retries (int, optional):
|
|
134
|
+
Number of allowed task retries.
|
|
135
|
+
See :const:`~hypershell.server.DEFAULT_ATTEMPTS`.
|
|
136
|
+
|
|
137
|
+
eager (bool, optional):
|
|
138
|
+
When enabled tasks are retried immediately ahead scheduling new tasks.
|
|
139
|
+
See :const:`~hypershell.server.DEFAULT_EAGER_MODE`.
|
|
140
|
+
|
|
141
|
+
redirect_failures (IO, optional):
|
|
142
|
+
Open file-like object to write failed tasks.
|
|
143
|
+
|
|
144
|
+
delay_start (float, optional):
|
|
145
|
+
Delay in seconds before connecting to server.
|
|
146
|
+
See :const:`~hypershell.client.DEFAULT_DELAY`.
|
|
147
|
+
|
|
148
|
+
capture (bool, optional):
|
|
149
|
+
Isolate task <stdout> and <stderr> in discrete files.
|
|
150
|
+
Defaults to `False`.
|
|
151
|
+
|
|
152
|
+
client_timeout (int, optional):
|
|
153
|
+
Timeout in seconds before disconnecting from server.
|
|
154
|
+
By default, the client waits for server tor request disconnect.
|
|
155
|
+
|
|
156
|
+
task_timeout (int, optional):
|
|
157
|
+
Task-level walltime limit in seconds.
|
|
158
|
+
By default, the client waits indefinitely on tasks.
|
|
159
|
+
|
|
160
|
+
task_signalwait (int, optional):
|
|
161
|
+
Signal escalation waiting period in seconds on task timeout.
|
|
162
|
+
See :const:`~hypershell.client.DEFAULT_SIGNALWAIT`.
|
|
163
|
+
|
|
164
|
+
Example:
|
|
165
|
+
>>> from hypershell.cluster import SSHCluster
|
|
166
|
+
>>> cluster = SSHCluster.new(
|
|
167
|
+
... nodelist=['a00.cluster', 'a01.cluster', 'a02.cluster'],
|
|
168
|
+
... restart_mode=True, max_retries=2, eager=True,
|
|
169
|
+
... client_timeout=600, task_timeout=300, capture=True
|
|
170
|
+
... )
|
|
171
|
+
>>> cluster.join()
|
|
172
|
+
|
|
173
|
+
See Also:
|
|
174
|
+
- :class:`~hypershell.server.ServerThread`
|
|
175
|
+
- :meth:`~hypershell.cluster.ssh.run_ssh`
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
server: ServerThread
|
|
179
|
+
clients: List[Popen]
|
|
180
|
+
client_argv: List[List[str]]
|
|
181
|
+
|
|
182
|
+
def __init__(self: SSHCluster,
|
|
183
|
+
source: Iterable[str] = None,
|
|
184
|
+
nodelist: List[str] = None,
|
|
185
|
+
num_tasks: int = 1,
|
|
186
|
+
template: str = DEFAULT_TEMPLATE,
|
|
187
|
+
bundlesize: int = DEFAULT_BUNDLESIZE,
|
|
188
|
+
bundlewait: int = DEFAULT_BUNDLEWAIT,
|
|
189
|
+
bind: Tuple[str, int] = ('0.0.0.0', DEFAULT_PORT),
|
|
190
|
+
launcher: str = 'ssh',
|
|
191
|
+
launcher_args: List[str] = None,
|
|
192
|
+
remote_exe: str = DEFAULT_REMOTE_EXE,
|
|
193
|
+
export_env: bool = False,
|
|
194
|
+
in_memory: bool = False,
|
|
195
|
+
no_confirm: bool = False,
|
|
196
|
+
forever_mode: bool = False,
|
|
197
|
+
restart_mode: bool = False,
|
|
198
|
+
max_retries: int = DEFAULT_ATTEMPTS,
|
|
199
|
+
eager: bool = False,
|
|
200
|
+
redirect_failures: IO = None,
|
|
201
|
+
delay_start: float = DEFAULT_DELAY,
|
|
202
|
+
capture: bool = False,
|
|
203
|
+
client_timeout: int = None,
|
|
204
|
+
task_timeout: int = None,
|
|
205
|
+
task_signalwait: int = DEFAULT_SIGNALWAIT) -> None:
|
|
206
|
+
"""Initialize server and client threads."""
|
|
207
|
+
if nodelist is None:
|
|
208
|
+
raise AttributeError('Expected nodelist')
|
|
209
|
+
auth = secrets.token_hex(64)
|
|
210
|
+
self.server = ServerThread(source=source,
|
|
211
|
+
bundlesize=bundlesize,
|
|
212
|
+
bundlewait=bundlewait,
|
|
213
|
+
in_memory=in_memory,
|
|
214
|
+
no_confirm=no_confirm,
|
|
215
|
+
address=bind,
|
|
216
|
+
auth=auth,
|
|
217
|
+
max_retries=max_retries,
|
|
218
|
+
eager=eager,
|
|
219
|
+
forever_mode=forever_mode,
|
|
220
|
+
restart_mode=restart_mode,
|
|
221
|
+
redirect_failures=redirect_failures)
|
|
222
|
+
launcher = shlex.split(launcher)
|
|
223
|
+
launcher_env = shlex.split('' if not export_env else compile_env())
|
|
224
|
+
if launcher_args is None:
|
|
225
|
+
launcher_args = shlex.split(config.ssh.get('args', ''))
|
|
226
|
+
client_args = []
|
|
227
|
+
if capture:
|
|
228
|
+
client_args.append('--capture')
|
|
229
|
+
if no_confirm:
|
|
230
|
+
client_args.append('--no-confirm')
|
|
231
|
+
if client_timeout is not None:
|
|
232
|
+
client_args.extend(['-T', str(client_timeout)])
|
|
233
|
+
if task_timeout is not None:
|
|
234
|
+
client_args.extend(['-W', str(task_timeout)])
|
|
235
|
+
self.client_argv = [
|
|
236
|
+
[*launcher, *launcher_args, host, *launcher_env, remote_exe, 'client', '-H', HOSTNAME,
|
|
237
|
+
'-p', str(bind[1]), '-N', str(num_tasks), '-b', str(bundlesize), '-w', str(bundlewait),
|
|
238
|
+
'-t', f'\'{template}\'', '-k', auth, '-d', str(delay_start),
|
|
239
|
+
'-S', str(task_signalwait), *client_args]
|
|
240
|
+
for host in nodelist
|
|
241
|
+
]
|
|
242
|
+
super().__init__(name='hypershell-cluster')
|
|
243
|
+
|
|
244
|
+
def run_with_exceptions(self: SSHCluster) -> None:
|
|
245
|
+
"""Start child threads, wait."""
|
|
246
|
+
self.server.start()
|
|
247
|
+
while not self.server.queue.ready:
|
|
248
|
+
time.sleep(0.1)
|
|
249
|
+
self.clients = []
|
|
250
|
+
for argv in self.client_argv:
|
|
251
|
+
log.debug(f'Launching client: {argv}')
|
|
252
|
+
self.clients.append(Popen(argv, stdout=sys.stdout, stderr=sys.stderr))
|
|
253
|
+
for client in self.clients:
|
|
254
|
+
client.wait()
|
|
255
|
+
self.server.join()
|
|
256
|
+
|
|
257
|
+
def stop(self: SSHCluster, wait: bool = False, timeout: int = None) -> None:
|
|
258
|
+
"""Stop child threads before main thread."""
|
|
259
|
+
self.server.stop(wait=wait, timeout=timeout)
|
|
260
|
+
for client in self.clients:
|
|
261
|
+
client.terminate()
|
|
262
|
+
super().stop(wait=wait, timeout=timeout)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class NodeList(list):
|
|
266
|
+
"""A list of hostnames."""
|
|
267
|
+
|
|
268
|
+
group_pattern: re.Pattern = re.compile(r',(?![^[]*])')
|
|
269
|
+
range_pattern: re.Pattern = re.compile(r'\[((\d+|\d+-\d+)(,(\d+|\d+-\d+))*)]')
|
|
270
|
+
|
|
271
|
+
@classmethod
|
|
272
|
+
def from_cmdline(cls: Type[NodeList], arg: str = None) -> NodeList:
|
|
273
|
+
"""Smart initialization via some command-line `arg`."""
|
|
274
|
+
if not arg:
|
|
275
|
+
return cls.from_config()
|
|
276
|
+
if ',' in arg or cls.range_pattern.search(arg):
|
|
277
|
+
return cls.from_pattern(arg)
|
|
278
|
+
else:
|
|
279
|
+
return cls([arg, ])
|
|
280
|
+
|
|
281
|
+
@classmethod
|
|
282
|
+
def from_config(cls: Type[NodeList], groupname: str = None) -> NodeList:
|
|
283
|
+
"""Load list of hostnames from configuration file."""
|
|
284
|
+
|
|
285
|
+
if 'ssh' not in config:
|
|
286
|
+
raise ConfigurationError('No `ssh` section found in configuration')
|
|
287
|
+
if 'nodelist' not in config.ssh:
|
|
288
|
+
raise ConfigurationError('No `ssh.nodelist` section found in configuration')
|
|
289
|
+
|
|
290
|
+
label = blame(config, 'ssh', 'nodelist')
|
|
291
|
+
nodelist = config.ssh.nodelist
|
|
292
|
+
|
|
293
|
+
if groupname is None:
|
|
294
|
+
if isinstance(nodelist, str):
|
|
295
|
+
return cls.from_pattern(nodelist)
|
|
296
|
+
elif isinstance(nodelist, list):
|
|
297
|
+
return cls(nodelist)
|
|
298
|
+
elif isinstance(nodelist, dict):
|
|
299
|
+
raise ConfigurationError(f'SSH group unspecified but multiple groups in `ssh.nodelist` ({label})')
|
|
300
|
+
else:
|
|
301
|
+
raise ConfigurationError(f'Expected list for `ssh.nodelist` ({label})')
|
|
302
|
+
|
|
303
|
+
if isinstance(nodelist, dict):
|
|
304
|
+
nodelist = nodelist.get(groupname, None)
|
|
305
|
+
if not nodelist:
|
|
306
|
+
raise ConfigurationError(f'No list \'{groupname}\' found in `ssh.nodelist` section ({label})')
|
|
307
|
+
elif isinstance(nodelist, str):
|
|
308
|
+
return cls.from_pattern(nodelist)
|
|
309
|
+
elif isinstance(nodelist, list):
|
|
310
|
+
return cls(nodelist)
|
|
311
|
+
else:
|
|
312
|
+
raise ConfigurationError(f'Expected list for `ssh.nodelist.{groupname}` ({label})')
|
|
313
|
+
else:
|
|
314
|
+
raise ConfigurationError(f'Expected either list or section for `ssh.nodelist` ({label})')
|
|
315
|
+
|
|
316
|
+
@classmethod
|
|
317
|
+
def from_pattern(cls: Type[NodeList], pattern: str) -> NodeList:
|
|
318
|
+
"""Expand a `pattern` to multiple hostnames."""
|
|
319
|
+
pattern = pattern.strip(',')
|
|
320
|
+
if match := cls.group_pattern.search(pattern):
|
|
321
|
+
idx = match.start()
|
|
322
|
+
return cls(cls.from_pattern(pattern[0:idx]) + cls.from_pattern(pattern[idx+1:]))
|
|
323
|
+
else:
|
|
324
|
+
if match := cls.range_pattern.search(pattern):
|
|
325
|
+
range_spec = match.group()
|
|
326
|
+
prefix, suffix = pattern.split(range_spec)
|
|
327
|
+
segments = cls.expand_pattern(range_spec)
|
|
328
|
+
return cls([f'{prefix}{segment}{suffix}' for segment in segments])
|
|
329
|
+
else:
|
|
330
|
+
return cls([pattern, ])
|
|
331
|
+
|
|
332
|
+
@staticmethod
|
|
333
|
+
def expand_pattern(spec: str) -> List[str]:
|
|
334
|
+
"""Take a range spec (e.g., [4,6-8]) and expand to [4,6,7,8]."""
|
|
335
|
+
spec = spec.strip('[]')
|
|
336
|
+
result = []
|
|
337
|
+
for group in spec.split(','):
|
|
338
|
+
if '-' in group:
|
|
339
|
+
start_chars, stop_chars = group.strip().split('-')
|
|
340
|
+
apply_padding = str if start_chars[0] != '0' else lambda s: str(s).zfill(len(start_chars))
|
|
341
|
+
start, stop = int(start_chars), int(stop_chars)
|
|
342
|
+
if stop < start:
|
|
343
|
+
start, stop = stop, start
|
|
344
|
+
result.extend([apply_padding(value) for value in range(start, stop + 1)])
|
|
345
|
+
else:
|
|
346
|
+
result.extend([group, ])
|
|
347
|
+
return result
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def compile_env() -> str:
|
|
351
|
+
"""Build environment variable argument expansion for remote client launch command."""
|
|
352
|
+
return ' '.join([f'{key}="{value}"' for key, value in Namespace.from_env('HYPERSHELL').items()])
|