Fri, 09 Feb 2024 19:54:15 +0100
Implemented an eric-ide Server Shell.
--- a/eric7.epj Wed Feb 07 15:28:08 2024 +0100 +++ b/eric7.epj Fri Feb 09 19:54:15 2024 +0100 @@ -2126,9 +2126,11 @@ "src/eric7/QtHelpInterface/__init__.py", "src/eric7/RemoteServer/EricRequestCategory.py", "src/eric7/RemoteServer/EricServer.py", + "src/eric7/RemoteServer/EricServerDebuggerRequestHandler.py", "src/eric7/RemoteServer/EricServerFileSystemRequestHandler.py", "src/eric7/RemoteServer/__init__.py", "src/eric7/RemoteServerInterface/EricServerConnectionDialog.py", + "src/eric7/RemoteServerInterface/EricServerDebuggerInterface.py", "src/eric7/RemoteServerInterface/EricServerFileDialog.py", "src/eric7/RemoteServerInterface/EricServerFileSystemInterface.py", "src/eric7/RemoteServerInterface/EricServerInterface.py",
--- a/src/eric7/Debugger/DebugServer.py Wed Feb 07 15:28:08 2024 +0100 +++ b/src/eric7/Debugger/DebugServer.py Fri Feb 09 19:54:15 2024 +0100 @@ -492,28 +492,32 @@ forProject=False, runInConsole=False, venvName="", - workingDir=None, + workingDir="", configOverride=None, + startRemote=False ): """ Public method to start a debug client. - @param unplanned flag indicating that the client has died - @type bool - @param clType type of client to be started - @type str - @param forProject flag indicating a project related action - @type bool + @param unplanned flag indicating that the client has died (defaults to True) + @type bool (optional) + @param clType type of client to be started (defaults to None) + @type str (optional) + @param forProject flag indicating a project related action (defaults to False) + @type bool (optional) @param runInConsole flag indicating to start the debugger in a - console window - @type bool - @param venvName name of the virtual environment to be used - @type str - @param workingDir directory to start the debugger client in - @type str - @param configOverride dictionary containing the global config override - data - @type dict + console window (defaults to False) + @type bool (optional) + @param venvName name of the virtual environment to be used (defaults to "") + @type str (optional) + @param workingDir directory to start the debugger client in (defaults to "") + @type str (optional) + @param configOverride dictionary containing the global config override data + (defaults to None) + @type dict (optional) + @param startRemote flag indicating to start the client via an eric-ide server + (defaults to False) + @type bool (optional) """ self.running = False @@ -560,6 +564,7 @@ self.__originalPathString, workingDir=workingDir, configOverride=configOverride, + startRemote=startRemote, ) else: ( @@ -573,6 +578,7 @@ self.__originalPathString, workingDir=workingDir, configOverride=configOverride, + # TODO: add 'startRemote' parameter ) else: ( @@ -586,6 +592,7 @@ self.__originalPathString, workingDir=workingDir, configOverride=configOverride, + startRemote=startRemote, ) if self.clientProcess: @@ -603,6 +610,8 @@ elif self.__autoClearShell: self.__autoClearShell = False self.remoteBanner() + elif startRemote: + self.remoteBanner() else: if clType and self.lastClientType: self.__setClientType(self.lastClientType) @@ -2223,3 +2232,11 @@ except KeyError: # The project object is not present return "" + + def getEricServerEnvironmentString(self): + """ + Public method to get the string for an eric-ide server environment. + """ + # TODO: make this more elaborate once server environments definitions + # are supported + return "eric-ide Server"
--- a/src/eric7/Debugger/DebuggerInterfacePython.py Wed Feb 07 15:28:08 2024 +0100 +++ b/src/eric7/Debugger/DebuggerInterfacePython.py Fri Feb 09 19:54:15 2024 +0100 @@ -50,6 +50,17 @@ self.__autoContinued = [] self.__isStepCommand = False + self.__ericServerDebugging = False # are we debugging via the eric-ide server? + try: + self.__ericServerDebuggerInterface = ericApp().getObject( + "EricServer" + ).getServiceInterface("Debugger") + self.__ericServerDebuggerInterface.debugClientResponse.connect( + lambda jsonStr: self.handleJsonCommand(jsonStr, None) + ) + except KeyError: + self.__ericServerDebuggerInterface = None + self.debugServer = debugServer self.passive = passive self.process = None @@ -88,8 +99,8 @@ @param fn filename to be translated @type str @param remote2local flag indicating the direction of translation - (False = local to remote, True = remote to local [default]) - @type bool + (False = local to remote, True = remote to local) (defaults to True) + @type bool (optional) @return translated filename @rtype str """ @@ -102,8 +113,8 @@ @param fn filename to be translated @type str @param remote2local flag indicating the direction of translation - (False = local to remote, True = remote to local [default]) - @type bool + (False = local to remote, True = remote to local) (defaults to True) + @type bool (optional) @return translated filename @rtype str """ @@ -118,7 +129,24 @@ return path - def __startProcess(self, program, arguments, environment=None, workingDir=None): + def __ericServerTranslation(self, fn, remote2local=True): + """ + Private method to perform the eric-ide server path translation. + + @param fn filename to be translated + @type str + @param remote2local flag indicating the direction of translation + (False = local to remote, True = remote to local) (defaults to True) + @type bool (optional) + @return translated filename + @rtype str + """ + if remote2local: + return FileSystemUtilities.remoteFileName(fn) + else: + return FileSystemUtilities.plainFileName(fn) + + def __startProcess(self, program, arguments, environment=None, workingDir=""): """ Private method to start the debugger client process. @@ -154,8 +182,9 @@ runInConsole, venvName, originalPathString, - workingDir=None, + workingDir="", configOverride=None, + startRemote=False, ): """ Public method to start a remote Python interpreter. @@ -169,41 +198,54 @@ @type str @param originalPathString original PATH environment variable @type str - @param workingDir directory to start the debugger client in - @type str - @param configOverride dictionary containing the global config override - data - @type dict + @param workingDir directory to start the debugger client in (defaults to "") + @type str (optional) + @param configOverride dictionary containing the global config override data + (defaults to None) + @type dict (optional) + @param startRemote flag indicating to start the client via an eric-ide server + (defaults to False) + @type bool (optional) @return client process object, a flag to indicate a network connection and the name of the interpreter in case of a local execution @rtype tuple of (QProcess, bool, str) """ global origPathEnv - if not venvName: - venvName = Preferences.getDebugger("Python3VirtualEnv") - if venvName == self.debugServer.getProjectEnvironmentString(): - project = ericApp().getObject("Project") - venvName = project.getProjectVenv() - execPath = project.getProjectExecPath() - interpreter = project.getProjectInterpreter() + if startRemote or venvName == self.debugServer.getEricServerEnvironmentString(): + # TODO change this once server environment definitions are supported + startRemote = True + venvName = self.debugServer.getEricServerEnvironmentString() + interpreter = "" else: - venvManager = ericApp().getObject("VirtualEnvManager") - interpreter = venvManager.getVirtualenvInterpreter(venvName) - execPath = venvManager.getVirtualenvExecPath(venvName) - if interpreter == "": - # use the interpreter used to run eric for identical variants - interpreter = PythonUtilities.getPythonExecutable() - if interpreter == "": - EricMessageBox.critical( - None, - self.tr("Start Debugger"), - self.tr("""<p>No suitable Python3 environment configured.</p>"""), - ) - return None, False, "" + if not venvName: + venvName = Preferences.getDebugger("Python3VirtualEnv") + if venvName == self.debugServer.getProjectEnvironmentString(): + project = ericApp().getObject("Project") + venvName = project.getProjectVenv() + execPath = project.getProjectExecPath() + interpreter = project.getProjectInterpreter() + else: + venvManager = ericApp().getObject("VirtualEnvManager") + interpreter = venvManager.getVirtualenvInterpreter(venvName) + execPath = venvManager.getVirtualenvExecPath(venvName) + if interpreter == "": + # use the interpreter used to run eric for identical variants + interpreter = PythonUtilities.getPythonExecutable() + if interpreter == "": + EricMessageBox.critical( + None, + self.tr("Start Debugger"), + self.tr("""<p>No suitable Python3 environment configured.</p>"""), + ) + return None, False, "" self.__inShutdown = False + self.__ericServerDebugging = False + self.__ericServerDebuggerInterface.stopClient() + self.__mainDebugger = None + redirect = ( str(configOverride["redirect"]) if configOverride and configOverride["enable"] @@ -290,6 +332,28 @@ ) return None, False, "" + elif startRemote and self.__ericServerDebuggerInterface is not None: + # debugging via an eric-ide server + ##self.__ericServerDebuggerInterface.stopClient() + ##self.__mainDebugger = None +## + self.translate = self.__ericServerTranslation + self.__ericServerDebugging = True + + args = [] + if noencoding: + args.append(noencoding) + if multiprocessEnabled: + args.append(multiprocessEnabled) + if callTraceOptimization: + args.append(callTraceOptimization) + self.__ericServerDebuggerInterface.startClient( + interpreter, originalPathString, args, workingDir=workingDir, + ) + self.__startedVenv = venvName + + return None, self.__isNetworked, "" + else: # local debugging code below debugClient = self.__determineDebugClient() @@ -398,6 +462,7 @@ originalPathString, workingDir=None, configOverride=None, + startRemote=False, ): """ Public method to start a remote Python interpreter for a project. @@ -416,6 +481,9 @@ @param configOverride dictionary containing the global config override data @type dict + @param startRemote flag indicating to start the client via an eric-ide server + (defaults to False) + @type bool (optional) @return client process object, a flag to indicate a network connection and the name of the interpreter in case of a local execution @rtype tuple of (QProcess, bool, str) @@ -460,6 +528,10 @@ self.__inShutdown = False + self.__ericServerDebugging = False + self.__ericServerDebuggerInterface.stopClient() + self.__mainDebugger = None + if project.getDebugProperty("REMOTEDEBUGGER"): # remote debugging code ipaddr = self.debugServer.getHostAddress(False) @@ -517,6 +589,8 @@ # remote shell command is missing return None, self.__isNetworked, "" + # TODO: add server debugging for projects + else: # local debugging code below debugClient = project.getDebugProperty("DEBUGCLIENT") @@ -619,7 +693,7 @@ """ self.__pendingConnections.append(sock) - sock.readyRead.connect(lambda: self.__parseClientLine(sock)) + sock.readyRead.connect(lambda: self.__receiveJson(sock)) sock.disconnected.connect(lambda: self.__socketDisconnected(sock)) return True @@ -634,30 +708,30 @@ @param debuggerId id of the connected debug client @type str """ - if sock in self.__pendingConnections: + if sock and sock in self.__pendingConnections: self.__connections[debuggerId] = sock self.__pendingConnections.remove(sock) - if self.__mainDebugger is None: - self.__mainDebugger = debuggerId - # Get the remote clients capabilities - self.remoteCapabilities(debuggerId) + if self.__mainDebugger is None: + self.__mainDebugger = debuggerId + # Get the remote clients capabilities + self.remoteCapabilities(debuggerId) - self.debugServer.signalClientDebuggerId(debuggerId) + self.debugServer.signalClientDebuggerId(debuggerId) - if debuggerId == self.__mainDebugger: - self.__flush() - self.debugServer.mainClientConnected() + if debuggerId == self.__mainDebugger: + self.__flush() + self.debugServer.mainClientConnected() - self.debugServer.initializeClient(debuggerId) + self.debugServer.initializeClient(debuggerId) - # perform auto-continue except for main - if ( - debuggerId != self.__mainDebugger - and self.__autoContinue - and not self.__isStepCommand - ): - QTimer.singleShot(0, lambda: self.remoteContinue(debuggerId)) + # perform auto-continue except for main + if ( + debuggerId != self.__mainDebugger + and self.__autoContinue + and not self.__isStepCommand + ): + QTimer.singleShot(0, lambda: self.remoteContinue(debuggerId)) def __socketDisconnected(self, sock): """ @@ -707,9 +781,15 @@ """ if self.__mainDebugger: # Send commands that were waiting for the connection. - conn = self.__connections[self.__mainDebugger] - for jsonStr in self.__commandQueue: - self.__writeJsonCommandToSocket(jsonStr, conn) + if self.__ericServerDebugging: + for jsonStr in self.__commandQueue: + self.__ericServerDebuggerInterface.sendClientCommand( + self.__mainDebugger, jsonStr + ) + else: + conn = self.__connections[self.__mainDebugger] + for jsonStr in self.__commandQueue: + self.__writeJsonCommandToSocket(jsonStr, conn) self.__commandQueue.clear() @@ -1396,7 +1476,7 @@ debuggerId, ) - def __parseClientLine(self, sock): + def __receiveJson(self, sock): """ Private method to handle data from the client. @@ -1423,11 +1503,11 @@ logging.debug("<Debug-Server> %s", jsonStr) ##print("Server: ", jsonStr) ## debug # __IGNORE_WARNING_M891__ - self.__handleJsonCommand(jsonStr, sock) + self.handleJsonCommand(jsonStr, sock) - def __handleJsonCommand(self, jsonStr, sock): + def handleJsonCommand(self, jsonStr, sock): """ - Private method to handle a command or response serialized as a + Public method to handle a command or response serialized as a JSON string. @param jsonStr string containing the command or response received @@ -1663,14 +1743,25 @@ } jsonStr = json.dumps(commandDict) - if debuggerId and debuggerId in self.__connections: - sock = self.__connections[debuggerId] - elif sock is None and self.__mainDebugger is not None: - sock = self.__connections[self.__mainDebugger] - if sock is not None: - self.__writeJsonCommandToSocket(jsonStr, sock) + if self.__ericServerDebugging: + # Debugging via the eric-ide server -> pass the command on to it + if self.__mainDebugger is None: + # debugger has not connected yet -> queue the command + self.__commandQueue.append(jsonStr) + else: + self.__ericServerDebuggerInterface.sendClientCommand( + debuggerId, jsonStr + ) else: - self.__commandQueue.append(jsonStr) + # Local debugging -> send the command to the client + if debuggerId and debuggerId in self.__connections: + sock = self.__connections[debuggerId] + elif sock is None and self.__mainDebugger is not None: + sock = self.__connections[self.__mainDebugger] + if sock is not None: + self.__writeJsonCommandToSocket(jsonStr, sock) + else: + self.__commandQueue.append(jsonStr) def __writeJsonCommandToSocket(self, jsonCommand, sock): """
--- a/src/eric7/Preferences/ConfigurationPages/EricServerPage.py Wed Feb 07 15:28:08 2024 +0100 +++ b/src/eric7/Preferences/ConfigurationPages/EricServerPage.py Fri Feb 09 19:54:15 2024 +0100 @@ -28,12 +28,14 @@ # set initial values self.timeoutSpinBox.setValue(Preferences.getEricServer("ConnectionTimeout")) + self.startShellCheckBox.setChecked(Preferences.getEricServer("AutostartShell")) def save(self): """ Public slot to save the Cooperation configuration. """ Preferences.setEricServer("ConnectionTimeout", self.timeoutSpinBox.value()) + Preferences.setEricServer("AutostartShell", self.startShellCheckBox.isChecked()) def create(dlg): # noqa: U100
--- a/src/eric7/Preferences/ConfigurationPages/EricServerPage.ui Wed Feb 07 15:28:08 2024 +0100 +++ b/src/eric7/Preferences/ConfigurationPages/EricServerPage.ui Fri Feb 09 19:54:15 2024 +0100 @@ -6,8 +6,8 @@ <rect> <x>0</x> <y>0</y> - <width>496</width> - <height>300</height> + <width>510</width> + <height>452</height> </rect> </property> <layout class="QVBoxLayout" name="verticalLayout_2"> @@ -32,6 +32,16 @@ </widget> </item> <item> + <widget class="QLabel" name="label_2"> + <property name="text"> + <string><b>Note:</b> The eric-ide server is configured via command line parameters. The parameters of this page configure the interface to the eric-ide server.</string> + </property> + <property name="wordWrap"> + <bool>true</bool> + </property> + </widget> + </item> + <item> <widget class="QGroupBox" name="groupBox"> <property name="title"> <string>Server Connection</string> @@ -79,16 +89,16 @@ <item> <widget class="QGroupBox" name="groupBox_2"> <property name="title"> - <string>Notes</string> + <string>Shell</string> </property> <layout class="QVBoxLayout" name="verticalLayout"> <item> - <widget class="QLabel" name="label_2"> + <widget class="QCheckBox" name="startShellCheckBox"> + <property name="toolTip"> + <string>Select this to start an eric-ide Server Shell when a connection to an eric-ide Server is established.</string> + </property> <property name="text"> - <string><ul><li>The eric-ide server is configured via command line parameters. The parameters of this page configure the interface to the eric-ide server.</li></ul></string> - </property> - <property name="wordWrap"> - <bool>true</bool> + <string>Start server Shell when server is conncted</string> </property> </widget> </item> @@ -110,6 +120,10 @@ </item> </layout> </widget> + <tabstops> + <tabstop>timeoutSpinBox</tabstop> + <tabstop>startShellCheckBox</tabstop> + </tabstops> <resources/> <connections/> </ui>
--- a/src/eric7/Preferences/__init__.py Wed Feb 07 15:28:08 2024 +0100 +++ b/src/eric7/Preferences/__init__.py Fri Feb 09 19:54:15 2024 +0100 @@ -1726,6 +1726,7 @@ ericServerDefaults = { "ConnectionProfiles": "{}", # JSON encoded dictionary "ConnectionTimeout": 10, # timeout in seconds + "AutostartShell": True, } @@ -4206,6 +4207,10 @@ return int( Prefs.settings.value(f"{prefix}{key}", Prefs.ericServerDefaults[key]) ) + elif key in ("AutostartShell",): + return toBool( + Prefs.settings.value(f"{prefix}{key}", Prefs.ericServerDefaults[key]) + ) elif key in ("ConnectionProfiles",): jsonStr = Prefs.settings.value(f"{prefix}{key}", Prefs.ericServerDefaults[key]) if jsonStr:
--- a/src/eric7/QScintilla/Shell.py Wed Feb 07 15:28:08 2024 +0100 +++ b/src/eric7/QScintilla/Shell.py Fri Feb 09 19:54:15 2024 +0100 @@ -12,6 +12,7 @@ import pathlib import re import sys +import time from PyQt6.Qsci import QsciScintilla from PyQt6.QtCore import QEvent, QPoint, Qt, pyqtSignal, pyqtSlot @@ -388,10 +389,16 @@ self.lmenu.clear() venvManager = ericApp().getObject("VirtualEnvManager") for venvName in sorted(venvManager.getVirtualenvNames()): - self.lmenu.addAction(venvName) + act = self.lmenu.addAction(venvName) + act.setData(venvName) if self.__project.isOpen(): self.lmenu.addSeparator() - self.lmenu.addAction(self.tr("Project")) + act = self.lmenu.addAction(self.tr("Project")) + act.setData("<<project>>") + if ericApp().getObject("EricServer").isServerConnected(): + self.lmenu.addSeparator() + act = self.lmenu.addAction(self.tr("eric-ide Server")) + act.setData("<<eric-server>>") def __resizeLinenoMargin(self): """ @@ -1925,11 +1932,7 @@ # Display the banner. self.__getBanner() elif cmd in ["%reset", "%restart"]: - self.dbs.startClient( - False, - venvName=self.__currentVenv, - workingDir=self.__currentWorkingDirectory, - ) + self.doRestart() elif cmd in ["%envs", "%environments"]: venvs = ( ericApp().getObject("VirtualEnvManager").getVirtualenvNames() @@ -1981,9 +1984,12 @@ self.setFocus(Qt.FocusReason.OtherFocusReason) else: self.dbs.remoteStatement(self.__getSelectedDebuggerId(), cmd) - while self.inCommandExecution: + now = time.monotonic() + while self.inCommandExecution and time.monotonic() - now < 10: + # 10 seconds timeout with contextlib.suppress(KeyboardInterrupt): QApplication.processEvents() + self.inCommandExecution = False else: if not self.__echoInput: cmd = self.buff @@ -2138,13 +2144,15 @@ @param action context menu action that was triggered @type QAction """ - venvName = action.text() - if venvName == self.tr("Project"): + venvName = action.data() + if venvName == "<<project>>": if self.__project.isOpen(): self.__currentWorkingDirectory = self.__project.getProjectPath() self.dbs.startClient( False, forProject=True, workingDir=self.__currentWorkingDirectory ) + elif venvName == "<<eric-server>>": + self.dbs.startClient(False, startRemote=True) else: self.dbs.startClient(False, venvName=venvName) self.__getBanner() @@ -2616,6 +2624,26 @@ self.dbs.startClient(False) self.__getBanner() + ################################################################# + ## eric-ide Server Support + ################################################################# + + @pyqtSlot(bool) + def remoteConnectionChanged(self, connected): + """ + Public slot handling a change of the connection state to an eric-ide server. + + @param connected flag indicating the connection state + @type bool + """ + if connected: + if Preferences.getEricServer("AutostartShell"): + self.dbs.startClient(False, startRemote=True) + else: + if self.__currentVenv == self.dbs.getEricServerEnvironmentString(): + # start default backend + self.dbs.startClient(False) + self.__getBanner() # # eflag: noqa = M601
--- a/src/eric7/RemoteServer/EricServer.py Wed Feb 07 15:28:08 2024 +0100 +++ b/src/eric7/RemoteServer/EricServer.py Fri Feb 09 19:54:15 2024 +0100 @@ -9,17 +9,20 @@ import io import json -import select +import selectors import socket import struct import sys import traceback +import types import zlib from eric7.UI.Info import Version # TODO: remove dependency on 'eric7.UI.Info' from .EricRequestCategory import EricRequestCategory +from .EricServerDebuggerRequestHandler import EricServerDebuggerRequestHandler +from .EricServerFileSystemRequestHandler import EricServerFileSystemRequestHandler class EricServer: @@ -45,15 +48,38 @@ self.__connection = None + self.__selector = selectors.DefaultSelector() + + # create and register the 'Debugger' request handler + self.__debuggerRequestHandler = EricServerDebuggerRequestHandler(self) + self.registerRequestHandler( + EricRequestCategory.Debugger, + self.__debuggerRequestHandler.handleRequest, + ) + + # create and register the 'File System' request handler + self.__fileSystemRequestHandler = EricServerFileSystemRequestHandler(self) + self.registerRequestHandler( + EricRequestCategory.FileSystem, + self.__fileSystemRequestHandler.handleRequest, + ) + + # TODO: 'Project' handler not implemented yet + # TODO: implement an 'EditorConfig' handler (?) + 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 - ) + self.__socket = socket.create_server(address, family=socket.AF_INET) + + def getSelector(self): + """ + Public method to get a reference to the selector object. + """ + return self.__selector ####################################################################### ## Methods for receiving requests and sending the results. @@ -73,35 +99,109 @@ (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") + if self.__connection is not None: + commandDict = { + "jsonrpc": "2.0", + "category": category, + "reply": reply, + "params": params, + "uuid": reqestUuid, + } + self.sendJsonCommand(commandDict, self.__connection) + + def sendJsonCommand(self, jsonCommand, sock): + """ + Public method to send a JSON encoded command/response via a given socket. + + @param jsonCommand dictionary containing the command data or a JSON encoded + command string + @type dict or str + @param sock reference to the socket to send the data to + @type socket.socket + """ + if isinstance(jsonCommand, dict): + jsonCommand = json.dumps(jsonCommand) + data = jsonCommand.encode("utf8", "backslashreplace") header = struct.pack(b"!II", len(data), zlib.adler32(data) & 0xFFFFFFFF) - self.__connection.sendall(header) - self.__connection.sendall(data) + sock.sendall(header) + sock.sendall(data) - def __receiveBytes(self, length): + def __receiveBytes(self, length, sock): """ Private method to receive the given length of bytes. @param length bytes to receive @type int + @param sock reference to the socket to receive the data from + @type socket.socket @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 + try: + newData = sock.recv(length - len(data)) + if not newData: + print(str(newData)) + return None + + data += newData + except OSError as err: + if err.errno != 11: + data = None # in case some data was received already + break + return data + + def receiveJsonCommand(self, sock): + """ + Public method to receive a JSON encoded command and data. + + @param sock reference to the socket to receive the data from + @type socket.socket + @return dictionary containing the JSON command data or None to signal + an issue while receiving data + @rtype dict + """ + if self.isSocketClosed(sock): + return None + + header = self.__receiveBytes(struct.calcsize(b"!II"), sock) + if not header: + return {} + + length, datahash = struct.unpack(b"!II", header) - data += newData - return data + length = int(length) + data = self.__receiveBytes(length, sock) + if data is None: + return None + + if not data or zlib.adler32(data) & 0xFFFFFFFF != datahash: + self.sendJson( + category=EricRequestCategory.Error, + reply="EricServerChecksumException", + params={ + "ExceptionType": "ProtocolChecksumError", + "ExceptionValue": "The checksum of the data does not match.", + "ProtocolData": data.decode("utf8", "backslashreplace"), + }, + ) + return {} + + jsonStr = data.decode("utf8", "backslashreplace") + try: + return json.loads(jsonStr.strip()) + except (TypeError, ValueError) as err: + self.sendJson( + category=EricRequestCategory.Error, + reply="EricServerException", + params={ + "ExceptionType": "ProtocolError", + "ExceptionValue": str(err), + "ProtocolData": jsonStr.strip(), + }, + ) + return {} def __receiveJson(self): """ @@ -113,41 +213,9 @@ 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) + requestDict = self.receiveJsonCommand(self.__connection) - 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(), - }, - ) + if not requestDict: return EricRequestCategory.Error, None, None, None category = requestDict["category"] @@ -157,6 +225,28 @@ return category, request, params, reqestUuid + def isSocketClosed(self, sock): + """ + Public method to check, if a given socket is closed. + + @param sock reference to the socket to be checked + @type socket.socket + @return flag indicating a closed state + @rtype bool + """ + try: + # this will try to read bytes without removing them from buffer (peek only) + data = sock.recv(16, socket.MSG_PEEK) + if len(data) == 0: + return True + except BlockingIOError: + return False # socket is open and reading from it would block + except ConnectionError: + return True # socket was closed for some other reason + except Exception: + return False + return False + ####################################################################### ## Methods for the server main loop. ####################################################################### @@ -165,9 +255,66 @@ """ Private method to shut down the server. """ + self.__closeIdeConnection() + + print("Stop listening for 'eric-ide' connections.") self.__socket.shutdown(socket.SHUT_RDWR) self.__socket.close() + self.__selector.close() + + def __acceptIdeConnection(self, sock): + """ + Private method to accept the connection on the listening IDE server socket. + + @param sock reference to the listening socket + @type socket.socket + """ + self.__connection, address = sock.accept() # Should be ready to read + print(f"'eric-ide' connection from {address[0]}, port {address[1]}") + self.__connection.setblocking(False) + data = types.SimpleNamespace( + name="eric-ide", address=address, handler=self.__serviceIdeConnection + ) + events = selectors.EVENT_READ + self.__selector.register(self.__connection, events, data=data) + + def __closeIdeConnection(self): + """ + Private method to close the connection to an eric-ide. + """ + if self.__connection is not None: + print( + f"Closing 'eric-ide' connection to {self.__connection.getpeername()}." + ) + self.__selector.unregister(self.__connection) + self.__connection.shutdown(socket.SHUT_RDWR) + self.__connection.close() + self.__connection = None + + self.__debuggerRequestHandler.shutdownClients() + + def __serviceIdeConnection(self, key): + """ + Private method to service the eric-ide connection. + + @param key reference to the SelectorKey object associated with the connection + to be serviced + @type selectors.SelectorKey + """ + if key.data.name == "eric-ide": + category, request, params, reqestUuid = self.__receiveJson() + if category == EricRequestCategory.Error or request is None: + self.__closeIdeConnection() + return + + if category == EricRequestCategory.Server: + if request.lower() == "shutdown": + self.__shouldStop = True + return + + self.__handleRequest(category, request, params, reqestUuid) + def run(self): """ Public method implementing the remote server main loop. @@ -182,51 +329,33 @@ @return flag indicating a clean shutdown @rtype bool """ - shutdown = False cleanExit = True + self.__shouldStop = False # listen on the server socket for new connections self.__socket.listen(1) + self.__socket.setblocking(False) + print(f"Listening for 'eric-ide' connections on {self.__socket.getsockname()}") + data = types.SimpleNamespace( + name="server", acceptHandler=self.__acceptIdeConnection + ) + self.__selector.register(self.__socket, selectors.EVENT_READ, data=data) + + self.__debuggerRequestHandler.initServerSocket() 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 + events = self.__selector.select(timeout=None) + for key, mask in events: + if key.data.name == "server": + # it is an event for a server socket + key.data.acceptHandler(key.fileobj) + else: + key.data.handler(key) except KeyboardInterrupt: # intercept user pressing Ctrl+C - shutdown = True + self.__shouldStop = True except Exception: exctype, excval, exctb = sys.exc_info() @@ -237,15 +366,10 @@ print(f"{str(exctype)} / {str(excval)} / {tbinfo}") - shutdown = True + self.__shouldStop = True cleanExit = False - if self.__connection is not None: - self.__connection.shutdown(socket.SHUT_RDWR) - self.__connection.close() - self.__connection = None - - if shutdown: + if self.__shouldStop: # exit the outer loop and shut down the server self.__shutdown() break @@ -324,36 +448,8 @@ """ try: handler = self.__requestCategoryHandlerRegistry[category] + handler(request=request, params=params, reqestUuid=reqestUuid) except KeyError: - if category < EricRequestCategory.UserCategory: - # it is an internally supported category - if category == EricRequestCategory.FileSystem: - from .EricServerFileSystemRequestHandler import ( - EricServerFileSystemRequestHandler, - ) - handler = EricServerFileSystemRequestHandler(self).handleRequest - - elif category == EricRequestCategory.Project: - # TODO: 'Project' handler not implemented yet - handler = None - - elif category == EricRequestCategory.Debugger: - # TODO: 'Debugger' handler not implemented yet - handler = None - - # TODO: implement an 'EditorConfig' handler (?) - - else: - # That internal category does not exist. - handler = None - - self.registerRequestHandler(category, handler) - else: - handler = None - - if handler is not None: - handler(request=request, params=params, reqestUuid=reqestUuid) - else: self.sendJson( category=EricRequestCategory.Error, reply="UnsupportedServiceCategory",
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/RemoteServer/EricServerDebuggerRequestHandler.py Fri Feb 09 19:54:15 2024 +0100 @@ -0,0 +1,353 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing the debugger request handler of the eric-ide server. +""" + +import json +import os +import selectors +import socket +import subprocess +import sys +import types + +from .EricRequestCategory import EricRequestCategory + +class EricServerDebuggerRequestHandler: + """ + Class implementing the debugger request handler of the eric-ide server. + """ + + def __init__(self, server): + """ + Constructor + + @param server reference to the eric-ide server object + @type EricServer + """ + self.__server = server + + self.__requestMethodMapping = { + "StartClient": self.__startClient, + "StopClient": self.__stopClient, + "DebugClientCommand": self.__relayDebugClientCommand + } + + self.__mainClientId = None + self.__client = None + self.__pendingConnections = [] + self.__connections = {} + + address = ("127.0.0.1", 0) + self.__socket = socket.create_server(address, family=socket.AF_INET) + + def initServerSocket(self): + """ + Public method to initialize the server socket listening for debug client + connections. + """ + # listen on the debug server socket + self.__socket.listen() + self.__socket.setblocking(False) + print( + f"Listening for 'debug client' connections on" + f" {self.__socket.getsockname()}" + ) + data = types.SimpleNamespace( + name="server", acceptHandler=self.__acceptDbgClientConnection + ) + self.__server.getSelector().register( + self.__socket, selectors.EVENT_READ, data=data + ) + + def handleRequest(self, request, params, reqestUuid): + """ + Public method handling the received debugger requests. + + @param request request name + @type str + @param params dictionary containing the request parameters + @type dict + @param reqestUuid UUID of the associated request as sent by the eric IDE + @type str + """ + try: + result = self.__requestMethodMapping[request](params) + if result: + self.__server.sendJson( + category=EricRequestCategory.Debugger, + reply=request, + params=result, + reqestUuid=reqestUuid, + ) + + except KeyError: + self.__server.sendJson( + category=EricRequestCategory.Debugger, + reply="DebuggerRequestError", + params={"Error": f"Request type '{request}' is not supported."}, + ) + + ####################################################################### + ## DebugServer like methods. + ####################################################################### + + def __acceptDbgClientConnection(self, sock): + """ + Private method to accept the connection on the listening debug server socket. + + @param sock reference to the listening socket + @type socket.socket + """ + connection, address = sock.accept() # Should be ready to read + print(f"'Debug client' connection from {address[0]}, port {address[1]}") + connection.setblocking(False) + self.__pendingConnections.append(connection) + + data = types.SimpleNamespace( + name="debug_client", + address=address, + handler=self.__serviceDbgClientConnection, + ) + events = selectors.EVENT_READ + self.__server.getSelector().register(connection, events, data=data) + + def __serviceDbgClientConnection(self, key): + """ + Private method to service the debug client connection. + + @param key reference to the SelectorKey object associated with the connection + to be serviced + @type selectors.SelectorKey + """ + sock = key.fileobj + data = self.__server.receiveJsonCommand(sock) + + if data is None: + # socket was closed by debug client + self.__clientSocketDisconnected(sock) + elif data: + method = data["method"] + if method == "DebuggerId" and sock in self.__pendingConnections: + debuggerId = data['params']['debuggerId'] + self.__connections[debuggerId] = sock + self.__pendingConnections.remove(sock) + if self.__mainClientId is None: + self.__mainClientId = debuggerId + + elif method == "ResponseBanner": + # add an indicator for the eric-ide server + data["params"]["platform"] += " (eric-ide Server)" + + # pass on the data to the eric-ide + jsonStr = json.dumps(data) + print("Client Response:", jsonStr) + self.__server.sendJson( + category=EricRequestCategory.Debugger, + reply="DebugClientResponse", + params={"response": jsonStr}, + ) + + def __clientSocketDisconnected(self, sock): + """ + Private slot handling a socket disconnecting. + + @param sock reference to the disconnected socket + @type QTcpSocket + """ + self.__server.getSelector().unregister(sock) + + for debuggerId in list(self.__connections): + if self.__connections[debuggerId] is sock: + del self.__connections[debuggerId] + self.__server.sendJson( + category=EricRequestCategory.Debugger, + reply="DebugClientDisconnected", + params={"debugger_id": debuggerId}, + ) + + if debuggerId == self.__mainClientId: + self.__mainClientId = None + + break + else: + if sock in self.__pendingConnections: + self.__pendingConnections.remove(sock) + + sock.shutdown(socket.SHUT_RDWR) + sock.close() + + if not self.__connections: + # no active connections anymore + self.__server.sendJson( + category=EricRequestCategory.Debugger, + reply="LastDebugClientExited", + params={}, + ) + + def __serviceDbgClientStdoutStderr(self, key): + """ + Private method to service the debug client stdout and stderr channels. + + @param key reference to the SelectorKey object associated with the connection + to be serviced + @type selectors.SelectorKey + """ + data = key.fileobj.read() + if key.data.name == "debug_client_stdout": + # TODO: stdout handling not implemented yet + print("stdout:", data) + elif key.data.name == "debug_client_stderr": + # TODO: stderr handling not implemented yet + print("stderr:", data) + + def shutdownClients(self): + """ + Public method to shut down all connected clients. + """ + if not self.__client: + # no client started yet + return + + while self.__pendingConnections: + sock = self.__pendingConnections.pop() + commandDict = self.__prepareClientCommand("RequestShutdown", {}) + self.__server.sendJsonCommand(commandDict, sock) + self.__shutdownSocket("", sock) + + while self.__connections: + debuggerId, sock = self.__connections.popitem() + commandDict = self.__prepareClientCommand("RequestShutdown", {}) + self.__server.sendJsonCommand(commandDict, sock) + self.__shutdownSocket(debuggerId, sock) + + # reinitialize + self.__mainClientId = None + self.__client = None + + # no active connections anymore + self.__server.sendJson( + category=EricRequestCategory.Debugger, + reply="LastDebugClientExited", + params={}, + ) + + def __shutdownSocket(self, debuggerId, sock): + """ + Private slot to shut down a socket. + + @param debuggerId ID of the debugger the socket belongs to + @type str + @param sock reference to the socket + @type socket.socket + """ + self.__server.getSelector().unregister(sock) + sock.shutdown(socket.SHUT_RDWR) + sock.close() + + if debuggerId: + self.__server.sendJson( + category=EricRequestCategory.Debugger, + reply="DebugClientDisconnected", + params={"debugger_id": debuggerId}, + ) + + def __prepareClientCommand(self, command, params): + """ + Private method to prepare a command dictionary for the debug client. + + @param command command to be sent + @type str + @param params dictionary containing the command parameters + @type dict + @return completed command dictionary to be sent to the debug client + @rtype dict + """ + return { + "jsonrpc": "2.0", + "method": command, + "params": params, + } + + ####################################################################### + ## Individual request handler methods. + ####################################################################### + + def __startClient(self, params): + """ + Private method to start a debug client process. + + @param params dictionary containing the request data + @type dict + """ + # 1. stop an already started debug client + if self.__client is not None: + self.__client.terminate() + self.__client = None + + # 2. start a debug client + debugClient = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "..", + "DebugClients", + "Python", + "DebugClient.py", + ) + ) + ipaddr, port = self.__socket.getsockname() + args = [sys.executable, debugClient] + args.extend(params["arguments"]) + args.extend([str(port), "True", ipaddr]) + + self.__client = subprocess.Popen( + args, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + # TODO: register stdin & stderr with selector + + def __stopClient(self, params): + """ + Private method to stop the current debug client process. + + @param params dictionary containing the request data + @type dict + @return dictionary containing the reply data + @rtype dict + """ + self.shutdownClients() + + return {"ok": True} + + def __relayDebugClientCommand(self, params): + """ + Private method to relay a debug client command to the client. + + @param params dictionary containing the request data + @type dict + """ + debuggerId = params["debugger_id"] + jsonStr = params["command"] + print(debuggerId, "->", jsonStr) + + if not debuggerId and self.__mainClientId: + debuggerId = self.__mainClientId + + try: + sock = self.__connections[debuggerId] + except KeyError: + print(f"Command for unknown debugger ID '{debuggerId}' received.") + # tell the eric-ide again, that this debugger ID is gone + self.__server.sendJson( + category=EricRequestCategory.Debugger, + reply="DebugClientDisconnected", + params={"debugger_id": debuggerId}, + ) + sock = ( + self.__connections[self.__mainClientId] if self.__mainClientId else None + ) + if sock: + self.__server.sendJsonCommand(jsonStr, sock)
--- a/src/eric7/RemoteServer/EricServerFileSystemRequestHandler.py Wed Feb 07 15:28:08 2024 +0100 +++ b/src/eric7/RemoteServer/EricServerFileSystemRequestHandler.py Fri Feb 09 19:54:15 2024 +0100 @@ -28,7 +28,7 @@ @type EricServer """ self.__server = server - + self.__requestMethodMapping = { "Chdir": self.__chdir, "Getcwd": self.__getcwd,
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/RemoteServerInterface/EricServerDebuggerInterface.py Fri Feb 09 19:54:15 2024 +0100 @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing the file system interface to the eric-ide server. +""" + +from PyQt6.QtCore import QEventLoop, QObject, pyqtSignal, pyqtSlot + +from eric7.EricWidgets import EricMessageBox +from eric7.EricWidgets.EricApplication import ericApp +from eric7.RemoteServer.EricRequestCategory import EricRequestCategory +from eric7.SystemUtilities import FileSystemUtilities + + +# TODO: sanitize all file names with FileSystemUtilities.plainFileName() +class EricServerDebuggerInterface(QObject): + """ + Class implementing the file system interface to the eric-ide server. + """ + + debugClientResponse = pyqtSignal(str) + + def __init__(self, serverInterface): + """ + Constructor + + @param serverInterface reference to the eric-ide server interface + @type EricServerInterface + """ + super().__init__(parent=serverInterface) + + self.__serverInterface = serverInterface + + self.__replyMethodMapping = { + "DebuggerRequestError": self.__handleDbgRequestError, + "DebugClientResponse": self.__handleDbgClientResponse, + "DebugClientDisconnected": self.__handleDbgClientDisconnected, + "LastDebugClientExited": self.__handleLastDbgClientExited, + } + + # connect some signals + self.__serverInterface.remoteDebuggerReply.connect(self.__handleDebuggerReply) + + def sendClientCommand(self, debuggerId, jsonCommand): + """ + Public method to rely a debug client command via the eric-ide server. + + @param debuggerId id of the debug client to send the command to + @type str + @param jsonCommand JSON encoded command dictionary to be relayed + @type str + """ + self.__serverInterface.sendJson( + category=EricRequestCategory.Debugger, + request="DebugClientCommand", + params={"debugger_id": debuggerId, "command": jsonCommand}, + ) + + @pyqtSlot(str, dict) + def __handleDebuggerReply(self, reply, params): + """ + Private slot to handle a debugger reply from the eric-ide server. + + @param reply name of the server reply + @type str + @param params dictionary containing the reply data + @type dict + """ + try: + self.__replyMethodMapping[reply](params) + except KeyError: + EricMessageBox.critical( + None, + self.tr("Unknown Server Reply"), + self.tr( + "<p>The eric-ide server debugger interface sent the unknown reply" + " <b>{0}</b>.</p>" + ).format(reply), + ) + + ####################################################################### + ## Methods for handling of debug client replies. + ####################################################################### + + def __handleDbgRequestError(self, params): + """ + Private method to handle an error reported by the debugger interface of + the eric-ide server. + + @param params dictionary containing the reply data + @type dict + """ + EricMessageBox.warning( + None, + self.tr("Debug Client Command"), + self.tr( + "<p>The IDE received an error message.</p><p>Error: {0}</p>" + ).format(params["Error"]), + ) + + def __handleDbgClientResponse(self, params): + """ + Private method to handle a response from a debug client connected to the + eric-ide server. + + @param params dictionary containing the reply data + @type dict + """ + self.debugClientResponse.emit(params["response"]) + + def __handleDbgClientDisconnected(self, params): + """ + Private method to handle a debug client disconnect report of the + eric-ide server. + + @param params dictionary containing the reply data + @type dict + """ + ericApp().getObject("DebugServer").signalClientDisconnected( + params["debugger_id"] + ) + + def __handleLastDbgClientExited(self, params): + """ + Private method to handle a report of the eric-ide server, that the last + debug client has disconnected. + + @param params dictionary containing the reply data + @type dict + """ + ericApp().getObject("DebugServer").signalLastClientExited() + + ####################################################################### + ## Methods for sending debug server commands to the eric-ide server. + ####################################################################### + + def startClient(self, interpreter, originalPathString, args, workingDir=""): + """ + Public method to send a command to start a debug client. + + @param interpreter path of the remote interpreter to be used + @type str + @param originalPathString original PATH environment variable + @type str + @param args list of command line parameters for the debug client + @type list of str + @param workingDir directory to start the debugger client in (defaults to "") + @type str (optional) + """ + self.__serverInterface.sendJson( + category=EricRequestCategory.Debugger, + request="StartClient", + params={ + "interpreter": FileSystemUtilities.plainFileName(interpreter), + "path": originalPathString, + "arguments": args, + "working_dir": FileSystemUtilities.plainFileName(workingDir), + }, + ) + + def stopClient(self): + """ + Public method to stop the debug client synchronously. + """ + if self.__serverInterface.isServerConnected(): + loop = QEventLoop() + + def callback(reply, params): + """ + Function to handle the server reply + + @param reply name of the server reply + @type str + @param params dictionary containing the reply data + @type dict + """ + if reply == "StopClient": + loop.quit() + + self.__serverInterface.sendJson( + category=EricRequestCategory.Debugger, + request="StopClient", + params={}, + callback=callback, + ) + + loop.exec()
--- a/src/eric7/RemoteServerInterface/EricServerFileSystemInterface.py Wed Feb 07 15:28:08 2024 +0100 +++ b/src/eric7/RemoteServerInterface/EricServerFileSystemInterface.py Fri Feb 09 19:54:15 2024 +0100 @@ -15,7 +15,7 @@ from eric7.RemoteServer.EricRequestCategory import EricRequestCategory -# TODO: sanitize all file name with FileSystemUtilities.plainFileName() +# TODO: sanitize all file names with FileSystemUtilities.plainFileName() class EricServerFileSystemInterface(QObject): """ Class implementing the file system interface to the eric-ide server.
--- a/src/eric7/RemoteServerInterface/EricServerInterface.py Wed Feb 07 15:28:08 2024 +0100 +++ b/src/eric7/RemoteServerInterface/EricServerInterface.py Fri Feb 09 19:54:15 2024 +0100 @@ -107,8 +107,8 @@ from .EricServerFileSystemInterface import EricServerFileSystemInterface self.__serviceInterfaces[lname] = EricServerFileSystemInterface(self) elif lname == "debugger": - # TODO: 'Debugger Interface' not implemented yet - pass + from .EricServerDebuggerInterface import EricServerDebuggerInterface + self.__serviceInterfaces[lname] = EricServerDebuggerInterface(self) elif lname == "project": # TODO: 'Project Interface' not implemented yet pass
--- a/src/eric7/UI/UserInterface.py Wed Feb 07 15:28:08 2024 +0100 +++ b/src/eric7/UI/UserInterface.py Fri Feb 09 19:54:15 2024 +0100 @@ -597,6 +597,9 @@ self.__ericServerInterface.connectionStateChanged.connect( self.viewmanager.remoteConnectionChanged ) + self.__ericServerInterface.connectionStateChanged.connect( + self.shell.remoteConnectionChanged + ) self.__ericServerInterface.aboutToDisconnect.connect( self.viewmanager.closeRemoteEditors )
--- a/src/eric7/VirtualEnv/VirtualenvManager.py Wed Feb 07 15:28:08 2024 +0100 +++ b/src/eric7/VirtualEnv/VirtualenvManager.py Fri Feb 09 19:54:15 2024 +0100 @@ -26,6 +26,7 @@ from .VirtualenvMeta import VirtualenvMetaData +# TODO: introduce 'eric-ide Server' environment definitions class VirtualenvManager(QObject): """ Class implementing an object to manage Python virtual environments.