shelltastic 0.3.1__tar.gz → 0.4.1__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.
Files changed (25) hide show
  1. {shelltastic-0.3.1 → shelltastic-0.4.1}/PKG-INFO +1 -1
  2. {shelltastic-0.3.1 → shelltastic-0.4.1}/pyproject.toml +1 -1
  3. shelltastic-0.4.1/src/shelltastic/__init__.py +21 -0
  4. shelltastic-0.4.1/src/shelltastic/backend/__init__.py +35 -0
  5. shelltastic-0.4.1/src/shelltastic/backend/base.py +47 -0
  6. shelltastic-0.4.1/src/shelltastic/backend/default/common.py +196 -0
  7. shelltastic-0.4.1/src/shelltastic/backend/default/local.py +14 -0
  8. shelltastic-0.4.1/src/shelltastic/backend/default/remote.py +65 -0
  9. shelltastic-0.4.1/src/shelltastic/display.py +99 -0
  10. shelltastic-0.4.1/src/shelltastic/enum.py +23 -0
  11. {shelltastic-0.3.1 → shelltastic-0.4.1}/src/shelltastic/exception.py +15 -0
  12. shelltastic-0.4.1/src/shelltastic/frontend/__init__.py +0 -0
  13. {shelltastic-0.3.1 → shelltastic-0.4.1}/src/shelltastic/frontend/git.py +14 -11
  14. {shelltastic-0.3.1 → shelltastic-0.4.1}/src/shelltastic/frontend/scp.py +6 -6
  15. {shelltastic-0.3.1 → shelltastic-0.4.1}/src/shelltastic/frontend/shell.py +23 -15
  16. shelltastic-0.4.1/src/shelltastic/result.py +16 -0
  17. shelltastic-0.3.1/src/shelltastic/__init__.py +0 -9
  18. shelltastic-0.3.1/src/shelltastic/backend/__init__.py +0 -119
  19. shelltastic-0.3.1/src/shelltastic/backend/base.py +0 -35
  20. shelltastic-0.3.1/src/shelltastic/enum.py +0 -12
  21. {shelltastic-0.3.1 → shelltastic-0.4.1}/LICENSE +0 -0
  22. {shelltastic-0.3.1 → shelltastic-0.4.1}/README.md +0 -0
  23. {shelltastic-0.3.1/src/shelltastic/frontend → shelltastic-0.4.1/src/shelltastic/backend/default}/__init__.py +0 -0
  24. {shelltastic-0.3.1 → shelltastic-0.4.1}/src/shelltastic/frontend/common.py +0 -0
  25. {shelltastic-0.3.1 → shelltastic-0.4.1}/src/shelltastic/host.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shelltastic
3
- Version: 0.3.1
3
+ Version: 0.4.1
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.1"
3
+ version = "0.4.1"
4
4
  description = "A fantastic shell command runner for python"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -0,0 +1,21 @@
