diff --git a/spyder/plugins/remoteclient/plugin.py b/spyder/plugins/remoteclient/plugin.py index 7d430c56653..9af62569e6d 100644 --- a/spyder/plugins/remoteclient/plugin.py +++ b/spyder/plugins/remoteclient/plugin.py @@ -332,13 +332,24 @@ def _load_ssh_client_options(self, config_id): f"{config_id}/passphrase", "", secure=True ): options["passphrase"] = passphrase - elif config := self.get_conf(f"{config_id}/configfile"): - options["config"] = config else: # Password is mandatory in this case password = self.get_conf(f"{config_id}/password", secure=True) options["password"] = password + if config := self.get_conf(f"{config_id}/{auth_method}/configfile"): + options["config"] = [config] + + # Some validations to avoid passing empty values + if options["port"] == 0: + # Ignore value if 0 is set because it means the port will be + # read from the config file. + options.pop("port") + + if not options["username"]: + # Ignore empty username + options.pop("username") + # Default for now options["platform"] = "linux" diff --git a/spyder/plugins/remoteclient/widgets/connectiondialog.py b/spyder/plugins/remoteclient/widgets/connectiondialog.py index bdaa8a4e066..83d5f5318f7 100644 --- a/spyder/plugins/remoteclient/widgets/connectiondialog.py +++ b/spyder/plugins/remoteclient/widgets/connectiondialog.py @@ -46,7 +46,7 @@ class ConnectionDialog(SidebarDialog): TITLE = _("Remote connections") FIXED_SIZE = True MIN_WIDTH = 895 if MAC else (810 if WIN else 860) - MIN_HEIGHT = 740 if MAC else (655 if WIN else 690) + MIN_HEIGHT = 855 if MAC else (780 if WIN else 795) PAGE_CLASSES = [NewConnectionPage] sig_start_server_requested = Signal(str) diff --git a/spyder/plugins/remoteclient/widgets/connectionpages.py b/spyder/plugins/remoteclient/widgets/connectionpages.py index 3f83521f321..d48917b12f1 100644 --- a/spyder/plugins/remoteclient/widgets/connectionpages.py +++ b/spyder/plugins/remoteclient/widgets/connectionpages.py @@ -9,6 +9,7 @@ # Standard library imports from __future__ import annotations from collections.abc import Iterable +import logging import os.path as osp import re from typing import TypedDict @@ -59,6 +60,8 @@ except Exception: ENV_MANAGER = False +logger = logging.getLogger(__name__) + # ============================================================================= # ---- Constants @@ -67,6 +70,7 @@ class ValidationReasons(TypedDict): repeated_name: bool | None missing_info: bool | None invalid_address: bool | None + invalid_host: bool | None invalid_url: bool | None file_not_found: bool | None keyfile_passphrase_is_wrong: bool | None @@ -86,7 +90,7 @@ class CreateEnvMethods: class BaseConnectionPage(SpyderConfigPage, SpyderFontsMixin): """Base class to create connection pages.""" - MIN_HEIGHT = 450 + MIN_HEIGHT = 650 NEW_CONNECTION = False CONF_SECTION = "remoteclient" @@ -114,6 +118,7 @@ def __init__(self, parent, host_id=None): self._address_widgets: dict[str, QWidget] = {} self._port_widgets: dict[str, QWidget] = {} self._username_widgets: dict[str, QWidget] = {} + self._config_file_widgets: dict[str, QWidget] = {} self._url_widgets: dict[str, QWidget] = {} # ---- Public API @@ -127,8 +132,6 @@ def auth_method(self, from_gui=False): auth_method = AuthenticationMethod.Password elif self._auth_methods.combobox.currentIndex() == 1: auth_method = AuthenticationMethod.KeyFile - else: - auth_method = AuthenticationMethod.ConfigFile else: auth_method = AuthenticationMethod.JupyterHub else: @@ -147,8 +150,44 @@ def validate_page(self): validate_label.setVisible(False) reasons: ValidationReasons = {} + + if auth_method == AuthenticationMethod.JupyterHub: + config_file = None + else: + config_file_widget = self._config_file_widgets[auth_method] + config_file = config_file_widget.textbox.text() + + if config_file: + host_widget = self._address_widgets[auth_method] + host = host_widget.textbox.text() + config_file_widget.textbox._validate(config_file) + + if host and not self._validate_config_file(config_file): + reasons["invalid_host"] = True + host_widget.status_action.setVisible(True) + host_widget.status_action.setToolTip(_("Invalid host")) + else: + host_widget.status_action.setVisible(False) + config_file_widget.textbox.error_action.setVisible(False) + for widget in widgets: if not widget.textbox.text(): + if auth_method != AuthenticationMethod.JupyterHub: + if ( + config_file + and ( + widget == self._username_widgets[auth_method] + or widget == self._keyfile + ) + or not config_file + and widget == self._config_file_widgets[auth_method] + ): + # Skip validation for empty username/keyfile when using + # a config file or for config file if no config file is + # provided for password and keyfile based + # authentication + continue + # Validate that the required fields are not empty widget.status_action.setVisible(True) widget.status_action.setToolTip(_("This field is empty")) @@ -163,11 +202,12 @@ def validate_page(self): widget.status_action.setVisible(True) elif widget == self._address_widgets.get(auth_method): # Validate address - widget.status_action.setVisible(False) - address = widget.textbox.text() - if not self._validate_address(address): - reasons["invalid_address"] = True - widget.status_action.setVisible(True) + if not config_file: + widget.status_action.setVisible(False) + address = widget.textbox.text() + if not self._validate_address(address): + reasons["invalid_address"] = True + widget.status_action.setVisible(True) elif widget == self._url_widgets.get(auth_method): # Validate URL widget.status_action.setVisible(False) @@ -217,6 +257,7 @@ def validate_page(self): n_reasons = list(reasons.values()).count(True) min_height = self.MIN_HEIGHT if self.get_client_type() == ClientType.SSH: + extra_description_space = 45 if self.NEW_CONNECTION else 0 if ( self.auth_method(from_gui=True) == AuthenticationMethod.Password @@ -225,11 +266,11 @@ def validate_page(self): min_height = self.MIN_HEIGHT if (WIN or MAC) else 600 else: if n_reasons == 1: - min_height = 640 if MAC else (580 if WIN else 620) + min_height = 640 if MAC else (620 if WIN else 635) else: - min_height = 700 if MAC else (620 if WIN else 680) + min_height = 720 if MAC else (680 if WIN else 690) - self.setMinimumHeight(min_height) + self.setMinimumHeight(min_height + extra_description_space) return False if reasons else True @@ -323,12 +364,9 @@ def create_ssh_connection_info_widget(self): intro_layout.addWidget(intro_tip) # Authentication methods - # TODO: The config file method is not implemented yet, so we need to - # disable it for now. methods = ( (_('Password'), AuthenticationMethod.Password), (_('Key file'), AuthenticationMethod.KeyFile), - # (_('Configuration file'), AuthenticationMethod.ConfigFile), ) self._auth_methods = self.create_combobox( @@ -341,12 +379,10 @@ def create_ssh_connection_info_widget(self): # Subpages password_subpage = self._create_password_subpage() keyfile_subpage = self._create_keyfile_subpage() - configfile_subpage = self._create_configfile_subpage() subpages = QStackedWidget(self) subpages.addWidget(password_subpage) subpages.addWidget(keyfile_subpage) - subpages.addWidget(configfile_subpage) # Signals self._auth_methods.combobox.currentIndexChanged.connect( @@ -422,8 +458,13 @@ def _create_common_elements(self, auth_method: str): prefix=_("Port"), suffix="", option=f"{self.host_id}/{auth_method}/port", - min_=1, - max_=65535 + min_=0, + max_=65535, + tip=_( + "Introduce a port to use for this connection. Set 0 " + "to use the port specified in the configuration file if " + "provided" + ), ) port.spinbox.setStyleSheet("margin-left: 5px") @@ -433,15 +474,32 @@ def _create_common_elements(self, auth_method: str): status_icon=ima.icon("error"), ) + configfile = self.create_browsefile( + text=_("Configuration file"), + option=f"{self.host_id}/{auth_method}/configfile", + tip=_("OpenSSH client configuration file to use"), + validate_callback=self._validate_config_file, + validate_reason=_( + "Unable to get OpenSSH client configuration from " + "the given file.\nCheck that the provided address corresponds " + "to the values available in the file" + ), + alignment=Qt.Vertical, + status_icon=ima.icon("error"), + word_wrap=False, + ) + self._widgets_for_validation[f"{auth_method}"] = [ name, address, username, + configfile, ] self._name_widgets[f"{auth_method}"] = name self._address_widgets[f"{auth_method}"] = address self._port_widgets[f"{auth_method}"] = port self._username_widgets[f"{auth_method}"] = username + self._config_file_widgets[f"{auth_method}"] = configfile # Set 22 as the default port for new conenctions if not self.LOAD_FROM_CONFIG: @@ -458,21 +516,29 @@ def _create_common_elements(self, auth_method: str): address_label_layout.addWidget(address.help_label) address_label_layout.addStretch() + # Layout for the port label + port_label_layout = QHBoxLayout() + port_label_layout.setSpacing(0) + port_label_layout.addWidget(port.plabel) + port_label_layout.addWidget(port.help_label) + # Address layout address_layout = QGridLayout() address_layout.setContentsMargins(0, 0, 0, 0) address_layout.addLayout(address_label_layout, 0, 0) address_layout.addWidget(address.textbox, 1, 0) - address_layout.addWidget(port.plabel, 0, 1) + address_layout.addLayout(port_label_layout, 0, 1) address_layout.addWidget(port.spinbox, 1, 1) - return name, address_layout, username + return name, address_layout, username, configfile def _create_password_subpage(self): # Widgets - name, address_layout, username = self._create_common_elements( - auth_method=AuthenticationMethod.Password + name, address_layout, username, configfile = ( + self._create_common_elements( + auth_method=AuthenticationMethod.Password + ) ) password = self.create_lineedit( @@ -509,6 +575,8 @@ def _create_password_subpage(self): password_layout.addWidget(username) password_layout.addSpacing(5 * AppStyle.MarginSize) password_layout.addWidget(password) + password_layout.addSpacing(5 * AppStyle.MarginSize) + password_layout.addWidget(configfile) password_layout.addSpacing(7 * AppStyle.MarginSize) password_layout.addWidget(validation_label) password_layout.addStretch() @@ -520,8 +588,10 @@ def _create_password_subpage(self): def _create_keyfile_subpage(self): # Widgets - name, address_layout, username = self._create_common_elements( - auth_method=AuthenticationMethod.KeyFile + name, address_layout, username, configfile = ( + self._create_common_elements( + auth_method=AuthenticationMethod.KeyFile + ) ) self._keyfile = self.create_browsefile( @@ -565,6 +635,8 @@ def _create_keyfile_subpage(self): keyfile_layout.addWidget(self._keyfile) keyfile_layout.addSpacing(5 * AppStyle.MarginSize) keyfile_layout.addWidget(self._passphrase) + keyfile_layout.addSpacing(5 * AppStyle.MarginSize) + keyfile_layout.addWidget(configfile) keyfile_layout.addSpacing(7 * AppStyle.MarginSize) keyfile_layout.addWidget(validation_label) keyfile_layout.addStretch() @@ -574,49 +646,6 @@ def _create_keyfile_subpage(self): return keyfile_subpage - def _create_configfile_subpage(self): - # Widgets - name = self.create_lineedit( - text=_("Name *"), - option=f"{self.host_id}/{AuthenticationMethod.ConfigFile}/name", - tip=_("Introduce a name to identify your connection"), - status_icon=ima.icon("error"), - ) - - configfile = self.create_browsefile( - text=_("Configuration file *"), - option=f"{self.host_id}/configfile", - alignment=Qt.Vertical, - status_icon=ima.icon("error"), - ) - - validation_label = MessageLabel(self) - - # Add widgets to their required dicts - self._name_widgets[AuthenticationMethod.ConfigFile] = name - self._widgets_for_validation[AuthenticationMethod.ConfigFile] = [ - name, - configfile, - ] - self._validation_labels[ - AuthenticationMethod.ConfigFile - ] = validation_label - - # Layout - configfile_layout = QVBoxLayout() - configfile_layout.setContentsMargins(0, 0, 0, 0) - configfile_layout.addWidget(name) - configfile_layout.addSpacing(5 * AppStyle.MarginSize) - configfile_layout.addWidget(configfile) - configfile_layout.addSpacing(7 * AppStyle.MarginSize) - configfile_layout.addWidget(validation_label) - configfile_layout.addStretch() - - configfile_widget = QWidget(self) - configfile_widget.setLayout(configfile_layout) - - return configfile_widget - def _create_jupyterhub_subpage(self): # Widgets name = self.create_lineedit( @@ -710,6 +739,14 @@ def _validate_url(self, url): def _validate_address(self, address): """Validate if address introduced by users is correct.""" + auth_method = self.auth_method(from_gui=True) + config_filepath_widget = self._config_file_widgets[auth_method] + if config_filepath_widget.textbox.text(): + # Address validation depends on config file parsing + return self._validate_config_file( + config_filepath_widget.textbox.text() + ) + # Regex pattern for a valid domain name (simplified version) domain_pattern = ( r'^([a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]\.){1,}[a-zA-Z]{2,}$' @@ -729,6 +766,77 @@ def _validate_address(self, address): address_re = re.compile(combined_pattern) return True if address_re.match(address) else False + def _validate_config_file(self, config_filepath, from_gui=True): + auth_method = self.auth_method(from_gui=from_gui) + + host_widget = self._address_widgets[auth_method] + host = host_widget.textbox.text() + username_widget = self._username_widgets[auth_method] + username_textbox = username_widget.textbox + # Pass empty tuple to prevent setting a `User` entry in the asyncssh + # parsed config + # See https://github.com/spyder-ide/spyder/pull/24343#discussion_r2733719853 + username = username_textbox.text() if username_textbox.text() else () + configfile_widget = self._config_file_widgets[auth_method] + + keyfile_widget = None + keyfile_textbox = None + if auth_method == AuthenticationMethod.KeyFile: + keyfile_widget = self._keyfile + keyfile_textbox = keyfile_widget.textbox + + config = None + if osp.isfile(config_filepath): + config = asyncssh.config.SSHClientConfig.load( + None, + config_filepath, + True, + True, + False, + "local_user", + username, + host, + () + ) + + if config and not config.get_options(False) or not config: + # If no valid config is available there is no value to set as + # placeholder for username and keyfile and there is a need to set + # it to an empty string in case previously a value was able to be + # set. + username_textbox.setPlaceholderText("") + if keyfile_textbox: + keyfile_textbox.setPlaceholderText("") + + # If no config is available point to host/address widget + # since the current value could be incorrect + host_widget.status_action.setVisible(True) + + # Set configfile widget status visibility also in case the + # validation wasn't triggered from the own widget + configfile_widget.textbox.error_action.setVisible(True) + + return False + + if config.get("User", None): + username_textbox.setPlaceholderText(config.get("User")) + + if config.get("IdentityFile", None) and keyfile_textbox: + keyfile_textbox.setPlaceholderText(config.get("IdentityFile")[0]) + + # Host/address, username and keyfile widgets should be valid since + # OpenSSH config file parsing was successful + host_widget.status_action.setVisible(False) + username_widget.status_action.setVisible(False) + if keyfile_widget: + keyfile_widget.status_action.setVisible(False) + + # Set configfile widget status visibility also in case the validation + # wasn't triggered from the own widget + configfile_widget.textbox.error_action.setVisible(False) + + return True + def _compose_failed_validation_text(self, reasons: ValidationReasons): """ Compose validation text from a dictionary of reasons for which it @@ -759,6 +867,13 @@ def _compose_failed_validation_text(self, reasons: ValidationReasons): + suffix ) + if reasons.get("invalid_host"): + text += ( + prefix + + _("The host you provided is not a valid one.") + + suffix + ) + if reasons.get("invalid_url"): text += ( prefix + _("The URL you provided is not valid.") + suffix @@ -1228,6 +1343,28 @@ def setup_page(self): self.create_tab(_("Connection status"), self.status_widget) self.create_tab(_("Connection info"), info_widget) + def initialize(self): + # Validate if config file is available so related widgets get updated + # when initializing the page. Also, ensure that previously created + # configs get an initial value set. + configfile_path = self.get_option( + f"{self.host_id}/{self.auth_method()}/configfile", default=None + ) + if configfile_path: + super().initialize() + self._validate_config_file(configfile_path, from_gui=False) + else: + if configfile_path is None: + self.set_option( + f"{self.host_id}/{AuthenticationMethod.Password}/configfile", + "" + ) + self.set_option( + f"{self.host_id}/{AuthenticationMethod.KeyFile}/configfile", + "" + ) + super().initialize() + def get_icon(self): return self.create_icon("remote_server") @@ -1252,14 +1389,14 @@ def remove_config_options(self): "password_login/address", "password_login/port", "password_login/username", + "password_login/configfile", "keyfile_login/name", "keyfile_login/address", "keyfile_login/port", "keyfile_login/username", - "configfile_login/name", + "keyfile_login/configfile", "jupyterhub_login/name", "keyfile", - "configfile", "status", "status_message", "url", diff --git a/spyder/widgets/config.py b/spyder/widgets/config.py index aef968c9d5e..a1a39ff53e6 100644 --- a/spyder/widgets/config.py +++ b/spyder/widgets/config.py @@ -784,7 +784,7 @@ def select_directory(self, edit): def create_browsefile(self, text, option, default=NoDefault, section=None, tip=None, filters=None, alignment=Qt.Horizontal, status_icon=None, validate_callback=None, - validate_reason=None): + validate_reason=None, word_wrap=True): widget = self.create_lineedit( text, option, @@ -797,6 +797,7 @@ def create_browsefile(self, text, option, default=NoDefault, section=None, status_icon=status_icon, validate_callback=validate_callback, validate_reason=validate_reason, + word_wrap=word_wrap, ) for edit in self.lineedits: