1# -*- coding: utf-8 -*- 2 3# Copyright (c) 2017 - 2021 Detlev Offenbach <detlev@die-offenbachs.de> 4# 5 6""" 7Module implementing the JSON based server base class. 8""" 9 10import contextlib 11import json 12 13from PyQt5.QtCore import ( 14 pyqtSlot, QProcess, QProcessEnvironment, QCoreApplication, QEventLoop, 15 QTimer 16) 17from PyQt5.QtNetwork import QTcpServer, QHostAddress 18 19from E5Gui import E5MessageBox 20 21import Preferences 22import Utilities 23 24 25class E5JsonServer(QTcpServer): 26 """ 27 Class implementing a JSON based server base class. 28 """ 29 def __init__(self, name="", multiplex=False, parent=None): 30 """ 31 Constructor 32 33 @param name name of the server (used for output only) 34 @type str 35 @param multiplex flag indicating a multiplexing server 36 @type bool 37 @param parent parent object 38 @type QObject 39 """ 40 super().__init__(parent) 41 42 self.__name = name 43 self.__multiplex = multiplex 44 if self.__multiplex: 45 self.__clientProcesses = {} 46 self.__connections = {} 47 else: 48 self.__clientProcess = None 49 self.__connection = None 50 51 # setup the network interface 52 networkInterface = Preferences.getDebugger("NetworkInterface") 53 if networkInterface == "all" or '.' in networkInterface: 54 # IPv4 55 self.__hostAddress = '127.0.0.1' 56 else: 57 # IPv6 58 self.__hostAddress = '::1' 59 self.listen(QHostAddress(self.__hostAddress)) 60 61 self.newConnection.connect(self.handleNewConnection) 62 63 port = self.serverPort() 64 ## Note: Need the port if client is started external in debugger. 65 print('JSON server ({1}) listening on: {0:d}' # __IGNORE_WARNING__ 66 .format(port, self.__name)) 67 68 @pyqtSlot() 69 def handleNewConnection(self): 70 """ 71 Public slot for new incoming connections from a client. 72 """ 73 connection = self.nextPendingConnection() 74 if not connection.isValid(): 75 return 76 77 if self.__multiplex: 78 if not connection.waitForReadyRead(3000): 79 return 80 idString = bytes(connection.readLine()).decode( 81 "utf-8", 'backslashreplace').strip() 82 if idString in self.__connections: 83 self.__connections[idString].close() 84 self.__connections[idString] = connection 85 else: 86 idString = "" 87 if self.__connection is not None: 88 self.__connection.close() 89 90 self.__connection = connection 91 92 connection.readyRead.connect( 93 lambda: self.__receiveJson(idString)) 94 connection.disconnected.connect( 95 lambda: self.__handleDisconnect(idString)) 96 97 @pyqtSlot() 98 def __handleDisconnect(self, idString): 99 """ 100 Private slot handling a disconnect of the client. 101 102 @param idString id of the connection been disconnected 103 @type str 104 """ 105 if idString: 106 if idString in self.__connections: 107 self.__connections[idString].close() 108 del self.__connections[idString] 109 else: 110 if self.__connection is not None: 111 self.__connection.close() 112 113 self.__connection = None 114 115 def connectionNames(self): 116 """ 117 Public method to get the list of active connection names. 118 119 If this is not a multiplexing server, an empty list is returned. 120 121 @return list of active connection names 122 @rtype list of str 123 """ 124 if self.__multiplex: 125 return list(self.__connections.keys()) 126 else: 127 return [] 128 129 @pyqtSlot() 130 def __receiveJson(self, idString): 131 """ 132 Private slot handling received data from the client. 133 134 @param idString id of the connection been disconnected 135 @type str 136 """ 137 if idString: 138 try: 139 connection = self.__connections[idString] 140 except KeyError: 141 connection = None 142 else: 143 connection = self.__connection 144 145 while connection and connection.canReadLine(): 146 data = connection.readLine() 147 jsonLine = bytes(data).decode("utf-8", 'backslashreplace') 148 149 #- print("JSON Server ({0}): {1}".format(self.__name, jsonLine)) 150 #- this is for debugging only 151 152 try: 153 clientDict = json.loads(jsonLine.strip()) 154 except (TypeError, ValueError) as err: 155 E5MessageBox.critical( 156 None, 157 self.tr("JSON Protocol Error"), 158 self.tr("""<p>The response received from the client""" 159 """ could not be decoded. Please report""" 160 """ this issue with the received data to the""" 161 """ eric bugs email address.</p>""" 162 """<p>Error: {0}</p>""" 163 """<p>Data:<br/>{1}</p>""").format( 164 str(err), Utilities.html_encode(jsonLine.strip())), 165 E5MessageBox.StandardButtons( 166 E5MessageBox.Ok)) 167 return 168 169 self.handleCall(clientDict["method"], clientDict["params"]) 170 171 def sendJson(self, command, params, flush=False, idString=""): 172 """ 173 Public method to send a single command to a client. 174 175 @param command command name to be sent 176 @type str 177 @param params dictionary of named parameters for the command 178 @type dict 179 @param flush flag indicating to flush the data to the socket 180 @type bool 181 @param idString id of the connection to send data to 182 @type str 183 """ 184 commandDict = { 185 "jsonrpc": "2.0", 186 "method": command, 187 "params": params, 188 } 189 cmd = json.dumps(commandDict) + '\n' 190 191 if idString: 192 try: 193 connection = self.__connections[idString] 194 except KeyError: 195 connection = None 196 else: 197 connection = self.__connection 198 199 if connection is not None: 200 data = cmd.encode('utf8', 'backslashreplace') 201 length = "{0:09d}".format(len(data)) 202 connection.write(length.encode() + data) 203 if flush: 204 connection.flush() 205 206 def startClient(self, interpreter, clientScript, clientArgs, idString="", 207 environment=None): 208 """ 209 Public method to start a client process. 210 211 @param interpreter interpreter to be used for the client 212 @type str 213 @param clientScript path to the client script 214 @type str 215 @param clientArgs list of arguments for the client 216 @param idString id of the client to be started 217 @type str 218 @param environment dictionary of environment settings to pass 219 @type dict 220 @return flag indicating a successful client start and the exit code 221 in case of an issue 222 @rtype bool, int 223 """ 224 if interpreter == "" or not Utilities.isinpath(interpreter): 225 return False 226 227 exitCode = None 228 229 proc = QProcess() 230 proc.setProcessChannelMode( 231 QProcess.ProcessChannelMode.ForwardedChannels) 232 if environment is not None: 233 env = QProcessEnvironment() 234 for key, value in list(environment.items()): 235 env.insert(key, value) 236 proc.setProcessEnvironment(env) 237 args = [clientScript, self.__hostAddress, str(self.serverPort())] 238 if idString: 239 args.append(idString) 240 args.extend(clientArgs) 241 proc.start(interpreter, args) 242 if not proc.waitForStarted(10000): 243 proc = None 244 245 if idString: 246 self.__clientProcesses[idString] = proc 247 if proc: 248 timer = QTimer() 249 timer.setSingleShot(True) 250 timer.start(30000) # 30s timeout 251 while ( 252 idString not in self.connectionNames() and 253 timer.isActive() 254 ): 255 # Give the event loop the chance to process the new 256 # connection of the client (= slow start). 257 QCoreApplication.processEvents( 258 QEventLoop.ProcessEventsFlag.ExcludeUserInputEvents) 259 260 # check if client exited prematurely 261 if proc.state() == QProcess.ProcessState.NotRunning: 262 exitCode = proc.exitCode() 263 proc = None 264 self.__clientProcesses[idString] = None 265 break 266 else: 267 if proc: 268 timer = QTimer() 269 timer.setSingleShot(True) 270 timer.start(1000) # 1s timeout 271 while timer.isActive(): 272 # check if client exited prematurely 273 QCoreApplication.processEvents( 274 QEventLoop.ProcessEventsFlag.ExcludeUserInputEvents) 275 if proc.state() == QProcess.ProcessState.NotRunning: 276 exitCode = proc.exitCode() 277 proc = None 278 break 279 self.__clientProcess = proc 280 281 return proc is not None, exitCode 282 283 def stopClient(self, idString=""): 284 """ 285 Public method to stop a client process. 286 287 @param idString id of the client to be stopped 288 @type str 289 """ 290 self.sendJson("Exit", {}, flush=True, idString=idString) 291 292 if idString: 293 try: 294 connection = self.__connections[idString] 295 except KeyError: 296 connection = None 297 else: 298 connection = self.__connection 299 if connection is not None: 300 connection.waitForDisconnected() 301 302 if idString: 303 with contextlib.suppress(KeyError): 304 if self .__clientProcesses[idString] is not None: 305 self .__clientProcesses[idString].close() 306 del self.__clientProcesses[idString] 307 else: 308 if self.__clientProcess is not None: 309 self.__clientProcess.close() 310 self.__clientProcess = None 311 312 def stopAllClients(self): 313 """ 314 Public method to stop all clients. 315 """ 316 clientNames = self.connectionNames()[:] 317 for clientName in clientNames: 318 self.stopClient(clientName) 319 320 ####################################################################### 321 ## The following methods should be overridden by derived classes 322 ####################################################################### 323 324 def handleCall(self, method, params): 325 """ 326 Public method to handle a method call from the client. 327 328 Note: This is an empty implementation that must be overridden in 329 derived classes. 330 331 @param method requested method name 332 @type str 333 @param params dictionary with method specific parameters 334 @type dict 335 """ 336 pass 337