shelltastic 0.5.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {shelltastic-0.5.0 → shelltastic-0.6.0}/PKG-INFO +1 -1
  2. {shelltastic-0.5.0 → shelltastic-0.6.0}/pyproject.toml +13 -1
  3. {shelltastic-0.5.0 → shelltastic-0.6.0}/src/shelltastic/backend/base.py +47 -6
  4. {shelltastic-0.5.0 → shelltastic-0.6.0}/src/shelltastic/backend/default/common.py +162 -4
  5. {shelltastic-0.5.0 → shelltastic-0.6.0}/src/shelltastic/exception.py +6 -3
  6. {shelltastic-0.5.0 → shelltastic-0.6.0}/src/shelltastic/frontend/shell.py +29 -2
  7. shelltastic-0.6.0/src/shelltastic/io.py +59 -0
  8. shelltastic-0.6.0/src/shelltastic/result.py +47 -0
  9. shelltastic-0.5.0/src/shelltastic/result.py +0 -16
  10. {shelltastic-0.5.0 → shelltastic-0.6.0}/LICENSE +0 -0
  11. {shelltastic-0.5.0 → shelltastic-0.6.0}/README.md +0 -0
  12. {shelltastic-0.5.0 → shelltastic-0.6.0}/src/shelltastic/__init__.py +0 -0
  13. {shelltastic-0.5.0 → shelltastic-0.6.0}/src/shelltastic/backend/__init__.py +0 -0
  14. {shelltastic-0.5.0 → shelltastic-0.6.0}/src/shelltastic/backend/default/__init__.py +0 -0
  15. {shelltastic-0.5.0 → shelltastic-0.6.0}/src/shelltastic/backend/default/local.py +0 -0
  16. {shelltastic-0.5.0 → shelltastic-0.6.0}/src/shelltastic/backend/default/remote.py +0 -0
  17. {shelltastic-0.5.0 → shelltastic-0.6.0}/src/shelltastic/display.py +0 -0
  18. {shelltastic-0.5.0 → shelltastic-0.6.0}/src/shelltastic/enum.py +0 -0
  19. {shelltastic-0.5.0 → shelltastic-0.6.0}/src/shelltastic/frontend/__init__.py +0 -0
  20. {shelltastic-0.5.0 → shelltastic-0.6.0}/src/shelltastic/frontend/common.py +0 -0
  21. {shelltastic-0.5.0 → shelltastic-0.6.0}/src/shelltastic/frontend/git.py +0 -0
  22. {shelltastic-0.5.0 → shelltastic-0.6.0}/src/shelltastic/frontend/scp.py +0 -0
  23. {shelltastic-0.5.0 → shelltastic-0.6.0}/src/shelltastic/host.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shelltastic
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: A fantastic shell command runner for python
5
5
  Author: Bearmine
