shelltastic 0.3.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shelltastic
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A fantastic shell command runner for python
5
5
  Author: Bearmine
6
6
  License-Expression: MPL-2.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "shelltastic"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "A fantastic shell command runner for python"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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__ = ["shell", "scp", "git"]
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()
@@ -0,0 +1,27 @@
1
+ class SSHConnectionError(Exception):
2
+ def __init__(
3
+ self, hostname: str, returncode: int, stdout: bytes | None, stderr: bytes | None
4
+ ) -> None:
5
+ if stderr:
6
+ super().__init__(f"{stderr.decode()}")
7
+ else:
8
+ super().__init__(f"Could not connect to {hostname}")
9
+ self.hostname = hostname
10
+ self.returncode = returncode
11
+ self.stdout = stdout
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 OutputMode
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=OutputMode.CAPTURE,
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=OutputMode.CAPTURE,
57
- stderr=OutputMode.CAPTURE,
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=OutputMode.CAPTURE,
72
- stderr=OutputMode.CAPTURE,
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=OutputMode.CAPTURE,
86
- stderr=OutputMode.DEVNULL,
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 OutputMode
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=OutputMode.DEVNULL,
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=OutputMode.DEVNULL,
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.enum import OutputMode, SystemType
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=OutputMode.CAPTURE)
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=OutputMode.DEVNULL,
33
- stderr=OutputMode.DEVNULL,
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=OutputMode.CAPTURE, stderr=OutputMode.DEVNULL
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=OutputMode.DEVNULL,
53
- stderr=OutputMode.DEVNULL,
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: OutputMode = OutputMode.PRINT,
66
- stderr: OutputMode = OutputMode.PRINT,
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 = False,
73
+ echo_cmd: DisplayMode | IODisplay | bool | None = None,
69
74
  **kwargs,
70
- ) -> CompletedProcess[bytes]:
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,
@@ -4,8 +4,8 @@ from dataclasses import dataclass
4
4
  @dataclass(slots=True)
5
5
  class Host:
6
6
  hostname: str
7
- port: int | None
8
- username: str | None
7
+ port: int | None = None
8
+ username: str | None = None
9
9
 
10
10
  def host_specifier(self):
11
11
  if self.username:
@@ -1,132 +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.host import Host
12
-
13
- LOGGER = logging.getLogger(__name__)
14
-
15
-
16
- class LocalShellBackend(ShellBackend):
17
- __slots__ = ()
18
-
19
- def run(
20
- self,
21
- cmd: str | list[str],
22
- *,
23
- check: bool = True,
24
- stdout: OutputMode = OutputMode.PRINT,
25
- stderr: OutputMode = OutputMode.PRINT,
26
- cwd: str | pathlib.Path | None = None,
27
- echo_cmd: bool = False,
28
- **kwargs,
29
- ) -> subprocess.CompletedProcess[bytes]:
30
- if kwargs:
31
- raise TypeError(f"Uknown argument/s {list(kwargs.keys())}")
32
-
33
- if isinstance(cmd, str):
34
- LOGGER.debug("%s> %s", cwd if cwd else "", cmd)
35
- else:
36
- LOGGER.debug("%s> %s", cwd if cwd else "", shlex.join(cmd))
37
-
38
- if echo_cmd:
39
- print(cmd if isinstance(cmd, str) else shlex.join(cmd))
40
-
41
- cmd_to_run = {}
42
- cmd_to_run["args"] = cmd
43
- cmd_to_run["check"] = check
44
- cmd_to_run["cwd"] = cwd
45
-
46
- if isinstance(cmd, str):
47
- cmd_to_run["shell"] = True
48
-
49
- # stdout
50
- if stdout == OutputMode.CAPTURE:
51
- cmd_to_run["stdout"] = subprocess.PIPE
52
- elif stdout == OutputMode.DEVNULL:
53
- cmd_to_run["stdout"] = subprocess.DEVNULL
54
-
55
- # stderr
56
- if stderr == OutputMode.CAPTURE:
57
- cmd_to_run["stderr"] = subprocess.PIPE
58
- elif stderr == OutputMode.DEVNULL:
59
- cmd_to_run["stderr"] = subprocess.DEVNULL
60
-
61
- # Flush stdout/stderr
62
- # This fixes out of order prints between python and commands
63
- sys.stdout.flush()
64
- sys.stderr.flush()
65
-
66
- return subprocess.run(**cmd_to_run)
67
-
68
-
69
- class SSHConnectionError(Exception):
70
- def __init__(
71
- self, hostname: str, returncode: int, stdout: bytes | None, stderr: bytes | None
72
- ) -> None:
73
- if stderr:
74
- super().__init__(f"{stderr.decode()}")
75
- else:
76
- super().__init__(f"Could not connect to {hostname}")
77
- self.hostname = hostname
78
- self.returncode = returncode
79
- self.stdout = stdout
80
- self.stderr = stderr
81
-
82
-
83
- class SSHShellBackend(LocalShellBackend, RemoteShellBackend):
84
- def __init__(self, host: Host) -> None:
85
- super().__init__()
86
- self.host: Host = host
87
-
88
- @staticmethod
89
- def for_host(host: Host) -> RemoteShellBackend:
90
- return SSHShellBackend(host)
91
-
92
- def run(
93
- self,
94
- cmd: str | list[str],
95
- *,
96
- subshell: bool = True,
97
- cwd: str | pathlib.Path | None = None,
98
- **kwargs,
99
- ) -> subprocess.CompletedProcess[bytes]:
100
- """
101
- Execute a remote command using SSH.
102
- """
103
- if isinstance(cmd, list):
104
- cmd = shlex.join(cmd)
105
-
106
- # Wrap user command in sh -c call
107
- if subshell:
108
- cmd = shlex.join(["bash", "-c", cmd])
109
-
110
- # cd into cwd first if cwd is set
111
- if cwd:
112
- cmd = shlex.join(["cd", str(cwd)]) + " && " + cmd
113
-
114
- port_flag = ["-p", str(self.host.port)] if self.host.port else []
115
-
116
- try:
117
- result = super().run(
118
- ["ssh", *port_flag, self.host.host_specifier(), cmd], **kwargs
119
- )
120
- if (
121
- result.returncode == 255
122
- ): # 255 is what ssh returns if it has an error with ssh itself
123
- raise SSHConnectionError(
124
- self.host.hostname, result.returncode, result.stdout, result.stderr
125
- )
126
- return result
127
- except subprocess.CalledProcessError as ex:
128
- if ex.returncode == 255:
129
- raise SSHConnectionError(
130
- self.host.hostname, ex.returncode, ex.stdout, ex.stderr
131
- ) from ex
132
- 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()
@@ -1,12 +0,0 @@
1
- from enum import Enum, auto
2
-
3
-
4
- class OutputMode(Enum):
5
- CAPTURE = auto()
6
- DEVNULL = auto()
7
- PRINT = auto()
8
-
9
-
10
- class SystemType(Enum):
11
- DEBIAN_BASED = auto()
12
- UNKNOWN = auto()
File without changes
File without changes