1
+ from shelltastic.enum import CaptureMode, DisplayMode, SystemType
2
+ from shelltastic.frontend.git import LocalGitFrontend
3
+ from shelltastic.frontend.scp import SCP
4
+ from shelltastic.frontend.shell import LocalShellFrontend
5
+ from shelltastic.host import Host
6
+ from shelltastic.result import ShellResult
7
+
8
+ __all__ = [
9
+ "shell",
10
+ "scp",
11
+ "git",
12
+ "DisplayMode",
13
+ "CaptureMode",
14
+ "SystemType",
15
+ "Host",
16
+ "ShellResult",
17
+ ]
18
+
19
+ shell = LocalShellFrontend()
20
+ scp = SCP()
21
+ git = LocalGitFrontend()
@@ -0,0 +1,35 @@
1
+ from typing import Type
2
+
3
+ from shelltastic.backend.base import LocalShellBackend, RemoteShellBackend
4
+ from shelltastic.backend.default.local import DefaultLocalBackend
5
+ from shelltastic.backend.default.remote import DefaultRemoteBackend
6
+ from shelltastic.host import Host
7
+
8
+ _default_local_backend: Type[LocalShellBackend] = DefaultLocalBackend
9
+ _default_remote_backend: Type[RemoteShellBackend] = DefaultRemoteBackend
10
+
11
+
12
+ def register_default_local_backend(backend: Type[LocalShellBackend]):
13
+ global _default_local_backend
14
+ _default_local_backend = backend
15
+
16
+
17
+ def register_default_remote_backend(backend: Type[RemoteShellBackend]):
18
+ global _default_remote_backend
19
+ _default_remote_backend = backend
20
+
21
+
22
+ def get_local_backend(
23
+ *, backend: Type[LocalShellBackend] | None = None
24
+ ) -> LocalShellBackend:
25
+ if backend:
26
+ return backend.local()
27
+ return _default_local_backend.local()
28
+
29
+
30
+ def get_remote_backend(
31
+ host: Host, *, backend: Type[RemoteShellBackend] | None = None
32
+ ) -> RemoteShellBackend:
33
+ if backend:
34
+ return backend.for_host(host)
35
+ return _default_remote_backend.for_host(host)
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from pathlib import Path
5
+
6
+ from shelltastic.display import IODisplay
7
+ from shelltastic.enum import CaptureMode, DisplayMode
8
+ from shelltastic.host import Host
9
+ from shelltastic.result import ShellResult
10
+
11
+
12
+ class ShellBackend(ABC):
13
+ __slots__ = ()
14
+
15
+ @abstractmethod
16
+ def run(
17
+ self,
18
+ cmd: str | list[str],
19
+ *,
20
+ check: bool = True,
21
+ stdout: CaptureMode | None = None,
22
+ stderr: CaptureMode | None = None,
23
+ stdout_display: DisplayMode | IODisplay | None = None,
24
+ stderr_display: DisplayMode | IODisplay | None = None,
25
+ cwd: str | Path | None = None,
26
+ echo_cmd: DisplayMode | IODisplay | bool | None = None,
27
+ **kwargs,
28
+ ) -> ShellResult:
29
+ raise NotImplementedError()
30
+
31
+
32
+ class LocalShellBackend(ShellBackend):
33
+ __slots__ = ()
34
+
35
+ @staticmethod
36
+ @abstractmethod
37
+ def local() -> LocalShellBackend:
38
+ raise NotImplementedError()
39
+
40
+
41
+ class RemoteShellBackend(ShellBackend):
42
+ __slots__ = ()
43
+
44
+ @staticmethod
45
+ @abstractmethod
46
+ def for_host(host: Host) -> RemoteShellBackend:
47
+ raise NotImplementedError()
@@ -0,0 +1,196 @@
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 ShellBackend
12
+ from shelltastic.enum import CaptureMode, DisplayMode
13
+ from shelltastic.result import ShellResult
14
+
15
+ LOGGER = logging.getLogger(__name__)
16
+
17
+
18
+ def _output_and_collect(
19
+ io: IO[bytes] | None, display: display.IODisplay | None, collect: bool = True
20
+ ) -> bytes | None:
21
+ if io is None:
22
+ return None
23
+
24
+ if collect:
25
+ if display:
26
+ return_bytes = bytes()
27
+ for line in io:
28
+ return_bytes += line
29
+ display.printbytes(line)
30
+ return return_bytes
31
+ else:
32
+ return io.read()
33
+ else:
34
+ if display:
35
+ for line in io:
36
+ display.printbytes(line)
37
+ return None
38
+
39
+
40
+ def _determine_subprocess_output_mode(
41
+ capture_mode: CaptureMode | None,
42
+ display_mode: DisplayMode | display.IODisplay | None,
43
+ output_name: Literal["stdout", "stderr"],
44
+ ) -> int | None:
45
+ # If DisplayMode is None, we act like subpress does
46
+ if display_mode is None:
47
+ if capture_mode == CaptureMode.PIPE:
48
+ return subprocess.PIPE
49
+ elif capture_mode == CaptureMode.STDOUT:
50
+ return subprocess.STDOUT
51
+ elif capture_mode == CaptureMode.DEVNULL:
52
+ return subprocess.DEVNULL
53
+ else:
54
+ assert capture_mode is None
55
+ return None
56
+
57
+ # Don't print
58
+ if display_mode == DisplayMode.DEVNULL:
59
+ if capture_mode == CaptureMode.PIPE:
60
+ return subprocess.PIPE
61
+ elif capture_mode == CaptureMode.STDOUT:
62
+ if output_name == "stdout":
63
+ # stdout
64
+ return subprocess.PIPE
65
+ else:
66
+ # stderr
67
+ return subprocess.STDOUT
68
+ elif capture_mode == CaptureMode.DEVNULL:
69
+ return subprocess.DEVNULL
70
+ else:
71
+ assert capture_mode is None
72
+ return subprocess.DEVNULL
73
+
74
+ # Print Output
75
+ # display_mode == STDOUT, STDERR, LOG, or is an IODisplay
76
+ if capture_mode == CaptureMode.PIPE:
77
+ return subprocess.PIPE
78
+ elif capture_mode == CaptureMode.STDOUT:
79
+ return subprocess.STDOUT
80
+ elif capture_mode == CaptureMode.DEVNULL:
81
+ # We need to PIPE to be able to print the output
82
+ return subprocess.PIPE
83
+ else:
84
+ assert capture_mode is None
85
+ # We need to PIPE to be able to print the output
86
+ return subprocess.PIPE
87
+
88
+
89
+ class CommonDefaultBackend(ShellBackend):
90
+ __slots__ = ()
91
+
92
+ def run(
93
+ self,
94
+ cmd: str | list[str],
95
+ *,
96
+ check: bool = True,
97
+ stdout: CaptureMode | None = None,
98
+ stderr: CaptureMode | None = None,
99
+ stdout_display: DisplayMode | display.IODisplay | None = None,
100
+ stderr_display: DisplayMode | display.IODisplay | None = None,
101
+ cwd: str | pathlib.Path | None = None,
102
+ echo_cmd: DisplayMode | display.IODisplay | bool | None = None,
103
+ **kwargs,
104
+ ) -> ShellResult:
105
+ if kwargs:
106
+ raise TypeError(f"Uknown argument/s {list(kwargs.keys())}")
107
+
108
+ # If cmd is a str, run as shell
109
+ if isinstance(cmd, str):
110
+ shell = True
111
+ else:
112
+ shell = False
113
+
114
+ # Echo command based on setting
115
+ # Defaults to DEBUG_LOG
116
+ # False to disable
117
+ if isinstance(echo_cmd, bool):
118
+ if echo_cmd:
119
+ echo_cmd = DisplayMode.STDOUT
120
+ else:
121
+ echo_cmd = DisplayMode.DEVNULL
122
+
123
+ if echo_cmd is None:
124
+ echo_cmd = DisplayMode.DEBUG_LOG
125
+
126
+ if isinstance(echo_cmd, display.IODisplay):
127
+ echo_display = echo_cmd
128
+ else:
129
+ echo_display = display.from_mode(echo_cmd)
130
+
131
+ if isinstance(cmd, str):
132
+ echo_display.printline("%s> %s", cwd if cwd else "", cmd)
133
+ else:
134
+ echo_display.printline("%s> %s", cwd if cwd else "", shlex.join(cmd))
135
+
136
+ # Determine PIPE mode for stdout
137
+ stdout_sub_mode = _determine_subprocess_output_mode(
138
+ stdout, stdout_display, "stdout"
139
+ )
140
+
141
+ # Determine PIPE mode for stderr
142
+ stderr_sub_mode = _determine_subprocess_output_mode(
143
+ stderr, stderr_display, "stderr"
144
+ )
145
+
146
+ # Run subprocess command
147
+ with subprocess.Popen(
148
+ cmd, shell=shell, stdout=stdout_sub_mode, stderr=stderr_sub_mode, cwd=cwd
149
+ ) as popen:
150
+ with ThreadPool(2) as display_pool:
151
+ # Print out and capture stdout
152
+ stdout_result = display_pool.apply_async(
153
+ _output_and_collect,
154
+ args=[
155
+ popen.stdout,
156
+ (
157
+ stdout_display
158
+ if stdout_display is None
159
+ or isinstance(stdout_display, display.IODisplay)
160
+ else display.from_mode(stdout_display)
161
+ ),
162
+ False if stdout == CaptureMode.DEVNULL else True,
163
+ ],
164
+ )
165
+
166
+ # Print out and capture stderr
167
+ stderr_result = display_pool.apply_async(
168
+ _output_and_collect,
169
+ args=[
170
+ popen.stderr,
171
+ (
172
+ stderr_display
173
+ if stderr_display is None
174
+ or isinstance(stderr_display, display.IODisplay)
175
+ else display.from_mode(stderr_display)
176
+ ),
177
+ False if stderr == CaptureMode.DEVNULL else True,
178
+ ],
179
+ )
180
+
181
+ # Wait on command completion
182
+ returncode = popen.wait()
183
+
184
+ # Build our result
185
+ result = ShellResult(
186
+ cmd=cmd,
187
+ stdout=stdout_result.get(),
188
+ stderr=stderr_result.get(),
189
+ returncode=returncode,
190
+ )
191
+
192
+ # If check is True, raise exception if returncode is not 0
193
+ if check:
194
+ result.check_returncode()
195
+
196
+ return result
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from shelltastic.backend.base import LocalShellBackend
6
+ from shelltastic.backend.default.common import CommonDefaultBackend
7
+
8
+ LOGGER = logging.getLogger(__name__)
9
+
10
+
11
+ class DefaultLocalBackend(CommonDefaultBackend, LocalShellBackend):
12
+ @staticmethod
13
+ def local() -> LocalShellBackend:
14
+ return DefaultLocalBackend()
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import pathlib
5
+ import shlex
6
+
7
+ from shelltastic.backend.base import RemoteShellBackend
8
+ from shelltastic.backend.default.common import CommonDefaultBackend
9
+ from shelltastic.exception import ShellException, SSHConnectionError
10
+ from shelltastic.host import Host
11
+ from shelltastic.result import ShellResult
12
+
13
+ LOGGER = logging.getLogger(__name__)
14
+
15
+
16
+ class DefaultRemoteBackend(CommonDefaultBackend, RemoteShellBackend):
17
+ def __init__(self, host: Host) -> None:
18
+ super().__init__()
19
+ self.host: Host = host
20
+
21
+ @staticmethod
22
+ def for_host(host: Host) -> RemoteShellBackend:
23
+ return DefaultRemoteBackend(host)
24
+
25
+ def run(
26
+ self,
27
+ cmd: str | list[str],
28
+ *,
29
+ subshell: bool = True,
30
+ cwd: str | pathlib.Path | None = None,
31
+ **kwargs,
32
+ ) -> ShellResult:
33
+ """
34
+ Execute a remote command using SSH.
35
+ """
36
+ if isinstance(cmd, list):
37
+ cmd = shlex.join(cmd)
38
+
39
+ # Wrap user command in sh -c call
40
+ if subshell:
41
+ cmd = shlex.join(["bash", "-c", cmd])
42
+
43
+ # cd into cwd first if cwd is set
44
+ if cwd:
45
+ cmd = shlex.join(["cd", str(cwd)]) + " && " + cmd
46
+
47
+ port_flag = ["-p", str(self.host.port)] if self.host.port else []
48
+
49
+ try:
50
+ result = super().run(
51
+ ["ssh", *port_flag, self.host.host_specifier(), cmd], **kwargs
52
+ )
53
+ if (
54
+ result.returncode == 255
55
+ ): # 255 is what ssh returns if it has an error with ssh itself
56
+ raise SSHConnectionError(
57
+ self.host.hostname, result.returncode, result.stdout, result.stderr
58
+ )
59
+ return result
60
+ except ShellException as ex:
61
+ if ex.returncode == 255:
62
+ raise SSHConnectionError(
63
+ self.host.hostname, ex.returncode, ex.stdout, ex.stderr
64
+ ) from ex
65
+ raise
@@ -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
File without changes
@@ -2,9 +2,9 @@ import logging
2
2
  from pathlib import Path
