shelltastic 0.4.0__tar.gz → 0.4.2__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 (23) hide show
  1. {shelltastic-0.4.0 → shelltastic-0.4.2}/PKG-INFO +1 -1
  2. shelltastic-0.4.2/pyproject.toml +45 -0
  3. {shelltastic-0.4.0 → shelltastic-0.4.2}/src/shelltastic/__init__.py +2 -0
  4. shelltastic-0.4.2/src/shelltastic/backend/__init__.py +33 -0
  5. {shelltastic-0.4.0 → shelltastic-0.4.2}/src/shelltastic/backend/base.py +16 -18
  6. shelltastic-0.4.2/src/shelltastic/backend/default/common.py +201 -0
  7. shelltastic-0.4.2/src/shelltastic/backend/default/local.py +14 -0
  8. shelltastic-0.4.2/src/shelltastic/backend/default/remote.py +69 -0
  9. shelltastic-0.4.2/src/shelltastic/frontend/__init__.py +0 -0
  10. {shelltastic-0.4.0 → shelltastic-0.4.2}/src/shelltastic/frontend/common.py +2 -4
  11. {shelltastic-0.4.0 → shelltastic-0.4.2}/src/shelltastic/frontend/git.py +8 -10
  12. {shelltastic-0.4.0 → shelltastic-0.4.2}/src/shelltastic/frontend/scp.py +3 -3
  13. {shelltastic-0.4.0 → shelltastic-0.4.2}/src/shelltastic/frontend/shell.py +11 -9
  14. {shelltastic-0.4.0 → shelltastic-0.4.2}/src/shelltastic/host.py +1 -2
  15. shelltastic-0.4.2/src/shelltastic/result.py +16 -0
  16. shelltastic-0.4.0/pyproject.toml +0 -21
  17. shelltastic-0.4.0/src/shelltastic/backend/__init__.py +0 -249
  18. {shelltastic-0.4.0 → shelltastic-0.4.2}/LICENSE +0 -0
  19. {shelltastic-0.4.0 → shelltastic-0.4.2}/README.md +0 -0
  20. {shelltastic-0.4.0/src/shelltastic/frontend → shelltastic-0.4.2/src/shelltastic/backend/default}/__init__.py +0 -0
  21. {shelltastic-0.4.0 → shelltastic-0.4.2}/src/shelltastic/display.py +0 -0
  22. {shelltastic-0.4.0 → shelltastic-0.4.2}/src/shelltastic/enum.py +0 -0
  23. {shelltastic-0.4.0 → shelltastic-0.4.2}/src/shelltastic/exception.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shelltastic
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: A fantastic shell command runner for python
5
5
  Author: Bearmine
6
6
  License-Expression: MPL-2.0
@@ -0,0 +1,45 @@
1
+ [project]
2
+ name = "shelltastic"
3
+ version = "0.4.2"
4
+ description = "A fantastic shell command runner for python"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Bearmine" }
8
+ ]
9
+ license = "MPL-2.0"
10
+ license-files = [
11
+ "LICENSE"
12
+ ]
13
+ requires-python = ">=3.12"
14
+ dependencies = []
15
+
16
+ [project.urls]
17
+ Repository = "https://forge.bearmine.com/bearmine/shelltastic"
18
+
19
+ [build-system]
20
+ requires = ["uv_build>=0.11.18,<0.12.0"]
21
+ build-backend = "uv_build"
22
+
23
+ [tool.ruff.lint]
24
+ extend-select = [
25
+ "F", # Pyflakes rules
26
+ "W", # PyCodeStyle warnings
27
+ "E", # PyCodeStyle errors
28
+ "I", # Sort imports properly
29
+ "UP", # Warn if certain things can changed due to newer Python versions
30
+ "C4", # Catch incorrect use of comprehensions, dict, list, etc
31
+ "FA", # Enforce from __future__ import annotations
32
+ "ISC", # Good use of string concatenation
33
+ "ICN", # Use common import conventions
34
+ "RET", # Good return practices
35
+ "SIM", # Common simplification rules
36
+ "TID", # Some good import practices
37
+ "TC", # Enforce importing certain types in a TYPE_CHECKING block
38
+ "PTH", # Use pathlib instead of os.path
39
+ "TD", # Be diligent with TODO comments
40
+ "NPY", # Some numpy-specific things
41
+ "FURB", # Suggest more idiomatic Python patterns
42
+ ]
43
+
44
+ [tool.ruff.lint.pycodestyle]
45
+ max-line-length = 100
@@ -3,6 +3,7 @@ from shelltastic.frontend.git import LocalGitFrontend
3
3
  from shelltastic.frontend.scp import SCP
4
4
  from shelltastic.frontend.shell import LocalShellFrontend
5
5
  from shelltastic.host import Host
6
+ from shelltastic.result import ShellResult
6
7
 
7
8
  __all__ = [
8
9
  "shell",
@@ -12,6 +13,7 @@ __all__ = [
12
13
  "CaptureMode",
13
14
  "SystemType",
14
15
  "Host",
16
+ "ShellResult",
15
17
  ]
16
18
 
17
19
  shell = LocalShellFrontend()
@@ -0,0 +1,33 @@
1
+ from shelltastic.backend.base import LocalShellBackend, RemoteShellBackend
2
+ from shelltastic.backend.default.local import DefaultLocalBackend
3
+ from shelltastic.backend.default.remote import DefaultRemoteBackend
4
+ from shelltastic.host import Host
5
+
6
+ _default_local_backend: type[LocalShellBackend] = DefaultLocalBackend
7
+ _default_remote_backend: type[RemoteShellBackend] = DefaultRemoteBackend
8
+
9
+
10
+ def register_default_local_backend(backend: type[LocalShellBackend]):
11
+ global _default_local_backend
12
+ _default_local_backend = backend
13
+
14
+
15
+ def register_default_remote_backend(backend: type[RemoteShellBackend]):
16
+ global _default_remote_backend
17
+ _default_remote_backend = backend
18
+
19
+
20
+ def get_local_backend(
21
+ *, backend: type[LocalShellBackend] | None = None
22
+ ) -> LocalShellBackend:
23
+ if backend:
24
+ return backend.local()
25
+ return _default_local_backend.local()
26
+
27
+
28
+ def get_remote_backend(
29
+ host: Host, *, backend: type[RemoteShellBackend] | None = None
30
+ ) -> RemoteShellBackend:
31
+ if backend:
32
+ return backend.for_host(host)
33
+ return _default_remote_backend.for_host(host)
@@ -1,26 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from abc import ABC, abstractmethod
4
- from dataclasses import dataclass
5
- from pathlib import Path
4
+ from typing import TYPE_CHECKING
6
5
 
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
6
+ if TYPE_CHECKING:
7
+ from pathlib import Path
11
8
 
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)
9
+ from shelltastic.display import IODisplay
10
+ from shelltastic.enum import CaptureMode, DisplayMode
11
+ from shelltastic.host import Host
12
+ from shelltastic.result import ShellResult
24
13
 
25
14
 
26
15
  class ShellBackend(ABC):
@@ -43,6 +32,15 @@ class ShellBackend(ABC):
43
32
  raise NotImplementedError()
44
33
 
45
34
 
35
+ class LocalShellBackend(ShellBackend):
36
+ __slots__ = ()
37
+
38
+ @staticmethod
39
+ @abstractmethod
40
+ def local() -> LocalShellBackend:
41
+ raise NotImplementedError()
42
+
43
+
46
44
  class RemoteShellBackend(ShellBackend):
47
45
  __slots__ = ()
48
46
 
