diff options
| author | Jeff Quast <contact@jeffquast.com> | 2014-11-23 04:23:31 -0800 |
|---|---|---|
| committer | Jeff Quast <contact@jeffquast.com> | 2014-11-23 04:23:31 -0800 |
| commit | 01ea0ff440facd47894c076747488c28291a47cc (patch) | |
| tree | 9a6a1f02e34f364ecfd9fed378a41ac6df8eb4eb | |
| parent | a88e60f8403b1d25db1cca857009f3612a8f1e19 (diff) | |
| parent | 00c8aaed9605c446844bbe379582753492a3627b (diff) | |
| download | pexpect-01ea0ff440facd47894c076747488c28291a47cc.tar.gz | |
Merge remote-tracking branch 'origin/master' into issue-104-cannot-exec-setuids
Conflicts:
doc/history.rst
| -rw-r--r-- | .travis.yml | 2 | ||||
| -rw-r--r-- | LICENSE | 2 | ||||
| -rw-r--r-- | README.rst | 1 | ||||
| -rw-r--r-- | doc/history.rst | 12 | ||||
| -rwxr-xr-x | examples/cgishell.cgi | 4 | ||||
| -rw-r--r-- | pexpect/ANSI.py | 26 | ||||
| -rw-r--r-- | pexpect/__init__.py | 182 | ||||
| -rw-r--r-- | pexpect/async.py | 68 | ||||
| -rw-r--r-- | pexpect/bashrc.sh | 5 | ||||
| -rw-r--r-- | pexpect/expect.py | 105 | ||||
| -rw-r--r-- | pexpect/pxssh.py | 4 | ||||
| -rw-r--r-- | pexpect/replwrap.py | 7 | ||||
| -rw-r--r-- | pexpect/screen.py | 102 | ||||
| -rw-r--r-- | setup.cfg | 2 | ||||
| -rwxr-xr-x | tests/test_ansi.py | 59 | ||||
| -rw-r--r-- | tests/test_async.py | 51 | ||||
| -rwxr-xr-x | tests/test_constructor.py | 10 | ||||
| -rwxr-xr-x | tests/test_expect.py | 37 | ||||
| -rw-r--r-- | tests/test_repr.py | 26 | ||||
| -rwxr-xr-x | tests/test_run.py | 38 | ||||
| -rwxr-xr-x | tests/test_screen.py | 124 |
21 files changed, 703 insertions, 164 deletions
diff --git a/.travis.yml b/.travis.yml index 4b14d16..3a2f331 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,7 @@ language: python python: - - 2.6 - 2.7 - - 3.2 - 3.3 - 3.4 - pypy @@ -3,7 +3,9 @@ PEXPECT LICENSE This license is approved by the OSI and FSF as GPL-compatible. http://opensource.org/licenses/isc-license.txt + Copyright (c) 2013-2014, Pexpect development team Copyright (c) 2012, Noah Spurrier <noah@noah.org> + PERMISSION TO USE, COPY, MODIFY, AND/OR DISTRIBUTE THIS SOFTWARE FOR ANY PURPOSE WITH OR WITHOUT FEE IS HEREBY GRANTED, PROVIDED THAT THE ABOVE COPYRIGHT NOTICE AND THIS PERMISSION NOTICE APPEAR IN ALL COPIES. @@ -36,6 +36,7 @@ PEXPECT LICENSE http://opensource.org/licenses/isc-license.txt + Copyright (c) 2013-2014, Pexpect development team Copyright (c) 2012, Noah Spurrier <noah@noah.org> PERMISSION TO USE, COPY, MODIFY, AND/OR DISTRIBUTE THIS SOFTWARE FOR ANY diff --git a/doc/history.rst b/doc/history.rst index 19c2973..c9d5640 100644 --- a/doc/history.rst +++ b/doc/history.rst @@ -4,12 +4,22 @@ History Releases -------- +Version 4.0 +``````````` + +* Integration with :mod:`asyncio`: passing ``async=True`` to :meth:`~.expect`, + :meth:`~.expect_exact` or :meth:`~.expect_list` will make them return a + coroutine. You can get the result using ``yield from``, or wrap it in an + :class:`asyncio.Task`. This allows the event loop to do other things while + waiting for output that matches a pattern. + Version 3.4 ``````````` * Fix regression that prevented executable, but unreadable files from being found when not specified by absolute path -- such as /usr/bin/sudo (:ghissue:`104`). - +* Fixed regression when executing pexpect with some prior releases of + the multiprocessing module where stdin has been closed (:ghissue:`86`). Version 3.3 ``````````` diff --git a/examples/cgishell.cgi b/examples/cgishell.cgi index b807a8b..23bef5f 100755 --- a/examples/cgishell.cgi +++ b/examples/cgishell.cgi @@ -176,11 +176,11 @@ def daemonize (stdin=None, stdout=None, stderr=None, daemon_pid_filename=None): if stderr is None: stderr = DEVNULL try: - pid = os.fork() + pid = os.fork() # fork first child except OSError as e: raise Exception("%s [%d]" % (e.strerror, e.errno)) - if pid != 0: # The first child. + if pid != 0: os.waitpid(pid,0) if daemon_pid_filename is not None: daemon_pid = int(file(daemon_pid_filename,'r').read()) diff --git a/pexpect/ANSI.py b/pexpect/ANSI.py index 3460114..1cd2e90 100644 --- a/pexpect/ANSI.py +++ b/pexpect/ANSI.py @@ -186,18 +186,18 @@ class term (screen.screen): provides a common base class for other terminals such as an ANSI terminal. ''' - def __init__ (self, r=24, c=80): + def __init__ (self, r=24, c=80, *args, **kwargs): - screen.screen.__init__(self, r,c) + screen.screen.__init__(self, r,c,*args,**kwargs) class ANSI (term): '''This class implements an ANSI (VT100) terminal. It is a stream filter that recognizes ANSI terminal escape sequences and maintains the state of a screen object. ''' - def __init__ (self, r=24,c=80): + def __init__ (self, r=24,c=80,*args,**kwargs): - term.__init__(self,r,c) + term.__init__(self,r,c,*args,**kwargs) #self.screen = screen (24,80) self.state = FSM.FSM ('INIT',[self]) @@ -279,7 +279,9 @@ class ANSI (term): self.state.add_transition (';', 'NUMBER_X', None, 'SEMICOLON_X') def process (self, c): - """Process a single byte. Called by :meth:`write`.""" + """Process a single character. Called by :meth:`write`.""" + if isinstance(c, bytes): + c = self._decode(c) self.state.process(c) def process_list (self, l): @@ -290,6 +292,8 @@ class ANSI (term): """Process text, writing it to the virtual screen while handling ANSI escape codes. """ + if isinstance(s, bytes): + s = self._decode(s) for c in s: self.process(c) @@ -301,23 +305,21 @@ class ANSI (term): position is moved forward with wrap-around, but no scrolling is done if the cursor hits the lower-right corner of the screen. ''' + if isinstance(ch, bytes): + ch = self._decode(ch) + #\r and \n both produce a call to cr() and lf(), respectively. ch = ch[0] - if ch == '\r': + if ch == u'\r': self.cr() return - if ch == '\n': + if ch == u'\n': self.crlf() return if ch == chr(screen.BS): self.cursor_back() return - if ch not in string.printable: - fout = open ('log', 'a') - fout.write ('Nonprint: ' + str(ord(ch)) + '\n') - fout.close() - return self.put_abs(self.cur_r, self.cur_c, ch) old_r = self.cur_r old_c = self.cur_c diff --git a/pexpect/__init__.py b/pexpect/__init__.py index 57d8d91..5583be7 100644 --- a/pexpect/__init__.py +++ b/pexpect/__init__.py @@ -88,6 +88,8 @@ except ImportError: # pragma: no cover A critical module was not found. Probably this operating system does not support it. Pexpect is intended for UNIX-like operating systems.''') +from .expect import Expecter + __version__ = '3.3' __revision__ = '' __all__ = ['ExceptionPexpect', 'EOF', 'TIMEOUT', 'spawn', 'spawnu', 'run', 'runu', @@ -113,7 +115,8 @@ class ExceptionPexpect(Exception): is not included. ''' tblist = traceback.extract_tb(sys.exc_info()[2]) - tblist = [item for item in tblist if 'pexpect/__init__' not in item[0]] + tblist = [item for item in tblist if ('pexpect/__init__' not in item[0]) + and ('pexpect/expect' not in item[0])] tblist = traceback.format_list(tblist) return ''.join(tblist) @@ -195,18 +198,29 @@ def run(command, timeout=-1, withexitstatus=False, events=None, run("mencoder dvd://1 -o video.avi -oac copy -ovc copy", events={TIMEOUT:print_ticks}, timeout=5) - The 'events' argument should be a dictionary of patterns and responses. - Whenever one of the patterns is seen in the command out run() will send the - associated response string. Note that you should put newlines in your - string if Enter is necessary. The responses may also contain callback - functions. Any callback is function that takes a dictionary as an argument. + The 'events' argument should be either a dictionary or a tuple list that + contains patterns and responses. Whenever one of the patterns is seen + in the command output, run() will send the associated response string. + So, run() in the above example can be also written as: + + run("mencoder dvd://1 -o video.avi -oac copy -ovc copy", + events=[(TIMEOUT,print_ticks)], timeout=5) + + Use a tuple list for events if the command output requires a delicate + control over what pattern should be matched, since the tuple list is passed + to pexpect() as its pattern list, with the order of patterns preserved. + + Note that you should put newlines in your string if Enter is necessary. + + Like the example above, the responses may also contain callback functions. + Any callback is a function that takes a dictionary as an argument. The dictionary contains all the locals from the run() function, so you can access the child spawn object or any other variable defined in run() (event_count, child, and extra_args are the most useful). A callback may - return True to stop the current run process otherwise run() continues until - the next event. A callback may also return a string which will be sent to - the child. 'extra_args' is not used by directly run(). It provides a way to - pass data to a callback function through run() through the locals + return True to stop the current run process. Otherwise run() continues + until the next event. A callback may also return a string which will be + sent to the child. 'extra_args' is not used by directly run(). It provides + a way to pass data to a callback function through run() through the locals dictionary passed to a callback. ''' return _run(command, timeout=timeout, withexitstatus=withexitstatus, @@ -232,7 +246,10 @@ def _run(command, timeout, withexitstatus, events, extra_args, logfile, cwd, else: child = _spawn(command, timeout=timeout, maxread=2000, logfile=logfile, cwd=cwd, env=env, **kwargs) - if events is not None: + if isinstance(events, list): + patterns= [x for x,y in events] + responses = [y for x,y in events] + elif isinstance(events, dict): patterns = list(events.keys()) responses = list(events.values()) else: @@ -498,12 +515,17 @@ class spawn(object): # inherit EOF and INTR definitions from controlling process. try: from termios import VEOF, VINTR - fd = sys.__stdin__.fileno() + try: + fd = sys.__stdin__.fileno() + except ValueError: + # ValueError: I/O operation on closed file + fd = sys.__stdout__.fileno() self._INTR = ord(termios.tcgetattr(fd)[6][VINTR]) self._EOF = ord(termios.tcgetattr(fd)[6][VEOF]) - except (ImportError, OSError, IOError, termios.error): + except (ImportError, OSError, IOError, ValueError, termios.error): # unless the controlling process is also not a terminal, - # such as cron(1). Fall-back to using CEOF and CINTR. + # such as cron(1), or when stdin and stdout are both closed. + # Fall-back to using CEOF and CINTR. There try: from termios import CEOF, CINTR (self._INTR, self._EOF) = (CINTR, CEOF) @@ -561,8 +583,10 @@ class spawn(object): s.append('command: ' + str(self.command)) s.append('args: %r' % (self.args,)) s.append('searcher: %r' % (self.searcher,)) - s.append('buffer (last 100 chars): %r' % (self.buffer)[-100:],) - s.append('before (last 100 chars): %r' % (self.before)[-100:],) + s.append('buffer (last 100 chars): %r' % ( + self.buffer[-100:] if self.buffer else self.buffer,)) + s.append('before (last 100 chars): %r' % ( + self.before[-100:] if self.before else self.before,)) s.append('after: %r' % (self.after,)) s.append('match: %r' % (self.match,)) s.append('match_index: ' + str(self.match_index)) @@ -1349,7 +1373,7 @@ class spawn(object): cpl = self.compile_pattern_list(my_pattern) while some_condition: ... - i = self.expect_list(clp, timeout) + i = self.expect_list(cpl, timeout) ... ''' @@ -1377,7 +1401,7 @@ class spawn(object): self._pattern_type_err(p) return compiled_pattern_list - def expect(self, pattern, timeout=-1, searchwindowsize=-1): + def expect(self, pattern, timeout=-1, searchwindowsize=-1, async=False): '''This seeks through the stream until a pattern is matched. The pattern is overloaded and may take several types. The pattern can be a @@ -1452,14 +1476,25 @@ class spawn(object): print p.before If you are trying to optimize for speed then see expect_list(). + + On Python 3.4, or Python 3.3 with asyncio installed, passing + ``async=True`` will make this return an :mod:`asyncio` coroutine, + which you can yield from to get the same result that this method would + normally give directly. So, inside a coroutine, you can replace this code:: + + index = p.expect(patterns) + + With this non-blocking form:: + + index = yield from p.expect(patterns, async=True) ''' compiled_pattern_list = self.compile_pattern_list(pattern) return self.expect_list(compiled_pattern_list, - timeout, searchwindowsize) - - def expect_list(self, pattern_list, timeout=-1, searchwindowsize=-1): + timeout, searchwindowsize, async) + def expect_list(self, pattern_list, timeout=-1, searchwindowsize=-1, + async=False): '''This takes a list of compiled regular expressions and returns the index into the pattern_list that matched the child output. The list may also contain EOF or TIMEOUT(which are not compiled regular @@ -1468,12 +1503,23 @@ class spawn(object): may help if you are trying to optimize for speed, otherwise just use the expect() method. This is called by expect(). If timeout==-1 then the self.timeout value is used. If searchwindowsize==-1 then the - self.searchwindowsize value is used. ''' + self.searchwindowsize value is used. - return self.expect_loop(searcher_re(pattern_list), - timeout, searchwindowsize) + Like :meth:`expect`, passing ``async=True`` will make this return an + asyncio coroutine. + ''' + if timeout == -1: + timeout = self.timeout - def expect_exact(self, pattern_list, timeout=-1, searchwindowsize=-1): + exp = Expecter(self, searcher_re(pattern_list), searchwindowsize) + if async: + from .async import expect_async + return expect_async(exp, timeout) + else: + return exp.expect_loop(timeout) + + def expect_exact(self, pattern_list, timeout=-1, searchwindowsize=-1, + async=False): '''This is similar to expect(), but uses plain string matching instead of compiled regular expressions in 'pattern_list'. The 'pattern_list' @@ -1485,7 +1531,13 @@ class spawn(object): search to just the end of the input buffer. This method is also useful when you don't want to have to worry about - escaping regular expression characters that you want to match.''' + escaping regular expression characters that you want to match. + + Like :meth:`expect`, passing ``async=True`` will make this return an + asyncio coroutine. + ''' + if timeout == -1: + timeout = self.timeout if (isinstance(pattern_list, self.allowed_string_types) or pattern_list in (TIMEOUT, EOF)): @@ -1503,83 +1555,23 @@ class spawn(object): except TypeError: self._pattern_type_err(pattern_list) pattern_list = [prepare_pattern(p) for p in pattern_list] - return self.expect_loop(searcher_string(pattern_list), - timeout, searchwindowsize) - def expect_loop(self, searcher, timeout=-1, searchwindowsize=-1): + exp = Expecter(self, searcher_string(pattern_list), searchwindowsize) + if async: + from .async import expect_async + return expect_async(exp, timeout) + else: + return exp.expect_loop(timeout) + def expect_loop(self, searcher, timeout=-1, searchwindowsize=-1): '''This is the common loop used inside expect. The 'searcher' should be an instance of searcher_re or searcher_string, which describes how and what to search for in the input. See expect() for other arguments, return value and exceptions. ''' - self.searcher = searcher - - if timeout == -1: - timeout = self.timeout - if timeout is not None: - end_time = time.time() + timeout - if searchwindowsize == -1: - searchwindowsize = self.searchwindowsize - - try: - incoming = self.buffer - freshlen = len(incoming) - while True: - # Keep reading until exception or return. - index = searcher.search(incoming, freshlen, searchwindowsize) - if index >= 0: - self.buffer = incoming[searcher.end:] - self.before = incoming[: searcher.start] - self.after = incoming[searcher.start: searcher.end] - self.match = searcher.match - self.match_index = index - return self.match_index - # No match at this point - if (timeout is not None) and (timeout < 0): - raise TIMEOUT('Timeout exceeded in expect_any().') - # Still have time left, so read more data - c = self.read_nonblocking(self.maxread, timeout) - freshlen = len(c) - time.sleep(0.0001) - incoming = incoming + c - if timeout is not None: - timeout = end_time - time.time() - except EOF: - err = sys.exc_info()[1] - self.buffer = self.string_type() - self.before = incoming - self.after = EOF - index = searcher.eof_index - if index >= 0: - self.match = EOF - self.match_index = index - return self.match_index - else: - self.match = None - self.match_index = None - raise EOF(str(err) + '\n' + str(self)) - except TIMEOUT: - err = sys.exc_info()[1] - self.buffer = incoming - self.before = incoming - self.after = TIMEOUT - index = searcher.timeout_index - if index >= 0: - self.match = TIMEOUT - self.match_index = index - return self.match_index - else: - self.match = None - self.match_index = None - raise TIMEOUT(str(err) + '\n' + str(self)) - except: - self.before = incoming - self.after = None - self.match = None - self.match_index = None - raise + exp = Expecter(self, searcher, searchwindowsize) + return exp.expect_loop(timeout) def getwinsize(self): diff --git a/pexpect/async.py b/pexpect/async.py new file mode 100644 index 0000000..8ec9c3c --- /dev/null +++ b/pexpect/async.py @@ -0,0 +1,68 @@ +import asyncio +import errno + +from pexpect import EOF + +@asyncio.coroutine +def expect_async(expecter, timeout=None): + # First process data that was previously read - if it maches, we don't need + # async stuff. + idx = expecter.new_data(expecter.spawn.buffer) + expecter.spawn.buffer = expecter.spawn.string_type() + if idx: + return idx + + transport, pw = yield from asyncio.get_event_loop()\ + .connect_read_pipe(lambda: PatternWaiter(expecter), expecter.spawn) + + try: + return (yield from asyncio.wait_for(pw.fut, timeout)) + except asyncio.TimeoutError as e: + transport.pause_reading() + return expecter.timeout(e) + +class PatternWaiter(asyncio.Protocol): + def __init__(self, expecter): + self.expecter = expecter + self.fut = asyncio.Future() + + def found(self, result): + if not self.fut.done(): + self.fut.set_result(result) + + def error(self, exc): + if not self.fut.done(): + self.fut.set_exception(exc) + + def data_received(self, data): + spawn = self.expecter.spawn + s = spawn._coerce_read_string(data) + spawn._log(s, 'read') + + if self.fut.done(): + spawn.buffer += data + return + + try: + index = self.expecter.new_data(data) + if index is not None: + # Found a match + self.found(index) + except Exception as e: + self.expecter.errored() + self.error(e) + + def eof_received(self): + try: + index = self.expecter.eof() + except EOF as e: + self.error(e) + else: + self.found(index) + + def connection_lost(self, exc): + if isinstance(exc, OSError) and exc.errno == errno.EIO: + # We may get here without eof_received being called, e.g on Linux + self.eof_received() + elif exc is not None: + self.error(exc)
\ No newline at end of file diff --git a/pexpect/bashrc.sh b/pexpect/bashrc.sh new file mode 100644 index 0000000..99a3ac2 --- /dev/null +++ b/pexpect/bashrc.sh @@ -0,0 +1,5 @@ +source /etc/bash.bashrc +source ~/.bashrc + +# Reset PS1 so pexpect can find it +PS1="$" diff --git a/pexpect/expect.py b/pexpect/expect.py new file mode 100644 index 0000000..b8da406 --- /dev/null +++ b/pexpect/expect.py @@ -0,0 +1,105 @@ +import time + +class Expecter(object): + def __init__(self, spawn, searcher, searchwindowsize=-1): + self.spawn = spawn + self.searcher = searcher + if searchwindowsize == -1: + searchwindowsize = spawn.searchwindowsize + self.searchwindowsize = searchwindowsize + + def new_data(self, data): + spawn = self.spawn + searcher = self.searcher + + incoming = spawn.buffer + data + freshlen = len(data) + index = searcher.search(incoming, freshlen, self.searchwindowsize) + if index >= 0: + spawn.buffer = incoming[searcher.end:] + spawn.before = incoming[: searcher.start] + spawn.after = incoming[searcher.start: searcher.end] + spawn.match = searcher.match + spawn.match_index = index + # Found a match + return index + + spawn.buffer = incoming + + def eof(self, err=None): + spawn = self.spawn + from . import EOF + + spawn.before = spawn.buffer + spawn.buffer = spawn.string_type() + spawn.after = EOF + index = self.searcher.eof_index + if index >= 0: + spawn.match = EOF + spawn.match_index = index + return index + else: + spawn.match = None + spawn.match_index = None + msg = str(spawn) + if err is not None: + msg = str(err) + '\n' + msg + raise EOF(msg) + + def timeout(self, err=None): + spawn = self.spawn + from . import TIMEOUT + + spawn.before = spawn.buffer + spawn.after = TIMEOUT + index = self.searcher.timeout_index + if index >= 0: + spawn.match = TIMEOUT + spawn.match_index = index + return index + else: + spawn.match = None + spawn.match_index = None + msg = str(spawn) + if err is not None: + msg = str(err) + '\n' + msg + raise TIMEOUT(msg) + + def errored(self): + spawn = self.spawn + spawn.before = spawn.buffer + spawn.after = None + spawn.match = None + spawn.match_index = None + + def expect_loop(self, timeout=-1): + """Blocking expect""" + spawn = self.spawn + from . import EOF, TIMEOUT + + if timeout is not None: + end_time = time.time() + timeout + + try: + incoming = spawn.buffer + spawn.buffer = spawn.string_type() # Treat buffer as new data + while True: + idx = self.new_data(incoming) + # Keep reading until exception or return. + if idx is not None: + return idx + # No match at this point + if (timeout is not None) and (timeout < 0): + return self.timeout() + # Still have time left, so read more data + incoming = spawn.read_nonblocking(spawn.maxread, timeout) + time.sleep(0.0001) + if timeout is not None: + timeout = end_time - time.time() + except EOF as e: + return self.eof(e) + except TIMEOUT as e: + return self.timeout(e) + except: + self.errored() + raise
\ No newline at end of file diff --git a/pexpect/pxssh.py b/pexpect/pxssh.py index ec8c525..bd34f97 100644 --- a/pexpect/pxssh.py +++ b/pexpect/pxssh.py @@ -86,9 +86,9 @@ class pxssh (spawn): ''' def __init__ (self, timeout=30, maxread=2000, searchwindowsize=None, - logfile=None, cwd=None, env=None): + logfile=None, cwd=None, env=None, ignore_sighup=True, echo=True): - spawn.__init__(self, None, timeout=timeout, maxread=maxread, searchwindowsize=searchwindowsize, logfile=logfile, cwd=cwd, env=env) + spawn.__init__(self, None, timeout=timeout, maxread=maxread, searchwindowsize=searchwindowsize, logfile=logfile, cwd=cwd, env=env, ignore_sighup=ignore_sighup, echo=echo) self.name = '<pxssh>' diff --git a/pexpect/replwrap.py b/pexpect/replwrap.py index 2e50286..0c879ff 100644 --- a/pexpect/replwrap.py +++ b/pexpect/replwrap.py @@ -1,5 +1,6 @@ """Generic wrapper for read-eval-print-loops, a.k.a. interactive shells """ +import os.path import signal import sys import re @@ -104,7 +105,9 @@ def python(command="python"): """Start a Python shell and return a :class:`REPLWrapper` object.""" return REPLWrapper(command, u(">>> "), u("import sys; sys.ps1={0!r}; sys.ps2={1!r}")) -def bash(command="bash", orig_prompt=re.compile('[$#]')): +def bash(command="bash"): """Start a bash shell and return a :class:`REPLWrapper` object.""" - return REPLWrapper(command, orig_prompt, u("PS1='{0}' PS2='{1}' PROMPT_COMMAND=''"), + bashrc = os.path.join(os.path.dirname(__file__), 'bashrc.sh') + child = pexpect.spawnu(command, ['--rcfile', bashrc]) + return REPLWrapper(child, u'\$', u("PS1='{0}' PS2='{1}' PROMPT_COMMAND=''"), extra_init_cmd="export PAGER=cat") diff --git a/pexpect/screen.py b/pexpect/screen.py index 61d3b97..efe9ee5 100644 --- a/pexpect/screen.py +++ b/pexpect/screen.py @@ -23,7 +23,9 @@ PEXPECT LICENSE ''' +import codecs import copy +import sys NUL = 0 # Fill character; ignored on input. ENQ = 5 # Transmit answerback message. @@ -42,7 +44,11 @@ CAN = 24 # Cancel escape sequence. SUB = 26 # Same as CAN. ESC = 27 # Introduce a control sequence. DEL = 127 # Fill character; ignored on input. -SPACE = chr(32) # Space or blank character. +SPACE = u' ' # Space or blank character. + +PY3 = (sys.version_info[0] >= 3) +if PY3: + unicode = str def constrain (n, min, max): @@ -59,47 +65,89 @@ class screen: rectangluar array. This maintains a virtual cursor position and handles scrolling as characters are added. This supports most of the methods needed by an ANSI text screen. Row and column indexes are 1-based (not zero-based, - like arrays). ''' - - def __init__ (self, r=24,c=80): + like arrays). + + Characters are represented internally using unicode. Methods that accept + input characters, when passed 'bytes' (which in Python 2 is equivalent to + 'str'), convert them from the encoding specified in the 'encoding' + parameter to the constructor. Methods that return screen contents return + unicode strings, with the exception of __str__() under Python 2. Passing + ``encoding=None`` limits the API to only accept unicode input, so passing + bytes in will raise :exc:`TypeError`. + ''' + def __init__(self, r=24, c=80, encoding='latin-1', encoding_errors='replace'): '''This initializes a blank screen of the given dimensions.''' self.rows = r self.cols = c + self.encoding = encoding + self.encoding_errors = encoding_errors + if encoding is not None: + self.decoder = codecs.getincrementaldecoder(encoding)(encoding_errors) + else: + self.decoder = None self.cur_r = 1 self.cur_c = 1 self.cur_saved_r = 1 self.cur_saved_c = 1 self.scroll_row_start = 1 self.scroll_row_end = self.rows - self.w = [ [SPACE] * self.cols for c in range(self.rows)] - - def __str__ (self): - '''This returns a printable representation of the screen. The end of - each screen line is terminated by a newline. ''' - - return '\n'.join ([ ''.join(c) for c in self.w ]) + self.w = [ [SPACE] * self.cols for _ in range(self.rows)] + + def _decode(self, s): + '''This converts from the external coding system (as passed to + the constructor) to the internal one (unicode). ''' + if self.decoder is not None: + return self.decoder.decode(s) + else: + raise TypeError("This screen was constructed with encoding=None, " + "so it does not handle bytes.") + + def _unicode(self): + '''This returns a printable representation of the screen as a unicode + string (which, under Python 3.x, is the same as 'str'). The end of each + screen line is terminated by a newline.''' + + return u'\n'.join ([ u''.join(c) for c in self.w ]) + + if PY3: + __str__ = _unicode + else: + __unicode__ = _unicode + + def __str__(self): + '''This returns a printable representation of the screen. The end of + each screen line is terminated by a newline. ''' + encoding = self.encoding or 'ascii' + return self._unicode().encode(encoding, 'replace') def dump (self): - '''This returns a copy of the screen as a string. This is similar to - __str__ except that lines are not terminated with line feeds. ''' + '''This returns a copy of the screen as a unicode string. This is similar to + __str__/__unicode__ except that lines are not terminated with line + feeds.''' - return ''.join ([ ''.join(c) for c in self.w ]) + return u''.join ([ u''.join(c) for c in self.w ]) def pretty (self): - '''This returns a copy of the screen as a string with an ASCII text box - around the screen border. This is similar to __str__ except that it - adds a box. ''' + '''This returns a copy of the screen as a unicode string with an ASCII + text box around the screen border. This is similar to + __str__/__unicode__ except that it adds a box.''' - top_bot = '+' + '-'*self.cols + '+\n' - return top_bot + '\n'.join(['|'+line+'|' for line in str(self).split('\n')]) + '\n' + top_bot + top_bot = u'+' + u'-'*self.cols + u'+\n' + return top_bot + u'\n'.join([u'|'+line+u'|' for line in unicode(self).split(u'\n')]) + u'\n' + top_bot def fill (self, ch=SPACE): + if isinstance(ch, bytes): + ch = self._decode(ch) + self.fill_region (1,1,self.rows,self.cols, ch) def fill_region (self, rs,cs, re,ce, ch=SPACE): + if isinstance(ch, bytes): + ch = self._decode(ch) + rs = constrain (rs, 1, self.rows) re = constrain (re, 1, self.rows) cs = constrain (cs, 1, self.cols) @@ -147,13 +195,19 @@ class screen: r = constrain (r, 1, self.rows) c = constrain (c, 1, self.cols) - ch = str(ch)[0] + if isinstance(ch, bytes): + ch = self._decode(ch)[0] + else: + ch = ch[0] self.w[r-1][c-1] = ch def put (self, ch): '''This puts a characters at the current cursor position. ''' + if isinstance(ch, bytes): + ch = self._decode(ch) + self.put_abs (self.cur_r, self.cur_c, ch) def insert_abs (self, r, c, ch): @@ -162,6 +216,9 @@ class screen: The last character of the line is lost. ''' + if isinstance(ch, bytes): + ch = self._decode(ch) + r = constrain (r, 1, self.rows) c = constrain (c, 1, self.cols) for ci in range (self.cols, c, -1): @@ -170,6 +227,9 @@ class screen: def insert (self, ch): + if isinstance(ch, bytes): + ch = self._decode(ch) + self.insert_abs (self.cur_r, self.cur_c, ch) def get_abs (self, r, c): @@ -196,7 +256,7 @@ class screen: cs, ce = ce, cs sc = [] for r in range (rs, re+1): - line = '' + line = u'' for c in range (cs, ce + 1): ch = self.get_abs (r,c) line = line + ch diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..ae62686 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[pytest] +norecursedirs = .git diff --git a/tests/test_ansi.py b/tests/test_ansi.py index 516509c..a9d445e 100755 --- a/tests/test_ansi.py +++ b/tests/test_ansi.py @@ -21,6 +21,9 @@ PEXPECT LICENSE from pexpect import ANSI import unittest from . import PexpectTestCase +import sys + +PY3 = (sys.version_info[0] >= 3) write_target = 'I\'ve got a ferret sticking up my nose. \n' +\ '(He\'s got a ferret sticking up his nose.) \n' +\ @@ -162,6 +165,62 @@ class ansiTestCase (PexpectTestCase.PexpectTestCase): assert str(s) == ('test ') assert s.state.memory == [s] + def test_utf8_bytes(self): + """Test that when bytes are passed in containing UTF-8 encoded + characters, where the encoding of each character consists of + multiple bytes, the characters are correctly decoded. + Incremental decoding is also tested.""" + s = ANSI.ANSI(2, 10, encoding='utf-8') + # This is the UTF-8 encoding of the UCS character "HOURGLASS" + # followed by the UTF-8 encoding of the UCS character + # "KEYBOARD". These characters can't be encoded in cp437 or + # latin-1. The "KEYBOARD" character is split into two + # separate writes. + s.write(b'\xe2\x8c\x9b') + s.write(b'\xe2\x8c') + s.write(b'\xa8') + if PY3: + assert str(s) == u'\u231b\u2328 \n ' + else: + assert unicode(s) == u'\u231b\u2328 \n ' + assert str(s) == b'\xe2\x8c\x9b\xe2\x8c\xa8 \n ' + assert s.dump() == u'\u231b\u2328 ' + assert s.pretty() == u'+----------+\n|\u231b\u2328 |\n| |\n+----------+\n' + assert s.get_abs(1, 1) == u'\u231b' + assert s.get_region(1, 1, 1, 5) == [u'\u231b\u2328 '] + + def test_unicode(self): + """Test passing in of a unicode string.""" + s = ANSI.ANSI(2, 10, encoding="utf-8") + s.write(u'\u231b\u2328') + if PY3: + assert str(s) == u'\u231b\u2328 \n ' + else: + assert unicode(s) == u'\u231b\u2328 \n ' + assert str(s) == b'\xe2\x8c\x9b\xe2\x8c\xa8 \n ' + assert s.dump() == u'\u231b\u2328 ' + assert s.pretty() == u'+----------+\n|\u231b\u2328 |\n| |\n+----------+\n' + assert s.get_abs(1, 1) == u'\u231b' + assert s.get_region(1, 1, 1, 5) == [u'\u231b\u2328 '] + + def test_decode_error(self): + """Test that default handling of decode errors replaces the + invalid characters.""" + s = ANSI.ANSI(2, 10, encoding="ascii") + s.write(b'\xff') # a non-ASCII character + # In unicode, the non-ASCII character is replaced with + # REPLACEMENT CHARACTER. + if PY3: + assert str(s) == u'\ufffd \n ' + else: + assert unicode(s) == u'\ufffd \n ' + assert str(s) == b'? \n ' + assert s.dump() == u'\ufffd ' + assert s.pretty() == u'+----------+\n|\ufffd |\n| |\n+----------+\n' + assert s.get_abs(1, 1) == u'\ufffd' + assert s.get_region(1, 1, 1, 5) == [u'\ufffd '] + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_async.py b/tests/test_async.py new file mode 100644 index 0000000..ce75572 --- /dev/null +++ b/tests/test_async.py @@ -0,0 +1,51 @@ +try: + import asyncio +except ImportError: + asyncio = None + +import sys +import unittest + +import pexpect +from .PexpectTestCase import PexpectTestCase + +def run(coro): + return asyncio.get_event_loop().run_until_complete(coro) + +@unittest.skipIf(asyncio is None, "Requires asyncio") +class AsyncTests(PexpectTestCase): + def test_simple_expect(self): + p = pexpect.spawn('cat') + p.sendline('Hello asyncio') + coro = p.expect(['Hello', pexpect.EOF] , async=True) + assert run(coro) == 0 + print('Done') + + def test_timeout(self): + p = pexpect.spawn('cat') + coro = p.expect('foo', timeout=1, async=True) + with self.assertRaises(pexpect.TIMEOUT): + run(coro) + + p = pexpect.spawn('cat') + coro = p.expect(['foo', pexpect.TIMEOUT], timeout=1, async=True) + assert run(coro) == 1 + + def test_eof(self): + p = pexpect.spawn('cat') + p.sendline('Hi') + coro = p.expect(pexpect.EOF, async=True) + p.sendeof() + assert run(coro) == 0 + + p = pexpect.spawn('cat') + p.sendeof() + coro = p.expect('Blah', async=True) + with self.assertRaises(pexpect.EOF): + run(coro) + + def test_expect_exact(self): + p = pexpect.spawn('%s list100.py' % sys.executable) + assert run(p.expect_exact(b'5', async=True)) == 0 + assert run(p.expect_exact(['wpeok', b'11'], async=True)) == 1 + assert run(p.expect_exact([b'foo', pexpect.EOF], async=True)) == 1 diff --git a/tests/test_constructor.py b/tests/test_constructor.py index 60525a0..98c473a 100755 --- a/tests/test_constructor.py +++ b/tests/test_constructor.py @@ -28,11 +28,11 @@ class TestCaseConstructor(PexpectTestCase.PexpectTestCase): the same results for different styles of invoking __init__(). This assumes that the root directory / is static during the test. ''' - p1 = pexpect.spawn('/bin/ls -l /bin') - p2 = pexpect.spawn('/bin/ls' ,['-l', '/bin']) - p1.expect (pexpect.EOF) - p2.expect (pexpect.EOF) - assert (p1.before == p2.before) + p1 = pexpect.spawn('uname -m -n -p -r -s -v') + p2 = pexpect.spawn('uname', ['-m', '-n', '-p', '-r', '-s', '-v']) + p1.expect(pexpect.EOF) + p2.expect(pexpect.EOF) + assert p1.before == p2.before def test_named_parameters (self): '''This tests that named parameters work. diff --git a/tests/test_expect.py b/tests/test_expect.py index 8ccb9c5..3f4c9d8 100755 --- a/tests/test_expect.py +++ b/tests/test_expect.py @@ -18,11 +18,13 @@ PEXPECT LICENSE OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ''' +import multiprocessing import unittest import subprocess import time import signal import sys +import os import pexpect from . import PexpectTestCase @@ -542,7 +544,40 @@ class ExpectTestCase (PexpectTestCase.PexpectTestCase): signal.alarm(1) p1.expect('END') + def test_stdin_closed(self): + ''' + Ensure pexpect continues to operate even when stdin is closed + ''' + class Closed_stdin_proc(multiprocessing.Process): + def run(self): + sys.__stdin__.close() + cat = pexpect.spawn('cat') + cat.sendeof() + cat.expect(pexpect.EOF) + + proc = Closed_stdin_proc() + proc.start() + proc.join() + assert proc.exitcode == 0 + + def test_stdin_stdout_closed(self): + ''' + Ensure pexpect continues to operate even when stdin and stdout is closed + ''' + class Closed_stdin_stdout_proc(multiprocessing.Process): + def run(self): + sys.__stdin__.close() + sys.__stdout__.close() + cat = pexpect.spawn('cat') + cat.sendeof() + cat.expect(pexpect.EOF) + + proc = Closed_stdin_stdout_proc() + proc.start() + proc.join() + assert proc.exitcode == 0 + if __name__ == '__main__': unittest.main() -suite = unittest.makeSuite(ExpectTestCase,'test') +suite = unittest.makeSuite(ExpectTestCase, 'test') diff --git a/tests/test_repr.py b/tests/test_repr.py new file mode 100644 index 0000000..ce618d4 --- /dev/null +++ b/tests/test_repr.py @@ -0,0 +1,26 @@ +""" Test __str__ methods. """ +import pexpect + +from . import PexpectTestCase + + +class TestCaseMisc(PexpectTestCase.PexpectTestCase): + + def test_str_spawnu(self): + """ Exercise spawnu.__str__() """ + # given, + p = pexpect.spawnu('cat') + # exercise, + value = str(p) + # verify + assert isinstance(value, str) + + def test_str_spawn(self): + """ Exercise spawn.__str__() """ + # given, + p = pexpect.spawn('cat') + # exercise, + value = str(p) + # verify + assert isinstance(value, str) + diff --git a/tests/test_run.py b/tests/test_run.py index 814b70a..c018b4d 100755 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -22,14 +22,11 @@ PEXPECT LICENSE import pexpect import unittest import subprocess +import tempfile import sys +import os from . import PexpectTestCase -# TODO Many of these test cases blindly assume that sequential -# TODO listing of the /bin directory will yield the same results. -# TODO This may not always be true, but seems adequate for testing for now. -# TODO I should fix this at some point. - unicode_type = str if pexpect.PY3 else unicode def timeout_callback (d): @@ -44,14 +41,24 @@ class RunFuncTestCase(PexpectTestCase.PexpectTestCase): empty = b'' prep_subprocess_out = staticmethod(lambda x: x) + def setUp(self): + fd, self.rcfile = tempfile.mkstemp() + os.write(fd, b'PS1=GO: \n') + os.close(fd) + super(RunFuncTestCase, self).setUp() + + def tearDown(self): + os.unlink(self.rcfile) + super(RunFuncTestCase, self).tearDown() + def test_run_exit (self): (data, exitstatus) = self.runfunc('python exit1.py', withexitstatus=1) assert exitstatus == 1, "Exit status of 'python exit1.py' should be 1." def test_run (self): - the_old_way = subprocess.Popen(args=['ls', '-l', '/bin'], + the_old_way = subprocess.Popen(args=['uname', '-m', '-n'], stdout=subprocess.PIPE).communicate()[0].rstrip() - (the_new_way, exitstatus) = self.runfunc('ls -l /bin', withexitstatus=1) + (the_new_way, exitstatus) = self.runfunc('uname -m -n', withexitstatus=1) the_new_way = the_new_way.replace(self.cr, self.empty).rstrip() self.assertEqual(self.prep_subprocess_out(the_old_way), the_new_way) self.assertEqual(exitstatus, 0) @@ -64,6 +71,23 @@ class RunFuncTestCase(PexpectTestCase.PexpectTestCase): withexitstatus=1) assert exitstatus != 0 + def test_run_tuple_list (self): + events = [ + # second match on 'abc', echo 'def' + ('abc\r\n.*GO:', 'echo "def"\n'), + # final match on 'def': exit + ('def\r\n.*GO:', 'exit\n'), + # first match on 'GO:' prompt, echo 'abc' + ('GO:', 'echo "abc"\n') + ] + + (data, exitstatus) = pexpect.run( + 'bash --rcfile {0}'.format(self.rcfile), + withexitstatus=True, + events=events, + timeout=10) + assert exitstatus == 0 + class RunUnicodeFuncTestCase(RunFuncTestCase): runfunc = staticmethod(pexpect.runu) cr = b'\r'.decode('ascii') diff --git a/tests/test_screen.py b/tests/test_screen.py index 3f0736b..2429e57 100755 --- a/tests/test_screen.py +++ b/tests/test_screen.py @@ -19,10 +19,14 @@ PEXPECT LICENSE ''' +import sys + from pexpect import screen import unittest from . import PexpectTestCase +PY3 = (sys.version_info[0] >= 3) + fill1_target='XXXXXXXXXX\n' + \ 'XOOOOOOOOX\n' + \ 'XO::::::OX\n' + \ @@ -76,6 +80,17 @@ insert_target = 'ZXZZZZZZXZ\n' +\ 'ZZ/2.4.6ZZ' get_region_target = ['......', '.\\/...', './\\...', '......'] +unicode_box_unicode_result = u'\u2554\u2557\n\u255A\u255D' +unicode_box_pretty_result = u'''\ ++--+ +|\u2554\u2557| +|\u255A\u255D| ++--+ +''' +unicode_box_ascii_bytes_result = b'??\n??' +unicode_box_cp437_bytes_result = b'\xc9\xbb\n\xc8\xbc' +unicode_box_utf8_bytes_result = b'\xe2\x95\x94\xe2\x95\x97\n\xe2\x95\x9a\xe2\x95\x9d' + class screenTestCase (PexpectTestCase.PexpectTestCase): def make_screen_with_put (self): s = screen.screen(10,10) @@ -168,20 +183,101 @@ class screenTestCase (PexpectTestCase.PexpectTestCase): s.insert_abs (10,9,'Z') s.insert_abs (10,9,'Z') assert str(s) == insert_target - # def test_write (self): - # s = screen.screen (6,65) - # s.fill('.') - # s.cursor_home() - # for c in write_text: - # s.write (c) - # print str(s) - # assert str(s) == write_target - # def test_tetris (self): - # s = screen.screen (24,80) - # tetris_text = open ('tetris.data').read() - # for c in tetris_text: - # s.write (c) - # assert str(s) == tetris_target + + def make_screen_with_box_unicode(self, *args, **kwargs): + '''Creates a screen containing a box drawn using double-line + line drawing characters. The characters are fed in as + unicode. ''' + s = screen.screen (2,2,*args,**kwargs) + s.put_abs (1,1,u'\u2554') + s.put_abs (1,2,u'\u2557') + s.put_abs (2,1,u'\u255A') + s.put_abs (2,2,u'\u255D') + return s + + def make_screen_with_box_cp437(self, *args, **kwargs): + '''Creates a screen containing a box drawn using double-line + line drawing characters. The characters are fed in as + CP437. ''' + s = screen.screen (2,2,*args,**kwargs) + s.put_abs (1,1,b'\xc9') + s.put_abs (1,2,b'\xbb') + s.put_abs (2,1,b'\xc8') + s.put_abs (2,2,b'\xbc') + return s + + def make_screen_with_box_utf8(self, *args, **kwargs): + '''Creates a screen containing a box drawn using double-line + line drawing characters. The characters are fed in as + UTF-8. ''' + s = screen.screen (2,2,*args,**kwargs) + s.put_abs (1,1,b'\xe2\x95\x94') + s.put_abs (1,2,b'\xe2\x95\x97') + s.put_abs (2,1,b'\xe2\x95\x9a') + s.put_abs (2,2,b'\xe2\x95\x9d') + return s + + def test_unicode_ascii (self): + # With the default encoding set to ASCII, we should still be + # able to feed in unicode strings and get them back out: + s = self.make_screen_with_box_unicode('ascii') + if PY3: + assert str(s) == unicode_box_unicode_result + else: + assert unicode(s) == unicode_box_unicode_result + # And we should still get something for Python 2 str(), though + # it might not be very useful + str(s) + + assert s.pretty() == unicode_box_pretty_result + + def test_decoding_errors(self): + # With strict error handling, it should reject bytes it can't decode + with self.assertRaises(UnicodeDecodeError): + self.make_screen_with_box_cp437('ascii', 'strict') + + # replace should turn them into unicode replacement characters, U+FFFD + s = self.make_screen_with_box_cp437('ascii', 'replace') + expected = u'\ufffd\ufffd\n\ufffd\ufffd' + if PY3: + assert str(s) == expected + else: + assert unicode(s) == expected + + def test_unicode_cp437 (self): + # Verify decoding from and re-encoding to CP437. + s = self.make_screen_with_box_cp437('cp437','strict') + if PY3: + assert str(s) == unicode_box_unicode_result + else: + assert unicode(s) == unicode_box_unicode_result + assert str(s) == unicode_box_cp437_bytes_result + assert s.pretty() == unicode_box_pretty_result + + def test_unicode_utf8 (self): + # Verify decoding from and re-encoding to UTF-8. + s = self.make_screen_with_box_utf8('utf-8','strict') + if PY3: + assert str(s) == unicode_box_unicode_result + else: + assert unicode(s) == unicode_box_unicode_result + assert str(s) == unicode_box_utf8_bytes_result + assert s.pretty() == unicode_box_pretty_result + + def test_no_bytes(self): + s = screen.screen(2, 2, encoding=None) + s.put_abs(1, 1, u'A') + s.put_abs(2, 2, u'D') + + with self.assertRaises(TypeError): + s.put_abs(1, 2, b'B') + + if PY3: + assert str(s) == u'A \n D' + else: + assert unicode(s) == u'A \n D' + # This will still work if it's limited to ascii + assert str(s) == b'A \n D' if __name__ == '__main__': unittest.main() |
