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.
@@ -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()])