1- #
21# coding=utf-8
32"""Shared utility functions"""
43
54import collections
65import os
76import re
7+ import subprocess
88import sys
9+ import threading
910import unicodedata
10- from typing import Any , Iterable , List , Optional , Union
11+ from typing import Any , BinaryIO , Iterable , List , Optional , TextIO , Union
1112
1213from 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+
265291class 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
0 commit comments