@@ -0,0 +1,201 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import shlex
5
+ import subprocess
6
+ from multiprocessing.pool import ThreadPool
7
+ from typing import IO, TYPE_CHECKING, Literal
8
+
9
+ from shelltastic import display
10
+ from shelltastic.backend.base import ShellBackend
11
+ from shelltastic.enum import CaptureMode, DisplayMode
12
+ from shelltastic.result import ShellResult
13
+
14
+ if TYPE_CHECKING:
15
+ from pathlib import Path
16
+
17
+ LOGGER = logging.getLogger(__name__)
18
+
19
+
20
+ def _output_and_collect(
21
+ io: IO[bytes] | None, display: display.IODisplay | None, collect: bool = True
22
+ ) -> bytes | None:
23
+ if io is None:
24
+ return None
25
+
26
+ if collect:
27
+ if display:
28
+ return_bytes: bytes = b""
29
+ for line in io:
30
+ return_bytes += line
31
+ display.printbytes(line)
32
+ return return_bytes
33
+ return io.read()
34
+
35
+ if display:
36
+ for line in io:
37
+ display.printbytes(line)
38
+
39
+ return None
40
+
41
+
42
+ def _determine_subprocess_output_mode(
43
+ capture_mode: CaptureMode | None,
44
+ display_mode: DisplayMode | display.IODisplay | None,
45
+ output_name: Literal["stdout", "stderr"],
46
+ ) -> int:
47
+ # If DisplayMode is None, we act like subprocess does
48
+ if display_mode is None:
49
+ if capture_mode == CaptureMode.PIPE:
50
+ return subprocess.PIPE
51
+ if capture_mode == CaptureMode.STDOUT:
52
+ return subprocess.STDOUT
53
+ if capture_mode == CaptureMode.DEVNULL:
54
+ return subprocess.DEVNULL
55
+ return subprocess.PIPE
56
+
57
+ # Don't print
58
+ if display_mode == DisplayMode.DEVNULL:
59
+ if capture_mode == CaptureMode.PIPE:
60
+ return subprocess.PIPE
61
+ if capture_mode == CaptureMode.STDOUT:
62
+ if output_name == "stdout":
63
+ # stdout
64
+ return subprocess.PIPE
65
+ # stderr
66
+ return subprocess.STDOUT
67
+ if capture_mode == CaptureMode.DEVNULL:
68
+ return subprocess.DEVNULL
69
+ assert capture_mode is None
70
+ return subprocess.DEVNULL
71
+
72
+ # Print Output
73
+ # display_mode == STDOUT, STDERR, LOG, or is an IODisplay
74
+ if capture_mode == CaptureMode.PIPE:
75
+ return subprocess.PIPE
76
+ if capture_mode == CaptureMode.STDOUT:
77
+ return subprocess.STDOUT
78
+ if capture_mode == CaptureMode.DEVNULL:
79
+ # We need to PIPE to be able to print the output
80
+ return subprocess.PIPE
81
+ assert capture_mode is None
82
+ # We need to PIPE to be able to print the output
83
+ return subprocess.PIPE
84
+
85
+
86
+ class CommonDefaultBackend(ShellBackend):
87
+ __slots__ = ()
88
+
89
+ def run(
90
+ self,
91
+ cmd: str | list[str],
92
+ *,
93
+ check: bool = True,
94
+ stdout: CaptureMode | None = None,
95
+ stderr: CaptureMode | None = None,
96
+ stdout_display: DisplayMode | display.IODisplay | None = None,
97
+ stderr_display: DisplayMode | display.IODisplay | None = None,
98
+ cwd: str | Path | None = None,
99
+ echo_cmd: DisplayMode | display.IODisplay | bool | None = None,
100
+ **kwargs,
101
+ ) -> ShellResult:
102
+ if kwargs:
103
+ raise TypeError(f"Uknown argument/s {list(kwargs.keys())}")
104
+
105
+ # If cmd is a str, run as shell
106
+ shell = isinstance(cmd, str)
107
+
108
+ # Echo command based on setting
109
+ # Defaults to DEBUG_LOG
110
+ # False to disable
111
+ if isinstance(echo_cmd, bool):
112
+ echo_cmd = DisplayMode.STDOUT if echo_cmd else DisplayMode.DEVNULL
113
+
114
+ if echo_cmd is None:
115
+ echo_cmd = DisplayMode.DEBUG_LOG
116
+
117
+ if isinstance(echo_cmd, display.IODisplay):
118
+ echo_display = echo_cmd
119
+ else:
120
+ echo_display = display.from_mode(echo_cmd)
121
+
122
+ if isinstance(cmd, str):
123
+ echo_display.printline("%s> %s", cwd or "", cmd)
124
+ else:
125
+ echo_display.printline("%s> %s", cwd or "", shlex.join(cmd))
126
+
127
+ # Determine PIPE mode for stdout
128
+ stdout_sub_mode = _determine_subprocess_output_mode(
129
+ stdout, stdout_display, "stdout"
130
+ )
131
+
132
+ # Determine PIPE mode for stderr
133
+ stderr_sub_mode = _determine_subprocess_output_mode(
134
+ stderr, stderr_display, "stderr"
135
+ )
136
+
137
+ if stdout_display is None:
138
+ stdout_display = (
139
+ DisplayMode.STDOUT if stdout is None else DisplayMode.DEVNULL
140
+ )
141
+
142
+ if stderr_display is None:
143
+ stderr_display = (
144
+ DisplayMode.STDERR if stderr is None else DisplayMode.DEVNULL
145
+ )
146
+
147
+ # Run subprocess command
148
+ with (
149
+ subprocess.Popen(
150
+ cmd,
151
+ shell=shell,
152
+ stdout=stdout_sub_mode,
153
+ stderr=stderr_sub_mode,
154
+ cwd=cwd,
155
+ ) as popen,
156
+ ThreadPool(2) as display_pool,
157
+ ):
158
+ # Print out and capture stdout
159
+ stdout_result = display_pool.apply_async(
160
+ _output_and_collect,
161
+ args=[
162
+ popen.stdout,
163
+ (
164
+ stdout_display
165
+ if isinstance(stdout_display, display.IODisplay)
166
+ else display.from_mode(stdout_display)
167
+ ),
168
+ stdout is not None and stdout != CaptureMode.DEVNULL,
169
+ ],
170
+ )
171
+
172
+ # Print out and capture stderr
173
+ stderr_result = display_pool.apply_async(
174
+ _output_and_collect,
175
+ args=[
176
+ popen.stderr,
177
+ (
178
+ stderr_display
179
+ if isinstance(stderr_display, display.IODisplay)
180
+ else display.from_mode(stderr_display)
181
+ ),
182
+ stderr is not None and stderr != CaptureMode.DEVNULL,
183
+ ],
184
+ )
185
+
186
+ # Wait on command completion
187
+ returncode = popen.wait()
188
+
189
+ # Build our result
190
+ result = ShellResult(
191
+ cmd=cmd,
192
+ stdout=stdout_result.get(),
193
+ stderr=stderr_result.get(),
194
+ returncode=returncode,
195
+ )
196
+
197
+ # If check is True, raise exception if returncode is not 0
198
+ if check:
199
+ result.check_returncode()
200
+
201
+ 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,69 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import shlex
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shelltastic.backend.base import RemoteShellBackend
8
+ from shelltastic.backend.default.common import CommonDefaultBackend
9
+ from shelltastic.exception import ShellException, SSHConnectionError
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+ from shelltastic.host import Host
15
+ from shelltastic.result import ShellResult
16
+
17
+ LOGGER = logging.getLogger(__name__)
18
+
19
+
20
+ class DefaultRemoteBackend(CommonDefaultBackend, RemoteShellBackend):
21
+ def __init__(self, host: Host) -> None:
22
+ super().__init__()
23
+ self.host: Host = host
24
+
25
+ @staticmethod
26
+ def for_host(host: Host) -> RemoteShellBackend:
27
+ return DefaultRemoteBackend(host)
28
+
29
+ def run(
30
+ self,
31
+ cmd: str | list[str],
32
+ *,
33
+ subshell: bool = True,
34
+ cwd: str | Path | None = None,
35
+ **kwargs,
36
+ ) -> ShellResult:
37
+ """
38
+ Execute a remote command using SSH.
39
+ """
40
+ if isinstance(cmd, list):
41
+ cmd = shlex.join(cmd)
42
+
43
+ # Wrap user command in sh -c call
44
+ if subshell:
45
+ cmd = shlex.join(["bash", "-c", cmd])
46
+
47
+ # cd into cwd first if cwd is set
48
+ if cwd:
49
+ cmd = shlex.join(["cd", str(cwd)]) + " && " + cmd
50
+
51
+ port_flag = ["-p", str(self.host.port)] if self.host.port else []
52
+
53
+ try:
54
+ result = super().run(
55
+ ["ssh", *port_flag, self.host.host_specifier(), cmd], **kwargs
56
+ )
57
+ if (
58
+ result.returncode == 255
59
+ ): # 255 is what ssh returns if it has an error with ssh itself
60
+ raise SSHConnectionError(
61
+ self.host.hostname, result.returncode, result.stdout, result.stderr
62
+ )
63
+ return result
64
+ except ShellException as ex:
65
+ if ex.returncode == 255:
66
+ raise SSHConnectionError(
67
+ self.host.hostname, ex.returncode, ex.stdout, ex.stderr
68
+ ) from ex
69
+ raise
File without changes
@@ -1,5 +1,5 @@
1
1
  from abc import ABC, abstractmethod
