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,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
@@ -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)