diff options
author | Red_M <1468433+Red-M@users.noreply.github.com> | 2023-03-14 07:21:54 +1000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-03-14 07:21:54 +1000 |
commit | dd3cc5c8ea71e1400f0e21fbcc3749c49af5362b (patch) | |
tree | 9a74b1ac8ce67b814bd7663998fbd043c076593c | |
parent | 5c59b0bc6e1ccc3082f114f2a8615198b86064c9 (diff) | |
parent | 571ca4b49d2c469aaaa57bfea6d8a28d8a91c4fc (diff) | |
download | pexpect-dd3cc5c8ea71e1400f0e21fbcc3749c49af5362b.tar.gz |
Merge pull request #745 from tapple/socket_spawn
Socket Pexpect (for Windows support)
-rw-r--r-- | doc/api/index.rst | 1 | ||||
-rw-r--r-- | doc/api/socket_pexpect.rst | 20 | ||||
-rw-r--r-- | pexpect/fdpexpect.py | 6 | ||||
-rw-r--r-- | pexpect/socket_pexpect.py | 145 | ||||
-rw-r--r-- | tests/PexpectTestCase.py | 35 | ||||
-rwxr-xr-x | tests/test_ctrl_chars.py | 5 | ||||
-rw-r--r-- | tests/test_pxssh.py | 4 | ||||
-rwxr-xr-x | tests/test_run.py | 6 | ||||
-rw-r--r-- | tests/test_socket.py | 40 | ||||
-rw-r--r-- | tests/test_socket_fd.py | 64 | ||||
-rw-r--r-- | tests/test_socket_pexpect.py | 72 |
11 files changed, 350 insertions, 48 deletions
diff --git a/doc/api/index.rst b/doc/api/index.rst index 5277d1c..747c812 100644 --- a/doc/api/index.rst +++ b/doc/api/index.rst @@ -6,6 +6,7 @@ API documentation pexpect fdpexpect + socket_pexpect popen_spawn replwrap pxssh diff --git a/doc/api/socket_pexpect.rst b/doc/api/socket_pexpect.rst new file mode 100644 index 0000000..726a999 --- /dev/null +++ b/doc/api/socket_pexpect.rst @@ -0,0 +1,20 @@ +socket_pexpect - use pexpect with a socket +========================================== + +.. automodule:: pexpect.socket_pexpect + +SocketSpawn class +----------------- + +.. autoclass:: SocketSpawn + :show-inheritance: + + .. automethod:: __init__ + .. automethod:: isalive + .. automethod:: close + + .. method:: expect + expect_exact + expect_list + + As :class:`pexpect.spawn`. diff --git a/pexpect/fdpexpect.py b/pexpect/fdpexpect.py index cddd50e..140bdfe 100644 --- a/pexpect/fdpexpect.py +++ b/pexpect/fdpexpect.py @@ -1,7 +1,11 @@ -'''This is like pexpect, but it will work with any file descriptor that you +'''This is like :mod:`pexpect`, but it will work with any file descriptor that you pass it. You are responsible for opening and close the file descriptor. This allows you to use Pexpect with sockets and named pipes (FIFOs). +.. note:: + socket.fileno() does not give a readable file descriptor on windows. + Use :mod:`pexpect.socket_pexpect` for cross-platform socket support + PEXPECT LICENSE This license is approved by the OSI and FSF as GPL-compatible. diff --git a/pexpect/socket_pexpect.py b/pexpect/socket_pexpect.py new file mode 100644 index 0000000..cb11ac2 --- /dev/null +++ b/pexpect/socket_pexpect.py @@ -0,0 +1,145 @@ +"""This is like :mod:`pexpect`, but it will work with any socket that you +pass it. You are responsible for opening and closing the socket. + +PEXPECT LICENSE + + This license is approved by the OSI and FSF as GPL-compatible. + http://opensource.org/licenses/isc-license.txt + + 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. + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +""" + +import socket +from contextlib import contextmanager + +from .exceptions import TIMEOUT, EOF +from .spawnbase import SpawnBase + +__all__ = ["SocketSpawn"] + + +class SocketSpawn(SpawnBase): + """This is like :mod:`pexpect.fdpexpect` but uses the cross-platform python socket api, + rather than the unix-specific file descriptor api. Thus, it works with + remote connections on both unix and windows.""" + + def __init__( + self, + socket: socket.socket, + args=None, + timeout=30, + maxread=2000, + searchwindowsize=None, + logfile=None, + encoding=None, + codec_errors="strict", + use_poll=False, + ): + """This takes an open socket.""" + + self.args = None + self.command = None + SpawnBase.__init__( + self, + timeout, + maxread, + searchwindowsize, + logfile, + encoding=encoding, + codec_errors=codec_errors, + ) + self.socket = socket + self.child_fd = socket.fileno() + self.closed = False + self.name = "<socket %s>" % socket + self.use_poll = use_poll + + def close(self): + """Close the socket. + + Calling this method a second time does nothing, but if the file + descriptor was closed elsewhere, :class:`OSError` will be raised. + """ + if self.child_fd == -1: + return + + self.flush() + self.socket.shutdown(socket.SHUT_RDWR) + self.socket.close() + self.child_fd = -1 + self.closed = True + + def isalive(self): + """ Alive if the fileno is valid """ + return self.socket.fileno() >= 0 + + def send(self, s) -> int: + """Write to socket, return number of bytes written""" + s = self._coerce_send_string(s) + self._log(s, "send") + + b = self._encoder.encode(s, final=False) + self.socket.sendall(b) + return len(b) + + def sendline(self, s) -> int: + """Write to socket with trailing newline, return number of bytes written""" + s = self._coerce_send_string(s) + return self.send(s + self.linesep) + + def write(self, s): + """Write to socket, return None""" + self.send(s) + + def writelines(self, sequence): + "Call self.write() for each item in sequence" + for s in sequence: + self.write(s) + + @contextmanager + def _timeout(self, timeout): + saved_timeout = self.socket.gettimeout() + try: + self.socket.settimeout(timeout) + yield + finally: + self.socket.settimeout(saved_timeout) + + def read_nonblocking(self, size=1, timeout=-1): + """ + Read from the file descriptor and return the result as a string. + + The read_nonblocking method of :class:`SpawnBase` assumes that a call + to os.read will not block (timeout parameter is ignored). This is not + the case for POSIX file-like objects such as sockets and serial ports. + + Use :func:`select.select`, timeout is implemented conditionally for + POSIX systems. + + :param int size: Read at most *size* bytes. + :param int timeout: Wait timeout seconds for file descriptor to be + ready to read. When -1 (default), use self.timeout. When 0, poll. + :return: String containing the bytes read + """ + if timeout == -1: + timeout = self.timeout + try: + with self._timeout(timeout): + s = self.socket.recv(size) + if s == b'': + self.flag_eof = True + raise EOF("Socket closed") + return s + except socket.timeout: + raise TIMEOUT("Timeout exceeded.") diff --git a/tests/PexpectTestCase.py b/tests/PexpectTestCase.py index 5d7a168..a762d8f 100644 --- a/tests/PexpectTestCase.py +++ b/tests/PexpectTestCase.py @@ -49,22 +49,25 @@ class PexpectTestCase(unittest.TestCase): print('\n', self.id(), end=' ') sys.stdout.flush() - # some build agents will ignore SIGHUP and SIGINT, which python - # inherits. This causes some of the tests related to terminate() - # to fail. We set them to the default handlers that they should - # be, and restore them back to their SIG_IGN value on tearDown. - # - # I'm not entirely convinced they need to be restored, only our - # test runner is affected. - self.restore_ignored_signals = [ - value for value in (signal.SIGHUP, signal.SIGINT,) - if signal.getsignal(value) == signal.SIG_IGN] - if signal.SIGHUP in self.restore_ignored_signals: - # sighup should be set to default handler - signal.signal(signal.SIGHUP, signal.SIG_DFL) - if signal.SIGINT in self.restore_ignored_signals: - # SIGINT should be set to signal.default_int_handler - signal.signal(signal.SIGINT, signal.default_int_handler) + if sys.platform != 'win32': + # some build agents will ignore SIGHUP and SIGINT, which python + # inherits. This causes some of the tests related to terminate() + # to fail. We set them to the default handlers that they should + # be, and restore them back to their SIG_IGN value on tearDown. + # + # I'm not entirely convinced they need to be restored, only our + # test runner is affected. + self.restore_ignored_signals = [ + value for value in (signal.SIGHUP, signal.SIGINT,) + if signal.getsignal(value) == signal.SIG_IGN] + if signal.SIGHUP in self.restore_ignored_signals: + # sighup should be set to default handler + signal.signal(signal.SIGHUP, signal.SIG_DFL) + if signal.SIGINT in self.restore_ignored_signals: + # SIGINT should be set to signal.default_int_handler + signal.signal(signal.SIGINT, signal.default_int_handler) + else: + self.restore_ignored_signals = [] unittest.TestCase.setUp(self) def tearDown(self): diff --git a/tests/test_ctrl_chars.py b/tests/test_ctrl_chars.py index 0719fc7..11fb55c 100755 --- a/tests/test_ctrl_chars.py +++ b/tests/test_ctrl_chars.py @@ -26,8 +26,9 @@ from . import PexpectTestCase import time import sys -from ptyprocess import ptyprocess -ptyprocess._make_eof_intr() +if sys.platform != 'win32': + from ptyprocess import ptyprocess + ptyprocess._make_eof_intr() if sys.version_info[0] >= 3: def byte(i): diff --git a/tests/test_pxssh.py b/tests/test_pxssh.py index c6ec4e2..ba700c8 100644 --- a/tests/test_pxssh.py +++ b/tests/test_pxssh.py @@ -1,10 +1,12 @@ #!/usr/bin/env python +import sys import os import shutil import tempfile import unittest -from pexpect import pxssh +if sys.platform != 'win32': + from pexpect import pxssh from .PexpectTestCase import PexpectTestCase class SSHTestBase(PexpectTestCase): diff --git a/tests/test_run.py b/tests/test_run.py index baa6b49..15d0c40 100755 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -52,7 +52,8 @@ def function_events_callback(values): class RunFuncTestCase(PexpectTestCase.PexpectTestCase): - runfunc = staticmethod(pexpect.run) + if sys.platform != 'win32': + runfunc = staticmethod(pexpect.run) cr = b'\r' empty = b'' prep_subprocess_out = staticmethod(lambda x: x) @@ -160,7 +161,8 @@ class RunFuncTestCase(PexpectTestCase.PexpectTestCase): class RunUnicodeFuncTestCase(RunFuncTestCase): - runfunc = staticmethod(pexpect.runu) + if sys.platform != 'win32': + runfunc = staticmethod(pexpect.runu) cr = b'\r'.decode('ascii') empty = b''.decode('ascii') prep_subprocess_out = staticmethod(lambda x: x.decode('utf-8', 'replace')) diff --git a/tests/test_socket.py b/tests/test_socket.py index 548d90a..b801b00 100644 --- a/tests/test_socket.py +++ b/tests/test_socket.py @@ -19,7 +19,7 @@ PEXPECT LICENSE ''' import pexpect -from pexpect import fdpexpect +from pexpect import socket_pexpect import unittest from . import PexpectTestCase import multiprocessing @@ -133,12 +133,16 @@ class ExpectTestCase(PexpectTestCase.PexpectTestCase): pass exit(0) + def spawn(self, socket, timeout=30, use_poll=False): + """override me with other ways of spawning on a socket""" + return socket_pexpect.SocketSpawn(socket, timeout=timeout, use_poll=use_poll) + def socket_fn(self, timed_out, all_read): result = 0 try: sock = socket.socket(self.af, socket.SOCK_STREAM) sock.connect((self.host, self.port)) - session = fdpexpect.fdspawn(sock, timeout=10) + session = self.spawn(sock, timeout=10) # Get all data from server session.read_nonblocking(size=4096) all_read.set() @@ -152,7 +156,7 @@ class ExpectTestCase(PexpectTestCase.PexpectTestCase): def test_socket(self): sock = socket.socket(self.af, socket.SOCK_STREAM) sock.connect((self.host, self.port)) - session = fdpexpect.fdspawn(sock.fileno(), timeout=10) + session = self.spawn(sock, timeout=10) session.expect(self.prompt1) self.assertEqual(session.before, self.motd) session.send(self.enter) @@ -166,7 +170,7 @@ class ExpectTestCase(PexpectTestCase.PexpectTestCase): def test_socket_with_write(self): sock = socket.socket(self.af, socket.SOCK_STREAM) sock.connect((self.host, self.port)) - session = fdpexpect.fdspawn(sock.fileno(), timeout=10) + session = self.spawn(sock, timeout=10) session.expect(self.prompt1) self.assertEqual(session.before, self.motd) session.write(self.enter) @@ -177,19 +181,11 @@ class ExpectTestCase(PexpectTestCase.PexpectTestCase): session.expect(pexpect.EOF) self.assertEqual(session.before, b'') - def test_not_int(self): - with self.assertRaises(pexpect.ExceptionPexpect): - session = fdpexpect.fdspawn('bogus', timeout=10) - - def test_not_file_descriptor(self): - with self.assertRaises(pexpect.ExceptionPexpect): - session = fdpexpect.fdspawn(-1, timeout=10) - def test_timeout(self): with self.assertRaises(pexpect.TIMEOUT): sock = socket.socket(self.af, socket.SOCK_STREAM) sock.connect((self.host, self.port)) - session = fdpexpect.fdspawn(sock, timeout=10) + session = self.spawn(sock, timeout=10) session.expect(b'Bogus response') def test_interrupt(self): @@ -223,7 +219,7 @@ class ExpectTestCase(PexpectTestCase.PexpectTestCase): def test_maxread(self): sock = socket.socket(self.af, socket.SOCK_STREAM) sock.connect((self.host, self.port)) - session = fdpexpect.fdspawn(sock.fileno(), timeout=10) + session = self.spawn(sock, timeout=10) session.maxread = 1100 session.expect(self.prompt1) self.assertEqual(session.before, self.motd) @@ -238,7 +234,7 @@ class ExpectTestCase(PexpectTestCase.PexpectTestCase): def test_fd_isalive(self): sock = socket.socket(self.af, socket.SOCK_STREAM) sock.connect((self.host, self.port)) - session = fdpexpect.fdspawn(sock.fileno(), timeout=10) + session = self.spawn(sock, timeout=10) assert session.isalive() sock.close() assert not session.isalive(), "Should not be alive after close()" @@ -246,7 +242,7 @@ class ExpectTestCase(PexpectTestCase.PexpectTestCase): def test_fd_isalive_poll(self): sock = socket.socket(self.af, socket.SOCK_STREAM) sock.connect((self.host, self.port)) - session = fdpexpect.fdspawn(sock.fileno(), timeout=10, use_poll=True) + session = self.spawn(sock, timeout=10, use_poll=True) assert session.isalive() sock.close() assert not session.isalive(), "Should not be alive after close()" @@ -254,25 +250,17 @@ class ExpectTestCase(PexpectTestCase.PexpectTestCase): def test_fd_isatty(self): sock = socket.socket(self.af, socket.SOCK_STREAM) sock.connect((self.host, self.port)) - session = fdpexpect.fdspawn(sock.fileno(), timeout=10) + session = self.spawn(sock, timeout=10) assert not session.isatty() session.close() def test_fd_isatty_poll(self): sock = socket.socket(self.af, socket.SOCK_STREAM) sock.connect((self.host, self.port)) - session = fdpexpect.fdspawn(sock.fileno(), timeout=10, use_poll=True) + session = self.spawn(sock, timeout=10, use_poll=True) assert not session.isatty() session.close() - def test_fileobj(self): - sock = socket.socket(self.af, socket.SOCK_STREAM) - sock.connect((self.host, self.port)) - session = fdpexpect.fdspawn(sock, timeout=10) # Should get the fileno from the socket - session.expect(self.prompt1) - session.close() - assert not session.isalive() - session.close() # Smoketest - should be able to call this again if __name__ == '__main__': unittest.main() diff --git a/tests/test_socket_fd.py b/tests/test_socket_fd.py new file mode 100644 index 0000000..5be733c --- /dev/null +++ b/tests/test_socket_fd.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python +''' +PEXPECT LICENSE + + This license is approved by the OSI and FSF as GPL-compatible. + http://opensource.org/licenses/isc-license.txt + + 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. + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +''' +import pexpect +from pexpect import fdpexpect +import unittest +from . import test_socket +import multiprocessing +import os +import signal +import socket +import time +import errno + + +class SocketServerError(Exception): + pass + + +class ExpectTestCase(test_socket.ExpectTestCase): + """ duplicate of test_socket, but using fdpexpect rather than socket_expect """ + + def spawn(self, socket, timeout=30, use_poll=False): + return fdpexpect.fdspawn(socket.fileno(), timeout=timeout, use_poll=use_poll) + + def test_not_int(self): + with self.assertRaises(pexpect.ExceptionPexpect): + session = fdpexpect.fdspawn('bogus', timeout=10) + + def test_not_file_descriptor(self): + with self.assertRaises(pexpect.ExceptionPexpect): + session = fdpexpect.fdspawn(-1, timeout=10) + + def test_fileobj(self): + sock = socket.socket(self.af, socket.SOCK_STREAM) + sock.connect((self.host, self.port)) + session = fdpexpect.fdspawn(sock, timeout=10) # Should get the fileno from the socket + session.expect(self.prompt1) + session.close() + assert not session.isalive() + session.close() # Smoketest - should be able to call this again + + +if __name__ == '__main__': + unittest.main() + +suite = unittest.TestLoader().loadTestsFromTestCase(ExpectTestCase) diff --git a/tests/test_socket_pexpect.py b/tests/test_socket_pexpect.py new file mode 100644 index 0000000..8fbcebf --- /dev/null +++ b/tests/test_socket_pexpect.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python +''' +PEXPECT LICENSE + + This license is approved by the OSI and FSF as GPL-compatible. + http://opensource.org/licenses/isc-license.txt + + 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. + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +''' +import pexpect +from pexpect import socket_pexpect +import unittest +from . import PexpectTestCase +import socket + +def open_file_socket(filename): + read_socket, write_socket = socket.socketpair() + with open(filename, "rb") as file: + write_socket.sendall(file.read()) + write_socket.close() + return read_socket + +class ExpectTestCase(PexpectTestCase.PexpectTestCase): + def setUp(self): + print(self.id()) + PexpectTestCase.PexpectTestCase.setUp(self) + + def test_socket (self): + socket = open_file_socket('TESTDATA.txt') + s = socket_pexpect.SocketSpawn(socket) + s.expect(b'This is the end of test data:') + s.expect(pexpect.EOF) + self.assertEqual(s.before, b' END\n') + + def test_maxread (self): + socket = open_file_socket('TESTDATA.txt') + s = socket_pexpect.SocketSpawn(socket) + s.maxread = 100 + s.expect('2') + s.expect ('This is the end of test data:') + s.expect (pexpect.EOF) + self.assertEqual(s.before, b' END\n') + + def test_socket_isalive (self): + socket = open_file_socket('TESTDATA.txt') + s = socket_pexpect.SocketSpawn(socket) + assert s.isalive() + s.close() + assert not s.isalive(), "Should not be alive after close()" + + def test_socket_isatty (self): + socket = open_file_socket('TESTDATA.txt') + s = socket_pexpect.SocketSpawn(socket) + assert not s.isatty() + s.close() + + +if __name__ == '__main__': + unittest.main() + +suite = unittest.TestLoader().loadTestsFromTestCase(ExpectTestCase) |