2
- from typing import Generic, TypeVar, overload
2
+ from typing import overload
3
3
 
4
4
  from shelltastic.host import Host
5
5
 
@@ -12,10 +12,8 @@ class RemoteFrontend(ABC):
12
12
  return self._host
13
13
 
14
14
 
15
- T = TypeVar("T", bound=RemoteFrontend)
16
15
 
17
-
18
- class RemoteFrontendFactory(Generic[T], ABC):
16
+ class RemoteFrontendFactory[T: RemoteFrontend](ABC):
19
17
  __slots__ = ()
20
18
 
21
19
  @overload
@@ -2,7 +2,7 @@ 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
7
  from shelltastic.enum import CaptureMode
8
8
  from shelltastic.frontend.common import RemoteFrontend, RemoteFrontendFactory
@@ -29,7 +29,7 @@ class GitFrontend:
29
29
  stdout=CaptureMode.PIPE,
30
30
  cwd=repo_path,
31
31
  )
32
- assert(result.stdout is not None)
32
+ assert result.stdout is not None
33
33
  return result.stdout.decode().strip()
34
34
 
35
35
  def set_remote_url(
@@ -57,9 +57,8 @@ class GitFrontend:
57
57
  stdout=CaptureMode.PIPE,
58
58
  stderr=CaptureMode.PIPE,
59
59
  )
