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,359 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025 Geoffrey Lentner
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Runtime configuration for HyperShell."""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# type annotations
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from typing import TypeVar, Union, List, Optional, Protocol, Final, Iterator
|
|
10
|
+
|
|
11
|
+
# standard libs
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
import shutil
|
|
16
|
+
import tomlkit
|
|
17
|
+
import logging
|
|
18
|
+
import socket
|
|
19
|
+
import functools
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
|
|
22
|
+
# external libs
|
|
23
|
+
from cmdkit.config import Namespace, Configuration, Environ, ConfigurationError
|
|
24
|
+
from cmdkit.app import exit_status
|
|
25
|
+
|
|
26
|
+
# internal libs
|
|
27
|
+
from hypershell.core.platform import path, home
|
|
28
|
+
from hypershell.core.exceptions import write_traceback
|
|
29
|
+
|
|
30
|
+
# public interface
|
|
31
|
+
__all__ = ['config', 'update', 'default', 'ConfigurationError', 'Namespace', 'blame',
|
|
32
|
+
'load', 'reload', 'reload_local', 'load_file', 'reload_file', 'load_env', 'reload_env', 'load_task_env',
|
|
33
|
+
'DEFAULT_LOGGING_STYLE', 'LOGGING_STYLES', 'ACTIVE_CONFIG_VARS', 'SSH_GROUPS',
|
|
34
|
+
'find_available_ports']
|
|
35
|
+
|
|
36
|
+
# partial logging (not yet configured - initialized afterward)
|
|
37
|
+
log = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
DEFAULT_LOGGING_STYLE = 'default'
|
|
41
|
+
LOGGING_STYLES = {
|
|
42
|
+
'default': {
|
|
43
|
+
'format': ('%(ansi_bold)s%(ansi_level)s%(levelname)8s%(ansi_reset)s %(ansi_faint)s[%(name)s]%(ansi_reset)s'
|
|
44
|
+
' %(message)s'),
|
|
45
|
+
},
|
|
46
|
+
'system': {
|
|
47
|
+
'format': '%(asctime)s.%(msecs)03d %(hostname)s %(levelname)8s [%(app_id)s] [%(name)s] %(message)s',
|
|
48
|
+
},
|
|
49
|
+
'detailed': {
|
|
50
|
+
'format': ('%(ansi_faint)s%(asctime)s.%(msecs)03d %(hostname)s %(ansi_reset)s'
|
|
51
|
+
'%(ansi_level)s%(ansi_bold)s%(levelname)8s%(ansi_reset)s '
|
|
52
|
+
'%(ansi_faint)s[%(name)s]%(ansi_reset)s %(message)s'),
|
|
53
|
+
},
|
|
54
|
+
'detailed-compact': {
|
|
55
|
+
'format': ('%(ansi_faint)s%(elapsed_hms)s [%(hostname_short)s] %(ansi_reset)s'
|
|
56
|
+
'%(ansi_level)s%(ansi_bold)s%(levelname)8s%(ansi_reset)s '
|
|
57
|
+
'%(ansi_faint)s[%(relative_name)s]%(ansi_reset)s %(message)s'),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# environment variables and configuration files are automatically
|
|
63
|
+
# depth-first merged with defaults
|
|
64
|
+
default = Namespace({
|
|
65
|
+
|
|
66
|
+
'database': {
|
|
67
|
+
'provider': 'sqlite',
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
'logging': {
|
|
71
|
+
'color': True,
|
|
72
|
+
'level': 'warning',
|
|
73
|
+
'datefmt': '%Y-%m-%d %H:%M:%S',
|
|
74
|
+
'style': DEFAULT_LOGGING_STYLE,
|
|
75
|
+
**LOGGING_STYLES.get(DEFAULT_LOGGING_STYLE),
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
'task': {
|
|
79
|
+
'cwd': os.getcwd(),
|
|
80
|
+
'timeout': None, # seconds, period to wait before killing tasks
|
|
81
|
+
'signalwait': 10, # seconds to wait between signal escalation (INT, TERM, KILL)
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
'submit': {
|
|
85
|
+
'bundlesize': 1, # size of task bundle to accumulate before committing
|
|
86
|
+
'bundlewait': 5 # seconds to wait before committing regardless of size
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
'server': {
|
|
90
|
+
'bind': 'localhost',
|
|
91
|
+
'port': 50_001,
|
|
92
|
+
'auth': '__HYPERSHELL__BAD__AUTHKEY__',
|
|
93
|
+
'queuesize': 1, # only allow a single bundle (scheduler must wait)
|
|
94
|
+
'bundlesize': 1,
|
|
95
|
+
'bundlewait': 5, # seconds
|
|
96
|
+
'attempts': 1,
|
|
97
|
+
'eager': False, # prefer failed tasks to new tasks
|
|
98
|
+
'wait': 5, # seconds to wait between database queries
|
|
99
|
+
'evict': 600, # assume client is gone if no heartbeat after this many seconds
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
'client': {
|
|
103
|
+
'bundlesize': 1, # size of task bundle to accumulate before returning
|
|
104
|
+
'bundlewait': 5, # seconds to wait before returning regardless of size
|
|
105
|
+
'heartrate': 10, # seconds to wait between heartbeats
|
|
106
|
+
'timeout': None, # seconds to wait for bundle from server before shutting down
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
'ssh': {
|
|
110
|
+
'config': os.path.join(home, '.ssh', 'config'),
|
|
111
|
+
'nodelist': {} # Populated by user configuration
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
'autoscale': {
|
|
115
|
+
'policy': 'fixed', # Either 'fixed' or 'dynamic'
|
|
116
|
+
'factor': 1,
|
|
117
|
+
'period': 60, # seconds to wait between checks
|
|
118
|
+
'launcher': '', # empty means just 'hs client'
|
|
119
|
+
'size': {
|
|
120
|
+
'init': 1,
|
|
121
|
+
'min': 0,
|
|
122
|
+
'max': 2,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
'console': {
|
|
127
|
+
'theme': 'monokai',
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
# NOTE: defining HYPERSHELL_EXPORT_XXX defines XXX within task env
|
|
131
|
+
'export': {}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def reload_file(filepath: str) -> Namespace:
|
|
136
|
+
"""Force reloading configuration file."""
|
|
137
|
+
if not os.path.exists(filepath):
|
|
138
|
+
return Namespace({})
|
|
139
|
+
try:
|
|
140
|
+
return Namespace.from_toml(filepath)
|
|
141
|
+
except Exception as err:
|
|
142
|
+
raise ConfigurationError(f'(from file: {filepath}) {err.__class__.__name__}: {err}')
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@functools.lru_cache(maxsize=None)
|
|
146
|
+
def load_file(filepath: str) -> Namespace:
|
|
147
|
+
"""Load configuration file."""
|
|
148
|
+
return reload_file(filepath)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def reload_env() -> Environ:
|
|
152
|
+
"""Force reloading environment variables and expanding hierarchy as namespace."""
|
|
153
|
+
return Environ(prefix='HYPERSHELL').expand()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@functools.lru_cache(maxsize=None)
|
|
157
|
+
def load_env() -> Environ:
|
|
158
|
+
"""Load environment variables and expand hierarchy as namespace."""
|
|
159
|
+
return reload_env()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def partial_load(system: Optional[str] = path.system.config,
|
|
163
|
+
user: Optional[str] = path.user.config,
|
|
164
|
+
local: Optional[str] = path.local.config,
|
|
165
|
+
**preload: Namespace) -> Configuration:
|
|
166
|
+
"""Load configuration from files and merge environment variables."""
|
|
167
|
+
return Configuration(**{
|
|
168
|
+
'default': default, **preload,
|
|
169
|
+
'system': {} if not system else load_file(system),
|
|
170
|
+
'user': {} if not user else load_file(user),
|
|
171
|
+
'local': {} if not user else load_file(local),
|
|
172
|
+
'env': load_env(),
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def partial_reload(system: Optional[str] = path.system.config,
|
|
177
|
+
user: Optional[str] = path.user.config,
|
|
178
|
+
local: Optional[str] = path.local.config,
|
|
179
|
+
**preload: Namespace) -> Configuration:
|
|
180
|
+
"""Force reload configuration from files and merge environment variables."""
|
|
181
|
+
return Configuration(**{
|
|
182
|
+
'default': default, **preload,
|
|
183
|
+
'system': {} if not system else reload_file(system),
|
|
184
|
+
'user': {} if not user else reload_file(user),
|
|
185
|
+
'local': {} if not local else reload_file(local),
|
|
186
|
+
'env': reload_env(),
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def blame(base: Configuration, *varpath: str) -> Optional[str]:
|
|
191
|
+
"""Construct filename or variable assignment string based on precedent of `varpath`."""
|
|
192
|
+
source = base.which(*varpath)
|
|
193
|
+
if not source:
|
|
194
|
+
return None
|
|
195
|
+
if source in ('system', 'user', 'local'):
|
|
196
|
+
return f'from: {path.get(source).config}'
|
|
197
|
+
elif source == 'env':
|
|
198
|
+
return 'from: HYPERSHELL_' + '_'.join([node.upper() for node in varpath])
|
|
199
|
+
else:
|
|
200
|
+
return f'from: <{source}>'
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def get_logging_style(base: Configuration) -> str:
|
|
204
|
+
"""Get and check valid on `config.logging.style`."""
|
|
205
|
+
style = base.logging.style
|
|
206
|
+
label = blame(base, 'logging', 'style')
|
|
207
|
+
if not isinstance(style, str):
|
|
208
|
+
raise ConfigurationError(f'Expected string for `logging.style` ({label})')
|
|
209
|
+
style = style.lower()
|
|
210
|
+
if style in LOGGING_STYLES:
|
|
211
|
+
return style
|
|
212
|
+
else:
|
|
213
|
+
raise ConfigurationError(f'Unrecognized `logging.style` \'{style}\' ({label})')
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def build_preloads(base: Configuration) -> Namespace:
|
|
217
|
+
"""Build 'preload' namespace from base configuration."""
|
|
218
|
+
return Namespace({'logging': LOGGING_STYLES.get(get_logging_style(base))})
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class LoaderImpl(Protocol):
|
|
222
|
+
"""Loader interface for building configuration."""
|
|
223
|
+
def __call__(self: LoaderImpl,
|
|
224
|
+
system: Optional[str] = path.system.config,
|
|
225
|
+
user: Optional[str] = path.user.config,
|
|
226
|
+
local: Optional[str] = path.local.config,
|
|
227
|
+
**preload: Namespace) -> Configuration: ...
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def build_configuration(loader: LoaderImpl) -> Configuration:
|
|
231
|
+
"""Construct full configuration."""
|
|
232
|
+
return loader(preload=build_preloads(base=loader()))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def load() -> Configuration:
|
|
236
|
+
"""Load configuration from files and merge environment variables."""
|
|
237
|
+
return build_configuration(loader=partial_load)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def reload() -> Configuration:
|
|
241
|
+
"""Load configuration from files and merge environment variables."""
|
|
242
|
+
return build_configuration(loader=partial_reload)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def reload_local(filepath: Optional[str] = None) -> Configuration:
|
|
246
|
+
"""Load configuration but only include one file."""
|
|
247
|
+
loader = functools.partial(partial_reload, system=None, user=None, local=filepath)
|
|
248
|
+
return build_configuration(loader=loader)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
if (local_config := os.getenv('HYPERSHELL_CONFIG_FILE', None)) is not None:
|
|
253
|
+
path.local.config = local_config # Modified as to not lie to the user
|
|
254
|
+
config = reload_local(local_config)
|
|
255
|
+
else:
|
|
256
|
+
config = load()
|
|
257
|
+
except Exception as error:
|
|
258
|
+
write_traceback(error, module=__name__)
|
|
259
|
+
sys.exit(exit_status.bad_config)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
ACTIVE_CONFIG_VARS: Final[List[str]] = [
|
|
263
|
+
re.sub(r'_(?!(env|eval))', r'.', name.lower())
|
|
264
|
+
for name in Namespace(config).to_env().flatten()
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
SSH_GROUPS = []
|
|
269
|
+
try:
|
|
270
|
+
if isinstance(config.ssh.nodelist, dict):
|
|
271
|
+
SSH_GROUPS = list(config.ssh.nodelist)
|
|
272
|
+
except KeyError:
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def find_available_ports(start: int = default.server.port,
|
|
277
|
+
end: int = default.server.port + 1_000) -> Iterator[int]:
|
|
278
|
+
"""Yield available ports by testing each in turn."""
|
|
279
|
+
for port in range(start, end + 1):
|
|
280
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
281
|
+
sock.settimeout(1)
|
|
282
|
+
try:
|
|
283
|
+
sock.bind(("0.0.0.0", port))
|
|
284
|
+
yield port
|
|
285
|
+
except socket.error:
|
|
286
|
+
pass
|
|
287
|
+
else:
|
|
288
|
+
raise RuntimeError(f'Could not find available port in range {start}-{end}')
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
DEFAULT_CONFIG_HEADERS = f"""\
|
|
292
|
+
# File automatically created on {datetime.now()}
|
|
293
|
+
# Settings here are merged automatically with defaults and environment variables
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def update(scope: str, partial: dict) -> None:
|
|
298
|
+
"""Extend the current configuration and commit it to disk."""
|
|
299
|
+
config_path = path[scope].config
|
|
300
|
+
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
|
301
|
+
if os.path.exists(config_path):
|
|
302
|
+
timestamp = datetime.now().strftime('%Y%m%d-%H%M%S')
|
|
303
|
+
config_backup_path = os.path.join(os.path.dirname(config_path), f'.config.{timestamp}.toml')
|
|
304
|
+
shutil.copy(config_path, config_backup_path)
|
|
305
|
+
shutil.copystat(config_path, config_backup_path)
|
|
306
|
+
log.debug(f'Created backup file ({config_backup_path})')
|
|
307
|
+
else:
|
|
308
|
+
with open(config_path, mode='w') as stream:
|
|
309
|
+
stream.write(DEFAULT_CONFIG_HEADERS)
|
|
310
|
+
with open(config_path, mode='r') as stream:
|
|
311
|
+
new_config = tomlkit.parse(stream.read())
|
|
312
|
+
_inplace_update(new_config, partial)
|
|
313
|
+
with open(config_path, mode='w') as stream:
|
|
314
|
+
tomlkit.dump(new_config, stream)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# Re-implemented from `cmdkit.config.Namespace` (but works with `tomlkit`)
|
|
318
|
+
def _inplace_update(original: dict, partial: dict) -> dict:
|
|
319
|
+
"""
|
|
320
|
+
Like normal `dict.update` but if values in both are mappable, descend
|
|
321
|
+
a level deeper (recursive) and apply updates there instead.
|
|
322
|
+
"""
|
|
323
|
+
for key, value in partial.items():
|
|
324
|
+
if isinstance(value, dict) and isinstance(original.get(key), dict):
|
|
325
|
+
original[key] = _inplace_update(original.get(key, {}), value)
|
|
326
|
+
else:
|
|
327
|
+
original[key] = value
|
|
328
|
+
return original
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
if os.name == 'nt':
|
|
332
|
+
PATH_DELIMITER = ';'
|
|
333
|
+
else:
|
|
334
|
+
PATH_DELIMITER = ':'
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
T = TypeVar('T')
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def __collapse_if_list_impl(value: Union[T, List[str]]) -> Union[T, str]:
|
|
341
|
+
"""If `value` is a list, collapse it to a path-like list (with ':' or ';')."""
|
|
342
|
+
return value if not isinstance(value, list) else PATH_DELIMITER.join([str(member) for member in value])
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def __collapse_lists(ns: Namespace) -> Namespace:
|
|
346
|
+
"""Collapse member values if they are a list, recursively."""
|
|
347
|
+
result = Namespace()
|
|
348
|
+
for key, value in ns.items():
|
|
349
|
+
if isinstance(value, Namespace):
|
|
350
|
+
result[key] = __collapse_lists(value)
|
|
351
|
+
else:
|
|
352
|
+
result[key] = __collapse_if_list_impl(value)
|
|
353
|
+
return result
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@functools.cache
|
|
357
|
+
def load_task_env() -> Environ:
|
|
358
|
+
"""Export environment defined in `config.export`."""
|
|
359
|
+
return __collapse_lists(config.export).to_env().flatten()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025 Geoffrey Lentner
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Core exception handling useful for import-time issues."""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# type annotations
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from typing import Dict, Union, Callable, Type
|
|
10
|
+
|
|
11
|
+
# standard libraries
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import logging
|
|
15
|
+
import functools
|
|
16
|
+
import traceback
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
|
|
19
|
+
# external libs
|
|
20
|
+
from cmdkit.app import exit_status
|
|
21
|
+
from cmdkit.config import Namespace
|
|
22
|
+
from cmdkit.config import ConfigurationError
|
|
23
|
+
from cmdkit.ansi import faint, bold, magenta, yellow, red, COLOR_STDERR
|
|
24
|
+
|
|
25
|
+
# internal libs
|
|
26
|
+
from hypershell.core.platform import default_path
|
|
27
|
+
|
|
28
|
+
# public interface
|
|
29
|
+
__all__ = ['display_warning', 'display_error', 'display_critical', 'traceback_filepath', 'write_traceback',
|
|
30
|
+
'handle_exception', 'handle_exception_silently', 'handle_disconnect', 'handle_address_unknown',
|
|
31
|
+
'HostAddressInfo', 'DatabaseUninitialized',
|
|
32
|
+
'get_shared_exception_mapping', ]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _display_message(levelname: str, error: Union[Exception, str],
|
|
36
|
+
module: str = None, colorized: Callable[[str], str] = None) -> None:
|
|
37
|
+
"""Generic message display for import-time warnings and errors."""
|
|
38
|
+
text = error if isinstance(error, str) else f'{error.__class__.__name__}: {error}'
|
|
39
|
+
if COLOR_STDERR:
|
|
40
|
+
name = '' if not module else faint(f'[{module}]')
|
|
41
|
+
level = levelname if colorized is None else bold(colorized(levelname))
|
|
42
|
+
else:
|
|
43
|
+
name = '' if not module else f'[{module}]'
|
|
44
|
+
level = levelname
|
|
45
|
+
print(f'{level} {name} {text}', file=sys.stderr)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Specialized methods for each severity level
|
|
49
|
+
display_warning = functools.partial(_display_message, 'WARNING', colorized=yellow)
|
|
50
|
+
display_error = functools.partial(_display_message, 'ERROR', colorized=red)
|
|
51
|
+
display_critical = functools.partial(_display_message, 'CRITICAL', colorized=magenta)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class HostAddressInfo(Exception):
|
|
55
|
+
"""Could not resolve hostname."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class DatabaseUninitialized(Exception):
|
|
59
|
+
"""The database needs to be initialized before operations."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def traceback_filepath(path: Namespace = None) -> str:
|
|
63
|
+
"""Construct filepath for writing traceback."""
|
|
64
|
+
path = path or default_path
|
|
65
|
+
time = datetime.now().strftime('%Y%m%d-%H%M%S')
|
|
66
|
+
return os.path.join(path.log, f'exception-{time}.log')
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def write_traceback(exc: Exception, site: Namespace = None, logger: logging.Logger = None,
|
|
70
|
+
status: int = exit_status.uncaught_exception, module: str = None) -> int:
|
|
71
|
+
"""Write exception to file and return exit code."""
|
|
72
|
+
write = functools.partial(display_critical, module=module) if not logger else logger.critical
|
|
73
|
+
path = traceback_filepath(site)
|
|
74
|
+
with open(path, mode='w') as stream:
|
|
75
|
+
print(traceback.format_exc(), file=stream)
|
|
76
|
+
write(f'{exc.__class__.__name__}: ' + str(exc).replace('\n', ' - '))
|
|
77
|
+
write(f'Exception traceback written to {path}')
|
|
78
|
+
return status
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def handle_disconnect(exc: Exception, logger: logging.Logger) -> int:
|
|
82
|
+
"""An EOFError results from the server hanging up the client."""
|
|
83
|
+
logger.critical(f'{exc.__class__.__name__}: server disconnected')
|
|
84
|
+
return exit_status.runtime_error
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def handle_exception(exc: Exception, logger: logging.Logger, status: int) -> int:
|
|
88
|
+
"""Log the exception argument and exit with `status`."""
|
|
89
|
+
logger.critical(f'{exc.__class__.__name__}: ' + str(exc).replace('\n', ' - '))
|
|
90
|
+
return status
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def handle_exception_silently(exc: Exception) -> int:
|
|
94
|
+
"""Return status held by `exc.args` without logging."""
|
|
95
|
+
status, = exc.args
|
|
96
|
+
return status
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def handle_address_unknown(exc: Exception, # noqa: unused
|
|
100
|
+
logger: logging.Logger,
|
|
101
|
+
status: int = exit_status.runtime_error) -> int:
|
|
102
|
+
"""Could not get address info for hostname (see `socket.gaierror`)."""
|
|
103
|
+
logger.critical(f'{exc.__class__.__name__}: {exc}')
|
|
104
|
+
return status
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_shared_exception_mapping(modname: str = 'hypershell') -> Dict[Type[Exception], Callable[[Exception], int]]:
|
|
108
|
+
"""Globally defined exception cases for all application subcommands."""
|
|
109
|
+
# We need a single location to define basic exception cases but cannot define them under
|
|
110
|
+
# `hypershell.__init__` as a class-level override of `Application.exceptions` because the
|
|
111
|
+
# subcommand classes are imported first. This function is now part of core and has no
|
|
112
|
+
# internal dependencies, so it can be imported by all other modules.
|
|
113
|
+
logger = logging.getLogger(modname)
|
|
114
|
+
return {
|
|
115
|
+
RuntimeError: functools.partial(handle_exception, logger=logger, status=exit_status.runtime_error),
|
|
116
|
+
FileNotFoundError: functools.partial(handle_exception, logger=logger, status=exit_status.runtime_error),
|
|
117
|
+
ConfigurationError: functools.partial(handle_exception, logger=logger, status=exit_status.bad_config),
|
|
118
|
+
DatabaseUninitialized: functools.partial(handle_exception, logger=logger, status=exit_status.runtime_error),
|
|
119
|
+
Exception: functools.partial(write_traceback, logger=logger, status=exit_status.runtime_error),
|
|
120
|
+
}
|
hypershell/core/fsm.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025 Geoffrey Lentner
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Instrumentation for building finite state machines."""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# type annotations
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from typing import Dict, Callable, Type
|
|
10
|
+
|
|
11
|
+
# standard libs
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from abc import ABC
|
|
14
|
+
# FUZZ: import time
|
|
15
|
+
# FUZZ: import random
|
|
16
|
+
# PERF: from collections import defaultdict
|
|
17
|
+
# PERF: from time import perf_counter
|
|
18
|
+
|
|
19
|
+
# internal libs
|
|
20
|
+
from hypershell.core.exceptions import write_traceback
|
|
21
|
+
from hypershell.core.logging import Logger
|
|
22
|
+
|
|
23
|
+
# public interface
|
|
24
|
+
__all__ = ['State', 'StateMachine', ]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
log = Logger.with_name(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class State(Enum):
|
|
31
|
+
"""Shared base for finite state enums (must have at least HALT)."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class StateMachine(ABC):
|
|
35
|
+
"""Base class for a finite state machine implementation."""
|
|
36
|
+
|
|
37
|
+
state: State
|
|
38
|
+
states: Type[State]
|
|
39
|
+
actions: Dict[State, Callable[[], State]]
|
|
40
|
+
|
|
41
|
+
__should_halt: bool = False
|
|
42
|
+
|
|
43
|
+
# NOTE: Only needed during performance profiling
|
|
44
|
+
# PERF: __perf_counter: float = 0
|
|
45
|
+
# PERF: __perf_data: Dict[int, float]
|
|
46
|
+
|
|
47
|
+
def next(self) -> State:
|
|
48
|
+
"""Return next state (halt if necessary)."""
|
|
49
|
+
previous_state = self.state
|
|
50
|
+
try:
|
|
51
|
+
if self.__should_halt:
|
|
52
|
+
return self.states.HALT # noqa: HALT defined in implemented State enums
|
|
53
|
+
else:
|
|
54
|
+
action = self.actions.get(previous_state)
|
|
55
|
+
# PERF: self.__perf_counter = perf_counter()
|
|
56
|
+
next_state = action()
|
|
57
|
+
# PERF: self.__perf_data[previous_state.value] += perf_counter() - self.__perf_counter
|
|
58
|
+
except Exception as error:
|
|
59
|
+
# NOTE: Only non-RuntimeError instances are "unexpected"
|
|
60
|
+
if not isinstance(error, RuntimeError):
|
|
61
|
+
log.critical(f'Uncaught exception from {self.__class__}')
|
|
62
|
+
write_traceback(error, logger=log, module=__name__)
|
|
63
|
+
raise
|
|
64
|
+
else:
|
|
65
|
+
# NOTE: Development aids not typically engaged
|
|
66
|
+
# FUZZ: time.sleep(random.uniform(0, 5)) # FUZZ
|
|
67
|
+
# FUZZ: log.devel(f'{self.__class__.__name__}: {previous_state} -> {next_state}')
|
|
68
|
+
return next_state
|
|
69
|
+
|
|
70
|
+
def run(self) -> None:
|
|
71
|
+
"""Run machine until state is set to `HALT`."""
|
|
72
|
+
# PERF: self.__perf_data = defaultdict(lambda: 0)
|
|
73
|
+
while self.state is not self.states.HALT: # noqa: HALT defined in implemented State enums
|
|
74
|
+
self.state = self.next()
|
|
75
|
+
# PERF: time_total = sum(self.__perf_data.values())
|
|
76
|
+
# PERF: for key, value in self.__perf_data.items():
|
|
77
|
+
# PERF: t = 100 * value / time_total
|
|
78
|
+
# PERF: log.trace(f'Profiler[{self.__class__.__name__}] {self.states(key).name}: {t:.3f}')
|
|
79
|
+
|
|
80
|
+
def halt(self) -> None:
|
|
81
|
+
"""Set flag to signal for termination."""
|
|
82
|
+
self.__should_halt = True
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025 Geoffrey Lentner
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Heartbeat data passed between client and server."""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# type annotations
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from typing import Type
|
|
10
|
+
|
|
11
|
+
# standard libs
|
|
12
|
+
import json
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
# internal libs
|
|
18
|
+
from hypershell.core.logging import HOSTNAME, INSTANCE
|
|
19
|
+
|
|
20
|
+
# public interface
|
|
21
|
+
__all__ = ['ClientState', 'Heartbeat']
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ClientState(Enum):
|
|
25
|
+
"""Client state."""
|
|
26
|
+
|
|
27
|
+
RUNNING = 0
|
|
28
|
+
FINISHED = 1
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_value(cls: Type[ClientState], value: int) -> ClientState:
|
|
32
|
+
"""Instance from associated integer value."""
|
|
33
|
+
return {0: cls.RUNNING, 1: cls.FINISHED}.get(value)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Heartbeat:
|
|
38
|
+
"""Momentary notice of a client's active status."""
|
|
39
|
+
|
|
40
|
+
uuid: str
|
|
41
|
+
host: str
|
|
42
|
+
time: datetime
|
|
43
|
+
state: ClientState
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def new(cls: Type[Heartbeat],
|
|
47
|
+
uuid: str = None,
|
|
48
|
+
host: str = None,
|
|
49
|
+
time: datetime = None,
|
|
50
|
+
state: ClientState = None) -> Heartbeat:
|
|
51
|
+
"""Create new instance."""
|
|
52
|
+
return cls(uuid=(uuid or INSTANCE),
|
|
53
|
+
host=(host or HOSTNAME),
|
|
54
|
+
time=(time or datetime.now().astimezone()),
|
|
55
|
+
state=(state or ClientState.RUNNING))
|
|
56
|
+
|
|
57
|
+
def pack(self: Heartbeat) -> bytes:
|
|
58
|
+
"""Serialize data."""
|
|
59
|
+
return json.dumps({'uuid': self.uuid,
|
|
60
|
+
'host': self.host,
|
|
61
|
+
'time': str(self.time),
|
|
62
|
+
'state': self.state.value}).encode('utf-8')
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def unpack(cls: Type[Heartbeat], data: bytes) -> Heartbeat:
|
|
66
|
+
"""Deserialize from raw `data`."""
|
|
67
|
+
data = json.loads(data.decode('utf-8'))
|
|
68
|
+
data['time'] = datetime.fromisoformat(data['time'])
|
|
69
|
+
data['state'] = ClientState.from_value(data['state'])
|
|
70
|
+
return cls(**data)
|