Skip to content

Commit 3cba9c9

Browse files
authored
Merge pull request #198 from python-cmd2/pipe_improvement
Pipe improvement
2 parents 11f14ed + 8f32eea commit 3cba9c9

File tree

5 files changed

+142
-70
lines changed

5 files changed

+142
-70
lines changed

CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ rerNews
1515
* Enhanced tab-completion of cmd2 command names to support case-insensitive completion
1616
* Added an example showing how to remove unused commands
1717
* Improved how transcript testing handles prompts with ANSI escape codes by stripping them
18+
* Greatly improved implementation for how command output gets piped to a shell command
1819

1920
0.7.5
2021
-----

cmd2.py

Lines changed: 94 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,8 @@
1717
Easy transcript-based testing of applications (see examples/example.py)
1818
Bash-style ``select`` available
1919
20-
Note that redirection with > and | will only work if `self.stdout.write()`
21-
is used in place of `print`. The standard library's `cmd` module is
22-
written to use `self.stdout.write()`,
20+
Note that redirection with > and | will only work if `self.poutput()`
21+
is used in place of `print`.
2322
2423
- Catherine Devlin, Jan 03 2008 - catherinedevlin.blogspot.com
2524
@@ -30,13 +29,13 @@
3029
import collections
3130
import datetime
3231
import glob
32+
import io
3333
import optparse
3434
import os
3535
import platform
3636
import re
3737
import shlex
3838
import six
39-
import subprocess
4039
import sys
4140
import tempfile
4241
import traceback
@@ -59,6 +58,14 @@
5958
# itertools.zip() for Python 2 or zip() for Python 3 - produces an iterator in both cases
6059
from six.moves import zip
6160

61+
# If using Python 2.7, try to use the subprocess32 package backported from Python 3.2 due to various improvements
62+
# NOTE: The feature to pipe output to a shell command won't work correctly in Python 2.7 without this
63+
try:
64+
# noinspection PyPackageRequirements
65+
import subprocess32 as subprocess
66+
except ImportError:
67+
import subprocess
68+
6269
# Detect whether IPython is installed to determine if the built-in "ipy" command should be included
6370
ipython_available = True
6471
try:
@@ -299,6 +306,7 @@ def new_func(instance, arg):
299306

300307