60
- assert(result.stdout is not None)
61
- configs = [i.strip() for i in result.stdout.decode().split("\n") if i.strip()]
62
- return configs
60
+ assert result.stdout is not None
61
+ return [i.strip() for i in result.stdout.decode().split("\n") if i.strip()]
63
62
 
64
63
  def set_global_config(self, key: str, value: str | list[str]):
65
64
  LOGGER.info(
@@ -89,10 +88,9 @@ class GitFrontend:
89
88
  )
90
89
  if response.returncode != 0:
91
90
  return []
92
- else:
93
- assert(response.stdout is not None)
94
- gitcredentials = response.stdout.decode()
95
91
 
92
+ assert response.stdout is not None
93
+ gitcredentials = response.stdout.decode()
96
94
  return gitcredentials.splitlines()
97
95
 
98
96
 
@@ -100,7 +98,7 @@ class RemoteGitFrontend(GitFrontend, RemoteFrontend):
100
98
  __slots__ = ()
101
99
 
102
100
  def __init__(self, host: Host) -> None:
103
- super().__init__(SSHShellBackend.for_host(host))
101
+ super().__init__(backend.get_remote_backend(host))
104
102
  super(GitFrontend, self).__init__(host)
105
103
 
106
104
 
@@ -108,7 +106,7 @@ class LocalGitFrontend(GitFrontend, RemoteFrontendFactory[RemoteGitFrontend]):
108
106
  __slots__ = ()
109
107
 
110
108
  def __init__(self) -> None:
