""" This module contains the client socket API. This API is exposed to the user throught the backend manager ( :class:`pyqode.core.managers.BackendManager`) """ import locale import json import logging import socket import struct import uuid from pyqode.qt import QtCore, QtNetwork def _logger(): return logging.getLogger(__name__) #: Dictionary of socket errors messages SOCKET_ERROR_STRINGS = { 0: 'the connection was refused by the peer (or timed out).', 1: 'the remote host closed the connection.', 2: 'the host address was not found.', 3: 'the socket operation failed because the application lacked the ' 'required privileges.', 4: 'the local system ran out of resources (e.g., too many sockets).', 5: 'the socket operation timed out.', 6: "the datagram was larger than the operating system's limit (which can " "be as low as 8192 bytes).", 7: 'an error occurred with the network (e.g., the network cable was ' 'accidentally plugged out).', # 9 and 10 are UDP only, we only care about TCP. # all others erros are unlikely to happen in our case (proxy related # errors) - 1: 'an unidentified error occurred.', } #: Dictionary of process errors messages PROCESS_ERROR_STRING = { 0: 'the process failed to start. Either the invoked program is missing, ' 'or you may have insufficient permissions to invoke the program.', 1: 'the process crashed some time after starting successfully.', 2: 'the last waitFor...() function timed out. The state of QProcess is ' 'unchanged, and you can try calling waitFor...() again.', 4: 'an error occurred when attempting to write to the process. ' 'For example, the process may not be running, or it may have closed ' 'its input channel.', 3: 'an error occurred when attempting to read from the process. ' 'For example, the process may not be running.', 5: 'an unknown error occurred. This is the default return value of ' 'error().' } class JsonTcpClient(QtNetwork.QTcpSocket): """ A json tcp client socket used to start and communicate with the pyqode backend. It uses a simple message protocol. A message is made up of two parts. parts: - header: contains the length of the payload. (4bytes) - payload: data as a json string. """ #: Internal signal emitted when the backend request finished and the #: socket can be removed from the list of sockets maintained by the #: backend manager finished = QtCore.Signal(QtNetwork.QTcpSocket) def __init__(self, parent, port, worker_class_or_function, args, on_receive=None): super(JsonTcpClient, self).__init__(parent) self._port = port self._worker = worker_class_or_function self._args = args self._header_complete = False self._header_buf = bytes() self._to_read = 0 self._data_buf = bytes() self._callback = on_receive self.is_connected = False self._closed = False self.connected.connect(self._on_connected) self.error.connect(self._on_error) self.disconnected.connect(self._on_disconnected) self.readyRead.connect(self._on_ready_read) self._connect() def close(self): self._closed = True # fix issue with QTimer.singleShot super(JsonTcpClient, self).close() def _send_request(self): """ Sends the request to the backend. """ if isinstance(self._worker, str): classname = self._worker else: classname = '%s.%s' % (self._worker.__module__, self._worker.__name__) self.request_id = str(uuid.uuid4()) self.send({'request_id': self.request_id, 'worker': classname, 'data': self._args}) def send(self, obj, encoding='utf-8'): """ Sends a python object to the backend. The object **must be JSON serialisable**. :param obj: object to send :param encoding: encoding used to encode the json message into a bytes array, this should match CodeEdit.file.encoding. """ _logger().debug('sending request: %r', obj) msg = json.dumps(obj) msg = msg.encode(encoding) header = struct.pack('=I', len(msg)) self.write(header) self.write(msg) @staticmethod def pick_free_port(): """ Picks a free port """ test_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) test_socket.bind(('127.0.0.1', 0)) free_port = int(test_socket.getsockname()[1]) test_socket.close() return free_port def _connect(self): """ Connects our client socket to the backend socket """ _logger().debug('connecting to 127.0.0.1:%d', self._port) address = QtNetwork.QHostAddress('127.0.0.1') self.connectToHost(address, self._port) def _on_connected(self): _logger().debug('connected to backend: %s:%d', self.peerName(), self.peerPort()) self.is_connected = True self._send_request() def _on_error(self, error): if error not in SOCKET_ERROR_STRINGS: # pragma: no cover error = -1 if error == 1 and self.is_connected or ( not self.is_connected and error == 0 and not self._closed): log_fct = _logger().debug else: log_fct = _logger().warning if error == 0 and not self.is_connected and not self._closed: QtCore.QTimer.singleShot(100, self._connect) log_fct(SOCKET_ERROR_STRINGS[error]) def _on_disconnected(self): try: _logger().debug('disconnected from backend: %s:%d', self.peerName(), self.peerPort()) except (AttributeError, RuntimeError): # logger might be None if for some reason qt deletes the socket # after python global exit pass try: self.is_connected = False except AttributeError: pass def _read_header(self): _logger().debug('reading header') self._header_buf += self.read(4) if len(self._header_buf) == 4: self._header_complete = True try: header = struct.unpack('=I', self._header_buf) except TypeError: # pyside header = struct.unpack('=I', self._header_buf.data()) self._to_read = header[0] self._header_buf = bytes() _logger().debug('header content: %d', self._to_read) def _read_payload(self): """ Reads the payload (=data) """ _logger().debug('reading payload data') _logger().debug('remaining bytes to read: %d', self._to_read) data_read = self.read(self._to_read) nb_bytes_read = len(data_read) _logger().debug('%d bytes read', nb_bytes_read) self._data_buf += data_read self._to_read -= nb_bytes_read if self._to_read <= 0: try: data = self._data_buf.decode('utf-8') except AttributeError: data = bytes(self._data_buf.data()).decode('utf-8') _logger().debug('payload read: %r', data) _logger().debug('payload length: %r', len(self._data_buf)) _logger().debug('decoding payload as json object') obj = json.loads(data) _logger().debug('response received: %r', obj) results = obj['results'] # possible callback if self._callback: self._callback(results) self._header_complete = False self._data_buf = bytes() self.finished.emit(self) def _on_ready_read(self): """ Read bytes when ready read """ while self.bytesAvailable(): if not self._header_complete: self._read_header() else: self._read_payload() class BackendProcess(QtCore.QProcess): """ Extends QProcess with methods to easily manipulate the backend process. Also logs everything that is written to the process' stdout/stderr. """ def __init__(self, parent): super(BackendProcess, self).__init__(parent) self.started.connect(self._on_process_started) self.error.connect(self._on_process_error) self.finished.connect(self._on_process_finished) self.readyReadStandardOutput.connect(self._on_process_stdout_ready) self.readyReadStandardError.connect(self._on_process_stderr_ready) self.running = False self.starting = True self._srv_logger = logging.getLogger('pyqode.backend') self._prevent_logs = False self._encoding = locale.getpreferredencoding() def _on_process_started(self): """ Logs process started """ _logger().debug('backend process started') self.starting = False self.running = True def _on_process_error(self, error): """ Logs process error """ if error not in PROCESS_ERROR_STRING: error = -1 if not self._prevent_logs: _logger().warning(PROCESS_ERROR_STRING[error]) def _on_process_finished(self, exit_code): """ Logs process exit status """ _logger().debug('backend process finished with exit code %d', exit_code) try: self.running = False except AttributeError: pass def _on_process_stdout_ready(self): """ Logs process output """ o = self.readAllStandardOutput() try: output = bytes(o).decode(self._encoding) except TypeError: output = bytes(o.data()).decode(self._encoding) output = output[:output.rfind('\n')] for line in output.splitlines(): self._srv_logger.debug(line) def _on_process_stderr_ready(self): """ Logs process output (stderr) """ if not self: return o = self.readAllStandardError() try: output = bytes(o).decode(self._encoding) except TypeError: output = bytes(o.data()).decode(self._encoding) output = output[:output.rfind('\n')] for line in output.splitlines(): self._srv_logger.error(line) def terminate(self): """ Terminate the process """ self.running = False super(BackendProcess, self).terminate()