3
3
  from typing import overload
4
4
 
5
- from shelltastic.backend import LocalShellBackend, SSHShellBackend
5
+ from shelltastic import backend
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()
@@ -97,7 +100,7 @@ class RemoteGitFrontend(GitFrontend, RemoteFrontend):
97
100
  __slots__ = ()
98
101
 
99
102
  def __init__(self, host: Host) -> None:
100
- super().__init__(SSHShellBackend.for_host(host))
103
+ super().__init__(backend.get_remote_backend(host))
101
104
  super(GitFrontend, self).__init__(host)
102
105
 
103
106
 
@@ -105,7 +108,7 @@ class LocalGitFrontend(GitFrontend, RemoteFrontendFactory[RemoteGitFrontend]):
105
108
  __slots__ = ()
106
109
 
107
110
  def __init__(self) -> None:
108
- super().__init__(LocalShellBackend())
111
+ super().__init__(backend.get_local_backend())
109
112
 
110
113
  def _create_remote_frontend(self, host: Host) -> RemoteGitFrontend:
111
114
  return RemoteGitFrontend(host)
@@ -1,8 +1,8 @@
1
1
  import logging
2
2
  from pathlib import Path
3
3
 
4
- from shelltastic.backend import LocalShellBackend, SSHShellBackend
5
- from shelltastic.enum import OutputMode
4
+ from shelltastic import backend
5
+ from shelltastic.enum import CaptureMode
6
6
  from shelltastic.frontend.common import RemoteFrontend, RemoteFrontendFactory
