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