301308
# Can we access the clipboard? Should always be true on Windows and Mac, but only sometimes on Linux
309+
# noinspection PyUnresolvedReferences
302310
try:
303311
if six.PY3 and sys.platform.startswith('linux'):
304312
# Avoid extraneous output to stderr from xclip when clipboard is empty at cost of overwriting clipboard contents
@@ -513,9 +521,6 @@ def __init__(self, completekey='tab', stdin=None, stdout=None, use_ipython=False
513521
self.kept_state = None
514522
self.kept_sys = None
515523

516-
# Used for a temp file during a pipe (needed tempfile instead of real pipe for Python 3.x prior to 3.5)
517-
self._temp_filename = None
518-
519524
# Codes used for exit conditions
520525
self._STOP_AND_EXIT = True # cmd convention
521526

@@ -531,6 +536,9 @@ def __init__(self, completekey='tab', stdin=None, stdout=None, use_ipython=False
531536
# Used load command to store the current script dir as a LIFO queue to support _relative_load command
532537
self._script_dir = []
533538

539+
# Used when piping command output to a shell command
540+
self.pipe_proc = None
541+
534542
# ----- Methods related to presenting output to the user -----
535543

536544
@property
@@ -552,12 +560,26 @@ def _finalize_app_parameters(self):
552560
# Make sure settable parameters are sorted alphabetically by key
553561
self.settable = collections.OrderedDict(sorted(self.settable.items(), key=lambda t: t[0]))
554562

555-
def poutput(self, msg):
556-
"""Convenient shortcut for self.stdout.write(); adds newline if necessary."""
563+
def poutput(self, msg, end='\n'):
564+
"""Convenient shortcut for self.stdout.write(); by default adds newline to end if not already present.
565+
566+
Also handles BrokenPipeError exceptions for when a commands's output has been piped to another process and
567+
that process terminates before than command is finished executing.
568+
569+
:param msg: str - message to print to current stdout
570+
:param end: str - string appended after the end of the message, default a newline
571+
"""
557572
if msg:
558-
self.stdout.write(msg)
559-
if msg[-1] != '\n':
560-
self.stdout.write('\n')
573+
try:
574+
self.stdout.write(msg)
575+
if not msg.endswith(end):
576+
self.stdout.write(end)
577+
except BrokenPipeError:
578+
# This occurs if a command's output is being piped to another process and that process closes before the
579+
# command is finished. We intentionally don't print a warning message here since we know that stdout
580+
# will be restored by the _restore_output() method. If you would like your application to print a
581+
# warning message, then override this method.
582+
pass
561583

562584
def perror(self, errmsg, exception_type=None, traceback_war=True):
563585
""" Print error message to sys.stderr and if debug is true, print an exception Traceback if one exists.
@@ -765,18 +787,36 @@ def _redirect_output(self, statement):
765787
"""
766788
if statement.parsed.pipeTo:
767789
self.kept_state = Statekeeper(self, ('stdout',))
768-
self.kept_sys = Statekeeper(sys, ('stdout',))
769-
sys.stdout = self.stdout
770790

771-
# NOTE: We couldn't get a real pipe working via subprocess for Python 3.x prior to 3.5.
772-
# So to allow compatibility with Python 2.7 and 3.3+ we are redirecting output to a temporary file.
773-
# And once command is complete we are the temp file as stdin for the shell command to pipe to.
774-
# TODO: Once support for Python 3.x prior to 3.5 is no longer necessary, replace with a real subprocess pipe
791+
# Create a pipe with read and write sides
792+
read_fd, write_fd = os.pipe()
775793

776-
# Redirect stdout to a temporary file
777-
fd, self._temp_filename = tempfile.mkstemp()
778-
os.close(fd)
779-
self.stdout = open(self._temp_filename, 'w')
794+
# Make sure that self.poutput() expects unicode strings in Python 3 and byte strings in Python 2
795+
write_mode = 'w'
796+
read_mode = 'r'
797+
if six.PY2:
798+
write_mode = 'wb'
799+
read_mode = 'rb'
800+
801+
# Open each side of the pipe and set stdout accordingly
802+
# noinspection PyTypeChecker
803+
self.stdout = io.open(write_fd, write_mode)
804+
# noinspection PyTypeChecker
805+
subproc_stdin = io.open(read_fd, read_mode)
806+
807+
# We want Popen to raise an exception if it fails to open the process. Thus we don't set shell to True.
808+
try:
809+
self.pipe_proc = subprocess.Popen(shlex.split(statement.parsed.pipeTo), stdin=subproc_stdin)
810+
except Exception as ex:
811+
# Restore stdout to what it was and close the pipe
812+
self.stdout.close()
813+
subproc_stdin.close()
814+
self.pipe_proc = None
815+
self.kept_state.restore()
816+
self.kept_state = None
817+
818+
# Re-raise the exception
819+
raise ex
780820
elif statement.parsed.output:
781821
if (not statement.parsed.outputTo) and (not can_clip):
782822
raise EnvironmentError('Cannot redirect to paste buffer; install ``xclip`` and re-run to enable')
@@ -790,39 +830,39 @@ def _redirect_output(self, statement):
790830
else:
791831
sys.stdout = self.stdout = tempfile.TemporaryFile(mode="w+")
792832
if statement.parsed.output == '>>':
793-
self.stdout.write(get_paste_buffer())
833+
self.poutput(get_paste_buffer())
794834

795835
def _restore_output(self, statement):
796836
"""Handles restoring state after output redirection as well as the actual pipe operation if present.
797837
798838
:param statement: ParsedString - subclass of str which also contains pyparsing ParseResults instance
799839
"""
800-
if self.kept_state:
840+
# If we have redirected output to a file or the clipboard or piped it to a shell command, then restore state
841+
if self.kept_state is not None:
842+
# If we redirected output to the clipboard
843+
if statement.parsed.output and not statement.parsed.outputTo:
844+
self.stdout.seek(0)
845+
write_to_paste_buffer(self.stdout.read())
846+
847+
# Close the file or pipe that stdout was redirected to
801848
try:
802-
if statement.parsed.output:
803-
if not statement.parsed.outputTo:
804-
self.stdout.seek(0)
805-
write_to_paste_buffer(self.stdout.read())
806-
finally:
807849
self.stdout.close()
808-
self.kept_state.restore()
809-
self.kept_sys.restore()
810-
self.kept_state = None
850+
except BrokenPipeError:
851+
pass
811852

812-
if statement.parsed.pipeTo:
813-
# Pipe the contents of tempfile to the specified shell command
814-
with open(self._temp_filename) as fd:
815-
pipe_proc = subprocess.Popen(shlex.split(statement.parsed.pipeTo), stdin=fd,
816-
stdout=subprocess.PIPE)
817-
output, _ = pipe_proc.communicate()
853+
# If we were piping output to a shell command, then close the subprocess the shell command was running in
854+
if self.pipe_proc is not None:
855+
self.pipe_proc.communicate()
856+
self.pipe_proc = None
818857

819-
if six.PY3:
820-
self.stdout.write(output.decode())
821-
else:
822-
self.stdout.write(output)
858+
# Restore self.stdout
859+
self.kept_state.restore()
860+
self.kept_state = None
823861

824-
os.remove(self._temp_filename)
825-
self._temp_filename = None
862+
# Restore sys.stdout if need be
863+
if self.kept_sys is not None:
864+
self.kept_sys.restore()
865+
self.kept_sys = None
826866

827867
def _func_named(self, arg):
828868
"""Gets the method name associated with a given command.
@@ -921,7 +961,7 @@ def pseudo_raw_input(self, prompt):
921961
except EOFError:
922962
line = 'eof'
923963
else:
924-
self.stdout.write(safe_prompt)
964+
self.poutput(safe_prompt, end='')
925965
self.stdout.flush()
926966
line = self.stdin.readline()
927967
if not len(line):
@@ -964,7 +1004,7 @@ def _cmdloop(self):
9641004

9651005
# If echo is on and in the middle of running a script, then echo the line to the output
9661006
if self.echo and self._current_script_dir is not None:
967-
self.stdout.write(line + '\n')
1007+
self.poutput(line + '\n')
9681008

9691009
# Run the command along with all associated pre and post hooks
9701010
stop = self.onecmd_plus_hooks(line)
@@ -985,7 +1025,7 @@ def _cmdloop(self):
9851025
# noinspection PyUnusedLocal
9861026
def do_cmdenvironment(self, args):
9871027
"""Summary report of interactive parameters."""
988-
self.stdout.write("""
1028+
self.poutput("""
9891029
Commands are case-sensitive: {}
9901030
Commands may be terminated with: {}
9911031
Arguments at invocation allowed: {}
@@ -1043,7 +1083,7 @@ def _help_menu(self):
10431083
cmds_doc.append(command)
10441084
else:
10451085
cmds_undoc.append(command)
1046-
self.stdout.write("%s\n" % str(self.doc_leader))
1086+
self.poutput("%s\n" % str(self.doc_leader))
10471087
self.print_topics(self.doc_header, cmds_doc, 15, 80)
10481088
self.print_topics(self.misc_header, list(help_dict.keys()), 15, 80)
10491089
self.print_topics(self.undoc_header, cmds_undoc, 15, 80)
@@ -1052,7 +1092,7 @@ def _help_menu(self):
10521092
def do_shortcuts(self, args):
10531093
"""Lists shortcuts (aliases) available."""
10541094
result = "\n".join('%s: %s' % (sc[0], sc[1]) for sc in sorted(self.shortcuts))
1055-
self.stdout.write("Shortcuts for other commands:\n{}\n".format(result))
1095+
self.poutput("Shortcuts for other commands:\n{}\n".format(result))
10561096

10571097
# noinspection PyUnusedLocal
10581098
def do_eof(self, arg):
@@ -1097,9 +1137,8 @@ def select(self, opts, prompt='Your choice? '):
10971137
result = fulloptions[response - 1][0]
10981138
break
10991139
except (ValueError, IndexError):
1100-
self.stdout.write("{!r} isn't a valid choice. Pick a number "
1101-
"between 1 and {}:\n".format(
1102-
response, len(fulloptions)))
1140+
self.poutput("{!r} isn't a valid choice. Pick a number between 1 and {}:\n".format(response,
1141+
len(fulloptions)))
11031142
return result
11041143

11051144
@options([make_option('-l', '--long', action="store_true", help="describe function of parameter")])
@@ -1150,7 +1189,7 @@ def do_set(self, arg):
11501189
else:
11511190
val = cast(current_val, val)
11521191
setattr(self, param_name, val)
1153-
self.stdout.write('%s - was: %s\nnow: %s\n' % (param_name, current_val, val))
1192+
self.poutput('%s - was: %s\nnow: %s\n' % (param_name, current_val, val))
11541193
if current_val != val:
11551194
try:
11561195
onchange_hook = getattr(self, '_onchange_%s' % param_name)
@@ -1513,7 +1552,7 @@ def do_history(self, arg, opts):
15131552
if opts.script:
15141553
self.poutput(hi)
15151554
else:
1516-
self.stdout.write(hi.pr())
1555+
self.poutput(hi.pr())
15171556

15181557
def _last_matching(self, arg):
15191558
"""Return the last item from the history list that matches arg. Or if arg not provided, return last item.
@@ -1817,7 +1856,7 @@ def cmdloop(self, intro=None):
18171856

18181857
# Print the intro, if there is one, right after the preloop
18191858
if self.intro is not None:
1820-
self.stdout.write(str(self.intro) + "\n")
1859+
self.poutput(str(self.intro) + "\n")
18211860

18221861
# And then call _cmdloop() to enter the main loop
18231862
self._cmdloop()

docs/install.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,10 @@ If you wish to permanently uninstall ``cmd2``, this can also easily be done with
124124

125125
pip uninstall cmd2
126126

127+
Extra requirement for Python 2.7 only
128+
-------------------------------------
129+
If you want to be able to pipe the output of commands to a shell command on Python 2.7, then you will need one
130+
additional package installed:
131+
132+
* subprocess32
133+

tests/test_cmd2.py

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,10 @@ def test_set_quiet(base_app):
113113

114114
def test_base_shell(base_app, monkeypatch):
115115
m = mock.Mock()
116-
monkeypatch.setattr("subprocess.Popen", m)
116+
subprocess = 'subprocess'
117+
if six.PY2:
118+
subprocess = 'subprocess32'
119+
monkeypatch.setattr("{}.Popen".format(subprocess), m)
117120
out = run_cmd(base_app, 'shell echo a')
118121
assert out == []
119122
assert m.called
@@ -543,33 +546,54 @@ def test_input_redirection(base_app, request):
543546

544547
# NOTE: File 'redirect.txt" contains 1 word "history"
545548

546-
# Verify that redirecting input from a file works
549+
# Verify that redirecting input ffom a file works
547550
out = run_cmd(base_app, 'help < {}'.format(filename))
548551
expected = normalize(HELP_HISTORY)
549552
assert out == expected
550553

551554

552-
def test_pipe_to_shell(base_app):
555+
def test_pipe_to_shell(base_app, capsys):
553556
if sys.platform == "win32":
554557
# Windows
558+
command = 'help | sort'
555559
# Get help menu and pipe it's output to the sort shell command
556-
out = run_cmd(base_app, 'help | sort')
557-
expected = ['', '', '_relative_load edit history py quit save shell show',
558-
'========================================',
559-
'cmdenvironment help load pyscript run set shortcuts',
560-
'Documented commands (type help <topic>):']
561-
assert out == expected
560+
# expected = ['', '', '_relative_load edit history py quit save shell show',
561+
# '========================================',
562+
# 'cmdenvironment help load pyscript run set shortcuts',
563+
# 'Documented commands (type help <topic>):']
564+
# assert out == expected
562565
else:
563566
# Mac and Linux
564567
# Get help on help and pipe it's output to the input of the word count shell command
565-
out = run_cmd(base_app, 'help help | wc')
568+
command = 'help help | wc'
569+
# # Mac and Linux wc behave the same when piped from shell, but differently when piped stdin from file directly
570+
# if sys.platform == 'darwin':
571+
# expected = "1 11 70"
572+
# else:
573+
# expected = "1 11 70"
574+
# assert out.strip() == expected.strip()
566575

567-
# Mac and Linux wc behave the same when piped from shell, but differently when piped stdin from file directly
568-
if sys.platform == 'darwin':
569-
expected = normalize("1 11 70")
576+
run_cmd(base_app, command)
577+
out, err = capsys.readouterr()
578+
579+
# Unfortunately with the improved way of piping output to a subprocess, there isn't any good way of getting
580+
# access to the output produced by that subprocess within a unit test, but we can verify that no error occured
581+
assert not err
582+
583+
def test_pipe_to_shell_error(base_app, capsys):
584+
# Try to pipe command output to a shell command that doesn't exist in order to produce an error
585+
run_cmd(base_app, 'help | foobarbaz.this_does_not_exist')
586+
out, err = capsys.readouterr()
587+
588+
assert not out
589+
590+
expected_error = 'FileNotFoundError'
591+
if six.PY2:
592+
if sys.platform.startswith('win'):
593+
expected_error = 'WindowsError'
570594
else:
571-
expected = normalize("1 11 70")
572-
assert out[0].strip() == expected[0].strip()
595+
expected_error = 'OSError'
596+
assert err.startswith("EXCEPTION of type '{}' occurred with message:".format(expected_error))
573597

574598

575599
@pytest.mark.skipif(not cmd2.can_clip,

0 commit comments

Comments
 (0)