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.
- {shelltastic-0.4.0 → shelltastic-0.4.2}/PKG-INFO +1 -1
- shelltastic-0.4.2/pyproject.toml +45 -0
- {shelltastic-0.4.0 → shelltastic-0.4.2}/src/shelltastic/__init__.py +2 -0
- shelltastic-0.4.2/src/shelltastic/backend/__init__.py +33 -0
- {shelltastic-0.4.0 → shelltastic-0.4.2}/src/shelltastic/backend/base.py +16 -18
- shelltastic-0.4.2/src/shelltastic/backend/default/common.py +201 -0
- shelltastic-0.4.2/src/shelltastic/backend/default/local.py +14 -0
- shelltastic-0.4.2/src/shelltastic/backend/default/remote.py +69 -0
- shelltastic-0.4.2/src/shelltastic/frontend/__init__.py +0 -0
- {shelltastic-0.4.0 → shelltastic-0.4.2}/src/shelltastic/frontend/common.py +2 -4
- {shelltastic-0.4.0 → shelltastic-0.4.2}/src/shelltastic/frontend/git.py +8 -10
- {shelltastic-0.4.0 → shelltastic-0.4.2}/src/shelltastic/frontend/scp.py +3 -3
- {shelltastic-0.4.0 → shelltastic-0.4.2}/src/shelltastic/frontend/shell.py +11 -9
- {shelltastic-0.4.0 → shelltastic-0.4.2}/src/shelltastic/host.py +1 -2
- shelltastic-0.4.2/src/shelltastic/result.py +16 -0
- shelltastic-0.4.0/pyproject.toml +0 -21
- shelltastic-0.4.0/src/shelltastic/backend/__init__.py +0 -249
- {shelltastic-0.4.0 → shelltastic-0.4.2}/LICENSE +0 -0
- {shelltastic-0.4.0 → shelltastic-0.4.2}/README.md +0 -0
- {shelltastic-0.4.0/src/shelltastic/frontend → shelltastic-0.4.2/src/shelltastic/backend/default}/__init__.py +0 -0
- {shelltastic-0.4.0 → shelltastic-0.4.2}/src/shelltastic/display.py +0 -0
- {shelltastic-0.4.0 → shelltastic-0.4.2}/src/shelltastic/enum.py +0 -0
- {shelltastic-0.4.0 → shelltastic-0.4.2}/src/shelltastic/exception.py +0 -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
|
|
5
|
-
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
from
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
61
|
-
|
|
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__(
|
|
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__(
|
|
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
|
|
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 =
|
|
16
|
-
self._remote_backend =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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__(
|
|
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__(
|
|
104
|
+
super().__init__(backend.get_local_backend())
|
|
103
105
|
|
|
104
106
|
def _create_remote_frontend(self, host: Host) -> RemoteShellFrontend:
|
|
105
107
|
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)
|
shelltastic-0.4.0/pyproject.toml
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|