shelltastic 0.4.9__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 (24) hide show
  1. {shelltastic-0.4.9 → shelltastic-0.6.0}/PKG-INFO +1 -1
  2. {shelltastic-0.4.9 → shelltastic-0.6.0}/pyproject.toml +13 -1
  3. {shelltastic-0.4.9 → shelltastic-0.6.0}/src/shelltastic/backend/base.py +47 -6
  4. shelltastic-0.6.0/src/shelltastic/backend/default/common.py +429 -0
  5. {shelltastic-0.4.9 → shelltastic-0.6.0}/src/shelltastic/display.py +11 -11
  6. {shelltastic-0.4.9 → shelltastic-0.6.0}/src/shelltastic/exception.py +6 -3
  7. {shelltastic-0.4.9 → shelltastic-0.6.0}/src/shelltastic/frontend/shell.py +29 -2
  8. shelltastic-0.6.0/src/shelltastic/io.py +59 -0
  9. shelltastic-0.6.0/src/shelltastic/result.py +47 -0
  10. shelltastic-0.4.9/src/shelltastic/backend/default/common.py +0 -201
  11. shelltastic-0.4.9/src/shelltastic/result.py +0 -16
  12. {shelltastic-0.4.9 → shelltastic-0.6.0}/LICENSE +0 -0
  13. {shelltastic-0.4.9 → shelltastic-0.6.0}/README.md +0 -0
  14. {shelltastic-0.4.9 → shelltastic-0.6.0}/src/shelltastic/__init__.py +0 -0
  15. {shelltastic-0.4.9 → shelltastic-0.6.0}/src/shelltastic/backend/__init__.py +0 -0
  16. {shelltastic-0.4.9 → shelltastic-0.6.0}/src/shelltastic/backend/default/__init__.py +0 -0
  17. {shelltastic-0.4.9 → shelltastic-0.6.0}/src/shelltastic/backend/default/local.py +0 -0
  18. {shelltastic-0.4.9 → shelltastic-0.6.0}/src/shelltastic/backend/default/remote.py +0 -0
  19. {shelltastic-0.4.9 → shelltastic-0.6.0}/src/shelltastic/enum.py +0 -0
  20. {shelltastic-0.4.9 → shelltastic-0.6.0}/src/shelltastic/frontend/__init__.py +0 -0
  21. {shelltastic-0.4.9 → shelltastic-0.6.0}/src/shelltastic/frontend/common.py +0 -0
  22. {shelltastic-0.4.9 → shelltastic-0.6.0}/src/shelltastic/frontend/git.py +0 -0
  23. {shelltastic-0.4.9 → shelltastic-0.6.0}/src/shelltastic/frontend/scp.py +0 -0
  24. {shelltastic-0.4.9 → 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.4.9
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.4.9"
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
 
@@ -0,0 +1,429 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import shlex
6
+ import subprocess
7
+ import time
8
+ from io import RawIOBase
9
+ from multiprocessing import Pipe
10
+ from multiprocessing.pool import ThreadPool
11
+ from typing import IO, TYPE_CHECKING, Literal
12
+
13
+ from shelltastic import display
14
+ from shelltastic.backend.base import ShellBackend
15
+ from shelltastic.enum import CaptureMode, DisplayMode
16
+ from shelltastic.io import PipeReader
17
+ from shelltastic.result import ShellResult, ShellResultAsync
18
+
19
+ if TYPE_CHECKING:
20
+ from multiprocessing.connection import Connection
21
+ from pathlib import Path
22
+
23
+ LOGGER = logging.getLogger(__name__)
24
+
25
+
26
+ class IOTimeoutStream:
27
+ """
28
+ Iterable for reading from an IO stream. Return lines but
29
+ if no bytes have been read since the given timeout, return
30
+ whatever is in the buffer.
31
+ """
32
+
33
+ def __init__(
34
+ self, io: IO[bytes], *, timeout: float = 0.1, batch_size: int = 1000
35
+ ) -> None:
36
+ self.io = io
37
+ self.buffer = b""
38
+ self.batch_size = batch_size
39
+ self.timeout = timeout
40
+
41
+ def __iter__(self):
42
+ return self
43
+
44
+ def __next__(self) -> bytes:
45
+ last_read_time = time.time()
46
+ # Read into our buffer
47
+ while True:
48
+ # If we have no data in our buffer, then we block until we get data.
49
+ # Otherwise, we're waiting for end of line and should unblock.
50
+ if len(self.buffer) == 0:
51
+ os.set_blocking(self.io.fileno(), True)
52
+ batch_size = 1
53
+ else:
54
+ os.set_blocking(self.io.fileno(), False)
55
+ batch_size = self.batch_size
56
+
57
+ # Read from IO
58
+ batch = self.io.read(batch_size)
59
+
60
+ # Handle return batch
61
+ # None means no data to read, but NOT end of file
62
+ if batch is not None:
63
+ last_read_time = time.time()
64
+ self.buffer += batch
65
+
66
+ # If batch is not none,
67
+ # and the batch was 0 size,
68
+ # and our buffer is empty,
69
+ # we're at EOF
70
+ if len(self.buffer) == 0:
71
+ raise StopIteration()
72
+
73
+ # If buffer contains at least one line, return the first line, save the rest
74
+ if b"\n" in self.buffer:
75
+ buffer_lines = self.buffer.splitlines(keepends=True)
76
+ self.buffer = b""
77
+ for b in buffer_lines[1:]:
78
+ self.buffer += b
79
+ return buffer_lines[0]
80
+
81
+ # If we're at end of file, and we have no lines, just return what we have
82
+ if batch is not None and len(batch) == 0:
83
+ return_val = self.buffer
84
+ self.buffer = b""
85
+ return return_val
86
+
87
+ # Handle timeout
88
+ if time.time() - last_read_time >= self.timeout:
89
+ return_val = self.buffer
90
+ self.buffer = b""
91
+ return return_val
92
+
93
+
94
+ def _output_and_collect(
95
+ io: IO[bytes] | None, display: display.IODisplay | None, collect: bool = True
96
+ ) -> bytes | None:
97
+ if io is None:
98
+ return None
99
+
100
+ if collect:
101
+ if display:
102
+ return_bytes: bytes = b""
103
+ for line in IOTimeoutStream(io):
104
+ return_bytes += line
105
+ display.printbytes(line)
106
+ return return_bytes
107
+ return io.read()
108
+
109
+ if display:
110
+ for line in IOTimeoutStream(io):
111
+ display.printbytes(line)
112
+
113
+ return None
114
+
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
+
135
+ def _determine_subprocess_output_mode(
136
+ capture_mode: CaptureMode | None,
137
+ display_mode: DisplayMode | display.IODisplay | None,
138
+ output_name: Literal["stdout", "stderr"],
139
+ ) -> int:
140
+ # If DisplayMode is None, we act like subprocess does
141
+ if display_mode is None:
142
+ if capture_mode == CaptureMode.PIPE:
143
+ return subprocess.PIPE
144
+ if capture_mode == CaptureMode.STDOUT:
145
+ if output_name == "stdout":
146
+ raise ValueError("stdout cannot be piped to stdout")
147
+ return subprocess.STDOUT
148
+ if capture_mode == CaptureMode.DEVNULL:
149
+ return subprocess.DEVNULL
150
+ return subprocess.PIPE
151
+
152
+ # Don't print
153
+ if display_mode == DisplayMode.DEVNULL:
154
+ if capture_mode == CaptureMode.PIPE:
155
+ return subprocess.PIPE
156
+ if capture_mode == CaptureMode.STDOUT:
157
+ if output_name == "stdout":
158
+ raise ValueError("stdout cannot be piped to stdout")
159
+ # stderr
160
+ return subprocess.STDOUT
161
+ if capture_mode == CaptureMode.DEVNULL:
162
+ return subprocess.DEVNULL
163
+ assert capture_mode is None
164
+ return subprocess.DEVNULL
165
+
166
+ # Print Output
167
+ # display_mode == STDOUT, STDERR, LOG, or is an IODisplay
168
+ if capture_mode == CaptureMode.PIPE:
169
+ return subprocess.PIPE
170
+ if capture_mode == CaptureMode.STDOUT:
171
+ if output_name == "stdout":
172
+ raise ValueError("stdout cannot be piped to stdout")
173
+ return subprocess.STDOUT
174
+ if capture_mode == CaptureMode.DEVNULL:
175
+ # We need to PIPE to be able to print the output
176
+ return subprocess.PIPE
177
+ assert capture_mode is None
178
+ # We need to PIPE to be able to print the output
179
+ return subprocess.PIPE
180
+
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
+
232
+ class CommonDefaultBackend(ShellBackend):
233
+ __slots__ = ()
234
+
235
+ def run(
236
+ self,
237
+ cmd: str | list[str],
238
+ *,
239
+ check: bool = True,
240
+ stdout: CaptureMode | None = None,
241
+ stderr: CaptureMode | None = None,
242
+ stdout_display: DisplayMode | display.IODisplay | None = None,
243
+ stderr_display: DisplayMode | display.IODisplay | None = None,
244
+ cwd: str | Path | None = None,
245
+ echo_cmd: DisplayMode | display.IODisplay | bool | None = None,
246
+ **kwargs,
247
+ ) -> ShellResult[bytes | None, bytes | None]:
248
+ if kwargs:
249
+ raise TypeError(f"Uknown argument/s {list(kwargs.keys())}")
250
+
251
+ # If cmd is a str, run as shell
252
+ shell = isinstance(cmd, str)
253
+
254
+ # Echo command based on setting
255
+ # Defaults to DEBUG_LOG
256
+ # False to disable
257
+ if isinstance(echo_cmd, bool):
258
+ echo_cmd = DisplayMode.STDOUT if echo_cmd else DisplayMode.DEVNULL
259
+
260
+ if echo_cmd is None:
261
+ echo_cmd = DisplayMode.DEBUG_LOG
262
+
263
+ if isinstance(echo_cmd, display.IODisplay):
264
+ echo_display = echo_cmd
265
+ else:
266
+ echo_display = display.from_mode(echo_cmd)
267
+
268
+ if isinstance(cmd, str):
269
+ echo_display.printline("%s> %s", cwd or "", cmd)
270
+ else:
271
+ echo_display.printline("%s> %s", cwd or "", shlex.join(cmd))
272
+
273
+ # Determine PIPE mode for stdout
274
+ stdout_sub_mode = _determine_subprocess_output_mode(
275
+ stdout, stdout_display, "stdout"
276
+ )
277
+
278
+ # Determine PIPE mode for stderr
279
+ stderr_sub_mode = _determine_subprocess_output_mode(
280
+ stderr, stderr_display, "stderr"
281
+ )
282
+
283
+ if stdout_display is None:
284
+ stdout_display = (
285
+ DisplayMode.STDOUT if stdout is None else DisplayMode.DEVNULL
286
+ )
287
+
288
+ if stderr_display is None:
289
+ stderr_display = (
290
+ DisplayMode.STDERR if stderr is None else DisplayMode.DEVNULL
291
+ )
292
+
293
+ # Run subprocess command
294
+ with (
295
+ subprocess.Popen(
296
+ cmd,
297
+ shell=shell,
298
+ stdout=stdout_sub_mode,
299
+ stderr=stderr_sub_mode,
300
+ cwd=cwd,
301
+ ) as popen,
302
+ ThreadPool(2) as display_pool,
303
+ ):
304
+ # Print out and capture stdout
305
+ stdout_result = display_pool.apply_async(
306
+ _output_and_collect,
307
+ args=[
308
+ popen.stdout,
309
+ (
310
+ stdout_display
311
+ if isinstance(stdout_display, display.IODisplay)
312
+ else display.from_mode(stdout_display)
313
+ ),
314
+ stdout is not None and stdout != CaptureMode.DEVNULL,
315
+ ],
316
+ )
317
+
318
+ # Print out and capture stderr
319
+ stderr_result = display_pool.apply_async(
320
+ _output_and_collect,
321
+ args=[
322
+ popen.stderr,
323
+ (
324
+ stderr_display
325
+ if isinstance(stderr_display, display.IODisplay)
326
+ else display.from_mode(stderr_display)
327
+ ),
328
+ stderr is not None and stderr != CaptureMode.DEVNULL,
329
+ ],
330
+ )
331
+
332
+ # Wait on command completion
333
+ returncode = popen.wait()
334
+
335
+ # Build our result
336
+ result = ShellResult(
337
+ cmd=cmd,
338
+ stdout=stdout_result.get(),
339
+ stderr=stderr_result.get(),
340
+ returncode=returncode,
341
+ )
342
+
343
+ # If check is True, raise exception if returncode is not 0
344
+ if check:
345
+ result.check_returncode()
346
+
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
+ )
@@ -13,36 +13,36 @@ _io_display_lock = threading.RLock()
13
13
  class IODisplay(ABC):
