shelltastic 0.3.1__tar.gz → 0.4.0__tar.gz
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.
- {shelltastic-0.3.1 → shelltastic-0.4.0}/PKG-INFO +1 -1
- {shelltastic-0.3.1 → shelltastic-0.4.0}/pyproject.toml +1 -1
- {shelltastic-0.3.1 → shelltastic-0.4.0}/src/shelltastic/__init__.py +11 -1
- shelltastic-0.4.0/src/shelltastic/backend/__init__.py +249 -0
- shelltastic-0.4.0/src/shelltastic/backend/base.py +52 -0
- shelltastic-0.4.0/src/shelltastic/display.py +99 -0
- shelltastic-0.4.0/src/shelltastic/enum.py +23 -0
- {shelltastic-0.3.1 → shelltastic-0.4.0}/src/shelltastic/exception.py +15 -0
- {shelltastic-0.3.1 → shelltastic-0.4.0}/src/shelltastic/frontend/git.py +11 -8
- {shelltastic-0.3.1 → shelltastic-0.4.0}/src/shelltastic/frontend/scp.py +3 -3
- {shelltastic-0.3.1 → shelltastic-0.4.0}/src/shelltastic/frontend/shell.py +20 -13
- shelltastic-0.3.1/src/shelltastic/backend/__init__.py +0 -119
- shelltastic-0.3.1/src/shelltastic/backend/base.py +0 -35
- shelltastic-0.3.1/src/shelltastic/enum.py +0 -12
- {shelltastic-0.3.1 → shelltastic-0.4.0}/LICENSE +0 -0
- {shelltastic-0.3.1 → shelltastic-0.4.0}/README.md +0 -0
- {shelltastic-0.3.1 → shelltastic-0.4.0}/src/shelltastic/frontend/__init__.py +0 -0
- {shelltastic-0.3.1 → shelltastic-0.4.0}/src/shelltastic/frontend/common.py +0 -0
- {shelltastic-0.3.1 → shelltastic-0.4.0}/src/shelltastic/host.py +0 -0
|
@@ -1,8 +1,18 @@
|
|
|
1
|
+
from shelltastic.enum import CaptureMode, DisplayMode, SystemType
|
|
1
2
|
from shelltastic.frontend.git import LocalGitFrontend
|
|
2
3
|
from shelltastic.frontend.scp import SCP
|
|
3
4
|
from shelltastic.frontend.shell import LocalShellFrontend
|
|
5
|
+
from shelltastic.host import Host
|
|
4
6
|
|
|
5
|
-
__all__ = [
|
|
7
|
+
__all__ = [
|
|
8
|
+
"shell",
|
|
9
|
+
"scp",
|
|
10
|
+
"git",
|
|
11
|
+
"DisplayMode",
|
|
12
|
+
"CaptureMode",
|
|
13
|
+
"SystemType",
|
|
14
|
+
"Host",
|
|
15
|
+
]
|
|
6
16
|
|
|
7
17
|
shell = LocalShellFrontend()
|
|
8
18
|
scp = SCP()
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import pathlib
|
|
5
|
+
import shlex
|
|
6
|
+
import subprocess
|
|
7
|
+
from multiprocessing.pool import ThreadPool
|
|
8
|
+
from typing import IO, Literal
|
|
9
|
+
|
|
10
|
+
from shelltastic import display
|
|
11
|
+
from shelltastic.backend.base import RemoteShellBackend, ShellBackend, ShellResult
|
|
12
|
+
from shelltastic.enum import CaptureMode, DisplayMode
|
|
13
|
+
from shelltastic.exception import SSHConnectionError
|
|
14
|
+
from shelltastic.host import Host
|
|
15
|
+
|
|
16
|
+
LOGGER = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _output_and_collect(
|
|
20
|
+
io: IO[bytes] | None, display: display.IODisplay | None, collect: bool = True
|
|
21
|
+
) -> bytes | None:
|
|
22
|
+
if io is None:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
if collect:
|
|
26
|
+
if display:
|
|
27
|
+
return_bytes = bytes()
|
|
28
|
+
for line in io:
|
|
29
|
+
return_bytes += line
|
|
30
|
+
display.printbytes(line)
|
|
31
|
+
return return_bytes
|
|
32
|
+
else:
|
|
33
|
+
return io.read()
|
|
34
|
+
else:
|
|
35
|
+
if display:
|
|
36
|
+
for line in io:
|
|
37
|
+
display.printbytes(line)
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _determine_subprocess_output_mode(
|
|
42
|
+
capture_mode: CaptureMode | None,
|
|
43
|
+
display_mode: DisplayMode | display.IODisplay | None,
|
|
44
|
+
output_name: Literal["stdout", "stderr"],
|
|
45
|
+
) -> int | None:
|
|
46
|
+
# If DisplayMode is None, we act like subpress does
|
|
47
|
+
if display_mode is None:
|
|
48
|
+
if capture_mode == CaptureMode.PIPE:
|
|
49
|
+
return subprocess.PIPE
|
|
50
|
+
elif capture_mode == CaptureMode.STDOUT:
|
|
51
|
+
return subprocess.STDOUT
|
|
52
|
+
elif capture_mode == CaptureMode.DEVNULL:
|
|
53
|
+
return subprocess.DEVNULL
|
|
54
|
+
else:
|
|
55
|
+
assert capture_mode is None
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
# Don't print
|
|
59
|
+
if display_mode == DisplayMode.DEVNULL:
|
|
60
|
+
if capture_mode == CaptureMode.PIPE:
|
|
61
|
+
return subprocess.PIPE
|
|
62
|
+
elif capture_mode == CaptureMode.STDOUT:
|
|
63
|
+
if output_name == "stdout":
|
|
64
|
+
# stdout
|
|
65
|
+
return subprocess.PIPE
|
|
66
|
+
else:
|
|
67
|
+
# stderr
|
|
68
|
+
return subprocess.STDOUT
|
|
69
|
+
elif capture_mode == CaptureMode.DEVNULL:
|
|
70
|
+
return subprocess.DEVNULL
|
|
71
|
+
else:
|
|
72
|
+
assert capture_mode is None
|
|
73
|
+
return subprocess.DEVNULL
|
|
74
|
+
|
|
75
|
+
# Print Output
|
|
76
|
+
# display_mode == STDOUT, STDERR, LOG, or is an IODisplay
|
|
77
|
+
if capture_mode == CaptureMode.PIPE:
|
|
78
|
+
return subprocess.PIPE
|
|
79
|
+
elif capture_mode == CaptureMode.STDOUT:
|
|
80
|
+
return subprocess.STDOUT
|
|
81
|
+
elif capture_mode == CaptureMode.DEVNULL:
|
|
82
|
+
# We need to PIPE to be able to print the output
|
|
83
|
+
return subprocess.PIPE
|
|
84
|
+
else:
|
|
85
|
+
assert capture_mode is None
|
|
86
|
+
# We need to PIPE to be able to print the output
|
|
87
|
+
return subprocess.PIPE
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class LocalShellBackend(ShellBackend):
|
|
91
|
+
__slots__ = ()
|
|
92
|
+
|
|
93
|
+
def run(
|
|
94
|
+
self,
|
|
95
|
+
cmd: str | list[str],
|
|
96
|
+
*,
|
|
97
|
+
check: bool = True,
|
|
98
|
+
stdout: CaptureMode | None = None,
|
|
99
|
+
stderr: CaptureMode | None = None,
|
|
100
|
+
stdout_display: DisplayMode | display.IODisplay | None = None,
|
|
101
|
+
stderr_display: DisplayMode | display.IODisplay | None = None,
|
|
102
|
+
cwd: str | pathlib.Path | None = None,
|
|
103
|
+
echo_cmd: DisplayMode | display.IODisplay | bool | None = None,
|
|
104
|
+
**kwargs,
|
|
105
|
+
) -> ShellResult:
|
|
106
|
+
if kwargs:
|
|
107
|
+
raise TypeError(f"Uknown argument/s {list(kwargs.keys())}")
|
|
108
|
+
|
|
109
|
+
# If cmd is a str, run as shell
|
|
110
|
+
if isinstance(cmd, str):
|
|
111
|
+
shell = True
|
|
112
|
+
else:
|
|
113
|
+
shell = False
|
|
114
|
+
|
|
115
|
+
# Echo command based on setting
|
|
116
|
+
# Defaults to DEBUG_LOG
|
|
117
|
+
# False to disable
|
|
118
|
+
if isinstance(echo_cmd, bool):
|
|
119
|
+
if echo_cmd:
|
|
120
|
+
echo_cmd = DisplayMode.STDOUT
|
|
121
|
+
else:
|
|
122
|
+
echo_cmd = DisplayMode.DEVNULL
|
|
123
|
+
|
|
124
|
+
if echo_cmd is None:
|
|
125
|
+
echo_cmd = DisplayMode.DEBUG_LOG
|
|
126
|
+
|
|
127
|
+
if isinstance(echo_cmd, display.IODisplay):
|
|
128
|
+
echo_display = echo_cmd
|
|
129
|
+
else:
|
|
130
|
+
echo_display = display.from_mode(echo_cmd)
|
|
131
|
+
|
|
132
|
+
if isinstance(cmd, str):
|
|
133
|
+
echo_display.printline("%s> %s", cwd if cwd else "", cmd)
|
|
134
|
+
else:
|
|
135
|
+
echo_display.printline("%s> %s", cwd if cwd else "", shlex.join(cmd))
|
|
136
|
+
|
|
137
|
+
# Determine PIPE mode for stdout
|
|
138
|
+
stdout_sub_mode = _determine_subprocess_output_mode(
|
|
139
|
+
stdout, stdout_display, "stdout"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Determine PIPE mode for stderr
|
|
143
|
+
stderr_sub_mode = _determine_subprocess_output_mode(
|
|
144
|
+
stderr, stderr_display, "stderr"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Run subprocess command
|
|
148
|
+
with subprocess.Popen(
|
|
149
|
+
cmd, shell=shell, stdout=stdout_sub_mode, stderr=stderr_sub_mode, cwd=cwd
|
|
150
|
+
) as popen:
|
|
151
|
+
with ThreadPool(2) as display_pool:
|
|
152
|
+
# Print out and capture stdout
|
|
153
|
+
stdout_result = display_pool.apply_async(
|
|
154
|
+
_output_and_collect,
|
|
155
|
+
args=[
|
|
156
|
+
popen.stdout,
|
|
157
|
+
(
|
|
158
|
+
stdout_display
|
|
159
|
+
if stdout_display is None
|
|
160
|
+
or isinstance(stdout_display, display.IODisplay)
|
|
161
|
+
else display.from_mode(stdout_display)
|
|
162
|
+
),
|
|
163
|
+
False if stdout == CaptureMode.DEVNULL else True,
|
|
164
|
+
],
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Print out and capture stderr
|
|
168
|
+
stderr_result = display_pool.apply_async(
|
|
169
|
+
_output_and_collect,
|
|
170
|
+
args=[
|
|
171
|
+
popen.stderr,
|
|
172
|
+
(
|
|
173
|
+
stderr_display
|
|
174
|
+
if stderr_display is None
|
|
175
|
+
or isinstance(stderr_display, display.IODisplay)
|
|
176
|
+
else display.from_mode(stderr_display)
|
|
177
|
+
),
|
|
178
|
+
False if stderr == CaptureMode.DEVNULL else True,
|
|
179
|
+
],
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Wait on command completion
|
|
183
|
+
returncode = popen.wait()
|
|
184
|
+
|
|
185
|
+
# Build our result
|
|
186
|
+
result = ShellResult(
|
|
187
|
+
cmd=cmd,
|
|
188
|
+
stdout=stdout_result.get(),
|
|
189
|
+
stderr=stderr_result.get(),
|
|
190
|
+
returncode=returncode,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# If check is True, raise exception if returncode is not 0
|
|
194
|
+
if check:
|
|
195
|
+
result.check_returncode()
|
|
196
|
+
|
|
197
|
+
return result
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class SSHShellBackend(LocalShellBackend, RemoteShellBackend):
|
|
201
|
+
def __init__(self, host: Host) -> None:
|
|
202
|
+
super().__init__()
|
|
203
|
+
self.host: Host = host
|
|
204
|
+
|
|
205
|
+
@staticmethod
|
|
206
|
+
def for_host(host: Host) -> RemoteShellBackend:
|
|
207
|
+
return SSHShellBackend(host)
|
|
208
|
+
|
|
209
|
+
def run(
|
|
210
|
+
self,
|
|
211
|
+
cmd: str | list[str],
|
|
212
|
+
*,
|
|
213
|
+
subshell: bool = True,
|
|
214
|
+
cwd: str | pathlib.Path | None = None,
|
|
215
|
+
**kwargs,
|
|
216
|
+
) -> ShellResult:
|
|
217
|
+
"""
|
|
218
|
+
Execute a remote command using SSH.
|
|
219
|
+
"""
|
|
220
|
+
if isinstance(cmd, list):
|
|
221
|
+
cmd = shlex.join(cmd)
|
|
222
|
+
|
|
223
|
+
# Wrap user command in sh -c call
|
|
224
|
+
if subshell:
|
|
225
|
+
cmd = shlex.join(["bash", "-c", cmd])
|
|
226
|
+
|
|
227
|
+
# cd into cwd first if cwd is set
|
|
228
|
+
if cwd:
|
|
229
|
+
cmd = shlex.join(["cd", str(cwd)]) + " && " + cmd
|
|
230
|
+
|
|
231
|
+
port_flag = ["-p", str(self.host.port)] if self.host.port else []
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
result = super().run(
|
|
235
|
+
["ssh", *port_flag, self.host.host_specifier(), cmd], **kwargs
|
|
236
|
+
)
|
|
237
|
+
if (
|
|
238
|
+
result.returncode == 255
|
|
239
|
+
): # 255 is what ssh returns if it has an error with ssh itself
|
|
240
|
+
raise SSHConnectionError(
|
|
241
|
+
self.host.hostname, result.returncode, result.stdout, result.stderr
|
|
242
|
+
)
|
|
243
|
+
return result
|
|
244
|
+
except subprocess.CalledProcessError as ex:
|
|
245
|
+
if ex.returncode == 255:
|
|
246
|
+
raise SSHConnectionError(
|
|
247
|
+
self.host.hostname, ex.returncode, ex.stdout, ex.stderr
|
|
248
|
+
) from ex
|
|
249
|
+
raise
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from shelltastic.display import IODisplay
|
|
8
|
+
from shelltastic.enum import CaptureMode, DisplayMode
|
|
9
|
+
from shelltastic.exception import ShellException
|
|
10
|
+
from shelltastic.host import Host
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ShellResult:
|
|
15
|
+
cmd: str | list[str]
|
|
16
|
+
returncode: int
|
|
17
|
+
stdout: bytes | None
|
|
18
|
+
stderr: bytes | None
|
|
19
|
+
|
|
20
|
+
def check_returncode(self):
|
|
21
|
+
"""If returncode is not 0, raise ShellException"""
|
|
22
|
+
if self.returncode != 0:
|
|
23
|
+
raise ShellException(self.cmd, self.returncode, self.stdout, self.stderr)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ShellBackend(ABC):
|
|
27
|
+
__slots__ = ()
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def run(
|
|
31
|
+
self,
|
|
32
|
+
cmd: str | list[str],
|
|
33
|
+
*,
|
|
34
|
+
check: bool = True,
|
|
35
|
+
stdout: CaptureMode | None = None,
|
|
36
|
+
stderr: CaptureMode | None = None,
|
|
37
|
+
stdout_display: DisplayMode | IODisplay | None = None,
|
|
38
|
+
stderr_display: DisplayMode | IODisplay | None = None,
|
|
39
|
+
cwd: str | Path | None = None,
|
|
40
|
+
echo_cmd: DisplayMode | IODisplay | bool | None = None,
|
|
41
|
+
**kwargs,
|
|
42
|
+
) -> ShellResult:
|
|
43
|
+
raise NotImplementedError()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RemoteShellBackend(ShellBackend):
|
|
47
|
+
__slots__ = ()
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def for_host(host: Host) -> RemoteShellBackend:
|
|
52
|
+
raise NotImplementedError()
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from shelltastic.enum import DisplayMode
|
|
6
|
+
|
|
7
|
+
LOGGER = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class IODisplay(ABC):
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def printbytes(self, line: bytes):
|
|
13
|
+
raise NotImplementedError
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def printline(self, msg: object, *args: object):
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DevNullDisplay(IODisplay):
|
|
21
|
+
def printbytes(self, line: bytes):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
def printline(self, msg, *args):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class StdoutDisplay(IODisplay):
|
|
29
|
+
def printbytes(self, line: bytes):
|
|
30
|
+
print(line.decode(), end="")
|
|
31
|
+
|
|
32
|
+
def printline(self, msg, *args):
|
|
33
|
+
print(str(msg) % args)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class StderrDisplay(IODisplay):
|
|
37
|
+
def printbytes(self, line: bytes):
|
|
38
|
+
print(line.decode(), end="", file=sys.stderr)
|
|
39
|
+
|
|
40
|
+
def printline(self, msg, *args):
|
|
41
|
+
print(str(msg) % args, file=sys.stderr)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class LogDisplay(IODisplay):
|
|
45
|
+
def __init__(self, log_level) -> None:
|
|
46
|
+
super().__init__()
|
|
47
|
+
self.log_level = log_level
|
|
48
|
+
|
|
49
|
+
def printbytes(self, line: bytes):
|
|
50
|
+
LOGGER.log(self.log_level, line.decode().rstrip(), stacklevel=2)
|
|
51
|
+
|
|
52
|
+
def printline(self, msg: object, *args: object):
|
|
53
|
+
LOGGER.log(self.log_level, msg, *args, stacklevel=2)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CriticalLogDisplay(LogDisplay):
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
super().__init__(logging.CRITICAL)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ErrorLogDisplay(LogDisplay):
|
|
62
|
+
def __init__(self) -> None:
|
|
63
|
+
super().__init__(logging.ERROR)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class WarningLogDisplay(LogDisplay):
|
|
67
|
+
def __init__(self) -> None:
|
|
68
|
+
super().__init__(logging.WARNING)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class InfoLogDisplay(LogDisplay):
|
|
72
|
+
def __init__(self) -> None:
|
|
73
|
+
super().__init__(logging.INFO)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DebugLogDisplay(LogDisplay):
|
|
77
|
+
def __init__(self) -> None:
|
|
78
|
+
super().__init__(logging.DEBUG)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def from_mode(mode: DisplayMode) -> IODisplay:
|
|
82
|
+
"""Get a IODisplay for the given DisplayMode"""
|
|
83
|
+
if mode == DisplayMode.DEVNULL:
|
|
84
|
+
return DevNullDisplay()
|
|
85
|
+
if mode == DisplayMode.STDOUT:
|
|
86
|
+
return StdoutDisplay()
|
|
87
|
+
if mode == DisplayMode.STDERR:
|
|
88
|
+
return StderrDisplay()
|
|
89
|
+
if mode == DisplayMode.CRITICAL_LOG:
|
|
90
|
+
return CriticalLogDisplay()
|
|
91
|
+
if mode == DisplayMode.ERROR_LOG:
|
|
92
|
+
return ErrorLogDisplay()
|
|
93
|
+
if mode == DisplayMode.WARNING_LOG:
|
|
94
|
+
return WarningLogDisplay()
|
|
95
|
+
if mode == DisplayMode.INFO_LOG:
|
|
96
|
+
return InfoLogDisplay()
|
|
97
|
+
if mode == DisplayMode.DEBUG_LOG:
|
|
98
|
+
return DebugLogDisplay()
|
|
99
|
+
raise ValueError(f"Unknown DisplayMode {mode}")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from enum import Enum, auto
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SystemType(Enum):
|
|
5
|
+
DEBIAN_BASED = auto()
|
|
6
|
+
UNKNOWN = auto()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CaptureMode(Enum):
|
|
10
|
+
PIPE = auto()
|
|
11
|
+
STDOUT = auto()
|
|
12
|
+
DEVNULL = auto()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DisplayMode(Enum):
|
|
16
|
+
STDOUT = auto()
|
|
17
|
+
STDERR = auto()
|
|
18
|
+
DEVNULL = auto()
|
|
19
|
+
CRITICAL_LOG = auto()
|
|
20
|
+
ERROR_LOG = auto()
|
|
21
|
+
WARNING_LOG = auto()
|
|
22
|
+
INFO_LOG = auto()
|
|
23
|
+
DEBUG_LOG = auto()
|
|
@@ -10,3 +10,18 @@ class SSHConnectionError(Exception):
|
|
|
10
10
|
self.returncode = returncode
|
|
11
11
|
self.stdout = stdout
|
|
12
12
|
self.stderr = stderr
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ShellException(Exception):
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
cmd: str | list[str],
|
|
19
|
+
returncode: int,
|
|
20
|
+
stdout: bytes | None,
|
|
21
|
+
stderr: bytes | None,
|
|
22
|
+
):
|
|
23
|
+
super().__init__(f"Shell execution failed with returncode {returncode}")
|
|
24
|
+
self.cmd = cmd
|
|
25
|
+
self.returncode = returncode
|
|
26
|
+
self.stdout = stdout
|
|
27
|
+
self.stderr = stderr
|
|
@@ -4,7 +4,7 @@ from typing import overload
|
|
|
4
4
|
|
|
5
5
|
from shelltastic.backend import LocalShellBackend, SSHShellBackend
|
|
6
6
|
from shelltastic.backend.base import ShellBackend
|
|
7
|
-
from shelltastic.enum import
|
|
7
|
+
from shelltastic.enum import CaptureMode
|
|
8
8
|
from shelltastic.frontend.common import RemoteFrontend, RemoteFrontendFactory
|
|
9
9
|
from shelltastic.host import Host
|
|
10
10
|
|
|
@@ -26,9 +26,10 @@ class GitFrontend:
|
|
|
26
26
|
def get_remote_url(self, repo_path: str | Path, remote: str = "origin") -> str:
|
|
27
27
|
result = self._backend.run(
|
|
28
28
|
["git", "remote", "get-url", remote],
|
|
29
|
-
stdout=
|
|
29
|
+
stdout=CaptureMode.PIPE,
|
|
30
30
|
cwd=repo_path,
|
|
31
31
|
)
|
|
32
|
+
assert(result.stdout is not None)
|
|
32
33
|
return result.stdout.decode().strip()
|
|
33
34
|
|
|
34
35
|
def set_remote_url(
|
|
@@ -53,9 +54,10 @@ class GitFrontend:
|
|
|
53
54
|
result = self._backend.run(
|
|
54
55
|
["git", "config", "--global", "--get-all", key],
|
|
55
56
|
check=False,
|
|
56
|
-
stdout=
|
|
57
|
-
stderr=
|
|
57
|
+
stdout=CaptureMode.PIPE,
|
|
58
|
+
stderr=CaptureMode.PIPE,
|
|
58
59
|
)
|
|
60
|
+
assert(result.stdout is not None)
|
|
59
61
|
configs = [i.strip() for i in result.stdout.decode().split("\n") if i.strip()]
|
|
60
62
|
return configs
|
|
61
63
|
|
|
@@ -68,8 +70,8 @@ class GitFrontend:
|
|
|
68
70
|
self._backend.run(
|
|
69
71
|
["git", "config", "unset", "--global", "--all", key],
|
|
70
72
|
check=False,
|
|
71
|
-
stdout=
|
|
72
|
-
stderr=
|
|
73
|
+
stdout=CaptureMode.DEVNULL,
|
|
74
|
+
stderr=CaptureMode.DEVNULL,
|
|
73
75
|
)
|
|
74
76
|
if isinstance(value, str):
|
|
75
77
|
value = [value]
|
|
@@ -82,12 +84,13 @@ class GitFrontend:
|
|
|
82
84
|
response = self._backend.run(
|
|
83
85
|
"cat ~/.git-credentials",
|
|
84
86
|
check=False,
|
|
85
|
-
stdout=
|
|
86
|
-
stderr=
|
|
87
|
+
stdout=CaptureMode.PIPE,
|
|
88
|
+
stderr=CaptureMode.DEVNULL,
|
|
87
89
|
)
|
|
88
90
|
if response.returncode != 0:
|
|
89
91
|
return []
|
|
90
92
|
else:
|
|
93
|
+
assert(response.stdout is not None)
|
|
91
94
|
gitcredentials = response.stdout.decode()
|
|
92
95
|
|
|
93
96
|
return gitcredentials.splitlines()
|
|
@@ -2,7 +2,7 @@ import logging
|
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
|
|
4
4
|
from shelltastic.backend import LocalShellBackend, SSHShellBackend
|
|
5
|
-
from shelltastic.enum import
|
|
5
|
+
from shelltastic.enum import CaptureMode
|
|
6
6
|
from shelltastic.frontend.common import RemoteFrontend, RemoteFrontendFactory
|
|
7
7
|
from shelltastic.host import Host
|
|
8
8
|
|
|
@@ -58,7 +58,7 @@ class SCPFrontend(RemoteFrontend):
|
|
|
58
58
|
source_path,
|
|
59
59
|
dest,
|
|
60
60
|
],
|
|
61
|
-
stdout=
|
|
61
|
+
stdout=CaptureMode.DEVNULL,
|
|
62
62
|
)
|
|
63
63
|
|
|
64
64
|
# Set mode if configured
|
|
@@ -93,7 +93,7 @@ class SCPFrontend(RemoteFrontend):
|
|
|
93
93
|
src,
|
|
94
94
|
destination_path,
|
|
95
95
|
],
|
|
96
|
-
stdout=
|
|
96
|
+
stdout=CaptureMode.DEVNULL,
|
|
97
97
|
)
|
|
98
98
|
if mode:
|
|
99
99
|
LOGGER.info("chmod %s to %s", destination_path, mode)
|
|
@@ -2,11 +2,11 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import shutil
|
|
4
4
|
from pathlib import Path
|
|
5
|
-
from subprocess import CompletedProcess
|
|
6
5
|
|
|
7
6
|
from shelltastic.backend import LocalShellBackend, SSHShellBackend
|
|
8
|
-
from shelltastic.backend.base import ShellBackend
|
|
9
|
-
from shelltastic.
|
|
7
|
+
from shelltastic.backend.base import ShellBackend, ShellResult
|
|
8
|
+
from shelltastic.display import IODisplay
|
|
9
|
+
from shelltastic.enum import CaptureMode, DisplayMode, SystemType
|
|
10
10
|
from shelltastic.frontend.common import RemoteFrontend, RemoteFrontendFactory
|
|
11
11
|
from shelltastic.host import Host
|
|
12
12
|
|
|
@@ -17,11 +17,13 @@ class ShellFrontend:
|
|
|
17
17
|
|
|
18
18
|
def which(self, command: str) -> Path | None:
|
|
19
19
|
"""Return the path to the command or None if the command is not on the path."""
|
|
20
|
-
result = self.run(["which", command], check=False, stdout=
|
|
20
|
+
result = self.run(["which", command], check=False, stdout=CaptureMode.PIPE)
|
|
21
21
|
|
|
22
22
|
if result.returncode != 0:
|
|
23
23
|
return None
|
|
24
24
|
|
|
25
|
+
assert result.stdout is not None
|
|
26
|
+
|
|
25
27
|
return Path(result.stdout.decode().strip())
|
|
26
28
|
|
|
27
29
|
def file_exists(self, path: str | Path) -> bool:
|
|
@@ -29,8 +31,8 @@ class ShellFrontend:
|
|
|
29
31
|
result = self.run(
|
|
30
32
|
f'stat "{path}"',
|
|
31
33
|
check=False,
|
|
32
|
-
stdout=
|
|
33
|
-
stderr=
|
|
34
|
+
stdout=CaptureMode.DEVNULL,
|
|
35
|
+
stderr=CaptureMode.DEVNULL,
|
|
34
36
|
)
|
|
35
37
|
if result.returncode == 0:
|
|
36
38
|
return True
|
|
@@ -39,8 +41,9 @@ class ShellFrontend:
|
|
|
39
41
|
def home_dir(self) -> Path:
|
|
40
42
|
"""Return the path to the current user's home directory"""
|
|
41
43
|
result = self.run(
|
|
42
|
-
"echo ${HOME}", stdout=
|
|
44
|
+
"echo ${HOME}", stdout=CaptureMode.PIPE, stderr=CaptureMode.DEVNULL
|
|
43
45
|
)
|
|
46
|
+
assert result.stdout is not None
|
|
44
47
|
return Path(result.stdout.decode().strip())
|
|
45
48
|
|
|
46
49
|
def system_type(self) -> SystemType:
|
|
@@ -49,8 +52,8 @@ class ShellFrontend:
|
|
|
49
52
|
self.run(
|
|
50
53
|
"stat /etc/debian_version",
|
|
51
54
|
check=False,
|
|
52
|
-
stdout=
|
|
53
|
-
stderr=
|
|
55
|
+
stdout=CaptureMode.DEVNULL,
|
|
56
|
+
stderr=CaptureMode.DEVNULL,
|
|
54
57
|
).returncode
|
|
55
58
|
== 0
|
|
56
59
|
):
|
|
@@ -62,18 +65,22 @@ class ShellFrontend:
|
|
|
62
65
|
cmd: str | list[str],
|
|
63
66
|
*,
|
|
64
67
|
check: bool = True,
|
|
65
|
-
stdout:
|
|
66
|
-
stderr:
|
|
68
|
+
stdout: CaptureMode | None = None,
|
|
69
|
+
stderr: CaptureMode | None = None,
|
|
70
|
+
stdout_display: DisplayMode | IODisplay | None = None,
|
|
71
|
+
stderr_display: DisplayMode | IODisplay | None = None,
|
|
67
72
|
cwd: str | Path | None = None,
|
|
68
|
-
echo_cmd: bool =
|
|
73
|
+
echo_cmd: DisplayMode | IODisplay | bool | None = None,
|
|
69
74
|
**kwargs,
|
|
70
|
-
) ->
|
|
75
|
+
) -> ShellResult:
|
|
71
76
|
"""Run a shell command"""
|
|
72
77
|
return self._backend.run(
|
|
73
78
|
cmd,
|
|
74
79
|
check=check,
|
|
75
80
|
stdout=stdout,
|
|
76
81
|
stderr=stderr,
|
|
82
|
+
stdout_display=stdout_display,
|
|
83
|
+
stderr_display=stderr_display,
|
|
77
84
|
cwd=cwd,
|
|
78
85
|
echo_cmd=echo_cmd,
|
|
79
86
|
**kwargs,
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import logging
|
|
4
|
-
import pathlib
|
|
5
|
-
import shlex
|
|
6
|
-
import subprocess
|
|
7
|
-
import sys
|
|
8
|
-
|
|
9
|
-
from shelltastic.backend.base import RemoteShellBackend, ShellBackend
|
|
10
|
-
from shelltastic.enum import OutputMode
|
|
11
|
-
from shelltastic.exception import SSHConnectionError
|
|
12
|
-
from shelltastic.host import Host
|
|
13
|
-
|
|
14
|
-
LOGGER = logging.getLogger(__name__)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class LocalShellBackend(ShellBackend):
|
|
18
|
-
__slots__ = ()
|
|
19
|
-
|
|
20
|
-
def run(
|
|
21
|
-
self,
|
|
22
|
-
cmd: str | list[str],
|
|
23
|
-
*,
|
|
24
|
-
check: bool = True,
|
|
25
|
-
stdout: OutputMode = OutputMode.PRINT,
|
|
26
|
-
stderr: OutputMode = OutputMode.PRINT,
|
|
27
|
-
cwd: str | pathlib.Path | None = None,
|
|
28
|
-
echo_cmd: bool = False,
|
|
29
|
-
**kwargs,
|
|
30
|
-
) -> subprocess.CompletedProcess[bytes]:
|
|
31
|
-
if kwargs:
|
|
32
|
-
raise TypeError(f"Uknown argument/s {list(kwargs.keys())}")
|
|
33
|
-
|
|
34
|
-
if isinstance(cmd, str):
|
|
35
|
-
LOGGER.debug("%s> %s", cwd if cwd else "", cmd)
|
|
36
|
-
else:
|
|
37
|
-
LOGGER.debug("%s> %s", cwd if cwd else "", shlex.join(cmd))
|
|
38
|
-
|
|
39
|
-
if echo_cmd:
|
|
40
|
-
print(cmd if isinstance(cmd, str) else shlex.join(cmd))
|
|
41
|
-
|
|
42
|
-
cmd_to_run = {}
|
|
43
|
-
cmd_to_run["args"] = cmd
|
|
44
|
-
cmd_to_run["check"] = check
|
|
45
|
-
cmd_to_run["cwd"] = cwd
|
|
46
|
-
|
|
47
|
-
if isinstance(cmd, str):
|
|
48
|
-
cmd_to_run["shell"] = True
|
|
49
|
-
|
|
50
|
-
# stdout
|
|
51
|
-
if stdout == OutputMode.CAPTURE:
|
|
52
|
-
cmd_to_run["stdout"] = subprocess.PIPE
|
|
53
|
-
elif stdout == OutputMode.DEVNULL:
|
|
54
|
-
cmd_to_run["stdout"] = subprocess.DEVNULL
|
|
55
|
-
|
|
56
|
-
# stderr
|
|
57
|
-
if stderr == OutputMode.CAPTURE:
|
|
58
|
-
cmd_to_run["stderr"] = subprocess.PIPE
|
|
59
|
-
elif stderr == OutputMode.DEVNULL:
|
|
60
|
-
cmd_to_run["stderr"] = subprocess.DEVNULL
|
|
61
|
-
|
|
62
|
-
# Flush stdout/stderr
|
|
63
|
-
# This fixes out of order prints between python and commands
|
|
64
|
-
sys.stdout.flush()
|
|
65
|
-
sys.stderr.flush()
|
|
66
|
-
|
|
67
|
-
return subprocess.run(**cmd_to_run)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
class SSHShellBackend(LocalShellBackend, RemoteShellBackend):
|
|
71
|
-
def __init__(self, host: Host) -> None:
|
|
72
|
-
super().__init__()
|
|
73
|
-
self.host: Host = host
|
|
74
|
-
|
|
75
|
-
@staticmethod
|
|
76
|
-
def for_host(host: Host) -> RemoteShellBackend:
|
|
77
|
-
return SSHShellBackend(host)
|
|
78
|
-
|
|
79
|
-
def run(
|
|
80
|
-
self,
|
|
81
|
-
cmd: str | list[str],
|
|
82
|
-
*,
|
|
83
|
-
subshell: bool = True,
|
|
84
|
-
cwd: str | pathlib.Path | None = None,
|
|
85
|
-
**kwargs,
|
|
86
|
-
) -> subprocess.CompletedProcess[bytes]:
|
|
87
|
-
"""
|
|
88
|
-
Execute a remote command using SSH.
|
|
89
|
-
"""
|
|
90
|
-
if isinstance(cmd, list):
|
|
91
|
-
cmd = shlex.join(cmd)
|
|
92
|
-
|
|
93
|
-
# Wrap user command in sh -c call
|
|
94
|
-
if subshell:
|
|
95
|
-
cmd = shlex.join(["bash", "-c", cmd])
|
|
96
|
-
|
|
97
|
-
# cd into cwd first if cwd is set
|
|
98
|
-
if cwd:
|
|
99
|
-
cmd = shlex.join(["cd", str(cwd)]) + " && " + cmd
|
|
100
|
-
|
|
101
|
-
port_flag = ["-p", str(self.host.port)] if self.host.port else []
|
|
102
|
-
|
|
103
|
-
try:
|
|
104
|
-
result = super().run(
|
|
105
|
-
["ssh", *port_flag, self.host.host_specifier(), cmd], **kwargs
|
|
106
|
-
)
|
|
107
|
-
if (
|
|
108
|
-
result.returncode == 255
|
|
109
|
-
): # 255 is what ssh returns if it has an error with ssh itself
|
|
110
|
-
raise SSHConnectionError(
|
|
111
|
-
self.host.hostname, result.returncode, result.stdout, result.stderr
|
|
112
|
-
)
|
|
113
|
-
return result
|
|
114
|
-
except subprocess.CalledProcessError as ex:
|
|
115
|
-
if ex.returncode == 255:
|
|
116
|
-
raise SSHConnectionError(
|
|
117
|
-
self.host.hostname, ex.returncode, ex.stdout, ex.stderr
|
|
118
|
-
) from ex
|
|
119
|
-
raise
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import pathlib
|
|
4
|
-
import subprocess
|
|
5
|
-
from abc import ABC, abstractmethod
|
|
6
|
-
|
|
7
|
-
from shelltastic.enum import OutputMode
|
|
8
|
-
from shelltastic.host import Host
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class ShellBackend(ABC):
|
|
12
|
-
__slots__ = ()
|
|
13
|
-
|
|
14
|
-
@abstractmethod
|
|
15
|
-
def run(
|
|
16
|
-
self,
|
|
17
|
-
cmd: str | list[str],
|
|
18
|
-
*,
|
|
19
|
-
check: bool = True,
|
|
20
|
-
stdout: OutputMode = OutputMode.PRINT,
|
|
21
|
-
stderr: OutputMode = OutputMode.PRINT,
|
|
22
|
-
cwd: str | pathlib.Path | None = None,
|
|
23
|
-
echo_cmd: bool = False,
|
|
24
|
-
**kwargs,
|
|
25
|
-
) -> subprocess.CompletedProcess[bytes]:
|
|
26
|
-
raise NotImplementedError()
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
class RemoteShellBackend(ShellBackend):
|
|
30
|
-
__slots__ = ()
|
|
31
|
-
|
|
32
|
-
@staticmethod
|
|
33
|
-
@abstractmethod
|
|
34
|
-
def for_host(host: Host) -> RemoteShellBackend:
|
|
35
|
-
raise NotImplementedError()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|