cadquery-freecad-module/Libs/pyqode/core/api/client.py

417 lines
14 KiB
Python

"""
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 sys
import uuid
from weakref import ref
from pyqode.qt import QtCore, QtNetwork
def _logger():
return logging.getLogger(__name__)
#: log level for communication
COMM = 1
def comm(msg, *args):
_logger().log(COMM, msg, *args)
#: 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().'
}
if sys.version_info[0] >= 3:
class WeakMethod(ref):
"""
A custom `weakref.ref` subclass which simulates a weak reference to
a bound method, working around the lifetime problem of bound methods.
"""
__slots__ = "_func_ref", "_meth_type", "_alive", "__weakref__"
def __new__(cls, meth, callback=None):
try:
obj = meth.__self__
func = meth.__func__
except AttributeError:
raise TypeError("argument should be a bound method, not {}"
.format(type(meth)))
def _cb(arg):
# The self-weakref trick is needed to avoid creating a
# reference cycle.
self = self_wr()
if self._alive:
self._alive = False
if callback is not None:
callback(self)
self = ref.__new__(cls, obj, _cb)
self._func_ref = ref(func, _cb)
self._meth_type = type(meth)
self._alive = True
self_wr = ref(self)
return self
def __call__(self):
obj = super().__call__()
func = self._func_ref()
if obj is None or func is None:
return None
return self._meth_type(func, obj)
def __eq__(self, other):
if isinstance(other, WeakMethod):
if not self._alive or not other._alive:
return self is other
return ref.__eq__(self, other) and \
self._func_ref == other._func_ref
return False
def __ne__(self, other):
if isinstance(other, WeakMethod):
if not self._alive or not other._alive:
return self is not other
return ref.__ne__(self, other) or \
self._func_ref != other._func_ref
return True
__hash__ = ref.__hash__
else:
class _weak_callable:
def __init__(self, obj, func):
self._obj = obj
self._meth = func
def __call__(self, *args, **kws):
if self._obj is not None:
return self._meth(self._obj, *args, **kws)
else:
return self._meth(*args, **kws)
def __getattr__(self, attr):
if attr == 'im_self':
return self._obj
if attr == 'im_func':
return self._meth
raise AttributeError(attr)
class WeakMethod:
""" Wraps a function or, more importantly, a bound method, in
a way that allows a bound method's object to be GC'd, while
providing the same interface as a normal weak reference. """
def __init__(self, fn):
try:
self._obj = ref(fn.im_self)
self._meth = fn.im_func
except AttributeError:
# It's not a bound method.
self._obj = None
self._meth = fn
def __call__(self):
if self._dead():
return None
return _weak_callable(self._obj(), self._meth)
def _dead(self):
return self._obj is not None and self._obj() is None
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()
if on_receive:
try:
self._callback = WeakMethod(on_receive)
except TypeError:
# unbound method (i.e. free function)
self._callback = ref(on_receive)
else:
self._callback = None
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()
self._callback = None
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.
"""
comm('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 """
if self is None:
return
comm('connecting to 127.0.0.1:%d', self._port)
address = QtNetwork.QHostAddress('127.0.0.1')
self.connectToHost(address, self._port)
if sys.platform == 'darwin':
self.waitForConnected()
def _on_connected(self):
comm('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 = comm
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:
comm('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):
comm('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()
comm('header content: %d', self._to_read)
def _read_payload(self):
""" Reads the payload (=data) """
comm('reading payload data')
comm('remaining bytes to read: %d', self._to_read)
data_read = self.read(self._to_read)
nb_bytes_read = len(data_read)
comm('%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')
comm('payload read: %r', data)
comm('payload length: %r', len(self._data_buf))
comm('decoding payload as json object')
obj = json.loads(data)
comm('response received: %r', obj)
try:
results = obj['results']
except (KeyError, TypeError):
results = None
# possible callback
if self._callback and 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 """
comm('backend process started')
if self is None:
return
self.starting = False
self.running = True
def _on_process_error(self, error):
""" Logs process error """
if self is None:
return
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 """
comm('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 """
if not self:
return
o = self.readAllStandardOutput()
try:
output = bytes(o).decode(self._encoding)
except TypeError:
output = bytes(o.data()).decode(self._encoding)
for line in output.splitlines():
self._srv_logger.log(1, line)
def _on_process_stderr_ready(self):
""" Logs process output (stderr) """
try:
o = self.readAllStandardError()
except (TypeError, RuntimeError):
# widget already deleted
return
try:
output = bytes(o).decode(self._encoding)
except TypeError:
output = bytes(o.data()).decode(self._encoding)
for line in output.splitlines():
self._srv_logger.error(line)
def terminate(self):
""" Terminate the process """
self.running = False
super(BackendProcess, self).terminate()