7
7
  from shelltastic.host import Host
8
8
 
@@ -12,8 +12,8 @@ LOGGER = logging.getLogger(__name__)
12
12
  class SCPFrontend(RemoteFrontend):
13
13
  def __init__(self, host: Host) -> None:
14
14
  super().__init__(host)
15
- self._local_backend = LocalShellBackend()
16
- self._remote_backend = SSHShellBackend(host)
15
+ self._local_backend = backend.get_local_backend()
16
+ self._remote_backend = backend.get_remote_backend(host)
17
17
 
18
18
  def _scp_flags(self, recursive) -> list[str]:
19
19
  # Set Port
@@ -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,13 +2,14 @@ from __future__ import annotations
2
2
 
3
3
  import shutil
4
4
  from pathlib import Path
5
- from subprocess import CompletedProcess
6
5
 
7
- from shelltastic.backend import LocalShellBackend, SSHShellBackend
6
+ from shelltastic import backend
8
7
  from shelltastic.backend.base import ShellBackend
9
- from shelltastic.enum import OutputMode, SystemType
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
+ from shelltastic.result import ShellResult
12
13
 
13
14
 
14
15
  class ShellFrontend:
@@ -17,11 +18,13 @@ class ShellFrontend:
17
18
 
18
19
  def which(self, command: str) -> Path | None:
