Mon, 29 Jan 2024 19:50:44 +0100
Started implementing an eric-ide server for remote development (e.g. on a different host or in a Docker container).
# -*- coding: utf-8 -*- # Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de> # """ Module implementing the eric remote server. """ import io import json import select import socket import struct import sys import traceback import zlib from eric7.UI.Info import Version from .EricRequestCategory import EricRequestCategory class EricServer: """ Class implementing the eric remote server. """ def __init__(self, port=42024, useIPv6=False): """ Constructor @param port port to listen on (defaults to 42024) @type int (optional) @param useIPv6 flag indicating to use IPv6 protocol (defaults to False) @type bool (optional) """ self.__requestCategoryHandlerRegistry = { # Dictionary containing the defined and registered request category # handlers. The key is the request category and the value is the respective # handler method. This method must have the signature: # handler(request:str, params:dict, reqestUuid:str) -> None EricRequestCategory.Debugger: None, # TODO: not implemented yet EricRequestCategory.Echo: self.__handleEchoRequest, EricRequestCategory.FileSystem: None, # TODO: not implemented yet EricRequestCategory.Project: None, # TODO: not implemented yet EricRequestCategory.Server: self.__handleServerRequest } self.__connection = None address = ("", port) if socket.has_dualstack_ipv6() and useIPv6: self.__socket = socket.create_server( address, family=socket.AF_INET6, dualstack_ipv6=True ) else: self.__socket = socket.create_server( address, family=socket.AF_INET ) ####################################################################### ## Methods for receiving requests and sending the results. ####################################################################### def sendJson(self, category, reply, params, reqestUuid=""): """ Public method to send a single refactoring command to the server. @param category service category @type EricRequestCategory @param reply reply name to be sent @type str @param params dictionary of named parameters for the request @type dict @param reqestUuid UUID of the associated request as sent by the eric IDE (defaults to "", i.e. no UUID received) @type str """ commandDict = { "jsonrpc": "2.0", "category": category, "reply": reply, "params": params, "uuid": reqestUuid, } data = json.dumps(commandDict).encode("utf8", "backslashreplace") header = struct.pack(b"!II", len(data), zlib.adler32(data) & 0xFFFFFFFF) self.__connection.sendall(header) self.__connection.sendall(data) def __receiveBytes(self, length): """ Private method to receive the given length of bytes. @param length bytes to receive @type int @return received bytes or None if connection closed @rtype bytes """ data = bytearray() while len(data) < length: newData = self.__connection.recv(length - len(data)) if not newData: return None data += newData return data def __receiveJson(self): """ Private method to receive a JSON encoded command and data from the server. @return tuple containing the received service category, the command, a dictionary containing the associated data and the UUID of the request @rtype tuple of (int, str, dict, str) """ # step 1: receive the data header = self.__receiveBytes(struct.calcsize(b"!II")) if not header: return EricRequestCategory.Error, None, None, None length, datahash = struct.unpack(b"!II", header) length = int(length) data = self.__receiveBytes(length) if not data or zlib.adler32(data) & 0xFFFFFFFF != datahash: self.sendJson( category=EricRequestCategory.Error, reply="ClientChecksumException", params={ "ExceptionType": "ProtocolChecksumError", "ExceptionValue": "The checksum of the data does not match.", "ProtocolData": data.decode("utf8", "backslashreplace"), }, ) return EricRequestCategory.Error, None, None, None # step 2: decode and convert the data jsonString = data.decode("utf8", "backslashreplace") try: requestDict = json.loads(jsonString.strip()) except (TypeError, ValueError) as err: self.sendJson( category=EricRequestCategory.Error, reply="ClientException", params={ "ExceptionType": "ProtocolError", "ExceptionValue": str(err), "ProtocolData": jsonString.strip(), }, ) return EricRequestCategory.Error, None, None, None category = requestDict["category"] request = requestDict["request"] params = requestDict["params"] reqestUuid = requestDict["uuid"] return category, request, params, reqestUuid ####################################################################### ## Methods for the server main loop. ####################################################################### def __shutdown(self): """ Private method to shut down the server. """ self.__socket.shutdown(socket.SHUT_RDWR) self.__socket.close() def run(self): """ Public method implementing the remote server main loop. Exiting the inner loop, that receives and dispatches the requests, will cause the server to stop and exit. The main loop handles these requests. <ul> <li>exit - exit the handler loop and wait for the next connection</li> <li>shutdown - exit the handler loop and perform a clean shutdown</li> </ul> @return flag indicating a clean shutdown @rtype bool """ shutdown = False cleanExit = True # listen on the server socket for new connections self.__socket.listen(1) while True: try: # accept the next pending connection print("Waiting for connection...") self.__connection, address = self.__socket.accept() print(f"Connection from {address[0]}, port {address[1]}") selectErrors = 0 while selectErrors <= 10: # selected arbitrarily try: rrdy, wrdy, xrdy = select.select([self.__connection], [], []) # Just waiting for self.__connection. Therefore no check # needed. category, request, params, reqestUuid = self.__receiveJson() if category == EricRequestCategory.Error or request is None: selectErrors += 1 elif category == EricRequestCategory.Server: if request.lower() == "exit": break elif request.lower() == "shutdown": shutdown = True break else: self.__handleRequest( category, request, params, reqestUuid ) else: self.__handleRequest(category, request, params, reqestUuid) # reset select errors selectErrors = 0 except (select.error, socket.error): selectErrors += 1 except KeyboardInterrupt: # intercept user pressing Ctrl+C shutdown = True except Exception: exctype, excval, exctb = sys.exc_info() tbinfofile = io.StringIO() traceback.print_tb(exctb, None, tbinfofile) tbinfofile.seek(0) tbinfo = tbinfofile.read() print(f"{str(exctype)} / {str(excval)} / {tbinfo}") shutdown = True cleanExit = False if self.__connection is not None: self.__connection.shutdown(socket.SHUT_RDWR) self.__connection.close() self.__connection = None if shutdown: # exit the outer loop and shut down the server self.__shutdown() break return cleanExit ####################################################################### ## Request handler methods. ####################################################################### def __handleRequest(self, category, request, params, reqestUuid): """ Private method handling or dispatching the received requests. @param category category of the request @type EricRequestCategory @param request request name @type str @param params request parameters @type dict @param reqestUuid UUID of the associated request as sent by the eric IDE (defaults to "", i.e. no UUID received) @type str """ try: handler = self.__requestCategoryHandlerRegistry[category] if handler is None: raise ValueError("invalid handler function") handler(request=request, params=params, reqestUuid=reqestUuid) except (KeyError, ValueError): self.sendJson( category=EricRequestCategory.Error, reply="UnsupportedServiceCategory", params={"Category": category}, ) def __handleEchoRequest(self, request, params, reqestUuid): """ Private method to handle an 'Echo' request. @param request request name @type str @param params request parameters @type dict @param reqestUuid UUID of the associated request as sent by the eric IDE (defaults to "", i.e. no UUID received) @type str """ self.sendJson( category=EricRequestCategory.Echo, reply="Echo", params=params, reqestUuid=reqestUuid, ) def __handleServerRequest(self, request, params, reqestUuid): """ Private method to handle a 'Server' request. @param request request name @type str @param params request parameters @type dict @param reqestUuid UUID of the associated request as sent by the eric IDE (defaults to "", i.e. no UUID received) @type str """ # 'Exit' and 'Shutdown' are handled in the 'run()' method. if request.lower() == "versions": self.sendJson( category=EricRequestCategory.Server, reply="Versions", params={ "python": sys.version.split()[0], "py_bitsize": "64-Bit" if sys.maxsize > 2**32 else "32-Bit", "version": Version, }, reqestUuid=reqestUuid, )