Skip to content

Commit f3b9a35

Browse files
authored
Merge pull request #655 from python-cmd2/capture_popen
Capture popen
2 parents 6fd9cc6 + 626a01a commit f3b9a35

File tree

11 files changed

+847
-671
lines changed

11 files changed

+847
-671
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
`argparse.Namespace` object they pass to the `do_*` methods. It is stored in an attribute called `__statement__`.
1414
This can be useful if a command function needs to know the command line for things like logging.
1515
* Added a `-t` option to the `load` command for automatically generating a transcript based on a script file
16+
* When in a *pyscript*, the stdout and stderr streams of shell commands and processes being piped to are now
17+
captured and included in the ``CommandResult`` structure.
1618
* Potentially breaking changes
1719
* The following commands now write to stderr instead of stdout when printing an error. This will make catching
1820
errors easier in pyscript.
@@ -24,6 +26,9 @@
2426
* Added ``allow_redirection``, ``terminators``, ``multiline_commands``, and ``shortcuts`` as optional arguments
2527
to ``cmd.Cmd.__init__()`
2628
* A few instance attributes were moved inside ``StatementParser`` and properties were created for accessing them
29+
* ``self.pipe_proc`` is now called ``self.cur_pipe_proc_reader`` and is a ``ProcReader`` class.
30+
* Shell commands and commands being piped to while in a *pyscript* will function as if their output is going
31+
to a pipe and not a tty. This was necessary to be able to capture their output.
2732

2833
## 0.9.11 (March 13, 2019)
2934
* Bug Fixes

cmd2/cmd2.py

Lines changed: 196 additions & 157 deletions
Large diffs are not rendered by default.

cmd2/utils.py

Lines changed: 171 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
#
21
# coding=utf-8
32
"""Shared utility functions"""
43

54
import collections
65
import os
76
import re
7+
import subprocess
88
import sys
9+
import threading
910
import unicodedata
10-
from typing import Any, Iterable, List, Optional, Union
11+
from typing import Any, BinaryIO, Iterable, List, Optional, TextIO, Union
1112

1213
from wcwidth import wcswidth
1314

@@ -140,7 +141,6 @@ def which(editor: str) -> Optional[str]:
140141
:param editor: filename of the editor to check, ie 'notepad.exe' or 'vi'
141142
:return: a full path or None
142143
"""
143-
import subprocess
144144
try:
145145
editor_path = subprocess.check_output(['which', editor], stderr=subprocess.STDOUT).strip()
146146
editor_path = editor_path.decode()
@@ -262,6 +262,32 @@ def natural_sort(list_to_sort: Iterable[str]) -> List[str]:
262262
return sorted(list_to_sort, key=natural_keys)
263263

264264

265+
def unquote_redirection_tokens(args: List[str]) -> None:
266+
"""
267+
Unquote redirection tokens in a list of command-line arguments
268+
This is used when redirection tokens have to be passed to another command
269+
:param args: the command line args
270+
"""
271+
for i, arg in enumerate(args):
272+
unquoted_arg = strip_quotes(arg)
273+
if unquoted_arg in constants.REDIRECTION_TOKENS:
274+
args[i] = unquoted_arg
275+
276+
277+
def find_editor() -> str:
278+
"""Find a reasonable editor to use by default for the system that the cmd2 application is running on."""
279+
editor = os.environ.get('EDITOR')
280+
if not editor:
281+
if sys.platform[:3] == 'win':
282+
editor = 'notepad'
283+
else:
284+
# Favor command-line editors first so we don't leave the terminal to edit
285+
for editor in ['vim', 'vi', 'emacs', 'nano', 'pico', 'gedit', 'kate', 'subl', 'geany', 'atom']:
286+
if which(editor):
287+
break
288+
return editor
289+
290+
265291
class StdSim(object):
266292
"""
267293
Class to simulate behavior of sys.stdout or sys.stderr.
@@ -315,7 +341,14 @@ def readbytes(self) -> bytes:
315341

316342
def clear(self) -> None:
317343
"""Clear the internal contents"""
318-
self.buffer.byte_buf = b''
344+
self.buffer.byte_buf = bytearray()
345+
346+
def isatty(self) -> bool:
347+
"""StdSim only considered an interactive stream if `echo` is True and `inner_stream` is a tty."""
348+
if self.echo:
349+
return self.inner_stream.isatty()
350+
else:
351+
return False
319352

320353
def __getattr__(self, item: str):
321354
if item in self.__dict__:
@@ -329,7 +362,7 @@ class ByteBuf(object):
329362
Used by StdSim to write binary data and stores the actual bytes written
330363
"""
331364
def __init__(self, std_sim_instance: StdSim) -> None:
332-
self.byte_buf = b''
365+
self.byte_buf = bytearray()
333366
self.std_sim_instance = std_sim_instance
334367

335368
def write(self, b: bytes) -> None:
@@ -342,27 +375,140 @@ def write(self, b: bytes) -> None:
342375
self.std_sim_instance.inner_stream.buffer.write(b)
343376

344377

345-
def unquote_redirection_tokens(args: List[str]) -> None:
378+
class ProcReader(object):
346379
"""
347-
Unquote redirection tokens in a list of command-line arguments
348-
This is used when redirection tokens have to be passed to another command
349-
:param args: the command line args
380+
Used to captured stdout and stderr from a Popen process if any of those were set to subprocess.PIPE.
381+
If neither are pipes, then the process will run normally and no output will be captured.
350382
"""
351-
for i, arg in enumerate(args):
352-
unquoted_arg = strip_quotes(arg)
353-
if unquoted_arg in constants.REDIRECTION_TOKENS:
354-
args[i] = unquoted_arg
383+
def __init__(self, proc: subprocess.Popen, stdout: Union[StdSim, BinaryIO, TextIO],
384+
stderr: Union[StdSim, BinaryIO, TextIO]) -> None:
385+
"""
386+
ProcReader initializer
387+
:param proc: the Popen process being read from
388+
:param stdout: the stream to write captured stdout
389+
:param stderr: the stream to write captured stderr
390+
"""
391+
self._proc = proc
392+
self._stdout = stdout
393+
self._stderr = stderr
394+
395+
self._out_thread = threading.Thread(name='out_thread', target=self._reader_thread_func,
396+
kwargs={'read_stdout': True})
397+
398+
self._err_thread = threading.Thread(name='out_thread', target=self._reader_thread_func,
399+
kwargs={'read_stdout': False})
400+
401+
# Start the reader threads for pipes only
402+
if self._proc.stdout is not None:
403+
self._out_thread.start()
404+
if self._proc.stderr is not None:
405+
self._err_thread.start()
406+
407+
def send_sigint(self) -> None:
408+
"""Send a SIGINT to the process similar to if <Ctrl>+C were pressed."""
409+
import signal
410+
if sys.platform.startswith('win'):
411+
signal_to_send = signal.CTRL_C_EVENT
412+
else:
413+
signal_to_send = signal.SIGINT
414+
self._proc.send_signal(signal_to_send)
415+
416+
def terminate(self) -> None:
417+
"""Terminate the process"""
418+
self._proc.terminate()
419+
420+
def wait(self) -> None:
421+
"""Wait for the process to finish"""
422+
if self._out_thread.is_alive():
423+
self._out_thread.join()
424+
if self._err_thread.is_alive():
425+
self._err_thread.join()
426+
427+
# Handle case where the process ended before the last read could be done.
428+
# This will return None for the streams that weren't pipes.
429+
out, err = self._proc.communicate()
430+
431+
if out:
432+
self._write_bytes(self._stdout, out)
433+
if err:
434+
self._write_bytes(self._stderr, err)
435+
436+
def _reader_thread_func(self, read_stdout: bool) -> None:
437+
"""
438+
Thread function that reads a stream from the process
439+
:param read_stdout: if True, then this thread deals with stdout. Otherwise it deals with stderr.
440+
"""
441+
if read_stdout:
442+
read_stream = self._proc.stdout
443+
write_stream = self._stdout
444+
else:
445+
read_stream = self._proc.stderr
446+
write_stream = self._stderr
447+
448+
# The thread should have been started only if this stream was a pipe
449+
assert read_stream is not None
450+
451+
# Run until process completes
452+
while self._proc.poll() is None:
453+
# noinspection PyUnresolvedReferences
454+
available = read_stream.peek()
455+
if available:
456+
read_stream.read(len(available))
457+
self._write_bytes(write_stream, available)
458+
459+
@staticmethod
460+
def _write_bytes(stream: Union[StdSim, BinaryIO, TextIO], to_write: bytes) -> None:
461+
"""
462+
Write bytes to a stream
463+
:param stream: the stream being written to
464+
:param to_write: the bytes being written
465+
"""
466+
try:
467+
if hasattr(stream, 'buffer'):
468+
stream.buffer.write(to_write)
469+
else:
470+
stream.write(to_write)
471+
except BrokenPipeError:
472+
# This occurs if output is being piped to a process that closed
473+
pass
355474

356475

357-
def find_editor() -> str:
358-
"""Find a reasonable editor to use by default for the system that the cmd2 application is running on."""
359-
editor = os.environ.get('EDITOR')
360-
if not editor:
361-
if sys.platform[:3] == 'win':
362-
editor = 'notepad'
363-
else:
364-
# Favor command-line editors first so we don't leave the terminal to edit
365-
for editor in ['vim', 'vi', 'emacs', 'nano', 'pico', 'gedit', 'kate', 'subl', 'geany', 'atom']:
366-
if which(editor):
367-
break
368-
return editor
476+
class ContextFlag(object):
477+
"""A context manager which is also used as a boolean flag value within the default sigint handler.
478+
479+
Its main use is as a flag to prevent the SIGINT handler in cmd2 from raising a KeyboardInterrupt
480+
while a critical code section has set the flag to True. Because signal handling is always done on the
481+
main thread, this class is not thread-safe since there is no need.
482+
"""
483+
def __init__(self) -> None:
484+
# When this flag has a positive value, it is considered set.
485+
# When it is 0, it is not set. It should never go below 0.
486+
self.__count = 0
487+
488+
def __bool__(self) -> bool:
489+
return self.__count > 0
490+
491+
def __enter__(self) -> None:
492+
self.__count += 1
493+
494+
def __exit__(self, *args) -> None:
495+
self.__count -= 1
496+
if self.__count < 0:
497+
raise ValueError("count has gone below 0")
498+
499+
500+
class RedirectionSavedState(object):
501+
"""Created by each command to store information about their redirection."""
502+
503+
def __init__(self, self_stdout: Union[StdSim, BinaryIO, TextIO], sys_stdout: Union[StdSim, BinaryIO, TextIO],
504+
pipe_proc_reader: Optional[ProcReader]) -> None:
505+
# Used to restore values after the command ends
506+
self.saved_self_stdout = self_stdout
507+
self.saved_sys_stdout = sys_stdout
508+
self.saved_pipe_proc_reader = pipe_proc_reader
509+
510+
# Tells if the command is redirecting
511+
self.redirecting = False
512+
513+
# If the command created a process to pipe to, then then is its reader
514+
self.pipe_proc_reader = None

tests/conftest.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
Copyright 2016 Federico Ceratto <[email protected]>
66
Released under MIT license, see LICENSE file
77
"""
8+
import sys
89
from typing import Optional
910
from unittest import mock
1011

@@ -13,6 +14,13 @@
1314
import cmd2
1415
from cmd2.utils import StdSim
1516

17+
# Python 3.4 require contextlib2 for temporarily redirecting stderr and stdout
18+
if sys.version_info < (3, 5):
19+
# noinspection PyUnresolvedReferences
20+
from contextlib2 import redirect_stdout, redirect_stderr
21+
else:
22+
from contextlib import redirect_stdout, redirect_stderr
23+
1624
# Prefer statically linked gnureadline if available (for macOS compatibility due to issues with libedit)
1725
try:
1826
import gnureadline as readline
@@ -126,19 +134,33 @@ def normalize(block):
126134

127135

128136
def run_cmd(app, cmd):
129-
""" Clear StdSim buffer, run the command, extract the buffer contents, """
130-
app.stdout.clear()
131-
app.onecmd_plus_hooks(cmd)
132-
out = app.stdout.getvalue()
133-
app.stdout.clear()
134-
return normalize(out)
137+
""" Clear out and err StdSim buffers, run the command, and return out and err """
138+
saved_sysout = sys.stdout
139+
sys.stdout = app.stdout
140+
141+
# This will be used to capture app.stdout and sys.stdout
142+
copy_cmd_stdout = StdSim(app.stdout)
143+
144+
# This will be used to capture sys.stderr
145+
copy_stderr = StdSim(sys.stderr)
146+
147+
try:
148+
app.stdout = copy_cmd_stdout
149+
with redirect_stdout(copy_cmd_stdout):
150+
with redirect_stderr(copy_stderr):
151+
app.onecmd_plus_hooks(cmd)
152+
finally:
153+
app.stdout = copy_cmd_stdout.inner_stream
154+
sys.stdout = saved_sysout
155+
156+
out = copy_cmd_stdout.getvalue()
157+
err = copy_stderr.getvalue()
158+
return normalize(out), normalize(err)
135159

136160

137161
@fixture
138162
def base_app():
139-
c = cmd2.Cmd()
140-
c.stdout = StdSim(c.stdout)
141-
return c
163+
return cmd2.Cmd()
142164

143165

144166
def complete_tester(text: str, line: str, begidx: int, endidx: int, app) -> Optional[str]:

0 commit comments

Comments
 (0)