19
20
  """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)
21
+ result = self.run(["which", command], check=False, stdout=CaptureMode.PIPE)
21
22
 
22
23
  if result.returncode != 0:
23
24
  return None
24
25
 
26
+ assert result.stdout is not None
27
+
25
28
  return Path(result.stdout.decode().strip())
26
29
 
27
30
  def file_exists(self, path: str | Path) -> bool:
@@ -29,8 +32,8 @@ class ShellFrontend:
29
32
  result = self.run(
30
33
  f'stat "{path}"',
31
34
  check=False,
32
- stdout=OutputMode.DEVNULL,
33
- stderr=OutputMode.DEVNULL,
35
+ stdout=CaptureMode.DEVNULL,
36
+ stderr=CaptureMode.DEVNULL,
34
37
  )
35
38
  if result.returncode == 0:
36
39
  return True
@@ -39,8 +42,9 @@ class ShellFrontend:
39
42
  def home_dir(self) -> Path:
40
43
  """Return the path to the current user's home directory"""
41
44
  result = self.run(
42
- "echo ${HOME}", stdout=OutputMode.CAPTURE, stderr=OutputMode.DEVNULL
45
+ "echo ${HOME}", stdout=CaptureMode.PIPE, stderr=CaptureMode.DEVNULL
43
46
  )
