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,201 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025 Geoffrey Lentner
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Logging configuration."""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# type annotations
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from typing import Tuple, Dict, Any, Type
|
|
10
|
+
|
|
11
|
+
# standard libraries
|
|
12
|
+
import sys
|
|
13
|
+
import uuid
|
|
14
|
+
import socket
|
|
15
|
+
import logging
|
|
16
|
+
import functools
|
|
17
|
+
import datetime
|
|
18
|
+
|
|
19
|
+
# external libs
|
|
20
|
+
from cmdkit.app import exit_status
|
|
21
|
+
from cmdkit.config import ConfigurationError
|
|
22
|
+
from cmdkit.ansi import Ansi, COLOR_STDERR
|
|
23
|
+
|
|
24
|
+
# internal libs
|
|
25
|
+
from hypershell.core.config import config, blame
|
|
26
|
+
from hypershell.core.exceptions import write_traceback
|
|
27
|
+
|
|
28
|
+
# public interface
|
|
29
|
+
__all__ = ['Logger', 'HOSTNAME', 'INSTANCE', 'handler', 'initialize_logging', ]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Cached for later use
|
|
33
|
+
HOSTNAME = socket.gethostname()
|
|
34
|
+
HOSTNAME_SHORT = HOSTNAME.split('.', 1)[0]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Unique for every instance of hypershell
|
|
38
|
+
INSTANCE = str(uuid.uuid4())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Canonical colors for logging messages
|
|
42
|
+
level_color: Dict[str, Ansi] = {
|
|
43
|
+
'NULL': Ansi.NULL,
|
|
44
|
+
'DEVEL': Ansi.RED,
|
|
45
|
+
'TRACE': Ansi.CYAN,
|
|
46
|
+
'DEBUG': Ansi.BLUE,
|
|
47
|
+
'INFO': Ansi.GREEN,
|
|
48
|
+
'WARNING': Ansi.YELLOW,
|
|
49
|
+
'ERROR': Ansi.RED,
|
|
50
|
+
'CRITICAL': Ansi.MAGENTA
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
TRACE: int = logging.DEBUG - 5
|
|
55
|
+
logging.addLevelName(TRACE, 'TRACE')
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
DEVEL: int = 1
|
|
59
|
+
logging.addLevelName(DEVEL, 'DEVEL')
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Logger(logging.Logger):
|
|
63
|
+
"""Extend Logger to implement TRACE level."""
|
|
64
|
+
|
|
65
|
+
def trace(self, msg: str, *args, **kwargs):
|
|
66
|
+
"""Log 'msg % args' with severity 'TRACE'."""
|
|
67
|
+
if self.isEnabledFor(TRACE):
|
|
68
|
+
self._log(TRACE, msg, args, **kwargs)
|
|
69
|
+
|
|
70
|
+
def devel(self, msg: str, *args, **kwargs):
|
|
71
|
+
"""Log 'msg % args' with severity 'DEVEL'."""
|
|
72
|
+
if self.isEnabledFor(DEVEL):
|
|
73
|
+
self._log(DEVEL, msg, args, **kwargs)
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def with_name(cls: Type[Logger], name: str) -> Logger:
|
|
77
|
+
"""Shorthand for `log: Logger = logging.getLogger(name)`."""
|
|
78
|
+
return logging.getLogger(name)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# inject class back into logging library
|
|
82
|
+
logging.setLoggerClass(Logger)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def solve_relative_time(elapsed: float) -> Tuple[float, int, datetime.timedelta, str]:
|
|
86
|
+
"""
|
|
87
|
+
Multiple formats of relative time since `elapsed` seconds.
|
|
88
|
+
Returns:
|
|
89
|
+
- Relative time in seconds (i.e., `elapsed`)
|
|
90
|
+
- Relative time in milliseconds
|
|
91
|
+
- Relative time as `datetime.timedelta`
|
|
92
|
+
- Relative time in dd-hh:mm:ss.sss format
|
|
93
|
+
"""
|
|
94
|
+
elapsed_ms = int(elapsed * 1000)
|
|
95
|
+
reltime_delta = datetime.timedelta(seconds=elapsed)
|
|
96
|
+
reltime_delta_hours, remainder = divmod(reltime_delta.seconds, 3600)
|
|
97
|
+
reltime_delta_minutes, reltime_delta_seconds = divmod(remainder, 60)
|
|
98
|
+
reltime_delta_milliseconds = int(reltime_delta.microseconds / 1000)
|
|
99
|
+
return (
|
|
100
|
+
elapsed,
|
|
101
|
+
elapsed_ms,
|
|
102
|
+
reltime_delta,
|
|
103
|
+
f'{reltime_delta.days:02d}-{reltime_delta_hours:02d}:{reltime_delta_minutes:02d}:'
|
|
104
|
+
f'{reltime_delta_seconds:02d}.{reltime_delta_milliseconds:03d}'
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class LogRecord(logging.LogRecord):
|
|
109
|
+
"""Extends LogRecord to include ANSI colors, time formats, and other attributes."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
112
|
+
super().__init__(*args, **kwargs)
|
|
113
|
+
|
|
114
|
+
# Context attributes
|
|
115
|
+
self.app_id = INSTANCE
|
|
116
|
+
self.hostname = HOSTNAME
|
|
117
|
+
self.hostname_short = HOSTNAME_SHORT
|
|
118
|
+
self.relative_name = self.name.split('.', 1)[-1]
|
|
119
|
+
|
|
120
|
+
# Formatting attributes
|
|
121
|
+
self.ansi_level = level_color.get(self.levelname, Ansi.NULL).value if COLOR_STDERR else ''
|
|
122
|
+
self.ansi_reset = Ansi.RESET.value if COLOR_STDERR else ''
|
|
123
|
+
self.ansi_bold = Ansi.BOLD.value if COLOR_STDERR else ''
|
|
124
|
+
self.ansi_faint = Ansi.FAINT.value if COLOR_STDERR else ''
|
|
125
|
+
self.ansi_italic = Ansi.ITALIC.value if COLOR_STDERR else ''
|
|
126
|
+
self.ansi_underline = Ansi.UNDERLINE.value if COLOR_STDERR else ''
|
|
127
|
+
self.ansi_black = Ansi.BLACK.value if COLOR_STDERR else ''
|
|
128
|
+
self.ansi_red = Ansi.RED.value if COLOR_STDERR else ''
|
|
129
|
+
self.ansi_green = Ansi.GREEN.value if COLOR_STDERR else ''
|
|
130
|
+
self.ansi_yellow = Ansi.YELLOW.value if COLOR_STDERR else ''
|
|
131
|
+
self.ansi_blue = Ansi.BLUE.value if COLOR_STDERR else ''
|
|
132
|
+
self.ansi_magenta = Ansi.MAGENTA.value if COLOR_STDERR else ''
|
|
133
|
+
self.ansi_cyan = Ansi.CYAN.value if COLOR_STDERR else ''
|
|
134
|
+
self.ansi_white = Ansi.WHITE.value if COLOR_STDERR else ''
|
|
135
|
+
|
|
136
|
+
# Timing attributes
|
|
137
|
+
(self.elapsed,
|
|
138
|
+
self.elapsed_ms,
|
|
139
|
+
self.elapsed_delta,
|
|
140
|
+
self.elapsed_hms) = solve_relative_time(self.relativeCreated / 1000)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# inject factory back into logging library
|
|
144
|
+
logging.setLogRecordFactory(LogRecord)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class StreamHandler(logging.StreamHandler):
|
|
148
|
+
"""A StreamHandler that panics on exceptions in the logging configuration."""
|
|
149
|
+
|
|
150
|
+
def handleError(self, record: LogRecord) -> None:
|
|
151
|
+
"""Pretty-print message and write traceback to file."""
|
|
152
|
+
err_type, err_val, tb = sys.exc_info()
|
|
153
|
+
write_traceback(err_val, module=__name__)
|
|
154
|
+
sys.exit(exit_status.bad_config)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def level_from_name(name: Any, source: str = 'logging.level') -> int:
|
|
158
|
+
"""Get level value from `name`."""
|
|
159
|
+
label = blame(config, *source.split('.'))
|
|
160
|
+
if not isinstance(name, str):
|
|
161
|
+
raise ConfigurationError(f'Expected string for logging level, given \'{name}\' ({label})')
|
|
162
|
+
name = name.upper()
|
|
163
|
+
if name == 'TRACE':
|
|
164
|
+
return TRACE
|
|
165
|
+
elif name == 'DEVEL':
|
|
166
|
+
return DEVEL
|
|
167
|
+
elif name in ('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'):
|
|
168
|
+
return getattr(logging, name)
|
|
169
|
+
else:
|
|
170
|
+
raise ConfigurationError(f'Unsupported logging level \'{name}\' ({label})')
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
levelname = config.logging.level
|
|
175
|
+
level = level_from_name(levelname)
|
|
176
|
+
except Exception as error:
|
|
177
|
+
write_traceback(error, module=__name__)
|
|
178
|
+
sys.exit(exit_status.bad_config)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
handler = StreamHandler(stream=sys.stderr)
|
|
183
|
+
handler.setFormatter(
|
|
184
|
+
logging.Formatter(config.logging.format,
|
|
185
|
+
datefmt=config.logging.datefmt)
|
|
186
|
+
)
|
|
187
|
+
except Exception as error:
|
|
188
|
+
write_traceback(error, module=__name__)
|
|
189
|
+
sys.exit(exit_status.bad_config)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# null handler for library use
|
|
193
|
+
logger = logging.getLogger('hypershell')
|
|
194
|
+
logger.setLevel(level)
|
|
195
|
+
logger.addHandler(logging.NullHandler())
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@functools.cache
|
|
199
|
+
def initialize_logging() -> None:
|
|
200
|
+
"""Enable logging output to the console and rotating files."""
|
|
201
|
+
logger.addHandler(handler)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025 Geoffrey Lentner
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Platform specific file paths and initialization."""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# NOTE:
|
|
8
|
+
# A lot of the work done in this core module is provided by CmdKit at this point.
|
|
9
|
+
# For continuity and for fear of breaking other parts of the project we have decided
|
|
10
|
+
# to leave this module in place for the time being.
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# standard libs
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import ctypes
|
|
17
|
+
import platform
|
|
18
|
+
|
|
19
|
+
# external libs
|
|
20
|
+
from cmdkit.config import Namespace
|
|
21
|
+
from cmdkit.app import exit_status
|
|
22
|
+
from cmdkit.ansi import bold, magenta
|
|
23
|
+
|
|
24
|
+
# public interface
|
|
25
|
+
__all__ = ['cwd', 'home', 'site', 'path', 'default_path']
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
cwd = os.getcwd()
|
|
29
|
+
home = os.path.expanduser('~')
|
|
30
|
+
if 'HYPERSHELL_SITE' not in os.environ:
|
|
31
|
+
local_site = os.path.join(cwd, '.hypershell')
|
|
32
|
+
else:
|
|
33
|
+
local_site = os.getenv('HYPERSHELL_SITE')
|
|
34
|
+
if not os.path.isdir(local_site):
|
|
35
|
+
print(f'{bold(magenta("CRITICAL"))} [{__name__}] '
|
|
36
|
+
f'Directory does not exist (HYPERSHELL_SITE={local_site})', file=sys.stderr)
|
|
37
|
+
sys.exit(exit_status.bad_config)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if platform.system() == 'Windows':
|
|
41
|
+
is_admin = ctypes.windll.shell32.IsUserAnAdmin() == 1
|
|
42
|
+
site = Namespace(system=os.path.join(os.getenv('ProgramData'), 'HyperShell'),
|
|
43
|
+
user=os.path.join(os.getenv('AppData'), 'HyperShell'),
|
|
44
|
+
local=local_site)
|
|
45
|
+
path = Namespace({
|
|
46
|
+
'system': {
|
|
47
|
+
'lib': os.path.join(site.system, 'Library'),
|
|
48
|
+
'log': os.path.join(site.system, 'Logs'),
|
|
49
|
+
'config': os.path.join(site.system, 'Config.toml')},
|
|
50
|
+
'user': {
|
|
51
|
+
'lib': os.path.join(site.user, 'Library'),
|
|
52
|
+
'log': os.path.join(site.user, 'Logs'),
|
|
53
|
+
'config': os.path.join(site.user, 'Config.toml')},
|
|
54
|
+
'local': {
|
|
55
|
+
'lib': os.path.join(site.local, 'Library'),
|
|
56
|
+
'log': os.path.join(site.local, 'Logs'),
|
|
57
|
+
'config': os.path.join(site.local, 'Config.toml')}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
elif platform.system() == 'Darwin':
|
|
61
|
+
is_admin = os.getuid() == 0
|
|
62
|
+
site = Namespace(system='/', user=home, local=local_site)
|
|
63
|
+
path = Namespace({
|
|
64
|
+
'system': {
|
|
65
|
+
'lib': os.path.join(site['system'], 'Library', 'HyperShell'),
|
|
66
|
+
'log': os.path.join(site['system'], 'Library', 'Logs', 'HyperShell'),
|
|
67
|
+
'config': os.path.join(site['system'], 'Library', 'Preferences', 'HyperShell', 'config.toml')},
|
|
68
|
+
'user': {
|
|
69
|
+
'lib': os.path.join(site['user'], 'Library', 'HyperShell'),
|
|
70
|
+
'log': os.path.join(site['user'], 'Library', 'Logs', 'HyperShell'),
|
|
71
|
+
'config': os.path.join(site['user'], 'Library', 'Preferences', 'HyperShell', 'config.toml')},
|
|
72
|
+
'local': {
|
|
73
|
+
'lib': os.path.join(site['local'], 'Library'),
|
|
74
|
+
'log': os.path.join(site['local'], 'Logs'),
|
|
75
|
+
'config': os.path.join(site['local'], 'config.toml')}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
elif os.name == 'posix': # NOTE: likely Linux
|
|
79
|
+
is_admin = os.getuid() == 0
|
|
80
|
+
site = Namespace(system='/', user=os.path.join(home, '.hypershell'),
|
|
81
|
+
local=local_site)
|
|
82
|
+
path = Namespace({
|
|
83
|
+
'system': {
|
|
84
|
+
'lib': os.path.join(site.system, 'var', 'lib', 'hypershell'),
|
|
85
|
+
'log': os.path.join(site.system, 'var', 'log', 'hypershell'),
|
|
86
|
+
'config': os.path.join(site.system, 'etc', 'hypershell.toml')},
|
|
87
|
+
'user': {
|
|
88
|
+
'lib': os.path.join(site.user, 'lib'),
|
|
89
|
+
'log': os.path.join(site.user, 'log'),
|
|
90
|
+
'config': os.path.join(site.user, 'config.toml')},
|
|
91
|
+
'local': {
|
|
92
|
+
'lib': os.path.join(site.local, 'lib'),
|
|
93
|
+
'log': os.path.join(site.local, 'log'),
|
|
94
|
+
'config': os.path.join(site.local, 'config.toml')}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
else:
|
|
98
|
+
print(f'{bold(magenta("CRITICAL"))} [{__name__}] '
|
|
99
|
+
f'Platform unrecognized ({platform.system()})', file=sys.stderr)
|
|
100
|
+
sys.exit(exit_status.bad_config)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if 'HYPERSHELL_SITE' in os.environ:
|
|
104
|
+
default_path = path.local
|
|
105
|
+
else:
|
|
106
|
+
default_path = path.user if not is_admin else path.system
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# Automatically initialize default site directories
|
|
110
|
+
default_dirs = [
|
|
111
|
+
default_path.lib,
|
|
112
|
+
default_path.log,
|
|
113
|
+
os.path.join(default_path.lib, 'task'),
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
for default_dir in default_dirs:
|
|
118
|
+
try:
|
|
119
|
+
os.makedirs(default_dir, exist_ok=True)
|
|
120
|
+
except PermissionError:
|
|
121
|
+
pass
|
hypershell/core/queue.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025 Geoffrey Lentner
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Queue server/client implementation."""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# type annotations
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from typing import Dict, List, Callable, Union, Optional, Any, Iterable, Type
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
|
|
12
|
+
# standard libs
|
|
13
|
+
from multiprocessing.managers import BaseManager
|
|
14
|
+
from multiprocessing import JoinableQueue
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
# internal libs
|
|
19
|
+
from hypershell.core.config import default, config as _config
|
|
20
|
+
|
|
21
|
+
# public interface
|
|
22
|
+
__all__ = ['QueueConfig', 'QueueInterface', 'QueueServer', 'QueueClient']
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class QueueConfig:
|
|
27
|
+
"""Connection details for queue interface."""
|
|
28
|
+
|
|
29
|
+
host: str = default.server.bind
|
|
30
|
+
port: int = default.server.port
|
|
31
|
+
auth: str = default.server.auth
|
|
32
|
+
size: int = default.server.queuesize
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_dict(cls, data: Dict[str, Union[str, int]]) -> QueueConfig:
|
|
36
|
+
"""Load config from existing dictionary values."""
|
|
37
|
+
return cls(**data)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def load(cls: Type[QueueConfig]) -> QueueConfig:
|
|
41
|
+
"""Initialize from global configuration."""
|
|
42
|
+
return cls.from_dict({
|
|
43
|
+
'host': _config.server.host,
|
|
44
|
+
'port': _config.server.port,
|
|
45
|
+
'auth': _config.server.auth,
|
|
46
|
+
'size': _config.server.queuesize,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class QueueInterface(BaseManager, ABC):
|
|
51
|
+
"""The queue interface provides access to three managed distributed queues."""
|
|
52
|
+
|
|
53
|
+
config: QueueConfig
|
|
54
|
+
scheduled: JoinableQueue[Optional[List[bytes]]]
|
|
55
|
+
completed: JoinableQueue[Optional[List[bytes]]]
|
|
56
|
+
heartbeat: JoinableQueue[Optional[bytes]]
|
|
57
|
+
confirmed: JoinableQueue[Optional[bytes]]
|
|
58
|
+
ready: bool = False
|
|
59
|
+
|
|
60
|
+
def __init__(self: QueueInterface, config: QueueConfig) -> None:
|
|
61
|
+
"""Initialize queue interface."""
|
|
62
|
+
self.config = config
|
|
63
|
+
super().__init__(address=(self.config.host, self.config.port), authkey=self.config.auth.encode())
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def new(cls: Type[QueueInterface]) -> QueueInterface:
|
|
67
|
+
"""Create new interface from global configuration."""
|
|
68
|
+
return cls(config=QueueConfig.load())
|
|
69
|
+
|
|
70
|
+
@abstractmethod
|
|
71
|
+
def __enter__(self: QueueInterface) -> QueueInterface:
|
|
72
|
+
"""Start server or connect from client."""
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def __exit__(self: QueueInterface,
|
|
76
|
+
exc_type: Optional[Type[Exception]],
|
|
77
|
+
exc_val: Optional[Exception],
|
|
78
|
+
exc_tb: Optional[TracebackType]) -> None:
|
|
79
|
+
"""Stop or disconnect."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class QueueServer(QueueInterface):
|
|
83
|
+
"""Server for managing queue."""
|
|
84
|
+
|
|
85
|
+
def start(self: QueueServer,
|
|
86
|
+
initializer: Optional[Callable[..., Any]] = None,
|
|
87
|
+
initargs: Iterable[Any] = ()) -> None:
|
|
88
|
+
"""Initialize queues and start server."""
|
|
89
|
+
self.scheduled = JoinableQueue(maxsize=self.config.size)
|
|
90
|
+
self.completed = JoinableQueue(maxsize=self.config.size)
|
|
91
|
+
self.heartbeat = JoinableQueue(maxsize=0)
|
|
92
|
+
self.confirmed = JoinableQueue(maxsize=0)
|
|
93
|
+
self.register('_get_scheduled', callable=self._get_scheduled)
|
|
94
|
+
self.register('_get_completed', callable=self._get_completed)
|
|
95
|
+
self.register('_get_heartbeat', callable=self._get_heartbeat)
|
|
96
|
+
self.register('_get_confirmed', callable=self._get_confirmed)
|
|
97
|
+
super().start()
|
|
98
|
+
self.ready = True
|
|
99
|
+
|
|
100
|
+
def _get_scheduled(self: QueueServer) -> JoinableQueue[Optional[List[bytes]]]:
|
|
101
|
+
return self.scheduled
|
|
102
|
+
|
|
103
|
+
def _get_completed(self: QueueServer) -> JoinableQueue[Optional[List[bytes]]]:
|
|
104
|
+
return self.completed
|
|
105
|
+
|
|
106
|
+
def _get_heartbeat(self: QueueServer) -> JoinableQueue[Optional[bytes]]:
|
|
107
|
+
return self.heartbeat
|
|
108
|
+
|
|
109
|
+
def _get_confirmed(self: QueueServer) -> JoinableQueue[Optional[bytes]]:
|
|
110
|
+
return self.confirmed
|
|
111
|
+
|
|
112
|
+
def __enter__(self: QueueServer) -> QueueServer:
|
|
113
|
+
"""Start the server."""
|
|
114
|
+
self.start()
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def __exit__(self: QueueServer,
|
|
118
|
+
exc_type: Optional[Type[Exception]],
|
|
119
|
+
exc_val: Optional[Exception],
|
|
120
|
+
exc_tb: Optional[TracebackType]) -> None:
|
|
121
|
+
"""Shutdown the server."""
|
|
122
|
+
self.shutdown()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class QueueClient(QueueInterface):
|
|
126
|
+
"""Client connection to queue manager."""
|
|
127
|
+
|
|
128
|
+
_get_scheduled: Callable[[], JoinableQueue[Optional[List[bytes]]]]
|
|
129
|
+
_get_completed: Callable[[], JoinableQueue[Optional[List[bytes]]]]
|
|
130
|
+
_get_heartbeat: Callable[[], JoinableQueue[Optional[bytes]]]
|
|
131
|
+
_get_confirmed: Callable[[], JoinableQueue[Optional[bytes]]]
|
|
132
|
+
|
|
133
|
+
def connect(self) -> None:
|
|
134
|
+
"""Connect to server."""
|
|
135
|
+
self.register('_get_scheduled')
|
|
136
|
+
self.register('_get_completed')
|
|
137
|
+
self.register('_get_heartbeat')
|
|
138
|
+
self.register('_get_confirmed')
|
|
139
|
+
super().connect()
|
|
140
|
+
self.scheduled = self._get_scheduled()
|
|
141
|
+
self.completed = self._get_completed()
|
|
142
|
+
self.heartbeat = self._get_heartbeat()
|
|
143
|
+
self.confirmed = self._get_confirmed()
|
|
144
|
+
self.ready = True
|
|
145
|
+
|
|
146
|
+
def __enter__(self: QueueClient) -> QueueClient:
|
|
147
|
+
"""Connect to server."""
|
|
148
|
+
self.connect()
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
def __exit__(self: QueueClient,
|
|
152
|
+
exc_type: Optional[Type[Exception]],
|
|
153
|
+
exc_val: Optional[Exception],
|
|
154
|
+
exc_tb: Optional[TracebackType]) -> None:
|
|
155
|
+
"""Disconnect from server."""
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025 Geoffrey Lentner
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Manage remote connections and data."""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# type annotations
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from typing import Tuple, Optional, Type, Union, IO
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
|
|
12
|
+
# standard libs
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
# external libs
|
|
18
|
+
from paramiko import SSHClient, SFTPClient, ProxyCommand, AutoAddPolicy, SSHConfig as SSHConfigParser
|
|
19
|
+
from paramiko.channel import ChannelStdinFile, ChannelFile, ChannelStderrFile
|
|
20
|
+
|
|
21
|
+
# internal libs
|
|
22
|
+
from hypershell.core.logging import Logger
|
|
23
|
+
from hypershell.core.config import config
|
|
24
|
+
from hypershell.core.thread import Thread
|
|
25
|
+
|
|
26
|
+
# public interface
|
|
27
|
+
__all__ = ['SSHConfig', 'SSHConnection', 'RemoteProcess', ]
|
|
28
|
+
|
|
29
|
+
# initialize logger
|
|
30
|
+
log = Logger.with_name(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Default file path to ssh configuration
|
|
34
|
+
DEFAULT_SSH_CONFIG = config.ssh.config
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class SSHConfig:
|
|
39
|
+
"""Connection details for an SSHConnection."""
|
|
40
|
+
|
|
41
|
+
hostname: str
|
|
42
|
+
timeout: Optional[float] = None
|
|
43
|
+
username: Optional[str] = None
|
|
44
|
+
password: Optional[str] = None
|
|
45
|
+
key_filename: Optional[str] = None
|
|
46
|
+
sock: Optional[str] = None # proxy-command
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def check_config(hostname: str, filepath: str = DEFAULT_SSH_CONFIG) -> Optional[dict]:
|
|
50
|
+
"""Check to see if `hostname` is defined in `filepath`, return `paramiko.SSHConfig`."""
|
|
51
|
+
if not os.path.exists(filepath):
|
|
52
|
+
return None
|
|
53
|
+
with open(filepath, mode='r') as stream:
|
|
54
|
+
ssh_config = SSHConfigParser()
|
|
55
|
+
ssh_config.parse(stream)
|
|
56
|
+
return ssh_config.lookup(hostname)
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_config(cls: Type[SSHConfig], hostname: str, filepath: str = DEFAULT_SSH_CONFIG) -> SSHConfig:
|
|
60
|
+
"""Read configuration from file."""
|
|
61
|
+
if profile := cls.check_config(hostname, filepath):
|
|
62
|
+
return cls(**{
|
|
63
|
+
'hostname': profile.get('hostname', hostname),
|
|
64
|
+
'username': profile.get('user', None),
|
|
65
|
+
'key_filename': profile.get('identityfile', None),
|
|
66
|
+
'sock': None if 'proxycommand' not in profile else ProxyCommand(profile['proxycommand']),
|
|
67
|
+
})
|
|
68
|
+
else:
|
|
69
|
+
return cls(hostname=hostname)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SSHConnection:
|
|
73
|
+
"""Connect to remote machine over SSH protocol."""
|
|
74
|
+
|
|
75
|
+
config: SSHConfig
|
|
76
|
+
client: SSHClient = None
|
|
77
|
+
|
|
78
|
+
sftp: Optional[SFTPClient] = None
|
|
79
|
+
|
|
80
|
+
def __init__(self: SSHConnection, hostname_or_config: Union[str, SSHConfig]) -> None:
|
|
81
|
+
"""Initialize with hostname or prepared SSHConfig."""
|
|
82
|
+
if isinstance(hostname_or_config, SSHConfig):
|
|
83
|
+
self.config = hostname_or_config
|
|
84
|
+
else:
|
|
85
|
+
self.config = SSHConfig.from_config(hostname_or_config)
|
|
86
|
+
|
|
87
|
+
def __enter__(self: SSHConnection) -> SSHConnection:
|
|
88
|
+
"""Open connection."""
|
|
89
|
+
self.open()
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
def __exit__(self: SSHConnection,
|
|
93
|
+
exc_type: Optional[Type[Exception]],
|
|
94
|
+
exc_val: Optional[Exception],
|
|
95
|
+
exc_tb: Optional[TracebackType]) -> None:
|
|
96
|
+
"""Automatically close connection."""
|
|
97
|
+
self.close()
|
|
98
|
+
|
|
99
|
+
def open(self: SSHConnection) -> None:
|
|
100
|
+
"""Open connection."""
|
|
101
|
+
log.debug(f'Starting SSH ({self.config.hostname})')
|
|
102
|
+
self.client = SSHClient()
|
|
103
|
+
self.client.set_missing_host_key_policy(AutoAddPolicy())
|
|
104
|
+
self.client.connect(**vars(self.config))
|
|
105
|
+
|
|
106
|
+
def close(self: SSHConnection) -> None:
|
|
107
|
+
"""Close connection."""
|
|
108
|
+
if self.client:
|
|
109
|
+
log.debug('Stopping SSH')
|
|
110
|
+
self.client.close()
|
|
111
|
+
if self.sftp:
|
|
112
|
+
log.debug('Stopping SFTP')
|
|
113
|
+
self.sftp.close()
|
|
114
|
+
|
|
115
|
+
def run(self: SSHConnection, *args, **kwargs) -> Tuple[ChannelStdinFile, ChannelFile, ChannelStderrFile]:
|
|
116
|
+
"""Run remote command and return <stdin>, <stdout> and <stderr>."""
|
|
117
|
+
return self.client.exec_command(*args, **kwargs)
|
|
118
|
+
|
|
119
|
+
def open_sftp(self: SSHConnection) -> None:
|
|
120
|
+
"""Establish SFTP connection."""
|
|
121
|
+
if not self.sftp:
|
|
122
|
+
log.debug('Starting SFTP')
|
|
123
|
+
self.sftp = self.client.open_sftp()
|
|
124
|
+
|
|
125
|
+
def get_file(self: SSHConnection, remote_path: str, local_path: str) -> None:
|
|
126
|
+
"""Use SFTP to copy remote file to local file."""
|
|
127
|
+
self.open_sftp()
|
|
128
|
+
log.trace(f'GET {self.config.hostname}:{remote_path} -> {local_path}')
|
|
129
|
+
self.sftp.get(remote_path, local_path)
|
|
130
|
+
|
|
131
|
+
def put_file(self: SSHConnection, local_path: str, remote_path: str) -> None:
|
|
132
|
+
"""Use SFTP to copy local file to remote file."""
|
|
133
|
+
self.open_sftp()
|
|
134
|
+
log.trace(f'PUT {local_path} -> {self.config.hostname}:{remote_path}')
|
|
135
|
+
self.sftp.put(local_path, remote_path, confirm=True)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class RemoteProcess(Thread):
|
|
139
|
+
"""Run a command remotely over SSH and connect local file descriptors."""
|
|
140
|
+
|
|
141
|
+
conn: SSHConnection
|
|
142
|
+
command: str
|
|
143
|
+
options: dict
|
|
144
|
+
|
|
145
|
+
stdout: IO
|
|
146
|
+
stderr: IO
|
|
147
|
+
|
|
148
|
+
def __init__(self: RemoteProcess,
|
|
149
|
+
host_or_config: Union[str, SSHConfig],
|
|
150
|
+
command: str, stdout: IO = None, stderr: IO = None, **options) -> None:
|
|
151
|
+
"""Initialize with connection and command details."""
|
|
152
|
+
super().__init__('hypershell-remote-command')
|
|
153
|
+
self.conn = SSHConnection(host_or_config)
|
|
154
|
+
self.command = command
|
|
155
|
+
self.options = options
|
|
156
|
+
self.stdout = stdout or sys.stdout
|
|
157
|
+
self.stderr = stderr or sys.stderr
|
|
158
|
+
|
|
159
|
+
def run_with_exceptions(self: RemoteProcess) -> None:
|
|
160
|
+
"""Continuously redirect output."""
|
|
161
|
+
with self.conn as conn:
|
|
162
|
+
stdin, stdout, stderr = conn.run(self.command, **self.options)
|
|
163
|
+
stdin.close()
|
|
164
|
+
stdout_thread = BufferedTransport.new(stdout, self.stdout)
|
|
165
|
+
stderr_thread = BufferedTransport.new(stderr, self.stderr)
|
|
166
|
+
stdout_thread.join()
|
|
167
|
+
stderr_thread.join()
|
|
168
|
+
|
|
169
|
+
@classmethod
|
|
170
|
+
def run_and_wait(cls: Type[RemoteProcess], *args, **kwargs) -> None:
|
|
171
|
+
"""Run command in blocking mode."""
|
|
172
|
+
thread = cls.new(*args, **kwargs)
|
|
173
|
+
thread.join()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class BufferedTransport(Thread):
|
|
177
|
+
"""Continuously copy data from input channel to local file stream."""
|
|
178
|
+
|
|
179
|
+
remote: ChannelFile
|
|
180
|
+
local: IO
|
|
181
|
+
|
|
182
|
+
def __init__(self: BufferedTransport, remote: ChannelFile, local: IO) -> None:
|
|
183
|
+
"""Initialize with local and remote file descriptors."""
|
|
184
|
+
super().__init__('hypershell-buffered-transport')
|
|
185
|
+
self.remote = remote
|
|
186
|
+
self.local = local
|
|
187
|
+
|
|
188
|
+
def run_with_exceptions(self: BufferedTransport) -> None:
|
|
189
|
+
"""Start reading data."""
|
|
190
|
+
while not self.remote.channel.exit_status_ready():
|
|
191
|
+
buffer = self.remote.readline()
|
|
192
|
+
self.local.write(buffer)
|