6
6
  License-Expression: MPL-2.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "shelltastic"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  description = "A fantastic shell command runner for python"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -43,3 +43,15 @@ extend-select = [
43
43
 
44
44
  [tool.ruff.lint.pycodestyle]
45
45
  max-line-length = 100
46
+
47
+ [dependency-groups]
48
+ dev = [
49
+ {include-group = "lint"},
50
+ {include-group = "test"}
51
+ ]
52
+ lint = [
53
+ "ruff"
54
+ ]
55
+ test = [
56
+ "pytest"
57
+ ]
@@ -5,22 +5,26 @@ from functools import wraps
5
5
  from typing import TYPE_CHECKING, Any
6
6
 
7
7
  if TYPE_CHECKING:
8
+ from io import RawIOBase
8
9
  from pathlib import Path
9
10
 
10
11
  from shelltastic.display import IODisplay
11
12
  from shelltastic.enum import CaptureMode, DisplayMode
12
13
  from shelltastic.host import Host
13
- from shelltastic.result import ShellResult
14
+ from shelltastic.result import ShellResult, ShellResultAsync
14
15
 
15
16
 
16
17
  class BackendShimResult:
18
+ __slots__ = ("args", "kwargs")
19
+
17
20
  def __init__(self, *args, **kwargs) -> None:
18
21
  self.args = args
19
22
  self.kwargs = kwargs
20
23
 
21
24
 
22
25
  class BackendShim(ABC):
23
- @abstractmethod
26
+ __slots__ = ()
27
+
24
28
  def run_shim(
25
29
  self,
26
30
  backend_type: type,
@@ -31,15 +35,33 @@ class BackendShim(ABC):
31
35
  """
32
36
  Shim for run() method of `ShellBackend`
33
37
 
38
+ Use `self.result(...)` to create and return a `BackendShimResult`
39
+
40
+ ```python
41
+ return self.result(*args, **kwargs)
42
+ ```
43
+ """
44
+ return self.result(*args, **kwargs)
45
+
46
+ def run_async_shim(
47
+ self,
48
+ backend_type: type,
49
+ /,
50
+ *args,
51
+ **kwargs,
52
+ ) -> BackendShimResult:
53
+ """
54
+ Shim for run() method of `ShellBackend`
55
+
34
56
  Use `self.run(...)` to create and return a `BackendShimResult`
35
57
 
36
58
  ```python
37
59
  return self.run(*args, **kwargs)
38
60
  ```
39
61
  """
40
- raise NotImplementedError()
62
+ return self.result(*args, **kwargs)
41
63
 
42
- def run(
64
+ def result(
43
65
  self,
44
66
  cmd: str | list[str],
45
67
  *,
@@ -80,7 +102,9 @@ def _shim_run(f, backend_type: type):
80
102
  def wrapper(*args, **kwargs):
81
103
  result: BackendShimResult = BackendShimResult(*args, **kwargs)
82
104
  for shim in _backend_shims:
83
- result = shim.run_shim(backend_type, *result.args, **result.kwargs)
105
+ result = shim.run_shim(
106
+ backend_type, f.__name__, *result.args, **result.kwargs
107
+ )
84
108
  return f(*result.args, **result.kwargs)
85
109
 
86
110
  return wrapper
@@ -90,6 +114,7 @@ class _InstallShims(ABCMeta):
90
114
  def __call__(self, *args: Any, **kwds: Any) -> Any:
91
115
  instance = super().__call__(*args, **kwds)
92
116
  instance.run = _shim_run(instance.run, instance.__class__)
117
+ instance.run_async = _shim_run(instance.run_async, instance.__class__)
93
118
  return instance
94
119
 
95
120
 
@@ -109,7 +134,23 @@ class ShellBackend(metaclass=_InstallShims):
109
134
  cwd: str | Path | None = None,
110
135
  echo_cmd: DisplayMode | IODisplay | bool | None = None,
111
136
  **kwargs,
112
- ) -> ShellResult:
137
+ ) -> ShellResult[bytes | None, bytes | None]:
138
+ raise NotImplementedError()
139
+
140
+ @abstractmethod
141
+ def run_async(
142
+ self,
143
+ cmd: str | list[str],
144
+ *,
145
+ check: bool = True,
146
+ stdout: CaptureMode | None = None,
147
+ stderr: CaptureMode | None = None,
148
+ stdout_display: DisplayMode | IODisplay | None = None,
149
+ stderr_display: DisplayMode | IODisplay | None = None,
150
+ cwd: str | Path | None = None,
151
+ echo_cmd: DisplayMode | IODisplay | bool | None = None,
152
+ **kwargs,
153
+ ) -> ShellResultAsync[RawIOBase | None, RawIOBase | None]:
113
154
  raise NotImplementedError()
114
155
 
115
156
 
@@ -5,15 +5,19 @@ import os
5
5
  import shlex
6
6
  import subprocess
7
7
  import time
8
+ from io import RawIOBase
9
+ from multiprocessing import Pipe
8
10
  from multiprocessing.pool import ThreadPool
9
11
  from typing import IO, TYPE_CHECKING, Literal
10
12
 
11
13
  from shelltastic import display
12
14
  from shelltastic.backend.base import ShellBackend
13
15
  from shelltastic.enum import CaptureMode, DisplayMode
14
- from shelltastic.result import ShellResult
16
+ from shelltastic.io import PipeReader
17
+ from shelltastic.result import ShellResult, ShellResultAsync
15
18
 
16
19
  if TYPE_CHECKING:
20
+ from multiprocessing.connection import Connection
17
21
  from pathlib import Path
18
22
 
19
23
  LOGGER = logging.getLogger(__name__)
@@ -109,6 +113,25 @@ def _output_and_collect(
109
113
  return None
110
114
 
111
115
 
116
+ def _output_and_pipe(
117
+ io: IO[bytes] | None, display: display.IODisplay | None, pipe: Connection | None
118
+ ) -> None:
119
+ if io is None:
120
+ return
121
+
122
+ if pipe:
123
+ if display:
124
+ for line in IOTimeoutStream(io):
125
+ pipe.send_bytes(line)
126
+ display.printbytes(line)
127
+ for line in IOTimeoutStream(io):
128
+ pipe.send_bytes(line)
129
+
130
+ if display:
131
+ for line in IOTimeoutStream(io):
132
+ display.printbytes(line)
133
+
134
+
112
135
  def _determine_subprocess_output_mode(
113
136
  capture_mode: CaptureMode | None,
114
137
  display_mode: DisplayMode | display.IODisplay | None,
@@ -119,6 +142,8 @@ def _determine_subprocess_output_mode(
119
142
  if capture_mode == CaptureMode.PIPE:
120
143
  return subprocess.PIPE
121
144
  if capture_mode == CaptureMode.STDOUT:
145
+ if output_name == "stdout":
146
+ raise ValueError("stdout cannot be piped to stdout")
122
147
  return subprocess.STDOUT
123
148
  if capture_mode == CaptureMode.DEVNULL:
124
149
  return subprocess.DEVNULL
@@ -130,8 +155,7 @@ def _determine_subprocess_output_mode(
130
155
  return subprocess.PIPE
131
156
  if capture_mode == CaptureMode.STDOUT:
132
157
  if output_name == "stdout":
133
- # stdout
134
- return subprocess.PIPE
158
+ raise ValueError("stdout cannot be piped to stdout")
135
159
  # stderr
136
160
  return subprocess.STDOUT
137
161
  if capture_mode == CaptureMode.DEVNULL:
@@ -144,6 +168,8 @@ def _determine_subprocess_output_mode(
144
168
  if capture_mode == CaptureMode.PIPE:
145
169
  return subprocess.PIPE
146
170
  if capture_mode == CaptureMode.STDOUT:
171
+ if output_name == "stdout":
172
+ raise ValueError("stdout cannot be piped to stdout")
147
173
  return subprocess.STDOUT
148
174
  if capture_mode == CaptureMode.DEVNULL:
149
175
  # We need to PIPE to be able to print the output
@@ -153,6 +179,56 @@ def _determine_subprocess_output_mode(
153
179
  return subprocess.PIPE
154
180
 
155
181
 
182
+ class CommonShellAcyncResult(ShellResultAsync[RawIOBase | None, RawIOBase | None]):
183
+ def __init__(
184
+ self,
185
+ cmd: str | list[str],
186
+ popen: subprocess.Popen,
187
+ stdout_display: display.IODisplay | None,
188
+ stderr_display: display.IODisplay | None,
189
+ ) -> None:
190
+ stdout_rcv, stdout_snd = Pipe(False)
191
+ stderr_rcv, stderr_snd = Pipe(False)
192
+ super().__init__(cmd, PipeReader(stdout_rcv), PipeReader(stderr_rcv))
193
+ self._popen = popen
194
+ self._display_pool = ThreadPool(2)
195
+
196
+ # Print out and capture stdout
197
+ self._stdout_result = self._display_pool.apply_async(
198
+ _output_and_pipe,
199
+ args=[
200
+ popen.stdout,
201
+ stdout_display,
202
+ stdout_snd,
203
+ ],
204
+ )
205
+
206
+ # Print out and capture stderr
207
+ self._stderr_result = self._display_pool.apply_async(
208
+ _output_and_pipe,
209
+ args=[
210
+ popen.stderr,
211
+ stderr_display,
212
+ stderr_snd,
213
+ ],
214
+ )
215
+
216
+ def __exit__(self, exc_type, exc, tb):
217
+ self._popen.__exit__(exc_type, exc, tb)
218
+ self._display_pool.__exit__(exc_type, exc, tb)
219
+
220
+ def close(self) -> None:
221
+ self.__exit__(None, None, None)
222
+
223
+ def result(self) -> ShellResult[RawIOBase | None, RawIOBase | None]:
224
+ return ShellResult(
225
+ cmd=self.cmd,
226
+ returncode=self._popen.wait(),
227
+ stdout=self.stdout,
228
+ stderr=self.stderr,
229
+ )
230
+
231
+
156
232
  class CommonDefaultBackend(ShellBackend):
157
233
  __slots__ = ()
158
234
 
@@ -168,7 +244,7 @@ class CommonDefaultBackend(ShellBackend):
168
244
  cwd: str | Path | None = None,
169
245
  echo_cmd: DisplayMode | display.IODisplay | bool | None = None,
170
246
  **kwargs,
171
- ) -> ShellResult:
247
+ ) -> ShellResult[bytes | None, bytes | None]:
172
248
  if kwargs:
173
249
  raise TypeError(f"Uknown argument/s {list(kwargs.keys())}")
174
250
 
@@ -269,3 +345,85 @@ class CommonDefaultBackend(ShellBackend):
269
345
  result.check_returncode()
270
346
 
271
347
  return result
348
+
349
+ def run_async(
350
+ self,
351
+ cmd: str | list[str],
352
+ *,
353
+ check: bool = True,
354
+ stdout: CaptureMode | None = None,
355
+ stderr: CaptureMode | None = None,
356
+ stdout_display: DisplayMode | display.IODisplay | None = None,
357
+ stderr_display: DisplayMode | display.IODisplay | None = None,
358
+ cwd: str | Path | None = None,
359
+ echo_cmd: DisplayMode | display.IODisplay | bool | None = None,
360
+ **kwargs,
361
+ ) -> ShellResultAsync[RawIOBase | None, RawIOBase | None]:
362
+ if kwargs:
363
+ raise TypeError(f"Uknown argument/s {list(kwargs.keys())}")
364
+
365
+ # If cmd is a str, run as shell
366
+ shell = isinstance(cmd, str)
367
+
368
+ # Echo command based on setting
369
+ # Defaults to DEBUG_LOG
370
+ # False to disable
371
+ if isinstance(echo_cmd, bool):
372
+ echo_cmd = DisplayMode.STDOUT if echo_cmd else DisplayMode.DEVNULL
373
+
374
+ if echo_cmd is None:
375
+ echo_cmd = DisplayMode.DEBUG_LOG
376
+
377
+ if isinstance(echo_cmd, display.IODisplay):
378
+ echo_display = echo_cmd
379
+ else:
380
+ echo_display = display.from_mode(echo_cmd)
381
+
382
+ if isinstance(cmd, str):
383
+ echo_display.printline("%s> %s", cwd or "", cmd)
384
+ else:
385
+ echo_display.printline("%s> %s", cwd or "", shlex.join(cmd))
386
+
387
+ # Determine PIPE mode for stdout
388
+ stdout_sub_mode = _determine_subprocess_output_mode(
389
+ stdout, stdout_display, "stdout"
390
+ )
391
+
392
+ # Determine PIPE mode for stderr
393
+ stderr_sub_mode = _determine_subprocess_output_mode(
394
+ stderr, stderr_display, "stderr"
395
+ )
396
+
397
+ if stdout_display is None:
398
+ stdout_display = (
399
+ DisplayMode.STDOUT if stdout is None else DisplayMode.DEVNULL
400
+ )
401
+
402
+ if stderr_display is None:
403
+ stderr_display = (
404
+ DisplayMode.STDERR if stderr is None else DisplayMode.DEVNULL
405
+ )
406
+
407
+ # Run subprocess command
408
+ popen = subprocess.Popen(
409
+ cmd,
410
+ shell=shell,
411
+ stdout=stdout_sub_mode,
412
+ stderr=stderr_sub_mode,
413
+ cwd=cwd,
414
+ )
415
+
416
+ return CommonShellAcyncResult(
417
+ cmd,
418
+ popen,
419
+ (
420
+ stdout_display
421
+ if isinstance(stdout_display, display.IODisplay)
422
+ else display.from_mode(stdout_display)
423
+ ),
424
+ (
425
+ stderr_display
426
+ if isinstance(stderr_display, display.IODisplay)
427
+ else display.from_mode(stderr_display)
428
+ ),
429
+ )
@@ -1,3 +1,6 @@
1
+ from io import RawIOBase
2
+
3
+
1
4
  class SSHConnectionError(Exception):
2
5
  def __init__(
3
6
  self, hostname: str, returncode: int, stdout: bytes | None, stderr: bytes | None
@@ -12,13 +15,13 @@ class SSHConnectionError(Exception):
12
15
  self.stderr = stderr
13
16
 
14
17
 
15
- class ShellException(Exception):
18
+ class ShellException[SO: RawIOBase | bytes | None, SE: RawIOBase | bytes | None](Exception):
16
19
  def __init__(
17
20
  self,
18
21
  cmd: str | list[str],
19
22
  returncode: int,
20
- stdout: bytes | None,
21
- stderr: bytes | None,
23
+ stdout: SO,
24
+ stderr: SE,
22
25
  ):
23
26
  super().__init__(f"Shell execution failed with returncode {returncode}")
24
27
  self.cmd = cmd
@@ -9,10 +9,12 @@ from shelltastic.enum import CaptureMode, DisplayMode, SystemType
9
9
  from shelltastic.frontend.common import RemoteFrontend, RemoteFrontendFactory
10
10
 
11
11
  if TYPE_CHECKING:
12
+ from io import RawIOBase
13
+
12
14
  from shelltastic.backend.base import ShellBackend
13
15
  from shelltastic.display import IODisplay
14
16
  from shelltastic.host import Host
15
- from shelltastic.result import ShellResult
17
+ from shelltastic.result import ShellResult, ShellResultAsync
16
18
 
17
19
 
18
20
  class ShellFrontend:
@@ -74,7 +76,7 @@ class ShellFrontend:
74
76
  cwd: str | Path | None = None,
75
77
  echo_cmd: DisplayMode | IODisplay | bool | None = None,
76
78
  **kwargs,
77
- ) -> ShellResult:
79
+ ) -> ShellResult[bytes | None, bytes | None]:
78
80
  """Run a shell command"""
79
81
  return self._backend.run(
80
82
  cmd,
@@ -88,6 +90,31 @@ class ShellFrontend:
88
90
  **kwargs,
89
91
  )
90
92
 
93
+ def run_async(
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 | IODisplay | None = None,
101
+ stderr_display: DisplayMode | IODisplay | None = None,
102
+ cwd: str | Path | None = None,
103
+ echo_cmd: DisplayMode | IODisplay | bool | None = None,
104
+ **kwargs,
105
+ ) -> ShellResultAsync[RawIOBase | None, RawIOBase | None]:
106
+ return self._backend.run_async(
107
+ cmd,
108
+ check=check,
109
+ stdout=stdout,
110
+ stderr=stderr,
111
+ stdout_display=stdout_display,
112
+ stderr_display=stderr_display,
113
+ cwd=cwd,
114
+ echo_cmd=echo_cmd,
115
+ **kwargs,
116
+ )
117
+
91
118
 
92
119
  class RemoteShellFrontend(ShellFrontend, RemoteFrontend):
93
120
  __slots__ = ()
@@ -0,0 +1,59 @@
1
+ from io import RawIOBase
2
+ from multiprocessing.connection import Connection
3
+
4
+
5
+ class PipeReader(RawIOBase):
6
+ def __init__(self, receiver: Connection) -> None:
7
+ super().__init__()
8
+ if not receiver.readable:
9
+ raise TypeError("Receiver Connection must be readable")
10
+ self.receiver = receiver
11
+ self._last_byte_read: bytes | None = None
12
+
13
+ def readinto(self, buffer) -> int:
14
+ # Fill buffer or flush last read
15
+ with memoryview(buffer) as buf:
16
+ # If there's nothing in our buffer, read bytes
17
+ if self._last_byte_read is None:
18
+ try:
19
+ self._last_byte_read = self.receiver.recv_bytes()
20
+ except EOFError:
21
+ return 0
22
+
23
+ if len(buf) >= len(self._last_byte_read):
24
+ # Buffer is bigger than read bytes
25
+ size = len(self._last_byte_read)
26
+ buf[0:size] = self._last_byte_read
27
+ self._last_byte_read = None
28
+ return size
29
+
30
+ # More bytes read than size of buffer
31
+ size = len(buf)
32
+ buf[0:size] = self._last_byte_read[0:size]
33
+ self._last_byte_read = self._last_byte_read[size:]
34
+ return size
35
+
36
+ def readall(self) -> bytes:
37
+ # Read until EOF
38
+ if self._last_byte_read is None:
39
+ self._last_byte_read = b""
40
+ while True:
41
+ try:
42
+ self._last_byte_read = self._last_byte_read + self.receiver.recv_bytes()
43
+ except EOFError:
44
+ return self._last_byte_read
45
+
46
+ def readable(self) -> bool:
47
+ return True
48
+
49
+ def writable(self) -> bool:
50
+ return False
51
+
52
+ def seekable(self) -> bool:
53
+ return False
54
+
55
+ def fileno(self) -> int:
56
+ return self.receiver.fileno()
57
+
58
+ def close(self) -> None:
59
+ return self.receiver.close()
@@ -0,0 +1,47 @@
1
+ from abc import ABC, abstractmethod
2
+ from dataclasses import dataclass
3
+ from io import RawIOBase
4
+
5
+ from shelltastic.exception import ShellException
6
+
7
+
8
+ @dataclass
9
+ class ShellResult[SO: RawIOBase | bytes | None, SE: RawIOBase | bytes | None]:
10
+ cmd: str | list[str]
11
+ returncode: int
12
+ stdout: SO
13
+ stderr: SE
14
+
15
+ def check_returncode(self):
16
+ """If returncode is not 0, raise ShellException"""
17
+ if self.returncode != 0:
18
+ raise ShellException(self.cmd, self.returncode, self.stdout, self.stderr)
19
+
20
+
21
+ class ShellResultAsync[SO: RawIOBase | None, SE: RawIOBase | None](ABC):
22
+ def __init__(self, cmd: str | list[str], stdout: SO, stderr: SE) -> None:
23
+ self.cmd = cmd
24
+ self.stdout = stdout
25
+ self.stderr = stderr
26
+
27
+ def __enter__(self):
28
+ return self
29
+
30
+ def __exit__(self, exc_type, exc, tb):
31
+ self.close()
32
+
33
+ def close(self) -> None:
34
+ return
35
+
36
+ @abstractmethod
37
+ def result(self) -> ShellResult[SO, SE]:
38
+ raise NotImplementedError()
39
+
40
+ def wait(self) -> None:
41
+ self.result()
42
+
43
+ def returncode(self) -> int:
44
+ return self.result().returncode
45
+
46
+ def check_returncode(self):
47
+ self.result().check_returncode()
@@ -1,16 +0,0 @@
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)
File without changes
File without changes