47
+ assert result.stdout is not None
44
48
  return Path(result.stdout.decode().strip())
45
49
 
46
50
  def system_type(self) -> SystemType:
@@ -49,8 +53,8 @@ class ShellFrontend:
49
53
  self.run(
50
54
  "stat /etc/debian_version",
51
55
  check=False,
52
- stdout=OutputMode.DEVNULL,
53
- stderr=OutputMode.DEVNULL,
56
+ stdout=CaptureMode.DEVNULL,
57
+ stderr=CaptureMode.DEVNULL,
54
58
  ).returncode
55
59
  == 0
56
60
  ):
@@ -62,18 +66,22 @@ class ShellFrontend:
62
66
  cmd: str | list[str],
63
67
  *,
64
68
  check: bool = True,
65
- stdout: OutputMode = OutputMode.PRINT,
66
- stderr: OutputMode = OutputMode.PRINT,
69
+ stdout: CaptureMode | None = None,
70
+ stderr: CaptureMode | None = None,
71
+ stdout_display: DisplayMode | IODisplay | None = None,
72
+ stderr_display: DisplayMode | IODisplay | None = None,
67
73
  cwd: str | Path | None = None,
68
- echo_cmd: bool = False,
74
+ echo_cmd: DisplayMode | IODisplay | bool | None = None,
69
75
  **kwargs,
70
- ) -> CompletedProcess[bytes]:
76
+ ) -> ShellResult:
71
77
  """Run a shell command"""
72
78
  return self._backend.run(
73
79
  cmd,
74
80
  check=check,
75
81
  stdout=stdout,
76
82
  stderr=stderr,
83
+ stdout_display=stdout_display,
84
+ stderr_display=stderr_display,
77
85
  cwd=cwd,
78
86
  echo_cmd=echo_cmd,
79
87
  **kwargs,
@@ -84,7 +92,7 @@ class RemoteShellFrontend(ShellFrontend, RemoteFrontend):
84
92
  __slots__ = ()
85
93
 
86
94
  def __init__(self, host: Host) -> None:
87
- super().__init__(SSHShellBackend.for_host(host))
95
+ super().__init__(backend.get_remote_backend(host))
88
96
  super(ShellFrontend, self).__init__(host)
89
97
 
90
98
 
@@ -92,7 +100,7 @@ class LocalShellFrontend(ShellFrontend, RemoteFrontendFactory[RemoteShellFronten
92
100
  __slots__ = ()
93
101
 
94
102
  def __init__(self) -> None:
95
- super().__init__(LocalShellBackend())
103
+ super().__init__(backend.get_local_backend())
96
104
 
97
105
  def _create_remote_frontend(self, host: Host) -> RemoteShellFrontend:
98
106
  return RemoteShellFrontend(host)
@@ -0,0 +1,16 @@
1
+ from dataclasses import dataclass
2
+
3
+ from shelltastic.exception import ShellException
4
+
5
+
6
+ @dataclass
7
+ class ShellResult:
8
+ cmd: str | list[str]
9
+ returncode: int
10
+ stdout: bytes | None
11
+ stderr: bytes | None
12
+
13
+ def check_returncode(self):
14
+ """If returncode is not 0, raise ShellException"""
15
+ if self.returncode != 0:
16
+ raise ShellException(self.cmd, self.returncode, self.stdout, self.stderr)
@@ -1,9 +0,0 @@
1
- from shelltastic.frontend.git import LocalGitFrontend
2
- from shelltastic.frontend.scp import SCP
3
- from shelltastic.frontend.shell import LocalShellFrontend
4
-
5
- __all__ = ["shell", "scp", "git"]
6
-
7
- shell = LocalShellFrontend()
8
- scp = SCP()
9
- git = LocalGitFrontend()
@@ -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()
@@ -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