shelltastic 0.5.0__tar.gz → 0.7.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.7.0}/PKG-INFO +1 -1
  2. {shelltastic-0.5.0 → shelltastic-0.7.0}/pyproject.toml +13 -1
  3. {shelltastic-0.5.0 → shelltastic-0.7.0}/src/shelltastic/backend/base.py +47 -6
  4. {shelltastic-0.5.0 → shelltastic-0.7.0}/src/shelltastic/backend/default/common.py +200 -79
  5. {shelltastic-0.5.0 → shelltastic-0.7.0}/src/shelltastic/backend/default/local.py +0 -2
  6. {shelltastic-0.5.0 → shelltastic-0.7.0}/src/shelltastic/backend/default/remote.py +3 -9
  7. {shelltastic-0.5.0 → shelltastic-0.7.0}/src/shelltastic/exception.py +6 -3
  8. {shelltastic-0.5.0 → shelltastic-0.7.0}/src/shelltastic/frontend/shell.py +29 -2
  9. shelltastic-0.7.0/src/shelltastic/io.py +59 -0
  10. shelltastic-0.7.0/src/shelltastic/result.py +101 -0
  11. shelltastic-0.5.0/src/shelltastic/result.py +0 -16
  12. {shelltastic-0.5.0 → shelltastic-0.7.0}/LICENSE +0 -0
  13. {shelltastic-0.5.0 → shelltastic-0.7.0}/README.md +0 -0
  14. {shelltastic-0.5.0 → shelltastic-0.7.0}/src/shelltastic/__init__.py +0 -0
  15. {shelltastic-0.5.0 → shelltastic-0.7.0}/src/shelltastic/backend/__init__.py +0 -0
  16. {shelltastic-0.5.0 → shelltastic-0.7.0}/src/shelltastic/backend/default/__init__.py +0 -0
  17. {shelltastic-0.5.0 → shelltastic-0.7.0}/src/shelltastic/display.py +0 -0
  18. {shelltastic-0.5.0 → shelltastic-0.7.0}/src/shelltastic/enum.py +0 -0
  19. {shelltastic-0.5.0 → shelltastic-0.7.0}/src/shelltastic/frontend/__init__.py +0 -0
  20. {shelltastic-0.5.0 → shelltastic-0.7.0}/src/shelltastic/frontend/common.py +0 -0
  21. {shelltastic-0.5.0 → shelltastic-0.7.0}/src/shelltastic/frontend/git.py +0 -0
  22. {shelltastic-0.5.0 → shelltastic-0.7.0}/src/shelltastic/frontend/scp.py +0 -0
  23. {shelltastic-0.5.0 → shelltastic-0.7.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.7.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.7.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
 
@@ -1,20 +1,20 @@
1
- from __future__ import annotations
2
-
3
1
  import logging
4
2
  import os
5
3
  import shlex
6
4
  import subprocess
7
5
  import time
6
+ from io import RawIOBase
7
+ from multiprocessing import Pipe
8
+ from multiprocessing.connection import Connection
8
9
  from multiprocessing.pool import ThreadPool
9
- from typing import IO, TYPE_CHECKING, Literal
10
+ from pathlib import Path
11
+ from typing import IO, 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
15
-
16
- if TYPE_CHECKING:
17
- from pathlib import Path
16
+ from shelltastic.io import PipeReader
17
+ from shelltastic.result import ShellResult, ShellResultAsync
18
18
 
19
19
  LOGGER = logging.getLogger(__name__)
20
20
 
@@ -87,26 +87,27 @@ class IOTimeoutStream:
87
87
  return return_val
88
88
 
89
89
 
90
- def _output_and_collect(
91
- io: IO[bytes] | None, display: display.IODisplay | None, collect: bool = True
92
- ) -> bytes | None:
93
- if io is None:
94
- return None
90
+ def _output_and_pipe(
91
+ io: IO[bytes] | None, display: display.IODisplay | None, pipe: Connection | None
92
+ ) -> None:
93
+ try:
94
+ if io is None:
95
+ return
96
+
97
+ if pipe:
98
+ if display:
99
+ for line in IOTimeoutStream(io):
100
+ pipe.send_bytes(line)
101
+ display.printbytes(line)
102
+ for line in IOTimeoutStream(io):
103
+ pipe.send_bytes(line)
95
104
 
96
- if collect:
97
105
  if display:
98
- return_bytes: bytes = b""
99
106
  for line in IOTimeoutStream(io):
100
- return_bytes += line
101
107
  display.printbytes(line)
102
- return return_bytes
103
- return io.read()
104
-
105
- if display:
106
- for line in IOTimeoutStream(io):
107
- display.printbytes(line)
108
-
109
- return None
108
+ finally:
109
+ if pipe:
110
+ pipe.close()
110
111
 
111
112
 
112
113
  def _determine_subprocess_output_mode(
@@ -119,6 +120,8 @@ def _determine_subprocess_output_mode(
119
120
  if capture_mode == CaptureMode.PIPE:
120
121
  return subprocess.PIPE
121
122
  if capture_mode == CaptureMode.STDOUT:
123
+ if output_name == "stdout":
124
+ raise ValueError("stdout cannot be piped to stdout")
122
125
  return subprocess.STDOUT
123
126
  if capture_mode == CaptureMode.DEVNULL:
124
127
  return subprocess.DEVNULL
@@ -130,8 +133,7 @@ def _determine_subprocess_output_mode(
130
133
  return subprocess.PIPE
131
134
  if capture_mode == CaptureMode.STDOUT:
132
135
  if output_name == "stdout":
133
- # stdout
134
- return subprocess.PIPE
136
+ raise ValueError("stdout cannot be piped to stdout")
135
137
  # stderr
136
138
  return subprocess.STDOUT
137
139
  if capture_mode == CaptureMode.DEVNULL:
@@ -144,6 +146,8 @@ def _determine_subprocess_output_mode(
144
146
  if capture_mode == CaptureMode.PIPE:
145
147
  return subprocess.PIPE
146
148
  if capture_mode == CaptureMode.STDOUT:
149
+ if output_name == "stdout":
150
+ raise ValueError("stdout cannot be piped to stdout")
147
151
  return subprocess.STDOUT
148
152
  if capture_mode == CaptureMode.DEVNULL:
149
153
  # We need to PIPE to be able to print the output
@@ -153,6 +157,126 @@ def _determine_subprocess_output_mode(
153
157
  return subprocess.PIPE
154
158
 
155
159
 
160
+ class CommonShellAcyncResult(ShellResultAsync[RawIOBase | None, RawIOBase | None]):
161
+ def __init__(
162
+ self,
163
+ cmd: str | list[str],
164
+ popen: subprocess.Popen,
165
+ stdout_capture: CaptureMode | None,
166
+ stderr_capture: CaptureMode | None,
167
+ stdout_display: display.IODisplay | None,
168
+ stderr_display: display.IODisplay | None,
169
+ check: bool,
170
+ ) -> None:
171
+ _display_pool = None
172
+ _stdout_async_result = None
173
+ _stderr_async_result = None
174
+
175
+ if popen.stdout:
176
+ # If stdout is PIPE, then setup reader
177
+ if stdout_capture == CaptureMode.PIPE:
178
+ stdout_rcv, stdout_snd = Pipe(False)
179
+ stdout = PipeReader(stdout_rcv)
180
+ else:
181
+ stdout_snd = None
182
+ stdout = None
183
+
184
+ if stdout_snd or stdout_display:
185
+ if _display_pool is None:
186
+ _display_pool = ThreadPool(2)
187
+
188
+ _stdout_async_result=_display_pool.apply_async(
189
+ _output_and_pipe,
190
+ args=[
191
+ popen.stdout,
192
+ stdout_display,
193
+ stdout_snd,
194
+ ],
195
+ )
196
+ else:
197
+ stdout = None
198
+
199
+ if popen.stderr:
200
+ if stderr_capture == CaptureMode.PIPE:
201
+ stderr_rcv, stderr_snd = Pipe(False)
202
+ stderr = PipeReader(stderr_rcv)
203
+ else:
204
+ stderr_snd = None
205
+ stderr = None
206
+
207
+ if stderr_snd or stderr_display:
208
+ if _display_pool is None:
209
+ _display_pool = ThreadPool(2)
210
+
211
+ _stderr_async_result = _display_pool.apply_async(
212
+ _output_and_pipe,
213
+ args=[
214
+ popen.stderr,
215
+ stderr_display,
216
+ stderr_snd,
217
+ ],
218
+ )
219
+ else:
220
+ stderr = None
221
+
222
+ super().__init__(cmd, stdout, stderr)
223
+
224
+ self._display_pool = _display_pool
225
+ self._stdout_async_result = _stdout_async_result
226
+ self._stderr_async_result = _stderr_async_result
227
+ self._popen = popen
228
+ self._check = check
229
+
230
+ def __exit__(self, exc_type, exc, tb):
231
+ # Wait for command to finish before closing everything
232
+ self.wait()
233
+
234
+ print("closing popen")
235
+ # Close popen
236
+ self._popen.__exit__(exc_type, exc, tb)
237
+
238
+ if self._display_pool:
239
+ print("closing _display_pool")
240
+ self._display_pool.__exit__(exc_type, exc, tb)
241
+
242
+ # Close stdout and stderr
243
+ if self.stdout:
244
+ print("closing stdout")
245
+ self.stdout.close()
246
+ if self.stderr:
247
+ print("closing stderr")
248
+ self.stderr.close()
249
+
250
+ def close(self) -> None:
251
+ self.__exit__(None, None, None)
252
+
253
+ def terminate(self) -> None:
254
+ self._popen.terminate()
255
+ self.close()
256
+
257
+ def result(self) -> ShellResult[RawIOBase | None, RawIOBase | None]:
258
+ # Get result
259
+ result = ShellResult(
260
+ cmd=self.cmd,
261
+ returncode=self._popen.wait(),
262
+ stdout=self.stdout,
263
+ stderr=self.stderr,
264
+ )
265
+
266
+ # Wait for our async reader to be complete
267
+ if self._stdout_async_result:
268
+ self._stdout_async_result.wait()
269
+
270
+ if self._stderr_async_result:
271
+ self._stderr_async_result.wait()
272
+
273
+ # Check if set
274
+ if self._check:
275
+ result.check_returncode()
276
+
277
+ return result
278
+
279
+
156
280
  class CommonDefaultBackend(ShellBackend):
157
281
  __slots__ = ()
158
282
 
@@ -168,7 +292,33 @@ class CommonDefaultBackend(ShellBackend):
168
292
  cwd: str | Path | None = None,
169
293
  echo_cmd: DisplayMode | display.IODisplay | bool | None = None,
170
294
  **kwargs,
171
- ) -> ShellResult:
295
+ ) -> ShellResult[bytes | None, bytes | None]:
296
+ with self.run_async(
297
+ cmd,
298
+ check=check,
299
+ stdout=stdout,
300
+ stderr=stderr,
301
+ stdout_display=stdout_display,
302
+ stderr_display=stderr_display,
303
+ cwd=cwd,
304
+ echo_cmd=echo_cmd,
305
+ **kwargs,
306
+ ) as running_cmd:
307
+ return running_cmd.result_bytes()
308
+
309
+ def run_async(
310
+ self,
311
+ cmd: str | list[str],
312
+ *,
313
+ check: bool = True,
314
+ stdout: CaptureMode | None = None,
315
+ stderr: CaptureMode | None = None,
316
+ stdout_display: DisplayMode | display.IODisplay | None = None,
317
+ stderr_display: DisplayMode | display.IODisplay | None = None,
318
+ cwd: str | Path | None = None,
319
+ echo_cmd: DisplayMode | display.IODisplay | bool | None = None,
320
+ **kwargs,
321
+ ) -> ShellResultAsync[RawIOBase | None, RawIOBase | None]:
172
322
  if kwargs:
173
323
  raise TypeError(f"Uknown argument/s {list(kwargs.keys())}")
174
324
 
@@ -215,57 +365,28 @@ class CommonDefaultBackend(ShellBackend):
215
365
  )
216
366
 
217
367
  # Run subprocess command
218
- with (
219
- subprocess.Popen(
220
- cmd,
221
- shell=shell,
222
- stdout=stdout_sub_mode,
223
- stderr=stderr_sub_mode,
224
- cwd=cwd,
225
- ) as popen,
226
- ThreadPool(2) as display_pool,
227
- ):
228
- # Print out and capture stdout
229
- stdout_result = display_pool.apply_async(
230
- _output_and_collect,
231
- args=[
232
- popen.stdout,
233
- (
234
- stdout_display
235
- if isinstance(stdout_display, display.IODisplay)
236
- else display.from_mode(stdout_display)
237
- ),
238
- stdout is not None and stdout != CaptureMode.DEVNULL,
239
- ],
240
- )
241
-
242
- # Print out and capture stderr
243
- stderr_result = display_pool.apply_async(
244
- _output_and_collect,
245
- args=[
246
- popen.stderr,
247
- (
248
- stderr_display
249
- if isinstance(stderr_display, display.IODisplay)
250
- else display.from_mode(stderr_display)
251
- ),
252
- stderr is not None and stderr != CaptureMode.DEVNULL,
253
- ],
254
- )
255
-
256
- # Wait on command completion
257
- returncode = popen.wait()
258
-
259
- # Build our result
260
- result = ShellResult(
261
- cmd=cmd,
262
- stdout=stdout_result.get(),
263
- stderr=stderr_result.get(),
264
- returncode=returncode,
265
- )
266
-
267
- # If check is True, raise exception if returncode is not 0
268
- if check:
269
- result.check_returncode()
368
+ popen = subprocess.Popen(
369
+ cmd,
370
+ shell=shell,
371
+ stdout=stdout_sub_mode,
372
+ stderr=stderr_sub_mode,
373
+ cwd=cwd,
374
+ )
270
375
 
271
- return result
376
+ return CommonShellAcyncResult(
377
+ cmd=cmd,
378
+ popen=popen,
379
+ stdout_capture=stdout,
380
+ stderr_capture=stderr,
381
+ stdout_display=(
382
+ stdout_display
383
+ if isinstance(stdout_display, display.IODisplay)
384
+ else display.from_mode(stdout_display)
385
+ ),
386
+ stderr_display=(
387
+ stderr_display
388
+ if isinstance(stderr_display, display.IODisplay)
389
+ else display.from_mode(stderr_display)
390
+ ),
391
+ check=check,
392
+ )
@@ -1,5 +1,3 @@
1
- from __future__ import annotations
2
-
3
1
  import logging
4
2
 
5
3
  from shelltastic.backend.base import LocalShellBackend
@@ -1,19 +1,13 @@
1
- from __future__ import annotations
2
-
3
1
  import logging
4
2
  import shlex
5
- from typing import TYPE_CHECKING
3
+ from pathlib import Path
6
4
 
7
5
  from shelltastic.backend.base import RemoteShellBackend
8
6
  from shelltastic.backend.default.common import CommonDefaultBackend
9
7
  from shelltastic.enum import CaptureMode
10
8
  from shelltastic.exception import ShellException, SSHConnectionError
11
-
12
- if TYPE_CHECKING:
13
- from pathlib import Path
14
-
15
- from shelltastic.host import Host
16
- from shelltastic.result import ShellResult
9
+ from shelltastic.host import Host
10
+ from shelltastic.result import ShellResult
17
11
 
18
12
  LOGGER = logging.getLogger(__name__)
19
13
 
@@ -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,101 @@
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
+ """
23
+ An asynchronsly running shell command.
24
+
25
+ cmd: String for the running shell command.
26
+
27
+ stdout: Bytes stream for stdout
28
+
29
+ stderr: Bytes stream for stderr
30
+ """
31
+
32
+ def __init__(self, cmd: str | list[str], stdout: SO, stderr: SE) -> None:
33
+ self.cmd = cmd
34
+ """The running command"""
35
+
36
+ self.stdout = stdout
37
+ """Byte based IO stream for stdout"""
38
+
39
+ self.stderr = stderr
40
+ """Byte based IO stream for stderr"""
41
+
42
+ def __enter__(self):
43
+ return self
44
+
45
+ def __exit__(self, exc_type, exc, tb):
46
+ self.close()
47
+
48
+ def close(self) -> None:
49
+ """Close the running command."""
50
+ return
51
+
52
+ def terminate(self) -> None:
53
+ """Terminate the running command"""
54
+ raise NotImplementedError()
55
+
56
+ @abstractmethod
57
+ def result(self) -> ShellResult[SO, SE]:
58
+ """
59
+ Blocking
60
+
61
+ Wait for the command to finish and return a ShellResult with the outcome.
62
+ """
63
+ raise NotImplementedError()
64
+
65
+ def wait(self) -> None:
66
+ """
67
+ Blocking
68
+
69
+ Wait for the command to finish.
70
+ """
71
+ self.result()
72
+
73
+ def returncode(self) -> int:
74
+ """
75
+ Blocking
76
+
77
+ Get the returncode of the command.
78
+ """
79
+ return self.result().returncode
80
+
81
+ def check_returncode(self):
82
+ """
83
+ Blocking
84
+
85
+ Raise a ShellException if the returncode is not zero.
86
+ """
87
+ self.result().check_returncode()
88
+
89
+ def result_bytes(self) -> ShellResult[bytes | None, bytes | None]:
90
+ result = self.result()
91
+ stdout = result.stdout
92
+ stdout_bytes = stdout.readall() if stdout is not None else None
93
+ stderr = result.stderr
94
+ stderr_bytes = stderr.readall() if stderr is not None else None
95
+
96
+ return ShellResult(
97
+ cmd=result.cmd,
98
+ returncode=result.returncode,
99
+ stdout=stdout_bytes,
100
+ stderr=stderr_bytes,
101
+ )
@@ -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