111
- super().__init__(LocalShellBackend())
109
+ super().__init__(backend.get_local_backend())
112
110
 
113
111
  def _create_remote_frontend(self, host: Host) -> RemoteGitFrontend:
114
112
  return RemoteGitFrontend(host)
@@ -1,7 +1,7 @@
1
1
  import logging
2
2
  from pathlib import Path
3
3
 
4
- from shelltastic.backend import LocalShellBackend, SSHShellBackend
4
+ from shelltastic import backend
5
5
  from shelltastic.enum import CaptureMode
6
6
  from shelltastic.frontend.common import RemoteFrontend, RemoteFrontendFactory
7
7
  from shelltastic.host import Host
@@ -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
@@ -2,13 +2,17 @@ from __future__ import annotations
2
2
 
3
3
  import shutil
4
4
  from pathlib import Path
5
+ from typing import TYPE_CHECKING
5
6
 
6
- from shelltastic.backend import LocalShellBackend, SSHShellBackend
7
- from shelltastic.backend.base import ShellBackend, ShellResult
8
- from shelltastic.display import IODisplay
7
+ from shelltastic import backend
9
8
  from shelltastic.enum import CaptureMode, DisplayMode, SystemType
10
9
  from shelltastic.frontend.common import RemoteFrontend, RemoteFrontendFactory
11
- from shelltastic.host import Host
10
+
11
+ if TYPE_CHECKING:
12
+ from shelltastic.backend.base import ShellBackend
13
+ from shelltastic.display import IODisplay
14
+ from shelltastic.host import Host
15
+ from shelltastic.result import ShellResult
12
16
 
13
17
 
14
18
  class ShellFrontend:
@@ -34,9 +38,7 @@ class ShellFrontend:
34
38
  stdout=CaptureMode.DEVNULL,
35
39
  stderr=CaptureMode.DEVNULL,
36
40
  )
37
- if result.returncode == 0:
38
- return True
39
- return False
41
+ return result.returncode == 0
40
42
 
41
43
  def home_dir(self) -> Path:
42
44
  """Return the path to the current user's home directory"""
@@ -91,7 +93,7 @@ class RemoteShellFrontend(ShellFrontend, RemoteFrontend):
91
93
  __slots__ = ()
92
94
 
93
95
  def __init__(self, host: Host) -> None:
94
- super().__init__(SSHShellBackend.for_host(host))
96
+ super().__init__(backend.get_remote_backend(host))
95
97
  super(ShellFrontend, self).__init__(host)
96
98
 
97
99
 
@@ -99,7 +101,7 @@ class LocalShellFrontend(ShellFrontend, RemoteFrontendFactory[RemoteShellFronten
99
101
  __slots__ = ()
100
102
 
101
103
  def __init__(self) -> None:
102
- super().__init__(LocalShellBackend())
104
+ super().__init__(backend.get_local_backend())
103
105
 
104
106
  def _create_remote_frontend(self, host: Host) -> RemoteShellFrontend:
105
107
  return RemoteShellFrontend(host)
@@ -10,5 +10,4 @@ class Host:
10
10
  def host_specifier(self):
11
11
  if self.username:
12
12
  return f"{self.username}@{self.hostname}"
13
- else:
14
- return self.hostname
13
+ return self.hostname
@@ -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,21 +0,0 @@
1
- [project]
2
- name = "shelltastic"
3
- version = "0.4.0"
4
- description = "A fantastic shell command runner for python"
5
- readme = "README.md"
6
- authors = [
7
- { name = "Bearmine" }
8
- ]
9
- license = "MPL-2.0"
10
- license-files = [
11
- "LICENSE"
12
- ]
13
- requires-python = ">=3.12"
14
- dependencies = []
15
-
16
- [project.urls]
17
- Repository = "https://forge.bearmine.com/bearmine/shelltastic"
18
-
19
- [build-system]
20
- requires = ["uv_build>=0.11.18,<0.12.0"]
21
- build-backend = "uv_build"
@@ -1,249 +0,0 @@
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
File without changes
File without changes