14
14
  def printbytes(self, line: bytes):
15
15
  with _io_display_lock:
16
- self._emit_line("%s", line.decode().rstrip())
16
+ self._emit("%s", line.decode(), end="")
17
17
 
18
18
  def printline(self, msg: object, *args: object):
19
19
  with _io_display_lock:
20
- self._emit_line(msg, *args)
20
+ self._emit(msg, *args)
21
21
 
22
22
  @abstractmethod
23
- def _emit_line(self, msg: object, *args: object):
23
+ def _emit(self, msg: object, *args: object, end="\n"):
24
24
  raise NotImplementedError
25
25
 
26
26
 
27
27
  class DevNullDisplay(IODisplay):
28
- def _emit_line(self, msg: object, *args: object):
28
+ def _emit(self, msg: object, *args: object, end="\n"):
29
29
  pass
30
30
 
31
31
 
32
32
  class StdoutDisplay(IODisplay):
33
- def _emit_line(self, msg: object, *args: object):
33
+ def _emit(self, msg: object, *args: object, end="\n"):
34
34
  if args:
35
- print(str(msg) % args, flush=True)
35
+ print(str(msg) % args, flush=True, end=end)
36
36
  return
37
- print(str(msg), flush=True)
37
+ print(str(msg), flush=True, end=end)
38
38
 
39
39
 
40
40
  class StderrDisplay(IODisplay):
41
- def _emit_line(self, msg: object, *args: object):
41
+ def _emit(self, msg: object, *args: object, end="\n"):
42
42
  if args:
43
- print(str(msg) % args, file=sys.stderr, flush=True)
43
+ print(str(msg) % args, file=sys.stderr, flush=True, end=end)
44
44
  return
45
- print(str(msg), file=sys.stderr, flush=True)
45
+ print(str(msg), file=sys.stderr, flush=True, end=end)
46
46
 
47
47
 
48
48
  class LogDisplay(IODisplay):
@@ -50,7 +50,7 @@ class LogDisplay(IODisplay):
50
50
  super().__init__()
51
51
  self.log_level = log_level
52
52
 
53
- def _emit_line(self, msg: object, *args: object):
53
+ def _emit(self, msg: object, *args: object, end="\n"):
54
54
  LOGGER.log(self.log_level, msg, *args, stacklevel=3)
55
55
 
56
56
 
@@ -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,201 +0,0 @@
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
@@ -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