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).
--- a/eric7.epj Fri Jan 26 16:17:05 2024 +0100 +++ b/eric7.epj Mon Jan 29 19:50:44 2024 +0100 @@ -630,6 +630,7 @@ "src/eric7/Preferences/ConfigurationPages/EditorSyntaxPage.ui", "src/eric7/Preferences/ConfigurationPages/EditorTypingPage.ui", "src/eric7/Preferences/ConfigurationPages/EmailPage.ui", + "src/eric7/Preferences/ConfigurationPages/EricServerPage.ui", "src/eric7/Preferences/ConfigurationPages/GraphicsPage.ui", "src/eric7/Preferences/ConfigurationPages/HelpDocumentationPage.ui", "src/eric7/Preferences/ConfigurationPages/HelpViewersPage.ui", @@ -709,6 +710,8 @@ "src/eric7/QtHelpInterface/QtHelpDocumentationConfigurationDialog.ui", "src/eric7/QtHelpInterface/QtHelpDocumentationSelectionDialog.ui", "src/eric7/QtHelpInterface/QtHelpDocumentationSettingsWidget.ui", + "src/eric7/RemoteServerInterface/EricServerConnectionDialog.ui", + "src/eric7/RemoteServerInterface/EricServerProfilesDialog.ui", "src/eric7/Snapshot/SnapWidget.ui", "src/eric7/SqlBrowser/SqlBrowserWidget.ui", "src/eric7/SqlBrowser/SqlConnectionDialog.ui", @@ -1928,6 +1931,7 @@ "src/eric7/Preferences/ConfigurationPages/EditorSyntaxPage.py", "src/eric7/Preferences/ConfigurationPages/EditorTypingPage.py", "src/eric7/Preferences/ConfigurationPages/EmailPage.py", + "src/eric7/Preferences/ConfigurationPages/EricServerPage.py", "src/eric7/Preferences/ConfigurationPages/GraphicsPage.py", "src/eric7/Preferences/ConfigurationPages/HelpDocumentationPage.py", "src/eric7/Preferences/ConfigurationPages/HelpViewersPage.py", @@ -2118,6 +2122,13 @@ "src/eric7/QtHelpInterface/QtHelpDocumentationSettingsWidget.py", "src/eric7/QtHelpInterface/QtHelpSchemeHandler.py", "src/eric7/QtHelpInterface/__init__.py", + "src/eric7/RemoteServer/EricRequestCategory.py", + "src/eric7/RemoteServer/EricServer.py", + "src/eric7/RemoteServer/__init__.py", + "src/eric7/RemoteServerInterface/EricServerConnectionDialog.py", + "src/eric7/RemoteServerInterface/EricServerInterface.py", + "src/eric7/RemoteServerInterface/EricServerProfilesDialog.py", + "src/eric7/RemoteServerInterface/__init__.py", "src/eric7/Sessions/SessionFile.py", "src/eric7/Sessions/__init__.py", "src/eric7/Snapshot/SnapWidget.py", @@ -2503,6 +2514,7 @@ "src/eric7/eric7_qregularexpression.pyw", "src/eric7/eric7_re.py", "src/eric7/eric7_re.pyw", + "src/eric7/eric7_server.py", "src/eric7/eric7_shell.py", "src/eric7/eric7_shell.pyw", "src/eric7/eric7_snap.py",
--- a/src/eric7/Preferences/ConfigurationDialog.py Fri Jan 26 16:17:05 2024 +0100 +++ b/src/eric7/Preferences/ConfigurationDialog.py Mon Jan 29 19:50:44 2024 +0100 @@ -210,6 +210,13 @@ None, None, ], + "ericServerPage": [ + self.tr("eric-ide Server"), + "preferences-eric-server", + "EricServerPage", + None, + None, + ], "graphicsPage": [ self.tr("Graphics"), "preferences-graphics",
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/Preferences/ConfigurationPages/EricServerPage.py Mon Jan 29 19:50:44 2024 +0100 @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing the eric-ide server related settings. +""" + +from eric7 import Preferences + +from .ConfigurationPageBase import ConfigurationPageBase +from .Ui_EricServerPage import Ui_EricServerPage + + +class EricServerPage(ConfigurationPageBase, Ui_EricServerPage): + """ + Class implementing the eric-ide server related settings. + """ + + def __init__(self): + """ + Constructor + """ + super().__init__() + self.setupUi(self) + self.setObjectName("EricServerPage") + + # set initial values + self.timeoutSpinBox.setValue(Preferences.getEricServer("ConnectionTimeout")) + + def save(self): + """ + Public slot to save the Cooperation configuration. + """ + Preferences.setEricServer("ConnectionTimeout", self.timeoutSpinBox.value()) + + +def create(dlg): # noqa: U100 + """ + Module function to create the configuration page. + + @param dlg reference to the configuration dialog + @type ConfigurationDialog + @return reference to the instantiated page + @rtype ConfigurationPageBase + """ + page = EricServerPage() + return page
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/Preferences/ConfigurationPages/EricServerPage.ui Mon Jan 29 19:50:44 2024 +0100 @@ -0,0 +1,115 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>EricServerPage</class> + <widget class="QWidget" name="EricServerPage"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>496</width> + <height>300</height> + </rect> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <widget class="QLabel" name="headerLabel"> + <property name="text"> + <string><b>Configure eric-ide Server Settings</b></string> + </property> + </widget> + </item> + <item> + <widget class="Line" name="line11"> + <property name="frameShape"> + <enum>QFrame::HLine</enum> + </property> + <property name="frameShadow"> + <enum>QFrame::Sunken</enum> + </property> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + </widget> + </item> + <item> + <widget class="QGroupBox" name="groupBox"> + <property name="title"> + <string>Server Connection</string> + </property> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <widget class="QLabel" name="label"> + <property name="text"> + <string>Default Timeout:</string> + </property> + </widget> + </item> + <item> + <widget class="QSpinBox" name="timeoutSpinBox"> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + <property name="suffix"> + <string> s</string> + </property> + <property name="minimum"> + <number>5</number> + </property> + <property name="maximum"> + <number>60</number> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>294</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + </item> + <item> + <widget class="QGroupBox" name="groupBox_2"> + <property name="title"> + <string>Notes</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <widget class="QLabel" name="label_2"> + <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> + </property> + </widget> + </item> + </layout> + </widget> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>87</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui>
--- a/src/eric7/Preferences/__init__.py Fri Jan 26 16:17:05 2024 +0100 +++ b/src/eric7/Preferences/__init__.py Mon Jan 29 19:50:44 2024 +0100 @@ -1708,7 +1708,7 @@ else: jediDefaults["MouseClickGotoModifiers"] = Qt.KeyboardModifier.ControlModifier - # defaults for Hex Editor + # defaults for PDF viewer pdfViewerDefaults = { "PdfViewerState": QByteArray(), "PdfViewerSplitterState": QByteArray(), @@ -1722,6 +1722,12 @@ "PdfSearchHighlightAll": True, } + # defaults for the eric-ide server interface + ericServerDefaults = { + "ConnectionProfiles": "{}", # JSON encoded dictionary + "ConnectionTimeout": 10, # timeout in seconds + } + def readToolGroups(): """ @@ -4140,7 +4146,7 @@ def getPdfViewer(key): """ - Module function to retrieve the Pdf Viewer related settings. + Module function to retrieve the PDF Viewer related settings. @param key the key of the value to get @type str @@ -4173,7 +4179,7 @@ def setPdfViewer(key, value): """ - Module function to store the Pdf Viewer related settings. + Module function to store the PDF Viewer related settings. @param key the key of the setting to be set @type str @@ -4185,6 +4191,48 @@ Prefs.settings.setValue("PdfViewer/" + key, value) +def getEricServer(key): + """ + Module function to retrieve the eric-ide server interface related settings. + + @param key the key of the value to get + @type str + @return the requested user setting + @rtype Any + """ + prefix = "EricServer/" + + if key in ("ConnectionTimeout",): + return int( + 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: + return json.loads(jsonStr) + else: + return None + else: + return Prefs.settings.value(f"{prefix}{key}", Prefs.ericServerDefaults[key]) + + +def setEricServer(key, value): + """ + Module function to store the eric-ide server interface related settings. + + @param key the key of the setting to be set + @type str + @param value the value to be set + @type Any + """ + prefix = "EricServer/" + + if key in ("ConnectionProfiles",): + Prefs.settings.setValue(f"{prefix}{key}", json.dumps(value)) + else: + Prefs.settings.setValue(f"{prefix}{key}", value) + + def getGeometry(key): """ Module function to retrieve the display geometry.
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/RemoteServer/EricRequestCategory.py Mon Jan 29 19:50:44 2024 +0100 @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing an enum for the various service categories. +""" + +import enum + +class EricRequestCategory(enum.IntEnum): + """ + Class defining the service categories of the eric remote server. + """ + FileSystem = 0 + Project = 1 + Debugger = 2 + + Echo = 253 # only used for testing + Error = 254 + Server = 255 # used by the remote server internally + + # user/plugins may define own categories starting with this value + UserCategory = 1024
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/RemoteServer/EricServer.py Mon Jan 29 19:50:44 2024 +0100 @@ -0,0 +1,332 @@ +# -*- 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, + )
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/RemoteServer/__init__.py Mon Jan 29 19:50:44 2024 +0100 @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Package implementing the components of the eric-ide remote server. +"""
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/RemoteServerInterface/EricServerConnectionDialog.py Mon Jan 29 19:50:44 2024 +0100 @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de> +# + + +""" +Module implementing a dialog to enter the parameters for a connection to an eric-ide +server. +""" + +import ipaddress + +from PyQt6.QtCore import pyqtSlot +from PyQt6.QtWidgets import QDialog, QDialogButtonBox + +from eric7 import Preferences + +from .Ui_EricServerConnectionDialog import Ui_EricServerConnectionDialog + + +class EricServerConnectionDialog(QDialog, Ui_EricServerConnectionDialog): + """ + Class implementing a dialog to enter the parameters for a connection to an eric-ide + server. + """ + + def __init__(self, profileNames=None, parent=None): + """ + Constructor + + @param profileNames list of defined connection profile names (defaults to None) + @type list of str (optional) + @param parent reference to the parent widget (defaults to None) + @type QWidget (optional) + """ + super().__init__(parent) + self.setupUi(self) + + self.timeoutSpinBox.setToolTip( + self.tr("Enter the timeout for the connection attempt (default: {0} s.") + .format(Preferences.getEricServer("ConnectionTimeout")) + ) + + self.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setEnabled(False) + + if profileNames is None: + self.nameLabel.setVisible(False) + self.nameEdit.setVisible(False) + self.nameEdit.setEnabled(False) + + self.__profileNames = profileNames[:] if bool(profileNames) else [] + self.__originalName = "" + + self.nameEdit.textChanged.connect(self.__updateOK) + self.hostnameEdit.textChanged.connect(self.__updateOK) + + msh = self.minimumSizeHint() + self.resize(max(self.width(), msh.width()), msh.height()) + + @pyqtSlot(str) + def on_hostnameEdit_textChanged(self, hostname): + """ + Private slot handling a change of the hostname. + + @param hostname text of the host name field + @type str + """ + @pyqtSlot() + def __updateOK(self): + """ + Private slot to update the enabled state of the OK button. + """ + hostname = self.hostnameEdit.text() + + if hostname and hostname[0] in "0123456789" and ":" not in hostname: + # possibly an IPv4 address + try: + ipaddress.IPv4Address(hostname) + valid = True + except ipaddress.AddressValueError: + # leading zeros are not allowed + valid = False + elif ":" in hostname: + # possibly an IPv6 address + try: + ipaddress.IPv6Address(hostname) + valid = True + except ipaddress.AddressValueError: + # leading zeros are not allowed + valid = False + elif ":" not in hostname: + valid = bool(hostname) + else: + valid = False + + if self.nameEdit.isEnabled(): + # connection profile mode + name = self.nameEdit.text() + valid &= name == self.__originalName or name not in self.__profileNames + + self.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setEnabled(valid) + + def getData(self): + """ + Public method to get the entered data. + + @return tuple containing the entered host name or IP address, the port number + and the timeout (in seconds) + @rtype tuple of (str, int, int) + """ + port = self.portSpinBox.value() + if port == self.portSpinBox.minimum(): + port = None + + timeout = self.timeoutSpinBox.value() + if timeout == self.timeoutSpinBox.minimum(): + timeout = None + + return self.hostnameEdit.text(), port, timeout + + def getProfileData(self): + """ + Public method to get the entered data for connection profile mode. + + @return tuple containing the profile name, host name or IP address, + the port number and the timeout (in seconds) + @rtype tuple of (str, str, int, int) + """ + port = self.portSpinBox.value() + if port == self.portSpinBox.minimum(): + port = 0 + + timeout = self.timeoutSpinBox.value() + if timeout == self.timeoutSpinBox.minimum(): + timeout = 0 + + return self.nameEdit.text(), self.hostnameEdit.text(), port, timeout + + def setProfileData(self, name, hostname, port, timeout): + """ + Public method to set the connection profile data to be edited. + + @param name profile name + @type str + @param hostname host name or IP address + @type str + @param port port number + @type int + @param timeout timeout value in seconds + @type int + """ + # adjust some values + if not bool(port): + port = self.portSpinBox.minimum() + if not bool(timeout): + timeout = self.timeoutSpinBox.minimum() + + self.__originalName = name + + self.nameEdit.setText(name) + self.hostnameEdit.setText(hostname) + self.portSpinBox.setValue(port) + self.timeoutSpinBox.setValue(timeout)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/RemoteServerInterface/EricServerConnectionDialog.ui Mon Jan 29 19:50:44 2024 +0100 @@ -0,0 +1,181 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>EricServerConnectionDialog</class> + <widget class="QDialog" name="EricServerConnectionDialog"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>400</width> + <height>169</height> + </rect> + </property> + <property name="windowTitle"> + <string>eric-ide Server Connection</string> + </property> + <property name="sizeGripEnabled"> + <bool>true</bool> + </property> + <layout class="QGridLayout" name="gridLayout"> + <item row="0" column="0"> + <widget class="QLabel" name="nameLabel"> + <property name="text"> + <string>Name:</string> + </property> + </widget> + </item> + <item row="0" column="1" colspan="2"> + <widget class="QLineEdit" name="nameEdit"> + <property name="toolTip"> + <string>Enter the name for the eric-ide server connection profile.</string> + </property> + <property name="clearButtonEnabled"> + <bool>true</bool> + </property> + </widget> + </item> + <item row="1" column="0"> + <widget class="QLabel" name="label"> + <property name="text"> + <string>Hostname:</string> + </property> + </widget> + </item> + <item row="1" column="1" colspan="2"> + <widget class="QLineEdit" name="hostnameEdit"> + <property name="toolTip"> + <string>Enter the hostname or IP address of the eric-ide server to connect to.</string> + </property> + <property name="clearButtonEnabled"> + <bool>true</bool> + </property> + </widget> + </item> + <item row="2" column="0"> + <widget class="QLabel" name="label_2"> + <property name="text"> + <string>Port:</string> + </property> + </widget> + </item> + <item row="2" column="1"> + <widget class="QSpinBox" name="portSpinBox"> + <property name="toolTip"> + <string>Enter the port number the eric-ide server listens on (default: 42024).</string> + </property> + <property name="wrapping"> + <bool>true</bool> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + <property name="specialValueText"> + <string>default</string> + </property> + <property name="accelerated"> + <bool>true</bool> + </property> + <property name="showGroupSeparator" stdset="0"> + <bool>true</bool> + </property> + <property name="minimum"> + <number>1024</number> + </property> + <property name="maximum"> + <number>65535</number> + </property> + <property name="value"> + <number>1024</number> + </property> + </widget> + </item> + <item row="2" column="2"> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>240</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item row="3" column="0"> + <widget class="QLabel" name="label_3"> + <property name="text"> + <string>Timeout:</string> + </property> + </widget> + </item> + <item row="3" column="1"> + <widget class="QSpinBox" name="timeoutSpinBox"> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + <property name="specialValueText"> + <string>default</string> + </property> + <property name="suffix"> + <string> s</string> + </property> + <property name="maximum"> + <number>60</number> + </property> + </widget> + </item> + <item row="4" column="0" colspan="3"> + <widget class="QDialogButtonBox" name="buttonBox"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="standardButtons"> + <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set> + </property> + </widget> + </item> + </layout> + </widget> + <tabstops> + <tabstop>nameEdit</tabstop> + <tabstop>hostnameEdit</tabstop> + <tabstop>portSpinBox</tabstop> + <tabstop>timeoutSpinBox</tabstop> + </tabstops> + <resources/> + <connections> + <connection> + <sender>buttonBox</sender> + <signal>accepted()</signal> + <receiver>EricServerConnectionDialog</receiver> + <slot>accept()</slot> + <hints> + <hint type="sourcelabel"> + <x>248</x> + <y>254</y> + </hint> + <hint type="destinationlabel"> + <x>157</x> + <y>274</y> + </hint> + </hints> + </connection> + <connection> + <sender>buttonBox</sender> + <signal>rejected()</signal> + <receiver>EricServerConnectionDialog</receiver> + <slot>reject()</slot> + <hints> + <hint type="sourcelabel"> + <x>316</x> + <y>260</y> + </hint> + <hint type="destinationlabel"> + <x>286</x> + <y>274</y> + </hint> + </hints> + </connection> + </connections> +</ui>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/RemoteServerInterface/EricServerInterface.py Mon Jan 29 19:50:44 2024 +0100 @@ -0,0 +1,680 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing the interface to the eric remote server. +""" + +import json +import struct +import uuid +import zlib + +from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot +from PyQt6.QtGui import QAction, QKeySequence +from PyQt6.QtNetwork import QAbstractSocket, QTcpSocket +from PyQt6.QtWidgets import QDialog, QMenu, QToolBar, QToolButton + +from eric7 import Preferences, Utilities +from eric7.EricGui import EricPixmapCache +from eric7.EricGui.EricAction import EricAction +from eric7.EricWidgets import EricMessageBox +from eric7.RemoteServer.EricRequestCategory import EricRequestCategory + + +class EricServerInterface(QObject): + """ + Class implementing the interface to the eric remote server. + + @signal showMenu(name:str, menu:QMenu) emitted when a menu is about to be shown. + The name of the menu and a reference to the menu are given. + + @signal connectionStateChanged(state:bool) emitted to indicate a change of the + connection state + @signal remoteReply(category:int, request:str, params:dict) emitted to deliver the + reply of an unknown category + @signal remoteDebuggerReply(request:str, params:dict) emitted to deliver the reply + of a remote server debugger request + @signal remoteEchoReply(request:str, params:dict) emitted to deliver the reply of + a remote server echo request + @signal remoteFileSystemReply(request:str, params:dict) emitted to deliver the + reply of a remote server file system request + @signal remoteProjectReply(request:str, params:dict) emitted to deliver the reply + of a remote server project related request + @signal remoteServerReply(request:str, params:dict) emitted to deliver the reply + of a remote server control request + """ + + showMenu = pyqtSignal(str, QMenu) + + connectionStateChanged = pyqtSignal(bool) + + remoteReply = pyqtSignal(int, str, dict) + + remoteDebuggerReply = pyqtSignal(str, dict) + remoteEchoReply = pyqtSignal(str, dict) + remoteFileSystemReply = pyqtSignal(str, dict) + remoteProjectReply = pyqtSignal(str, dict) + remoteServerReply = pyqtSignal(str, dict) + + def __init__(self, parent=None): + """ + Constructor + + @param parent reference to the parent object (defaults to None) + @type QObject (optional) + """ + super().__init__(parent=parent) + + self.__ui = parent + + self.__categorySignalMapping = { + EricRequestCategory.Debugger: self.remoteDebuggerReply, + EricRequestCategory.Echo: self.remoteEchoReply, + EricRequestCategory.FileSystem: self.remoteFileSystemReply, + EricRequestCategory.Project: self.remoteProjectReply, + EricRequestCategory.Server: self.remoteServerReply, + } + + self.__connection = None + self.__callbacks = {} # callback references indexed by UUID + + self.connectionStateChanged.connect(self.__connectionStateChanged) + + ####################################################################### + ## Methods for handling the server connection. + ####################################################################### + + def connectToServer(self, host, port=None, timeout=None): + """ + Public method to connect to the given host and port + + @param host host name or IP address of the eric remote server + @type str + @param port port number to connect to (defaults to None) + @type int (optional) + @param timeout timeout im seconds for the connection attempt + (defaults to None) + @type int (optional) + """ + if not bool(port): # None or 0 + # use default port + port = 42024 + + if not bool(timeout): # None or 0 + # use configured default timeout + timeout = Preferences.getEricServer("ConnectionTimeout") + timeout = timeout * 1000 # convert to milliseconds + + if self.__connection is not None: + self.disconnectFromServer() + + self.__connection = QTcpSocket(self) + self.__connection.connectToHost(host, port) + if not self.__connection.waitForConnected(timeout): + EricMessageBox.critical( + None, + self.tr("Connect to eric-ide Server"), + self.tr( + "<p>The connection to the eric-ide server {0}:{1} could not be" + " established.</p><p>Reason: {2}</p>" + ).format( + host if ":" not in host else f"[{host}]", + port, + self.__connection.errorString(), + ), + ) + + self.__connection = None + return False + + self.__connection.readyRead.connect(self.__receiveJson) + self.__connection.disconnected.connect(self.__handleDisconnect) + + self.connectionStateChanged.emit(True) + + return True + + @pyqtSlot() + def disconnectFromServer(self): + """ + Public method to disconnect from the eric remote server. + """ + if self.__connection is not None and self.__connection.isValid(): + self.__connection.disconnectFromHost() + if self.__connection is not None: + # may have disconnected already + self.__connection.waitForDisconnected( + Preferences.getEricServer("ConnectionTimeout") * 1000 + ) + + self.connectionStateChanged.emit(False) + self.__connection = None + self.__callbacks.clear() + + def isServerConnected(self): + """ + Public method to check, if a connection to an eric-ide server has been + established. + + @return flag indicating the interface connection state + @rtype bool + """ + return ( + self.__connection is not None + and self.__connection.state() == QAbstractSocket.SocketState.ConnectedState + ) + + @pyqtSlot() + def __handleDisconnect(self): + """ + Private slot handling a disconnect of the client. + """ + if self.__connection is not None: + self.__connection.close() + + self.connectionStateChanged.emit(False) + self.__connection = None + self.__callbacks.clear() + + ####################################################################### + ## Methods for sending requests and receiving the replies. + ####################################################################### + + @pyqtSlot() + def __receiveJson(self): + """ + Private slot handling received data from the eric remote server. + + @param idString id of the connection + @type str + """ + while self.__connection and self.__connection.bytesAvailable(): + header = self.__connection.read(struct.calcsize(b"!II")) + length, datahash = struct.unpack(b"!II", header) + + data = bytearray() + while len(data) < length: + maxSize = length - len(data) + if self.__connection.bytesAvailable() < maxSize: + self.__connection.waitForReadyRead(50) + data += self.__connection.read(maxSize) + + if zlib.adler32(data) & 0xFFFFFFFF != datahash: + # corrupted data -> discard and continue + continue + + jsonString = data.decode("utf-8", "backslashreplace") + + # - print("Remote Server Interface: {0}".format(jsonString)) + # - this is for debugging only + + try: + serverDataDict = json.loads(jsonString.strip()) + except (TypeError, ValueError) as err: + EricMessageBox.critical( + None, + self.tr("JSON Protocol Error"), + self.tr( + """<p>The response received from the remote server""" + """ could not be decoded. Please report""" + """ this issue with the received data to the""" + """ eric bugs email address.</p>""" + """<p>Error: {0}</p>""" + """<p>Data:<br/>{1}</p>""" + ).format(str(err), Utilities.html_encode(jsonString.strip())), + EricMessageBox.Ok, + ) + return + + reqUuid = serverDataDict["uuid"] + try: + self.__callbacks[reqUuid]( + serverDataDict["reply"], serverDataDict["params"] + ) + del self.__callbacks[reqUuid] + except KeyError: + # no callback for this UUID exists, send a signal + try: + self.__categorySignalMapping[serverDataDict["category"]].emit( + serverDataDict["reply"], serverDataDict["params"] + ) + except KeyError: + if serverDataDict["category"] == EricRequestCategory.Error: + # handle server errors in here + self.__handleServerError( + serverDataDict["reply"], serverDataDict["params"] + ) + else: + self.remoteReply.emit( + serverDataDict["category"], + serverDataDict["reply"], + serverDataDict["params"], + ) + + def sendJson(self, category, request, params, callback=None, flush=False): + """ + Public method to send a single command to a client. + + @param category service category + @type EricRequestCategory + @param request request name to be sent + @type str + @param params dictionary of named parameters for the request + @type dict + @param callback callback function for the reply from the eric remote server + (defaults to None) + @type function (optional) + @param flush flag indicating to flush the data to the socket + (defaults to False) + @type bool (optional) + """ + reqUuid = str(uuid.uuid4()) + if callback: + self.__callbacks[reqUuid] = callback + + serviceDict = { + "jsonrpc": "2.0", + "category": category, + "request": request, + "params": params, + "uuid": reqUuid, + } + jsonString = json.dumps(serviceDict) + "\n" + + if self.__connection is not None: + data = jsonString.encode("utf8", "backslashreplace") + header = struct.pack(b"!II", len(data), zlib.adler32(data) & 0xFFFFFFFF) + self.__connection.write(header) + self.__connection.write(data) + if flush: + self.__connection.flush() + + def shutdownServer(self): + """ + Public method shutdown the currebtly connected eric-ide remote server. + """ + if self.__connection: + self.sendJson( + category=EricRequestCategory.Server, + request="Shutdown", + params={}, + ) + + @pyqtSlot() + def serverVersions(self): + """ + Public slot to request the eric-ide version of the server. + """ + if self.__connection: + self.sendJson( + category=EricRequestCategory.Server, + request="Versions", + params={}, + callback=self.__handleServerVersionReply, + ) + + ####################################################################### + ## Callback methods + ####################################################################### + + def __handleServerVersionReply(self, reply, params): + """ + Private method to handle the reply of a 'Version' request. + + @param reply name of the eric-ide server reply + @type str + @param params dictionary containing the reply data + @type dict + @exception ValueError raised in case of an unsupported reply + """ + if reply == "Versions": + versionText = self.tr("""<h2>Server Version Numbers</h2><table>""") + + # Python version + versionText += ( + """<tr><td><b>Python</b></td><td>{0}, {1}</td></tr>""" + ).format(params["python"], params["py_bitsize"]) + + # eric7 version + versionText += ( + """<tr><td><b>eric7_server</b></td><td>{0}</td></tr>""" + ).format(params["version"]) + + versionText += self.tr("""</table>""") + + EricMessageBox.about( + None, + self.tr("eric-ide Server Versions"), + versionText, + ) + + else: + raise ValueError(f"unsupported reply received ({reply})") + + ####################################################################### + ## Reply handler methods + ####################################################################### + + def __handleServerError(self, reply, params): + """ + Public method handling server error replies. + + @param reply name of the error reply + @type str + @param params dictionary containing the specific reply data + @type dict + """ + if reply == "ClientChecksumException": + self.__ui.appendToStderr( + self.tr("eric-ide Server Checksum Error\nError: {0}\nData:\n{1}\n") + .format(params["ExceptionValue"], params["ProtocolData"]) + ) + + elif reply == "ClientException": + self.__ui.appendToStderr( + self.tr("eric-ide Server Data Error\nError: {0}\nData:\n{1}\n") + .format(params["ExceptionValue"], params["ProtocolData"]) + ) + + elif reply == "UnsupportedServiceCategory": + self.__ui.appendToStderr( + self.tr( + "eric-ide Server Unsupported Category\n" + "Error: The server received the unsupported request category '{0}'." + ).format(params["Category"]) + ) + + ####################################################################### + ## User interface related methods + ####################################################################### + + def initActions(self): + """ + Public slot to initialize the eric-ide server actions. + """ + self.actions = [] + + self.connectServerAct = EricAction( + self.tr("Connect"), + EricPixmapCache.getIcon("linkConnect"), + self.tr("Connect..."), + QKeySequence(self.tr("Meta+Shift+C")), + 0, + self, + "remote_server_connect", + ) + self.connectServerAct.setStatusTip( + self.tr("Show a dialog to connect to an 'eric-ide' server") + ) + self.connectServerAct.setWhatsThis( + self.tr( + """<b>Connect...</b>""" + """<p>This opens a dialog to enter the connection parameters to""" + """ connect to a remote 'eric-ide' server.</p>""" + ) + ) + self.connectServerAct.triggered.connect(self.__connectToServer) + self.actions.append(self.connectServerAct) + + self.disconnectServerAct = EricAction( + self.tr("Disconnect"), + EricPixmapCache.getIcon("linkDisconnect"), + self.tr("Disconnect"), + QKeySequence(self.tr("Meta+Shift+D")), + 0, + self, + "remote_server_disconnect", + ) + self.disconnectServerAct.setStatusTip( + self.tr("Disconnect from the currently connected 'eric-ide' server") + ) + self.disconnectServerAct.setWhatsThis( + self.tr( + """<b>Disconnect</b>""" + """<p>This disconnects from the currently connected 'eric-ide'""" + """ server.</p>""" + ) + ) + self.disconnectServerAct.triggered.connect(self.disconnectFromServer) + self.actions.append(self.disconnectServerAct) + + self.stopServerAct = EricAction( + self.tr("Stop Server"), + EricPixmapCache.getIcon("stopScript"), + self.tr("Stop Server"), + QKeySequence(self.tr("Meta+Shift+S")), + 0, + self, + "remote_server_shutdown", + ) + self.stopServerAct.setStatusTip( + self.tr("Stop the currently connected 'eric-ide' server") + ) + self.stopServerAct.setWhatsThis( + self.tr( + """<b>Stop Server</b>""" + """<p>This stops the currently connected 'eric-ide server.</p>""" + ) + ) + self.stopServerAct.triggered.connect(self.__shutdownServer) + self.actions.append(self.stopServerAct) + + self.serverVersionsAct = EricAction( + self.tr("Show Server Versions"), + EricPixmapCache.getIcon("helpAbout"), + self.tr("Show Server Versions"), + 0, + 0, + self, + "remote_server_versions", + ) + self.serverVersionsAct.setStatusTip( + self.tr("Show the eric-ide server versions") + ) + self.serverVersionsAct.setWhatsThis( + self.tr( + """<b>Show Server Versions</b>""" + """<p>This opens a dialog to show the eric-ide server versions.</p>""" + ) + ) + self.serverVersionsAct.triggered.connect(self.serverVersions) + self.actions.append(self.serverVersionsAct) + + self.disconnectServerAct.setEnabled(False) + self.stopServerAct.setEnabled(False) + self.serverVersionsAct.setEnabled(False) + + def initMenu(self): + """ + Public slot to initialize the eric-ide server menu. + + @return the menu generated + @rtype QMenu + """ + self.__serverProfilesMenu = QMenu(self.tr("Connect to"))##, self.__ui) + self.__serverProfilesMenu.aboutToShow.connect(self.__showServerProfilesMenu) + self.__serverProfilesMenu.triggered.connect(self.__serverProfileTriggered) + + menu = QMenu(self.tr("eric-ide Server"), self.__ui) + menu.setTearOffEnabled(True) + menu.aboutToShow.connect(self.__showEricServerMenu) + menu.addAction(self.connectServerAct) + menu.addMenu(self.__serverProfilesMenu) + # TODO: add a 'Recent Connections' submenu + menu.addSeparator() + menu.addAction(self.disconnectServerAct) + menu.addSeparator() + menu.addAction(self.stopServerAct) + menu.addSeparator() + menu.addAction(self.serverVersionsAct) + + self.__menus = { + "Main": menu, + ##"Recent": self.recentMenu, + } + + + return menu + + def initToolbar(self, toolbarManager): + """ + Public slot to initialize the eric-ide server toolbar. + + @param toolbarManager reference to a toolbar manager object + @type EricToolBarManager + @return the toolbar generated + @rtype QToolBar + """ + self.__connectButton = QToolButton() + self.__connectButton.setIcon(self.connectServerAct.icon()) + self.__connectButton.setToolTip(self.connectServerAct.toolTip()) + self.__connectButton.setWhatsThis(self.connectServerAct.whatsThis()) + self.__connectButton.setPopupMode( + QToolButton.ToolButtonPopupMode.MenuButtonPopup + ) + self.__connectButton.setMenu(self.__serverProfilesMenu) + self.connectServerAct.enabledChanged.connect(self.__connectButton.setEnabled) + self.__connectButton.clicked.connect(self.connectServerAct.triggered) + + tb = QToolBar(self.tr("eric-ide Server"), self.__ui) + tb.setObjectName("EricServerToolbar") + tb.setToolTip(self.tr("eric-ide Server")) + + act = tb.addWidget(self.__connectButton) + act.setText(self.connectServerAct.iconText()) + act.setIcon(self.connectServerAct.icon()) + tb.addAction(self.disconnectServerAct) + tb.addSeparator() + tb.addAction(self.stopServerAct) + tb.addSeparator() + tb.addAction(self.serverVersionsAct) + + toolbarManager.addToolBar(tb, tb.windowTitle()) + + return tb + + @pyqtSlot() + def __showEricServerMenu(self): + """ + Private slot to display the server menu. + """ + connected = self.isServerConnected() + self.connectServerAct.setEnabled(not connected) + self.disconnectServerAct.setEnabled(connected) + self.stopServerAct.setEnabled(connected) + self.serverVersionsAct.setEnabled(connected) + + self.showMenu.emit("Main", self.__menus["Main"]) + + @pyqtSlot() + def __showServerProfilesMenu(self): + """ + Private slot to prepare the eric server profiles menu. + """ + profiles = Preferences.getEricServer("ConnectionProfiles") + + self.__serverProfilesMenu.clear() + + if not self.isServerConnected(): + for profile in sorted(profiles): + act = self.__serverProfilesMenu.addAction(profile) + act.setData(profiles[profile]) + self.__serverProfilesMenu.addSeparator() + + self.__serverProfilesMenu.addAction( + self.tr("Manage Server Connections"), self.__manageServerProfiles + ) + + @pyqtSlot(bool) + def __connectionStateChanged(self, connected): + """ + Private slot to handle the connection state change. + + @param connected flag indicating the connection state + @type bool + """ + self.connectServerAct.setEnabled(not connected) + self.disconnectServerAct.setEnabled(connected) + self.stopServerAct.setEnabled(connected) + self.serverVersionsAct.setEnabled(connected) + + if connected: + peerName = self.__connection.peerName() + EricMessageBox.information( + None, + self.tr("Connect to eric-ide Server"), + self.tr( + "<p>The eric-ide server at <b>{0}:{1}</b> was connected" + " successfully.</p>" + ).format( + f"[{peerName}]" if ":" in peerName else peerName, + self.__connection.peerPort(), + ), + ) + else: + EricMessageBox.information( + None, + self.tr("Disonnect from eric-ide Server"), + self.tr("""The eric-ide server was disconnected."""), + ) + + @pyqtSlot() + def __connectToServer(self): + """ + Private slot to connect to a remote eric-ide server. + """ + from .EricServerConnectionDialog import EricServerConnectionDialog + + dlg = EricServerConnectionDialog(parent=self.__ui) + if dlg.exec() == QDialog.DialogCode.Accepted: + hostname, port, timeout = dlg.getData() + self.connectToServer(hostname, port=port, timeout=timeout) + + @pyqtSlot() + def __shutdownServer(self): + """ + Private slot to shut down the currently connected eric-ide server. + """ + ok = EricMessageBox.yesNo( + None, + self.tr("Stop Server"), + self.tr( + "Do you really want to stop the currently connected eric-ide server?" + " No further connections will be possible without restarting the" + " server." + ), + ) + if ok: + self.shutdownServer() + + @pyqtSlot(QAction) + def __serverProfileTriggered(self, act): + """ + Private slot to handle the selection of a remote server connection. + + @param act reference to the triggered profile action + @type QAction + """ + data = act.data() + if data is not None: + # handle the connection + hostname, port, timeout = data + self.connectToServer(hostname, port=port, timeout=timeout) + + @pyqtSlot() + def __manageServerProfiles(self): + """ + Private slot to show a dialog to manage the eric-ide server connection + profiles. + """ + from .EricServerProfilesDialog import EricServerProfilesDialog + + dlg = EricServerProfilesDialog( + Preferences.getEricServer("ConnectionProfiles"), self.__ui + ) + if dlg.exec() == QDialog.DialogCode.Accepted: + profiles = dlg.getConnectionProfiles() + Preferences.setEricServer("ConnectionProfiles", profiles)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/RemoteServerInterface/EricServerProfilesDialog.py Mon Jan 29 19:50:44 2024 +0100 @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing a dialog to manage server connection profiles. +""" + +import copy + +from PyQt6.QtCore import Qt, pyqtSlot +from PyQt6.QtWidgets import QDialog, QListWidgetItem + +from eric7.EricWidgets import EricMessageBox + +from .EricServerConnectionDialog import EricServerConnectionDialog +from .Ui_EricServerProfilesDialog import Ui_EricServerProfilesDialog + + +class EricServerProfilesDialog(QDialog, Ui_EricServerProfilesDialog): + """ + Class implementing a dialog to manage server connection profiles. + """ + + def __init__(self, profiles, parent=None): + """ + Constructor + + @param profiles dictionary containing the server connection profiles + @type dict + @param parent reference to the parent widget (defaults to None) + @type QWidget (optional) + """ + super().__init__(parent) + self.setupUi(self) + + self.__profiles = copy.deepcopy(profiles) + self.__populateProfilesList() + + self.on_connectionsList_itemSelectionChanged() + + def __populateProfilesList(self): + """ + Private method to (re-) populate the list of server connection profiles. + """ + self.connectionsList.clear() + + for profile in self.__profiles: + itm = QListWidgetItem(profile, self.connectionsList) + itm.setData(Qt.ItemDataRole.UserRole, self.__profiles[profile]) + + def __getProfilesList(self): + """ + Private method to get the list of defined profile names. + + @return list of defined profile names + @rtype list of str + """ + profileNames = [] + for row in range(self.connectionsList.count()): + itm = self.connectionsList.item(row) + profileNames.append(itm.text()) + + return profileNames + + @pyqtSlot() + def on_connectionsList_itemSelectionChanged(self): + """ + Private slot to handle a change of selected items. + """ + selectedItems = self.connectionsList.selectedItems() + self.editButton.setEnabled(len(selectedItems) == 1) + self.removeButton.setEnabled(len(selectedItems) > 0) + + @pyqtSlot() + def on_addButton_clicked(self): + """ + Private slot add a new connection profile. + """ + dlg = EricServerConnectionDialog( + profileNames=self.__getProfilesList(), parent=self + ) + if dlg.exec() == QDialog.DialogCode.Accepted: + profileData = dlg.getProfileData() + itm = QListWidgetItem(profileData[0], self.connectionsList) + itm.setData(Qt.ItemDataRole.UserRole, profileData[1:]) + + @pyqtSlot() + def on_editButton_clicked(self): + """ + Private slot to edit the selected entry. + """ + selectedItems = self.connectionsList.selectedItems() + if selectedItems: + itm = selectedItems[0] + dlg = EricServerConnectionDialog( + profileNames=self.__getProfilesList(), parent=self + ) + data = itm.data(Qt.ItemDataRole.UserRole) + dlg.setProfileData(itm.text(), *data) + if dlg.exec() == QDialog.DialogCode.Accepted: + profileData = dlg.getProfileData() + itm.setText(profileData[0]) + itm.setData(Qt.ItemDataRole.UserRole, profileData[1:]) + + @pyqtSlot() + def on_removeButton_clicked(self): + """ + Private slot to remove the selected connection profiles. + """ + yes = EricMessageBox.yesNo( + self, + self.tr("Remove Selected Entries"), + self.tr( + "Do you really want to remove the selected entries from the list?" + ), + ) + if yes: + for itm in self.connectionsList.selectedItems()[:]: + self.connectionsList.takeItem(self.connectionsList.row(itm)) + del itm + + @pyqtSlot() + def on_resetButton_clicked(self): + """ + Private slot to reset all changes performed. + """ + yes = EricMessageBox.yesNo( + self, + self.tr("Reset Changes"), + self.tr( + "Do you really want to reset all changes performed up to this point?" + ), + ) + if yes: + self.__populateProfilesList() + + def getConnectionProfiles(self): + """ + Public method to get the configured connection profiles. + + @return dictionary containing the configured connection profiles + @rtype dict + """ + profiles = {} + + for row in range(self.connectionsList.count()): + itm = self.connectionsList.item(row) + profiles[itm.text()] = itm.data(Qt.ItemDataRole.UserRole) + + return profiles +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/RemoteServerInterface/EricServerProfilesDialog.ui Mon Jan 29 19:50:44 2024 +0100 @@ -0,0 +1,140 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>EricServerProfilesDialog</class> + <widget class="QDialog" name="EricServerProfilesDialog"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>441</width> + <height>281</height> + </rect> + </property> + <property name="windowTitle"> + <string>Manage Server Connections</string> + </property> + <property name="sizeGripEnabled"> + <bool>true</bool> + </property> + <layout class="QGridLayout" name="gridLayout"> + <item row="0" column="0"> + <widget class="QListWidget" name="connectionsList"> + <property name="editTriggers"> + <set>QAbstractItemView::NoEditTriggers</set> + </property> + <property name="alternatingRowColors"> + <bool>true</bool> + </property> + <property name="selectionMode"> + <enum>QAbstractItemView::ExtendedSelection</enum> + </property> + <property name="sortingEnabled"> + <bool>true</bool> + </property> + </widget> + </item> + <item row="0" column="1"> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <widget class="QPushButton" name="addButton"> + <property name="toolTip"> + <string>Press to open a dialog to add a new server connection.</string> + </property> + <property name="text"> + <string>Add...</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="editButton"> + <property name="toolTip"> + <string>Press to open a dialog to edit the selected server connection.</string> + </property> + <property name="text"> + <string>Edit...</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="removeButton"> + <property name="toolTip"> + <string>Press to remove the selected server connections.</string> + </property> + <property name="text"> + <string>Remove</string> + </property> + </widget> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="resetButton"> + <property name="toolTip"> + <string>Press to reset all changes performed.</string> + </property> + <property name="text"> + <string>Reset</string> + </property> + </widget> + </item> + </layout> + </item> + <item row="1" column="0" colspan="2"> + <widget class="QDialogButtonBox" name="buttonBox"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="standardButtons"> + <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set> + </property> + </widget> + </item> + </layout> + </widget> + <resources/> + <connections> + <connection> + <sender>buttonBox</sender> + <signal>accepted()</signal> + <receiver>EricServerProfilesDialog</receiver> + <slot>accept()</slot> + <hints> + <hint type="sourcelabel"> + <x>248</x> + <y>254</y> + </hint> + <hint type="destinationlabel"> + <x>157</x> + <y>274</y> + </hint> + </hints> + </connection> + <connection> + <sender>buttonBox</sender> + <signal>rejected()</signal> + <receiver>EricServerProfilesDialog</receiver> + <slot>reject()</slot> + <hints> + <hint type="sourcelabel"> + <x>316</x> + <y>260</y> + </hint> + <hint type="destinationlabel"> + <x>286</x> + <y>274</y> + </hint> + </hints> + </connection> + </connections> +</ui>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/RemoteServerInterface/__init__.py Mon Jan 29 19:50:44 2024 +0100 @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Package implementing the components of the eric-ide remote server interface. +"""
--- a/src/eric7/UI/UserInterface.py Fri Jan 26 16:17:05 2024 +0100 +++ b/src/eric7/UI/UserInterface.py Mon Jan 29 19:50:44 2024 +0100 @@ -83,6 +83,7 @@ from eric7.Preferences import Shortcuts from eric7.Project.Project import Project from eric7.QScintilla.SpellChecker import SpellChecker +from eric7.RemoteServerInterface.EricServerInterface import EricServerInterface from eric7.Sessions.SessionFile import SessionFile from eric7.SystemUtilities import ( DesktopUtilities, @@ -393,6 +394,9 @@ self.stdout = Redirector(False, self) self.stderr = Redirector(True, self) + # create the remote server interface + self.__ericServerInterface = EricServerInterface(self) + # set a few dialog members for non-modal dialogs created on demand self.programsDialog = None self.shortcutsDialog = None @@ -635,6 +639,7 @@ ericApp().registerObject("MicroPython", self.microPythonWidget) ericApp().registerObject("JediAssistant", self.jediAssistant) ericApp().registerObject("PluginRepositoryViewer", self.pluginRepositoryViewer) + ericApp().registerObject("EricServer", self.__ericServerInterface) # create the various JSON file interfaces self.__sessionFile = SessionFile(True) @@ -3572,6 +3577,9 @@ # initialize multi project actions self.multiProject.initActions() + # initialize eric-ide server actions + self.__ericServerInterface.initActions() + def __initQtDocActions(self): """ Private slot to initialize the action to show the Qt documentation. @@ -3781,6 +3789,12 @@ mb.setNativeMenuBar(False) ############################################################## + ## Remote Server menu + ############################################################## + + self.__menus["server"] = self.__ericServerInterface.initMenu() + + ############################################################## ## File menu ############################################################## @@ -3795,6 +3809,9 @@ act = self.__menus["file"].actions()[0] sep = self.__menus["file"].insertSeparator(act) self.__menus["file"].insertAction(sep, self.newWindowAct) + self.__menus["file"].insertSeparator(sep) + self.__menus["file"].insertMenu(sep, self.__menus["server"]) + self.__menus["file"].insertSeparator(sep) self.__menus["file"].aboutToShow.connect(self.__showFileMenu) ############################################################## @@ -4092,6 +4109,7 @@ helptb = QToolBar(self.tr("Help"), self) profilestb = QToolBar(self.tr("Profiles"), self) pluginstb = QToolBar(self.tr("Plugins"), self) + servertb = self.__ericServerInterface.initToolbar(self.toolbarManager) toolstb.setObjectName("ToolsToolbar") testingtb.setObjectName("UnittestToolbar") @@ -4194,6 +4212,7 @@ # add the various toolbars self.addToolBar(filetb) + self.addToolBar(servertb) self.addToolBar(edittb) self.addToolBar(searchtb) self.addToolBar(viewtb) @@ -4244,6 +4263,7 @@ ] self.__toolbars["spelling"] = [spellingtb.windowTitle(), spellingtb, ""] self.__toolbars["vcs"] = [vcstb.windowTitle(), vcstb, "vcs"] + self.__toolbars["server"] = [servertb.windowTitle(), servertb, ""] def __initDebugToolbarsLayout(self): """
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/eric7_server.py Mon Jan 29 19:50:44 2024 +0100 @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +eric-ide Server. + +This is the main Python script of the eric-ide server. This is a server to perform +remote development (e.g. code hosted on another computer or through a docker +container). +""" + +import argparse +import socket +import sys + +from eric7.RemoteServer.EricServer import EricServer +from eric7.UI.Info import Version + + +def createArgumentParser(): + """ + Function to create an argument parser. + + @return created argument parser object + @rtype argparse.ArgumentParser + """ + parser = argparse.ArgumentParser( + description=( + "Start the eric-ide server component. This will listen for connections" + " from the eric IDE in order to perform remote development." + ), + epilog="Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de>.", + ) + + parser.add_argument( + "-p", + "--port", + type=int, + default=42024, + help="Listen on the given port for connections from an eric IDE." + ) + parser.add_argument( + "-6", + "--with-ipv6", + action="store_true", + help="Listen on IPv6 interfaces as well if the system supports the creation" + "of TCP sockets which can handle both IPv4 and IPv6. {0}".format( + "This system supports this feature." + if socket.has_dualstack_ipv6() + else "This system does not support this feature. Option will be ignored." + ) + ) + parser.add_argument( + "-V", + "--version", + action="version", + version="%(prog)s {0}".format(Version), + help="Show version information and exit.", + ) + + return parser + + +def main(): + """ + Main entry point into the application. + """ + global supportedExtensions + + parser = createArgumentParser() + args = parser.parse_args() + + server = EricServer(port=args.port, useIPv6=args.with_ipv6) + ok = server.run() + + sys.exit(0 if ok else 1) + + +if __name__ == "__main__": + main() + +# +# eflag: noqa = M801
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/icons/breeze-dark/preferences-eric-server.svg Mon Jan 29 19:50:44 2024 +0100 @@ -0,0 +1,155 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + height="22" + width="22" + version="1.1" + id="svg1718" + sodipodi:docname="preferences-eric-server.svg" + inkscape:version="1.2.2 (b0a8486541, 2022-12-01)" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg"> + <defs + id="defs1722" /> + <sodipodi:namedview + id="namedview1720" + pagecolor="#ffffff" + bordercolor="#000000" + borderopacity="0.25" + inkscape:showpageshadow="2" + inkscape:pageopacity="0.0" + inkscape:pagecheckerboard="0" + inkscape:deskcolor="#d1d1d1" + showgrid="false" + inkscape:zoom="32.09375" + inkscape:cx="16.623174" + inkscape:cy="16.327167" + inkscape:window-width="2580" + inkscape:window-height="1381" + inkscape:window-x="0" + inkscape:window-y="0" + inkscape:window-maximized="0" + inkscape:current-layer="svg1718" /> + <linearGradient + id="b" + gradientTransform="matrix(0.7,0,0,0.7,-0.7,-1.1)" + gradientUnits="userSpaceOnUse" + x2="0" + y1="44" + y2="4"> + <stop + offset="0" + stop-color="#1d1e1e" + id="stop1680" /> + <stop + offset="1" + stop-color="#44484c" + id="stop1682" /> + </linearGradient> + <linearGradient + id="c" + gradientTransform="matrix(0.7,0,0,0.7,-270.499,-350.76)" + x2="0" + xlink:href="#a" + y1="507.79999" + y2="506.79999" /> + <linearGradient + id="a" + gradientUnits="userSpaceOnUse" + x2="0" + y1="507.79999" + y2="506.79999"> + <stop + offset="0" + stop-color="#3da103" + id="stop1686" /> + <stop + offset="1" + stop-color="#7ddf07" + id="stop1688" /> + </linearGradient> + <linearGradient + id="d" + gradientTransform="matrix(0.7,0,0,0.7,-270.499,-339.76)" + x2="0" + xlink:href="#a" + y1="507.79999" + y2="506.79999" /> + <linearGradient + id="e" + gradientTransform="matrix(0.7,0,0,0.7,-270.499,-328.76)" + x2="0" + xlink:href="#a" + y1="507.79999" + y2="506.79999" /> + <linearGradient + id="f" + gradientUnits="userSpaceOnUse" + x1="5" + x2="18" + y1="12" + y2="25"> + <stop + offset="0" + stop-color="#292c2f" + id="stop1693" + style="stop-color:#c0c0c0;stop-opacity:1;" /> + <stop + offset="1" + stop-opacity="0" + id="stop1695" /> + </linearGradient> + <g + id="g2294" + transform="matrix(0.72413793,0,0,0.71428571,-1.1724138,-0.42857143)"> + <path + d="M 3,7 H 29 V 24 H 3 Z" + fill="#111213" + id="path1698" /> + <path + d="M 3,2 V 30 H 29 V 2 Z m 1,6 h 24 v 4 H 4 Z m 0,11 h 24 v 4 H 4 Z" + fill="url(#b)" + id="path1700" + style="fill:#ececec;stroke-width:0.7" /> + <path + d="M 5,4 H 7 V 5 H 5 Z" + fill="url(#c)" + id="path1702" + style="fill:url(#c);stroke-width:0.7" /> + <path + d="m 5,15 h 2 v 1 H 5 Z" + fill="url(#d)" + id="path1704" + style="fill:url(#d);stroke-width:0.7" /> + <path + d="m 5,26 h 2 v 1 H 5 Z" + fill="url(#e)" + id="path1706" + style="fill:url(#e);stroke-width:0.7" /> + <path + d="m 3,29 h 26 v 1 H 3 Z" + opacity="0.2" + id="path1710" /> + <path + d="m 4,12 7,7 h 17 v 4 H 15 l 7,7 h 7 V 13 l -1,-1 z" + fill="url(#f)" + fill-rule="evenodd" + opacity="0.4" + id="path1712" + style="fill:url(#f)" /> + <rect + fill="#eff0f1" + height="16" + rx="2" + width="16" + x="16" + y="14" + id="rect1714" /> + <path + d="m 19,16 v 2 h -1 v 2 h 1 v 4 h -1 v 2 h 1 v 2 H 30 V 16 Z m 1,1 h 1 v 10 h -1 z m 2,0 h 7 v 10 h -7 z" + fill="#232629" + id="path1716" /> + </g> +</svg>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/icons/breeze-light/preferences-eric-server.svg Mon Jan 29 19:50:44 2024 +0100 @@ -0,0 +1,158 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + height="22" + width="22" + version="1.1" + id="svg3827" + sodipodi:docname="preferences-eric-server.svg" + inkscape:version="1.2.2 (b0a8486541, 2022-12-01)" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg"> + <defs + id="defs3831" /> + <sodipodi:namedview + id="namedview3829" + pagecolor="#ffffff" + bordercolor="#000000" + borderopacity="0.25" + inkscape:showpageshadow="2" + inkscape:pageopacity="0.0" + inkscape:pagecheckerboard="0" + inkscape:deskcolor="#d1d1d1" + showgrid="false" + inkscape:zoom="32.09375" + inkscape:cx="16.747809" + inkscape:cy="16.389484" + inkscape:window-width="2580" + inkscape:window-height="1321" + inkscape:window-x="0" + inkscape:window-y="60" + inkscape:window-maximized="0" + inkscape:current-layer="svg3827" /> + <linearGradient + id="b" + gradientTransform="matrix(0.7,0,0,0.7,-0.7,-1.1)" + gradientUnits="userSpaceOnUse" + x2="0" + y1="44" + y2="4"> + <stop + offset="0" + stop-color="#1d1e1e" + id="stop3789" /> + <stop + offset="1" + stop-color="#44484c" + id="stop3791" /> + </linearGradient> + <linearGradient + id="c" + gradientTransform="matrix(0.7,0,0,0.7,-270.499,-350.76)" + x2="0" + xlink:href="#a" + y1="507.79999" + y2="506.79999" /> + <linearGradient + id="a" + gradientUnits="userSpaceOnUse" + x2="0" + y1="507.79999" + y2="506.79999"> + <stop + offset="0" + stop-color="#3da103" + id="stop3795" /> + <stop + offset="1" + stop-color="#7ddf07" + id="stop3797" /> + </linearGradient> + <linearGradient + id="d" + gradientTransform="matrix(0.7,0,0,0.7,-270.499,-339.76)" + x2="0" + xlink:href="#a" + y1="507.79999" + y2="506.79999" /> + <linearGradient + id="e" + gradientTransform="matrix(0.7,0,0,0.7,-270.499,-328.76)" + x2="0" + xlink:href="#a" + y1="507.79999" + y2="506.79999" /> + <linearGradient + id="f" + gradientUnits="userSpaceOnUse" + x1="5" + x2="18" + y1="12" + y2="25"> + <stop + offset="0" + stop-color="#292c2f" + id="stop3802" /> + <stop + offset="1" + stop-opacity="0" + id="stop3804" /> + </linearGradient> + <g + id="g5049" + transform="matrix(0.72413793,0,0,0.71428571,-1.1724138,-0.42857143)"> + <path + d="M 3,7 H 29 V 24 H 3 Z" + fill="#111213" + id="path3807" /> + <g + stroke-width="0.7" + id="g3817"> + <path + d="M 3,2 V 30 H 29 V 2 Z m 1,6 h 24 v 4 H 4 Z m 0,11 h 24 v 4 H 4 Z" + fill="url(#b)" + id="path3809" + style="fill:url(#b)" /> + <path + d="M 5,4 H 7 V 5 H 5 Z" + fill="url(#c)" + id="path3811" + style="fill:url(#c)" /> + <path + d="m 5,15 h 2 v 1 H 5 Z" + fill="url(#d)" + id="path3813" + style="fill:url(#d)" /> + <path + d="m 5,26 h 2 v 1 H 5 Z" + fill="url(#e)" + id="path3815" + style="fill:url(#e)" /> + </g> + <path + d="m 3,29 h 26 v 1 H 3 Z" + opacity="0.2" + id="path3819" /> + <path + d="m 4,12 7,7 h 17 v 4 H 15 l 7,7 h 7 V 13 l -1,-1 z" + fill="url(#f)" + fill-rule="evenodd" + opacity="0.4" + id="path3821" + style="fill:url(#f)" /> + <rect + fill="#eff0f1" + height="16" + rx="2" + width="16" + x="16" + y="14" + id="rect3823" /> + <path + d="m 19,16 v 2 h -1 v 2 h 1 v 4 h -1 v 2 h 1 v 2 H 30 V 16 Z m 1,1 h 1 v 10 h -1 z m 2,0 h 7 v 10 h -7 z" + fill="#232629" + id="path3825" /> + </g> +</svg>