1
- #
2
1
# coding=utf-8
3
2
"""Shared utility functions"""
4
3
5
4
import collections
6
5
import os
7
6
import re
7
+ import subprocess
8
8
import sys
9
+ import threading
9
10
import unicodedata
10
- from typing import Any , Iterable , List , Optional , Union
11
+ from typing import Any , BinaryIO , Iterable , List , Optional , TextIO , Union
11
12
12
13
from wcwidth import wcswidth
13
14
@@ -140,7 +141,6 @@ def which(editor: str) -> Optional[str]:
140
141
:param editor: filename of the editor to check, ie 'notepad.exe' or 'vi'
141
142
:return: a full path or None
142
143
"""
143
- import subprocess
144
144
try :
145
145
editor_path = subprocess .check_output (['which' , editor ], stderr = subprocess .STDOUT ).strip ()
146
146
editor_path = editor_path .decode ()
@@ -262,6 +262,32 @@ def natural_sort(list_to_sort: Iterable[str]) -> List[str]:
262
262
return sorted (list_to_sort , key = natural_keys )
263
263
264
264
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
+
265
291
class StdSim (object ):
266
292
"""
267
293
Class to simulate behavior of sys.stdout or sys.stderr.
@@ -315,7 +341,14 @@ def readbytes(self) -> bytes:
315
341
316
342
def clear (self ) -> None :
317
343
"""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
319
352
320
353
def __getattr__ (self , item : str ):
321
354
if item in self .__dict__ :
@@ -329,7 +362,7 @@ class ByteBuf(object):
329
362
Used by StdSim to write binary data and stores the actual bytes written
330
363
"""
331
364
def __init__ (self , std_sim_instance : StdSim ) -> None :
332
- self .byte_buf = b''
365
+ self .byte_buf = bytearray ()
333
366
self .std_sim_instance = std_sim_instance
334
367
335
368
def write (self , b : bytes ) -> None :
@@ -342,27 +375,140 @@ def write(self, b: bytes) -> None:
342
375
self .std_sim_instance .inner_stream .buffer .write (b )
343
376
344
377
345
- def unquote_redirection_tokens ( args : List [ str ]) -> None :
378
+ class ProcReader ( object ) :
346
379
"""
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.
350
382
"""
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
355
474
356
475
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