diff --git a/.github/workflows/linux-tests.yml b/.github/workflows/linux-tests.yml index 36c6e0d..0857dbf 100644 --- a/.github/workflows/linux-tests.yml +++ b/.github/workflows/linux-tests.yml @@ -21,6 +21,7 @@ jobs: matrix: PYTHON_VERSION: ['3.8', '3.9', '3.10'] USE_CONDA: ['True', 'False'] + timeout-minutes: 15 steps: - name: Checkout branch uses: actions/checkout@v2 @@ -49,6 +50,10 @@ jobs: run: | pip install -r requirements/conda.txt pip install -r requirements/tests.txt + - name: Install Spyder from master branch (Future Spyder 6) + shell: bash -l {0} + run: | + pip install git+https://github.com/spyder-ide/spyder.git@master - name: Install Package shell: bash -l {0} run: pip install --no-deps -e . diff --git a/requirements/conda.txt b/requirements/conda.txt index 48ab5cd..9d78ad1 100644 --- a/requirements/conda.txt +++ b/requirements/conda.txt @@ -1,5 +1,6 @@ -envs-manager>=0.1.2 +envs-manager>=0.1.3 qtawesome qtpy setuptools -spyder>=5.4.2 +# Uncomment when Spyder 6 is available +# spyder>=6 diff --git a/setup.py b/setup.py index 9ea6c58..9997509 100644 --- a/setup.py +++ b/setup.py @@ -17,15 +17,15 @@ version=__version__, author="Spyder Development Team and spyder-env-manager contributors", author_email="spyder.python@gmail.com", - description="Spyder 5+ plugin to manage Python virtual environments and packages", + description="Spyder 6+ plugin to manage Python virtual environments and packages", license="MIT license", url="https://github.com/spyder-ide/spyder-env-manager", - python_requires=">= 3.7", + python_requires=">= 3.8", install_requires=[ - "envs-manager>=0.1.1", + "envs-manager>=0.1.3", "qtpy", "qtawesome", - "spyder>=5.4.0", + "spyder>=6", ], packages=find_packages(), entry_points={ @@ -38,9 +38,10 @@ "Operating System :: Microsoft :: Windows", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Development Status :: 2 - Pre-Alpha", "Intended Audience :: Education", "Intended Audience :: Science/Research", diff --git a/spyder_env_manager/spyder/confpage.py b/spyder_env_manager/spyder/confpage.py index d1af190..ece91c8 100644 --- a/spyder_env_manager/spyder/confpage.py +++ b/spyder_env_manager/spyder/confpage.py @@ -29,7 +29,7 @@ def setup_page(self): conda_like_path_label.setToolTip(_("Path to the conda/micromamba executable")) conda_like_path_label.setWordWrap(True) - conda_like_path = QLabel(self.get_option("conda_file_executable_path")) + conda_like_path = QLabel(self.get_option("conda_file_executable_path", None)) conda_like_path.setTextInteractionFlags(Qt.TextSelectableByMouse) conda_like_path.setWordWrap(True) @@ -42,7 +42,7 @@ def setup_page(self): ) environments_path_label.setWordWrap(True) - environments_path = QLabel(self.get_option("environments_path")) + environments_path = QLabel(self.get_option("environments_path", None)) environments_path.setTextInteractionFlags(Qt.TextSelectableByMouse) environments_path.setWordWrap(True) diff --git a/spyder_env_manager/spyder/plugin.py b/spyder_env_manager/spyder/plugin.py index e1626c6..e7331a4 100644 --- a/spyder_env_manager/spyder/plugin.py +++ b/spyder_env_manager/spyder/plugin.py @@ -70,7 +70,7 @@ def get_name(): return _("Environments Manager") def get_description(self): - return _("Spyder 5+ plugin to manage Python virtual environments and packages") + return _("Spyder 6+ plugin to manage Python virtual environments and packages") def get_icon(self): return qta.icon("mdi.archive", color=ima.MAIN_FG_COLOR) diff --git a/spyder_env_manager/spyder/widgets/helper_widgets.py b/spyder_env_manager/spyder/widgets/helper_widgets.py index 35971d8..599a157 100644 --- a/spyder_env_manager/spyder/widgets/helper_widgets.py +++ b/spyder_env_manager/spyder/widgets/helper_widgets.py @@ -58,7 +58,7 @@ def __init__(self, parent, title, messages, types, contents): super().__init__(parent, Qt.WindowTitleHint | Qt.WindowCloseButtonHint) self.resize(450, 130) - self.setWindowTitle(_(title)) + self.setWindowTitle(title) self.setModal(True) self.lineedits = {} diff --git a/spyder_env_manager/spyder/widgets/main_widget.py b/spyder_env_manager/spyder/widgets/main_widget.py index e06d674..95ac269 100644 --- a/spyder_env_manager/spyder/widgets/main_widget.py +++ b/spyder_env_manager/spyder/widgets/main_widget.py @@ -127,8 +127,8 @@ class SpyderEnvManagerWidget(PluginMainWidget): Path to the environment Python interpreter. """ - def __init__(self, name=None, plugin=None, parent=None): - super().__init__(name, plugin, parent) + def __init__(self, name, plugin, parent=None): + super().__init__(name, plugin, parent=parent) # General attributes self.actions_enabled = True @@ -137,12 +137,11 @@ def __init__(self, name=None, plugin=None, parent=None): self.manager_worker = None # Select environment widget + root_path = self.get_conf("environments_path") envs, _ = Manager.list_environments( backend=CondaLikeInterface.ID, - root_path=self.get_conf("environments_path", DEFAULT_BACKENDS_ROOT_PATH), - external_executable=self.get_conf( - "conda_file_executable_path", conda_like_executable() - ), + root_path=root_path, + external_executable=self.get_conf("conda_file_executable_path"), ) self.select_environment = QComboBox(self) self.select_environment.ID = SpyderEnvManagerWidgetActions.SelectEnvironment @@ -158,12 +157,12 @@ def __init__(self, name=None, plugin=None, parent=None): QComboBox.AdjustToMinimumContentsLength ) self.select_environment.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - selected_environment = self.get_conf("selected_environment", None) + selected_environment = self.get_conf("selected_environment") if selected_environment: self.select_environment.setCurrentText(selected_environment) # Usage widget - self.css_path = self.get_conf("css_path", CSS_PATH, "appearance") + self.css_path = self.get_conf("css_path", str(CSS_PATH), "appearance") self.infowidget = FrameWebView(self) if WEBENGINE: self.infowidget.web_widget.page().setBackgroundColor(QColor(MAIN_BG_COLOR)) @@ -359,8 +358,8 @@ def current_environment_changed(self, index=None): def update_actions(self): if self.actions_enabled: - current_environment = self.select_environment.currentText() - environments_available = current_environment != "No environments available" + current_environment_path = self.select_environment.currentData() + environments_available = current_environment_path is not None actions_ids = [ SpyderEnvManagerWidgetActions.InstallPackage, SpyderEnvManagerWidgetActions.DeleteEnvironment, @@ -520,9 +519,7 @@ def _environment_as_custom_interpreter(self, environment_path=None): environment_path = self.select_environment.currentData() if not environment_path: return - external_executable = self.get_conf( - "conda_file_executable_path", conda_like_executable() - ) + external_executable = self.get_conf("conda_file_executable_path") backend = "conda-like" manager = Manager( backend, @@ -761,9 +758,9 @@ def _run_env_manager_action( *manager_action_args, **manager_action_kwargs, ) + self.manager_worker.moveToThread(self.env_manager_action_thread) self.manager_worker.sig_ready.connect(on_ready) self.manager_worker.sig_ready.connect(self.env_manager_action_thread.quit) - self.manager_worker.moveToThread(self.env_manager_action_thread) self.env_manager_action_thread.started.connect(self.manager_worker.start) self.start_spinner() self.env_manager_action_thread.start() @@ -1038,7 +1035,7 @@ def _message_new_environment(self): contents = [ {"conda-like"}, {}, - ["3.7.15", "3.8.15", "3.9.15", "3.10.8"], + ["3.8.16", "3.9.16", "3.10.9", "3.11.0"], ] self._message_box_editable( title, diff --git a/spyder_env_manager/tests/data/import_env.yml b/spyder_env_manager/tests/data/import_env.yml new file mode 100644 index 0000000..d56abe7 --- /dev/null +++ b/spyder_env_manager/tests/data/import_env.yml @@ -0,0 +1,6 @@ +name: +channels: +- https://conda.anaconda.org/conda-forge +dependencies: +- packaging=21.3 +- python=3.10.5 diff --git a/spyder_env_manager/tests/test_config.py b/spyder_env_manager/tests/test_config.py new file mode 100644 index 0000000..f31df99 --- /dev/null +++ b/spyder_env_manager/tests/test_config.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# ---------------------------------------------------------------------------- +# Copyright © 2022, Spyder Development Team and spyder-env-manager contributors +# +# Licensed under the terms of the MIT license +# ---------------------------------------------------------------------------- +""" +Spyder Env Manager ConfigPage tests. +""" + +# Third-party library imports +import pytest + +# Spyder imports +from spyder.plugins.preferences.widgets.configdialog import ConfigDialog + +# Local imports +from spyder_env_manager.spyder.confpage import SpyderEnvManagerConfigPage +from spyder_env_manager.tests.test_plugin import spyder_env_manager_conf + + +def test_config(spyder_env_manager_conf, qtbot): + """Test that config page can be created and shown.""" + dlg = ConfigDialog() + page = SpyderEnvManagerConfigPage( + spyder_env_manager_conf, parent=spyder_env_manager_conf.main + ) + page.initialize() + dlg.add_page(page) + qtbot.addWidget(dlg) + dlg.show() + # no assert, just check that the config page can be created + + +if __name__ == "__main__": + pytest.main() diff --git a/spyder_env_manager/tests/test_main_widget.py b/spyder_env_manager/tests/test_main_widget.py index 6a91dd7..905759f 100644 --- a/spyder_env_manager/tests/test_main_widget.py +++ b/spyder_env_manager/tests/test_main_widget.py @@ -30,6 +30,6 @@ def get_conf(self, option, default=None, section=None): monkeypatch.setattr(SpyderEnvManagerWidget, "get_conf", get_conf) SpyderEnvManagerWidget.CONF_SECTION = CONF_SECTION - widget = SpyderEnvManagerWidget(None) + widget = SpyderEnvManagerWidget(None, None) widget.setup() widget.show() diff --git a/spyder_env_manager/tests/test_plugin.py b/spyder_env_manager/tests/test_plugin.py index bf77e57..c3f28bd 100644 --- a/spyder_env_manager/tests/test_plugin.py +++ b/spyder_env_manager/tests/test_plugin.py @@ -7,3 +7,261 @@ """ Spyder Env Manager plugin tests. """ +# Standard library imports +import logging +from pathlib import Path +from unittest.mock import Mock + +# Third-party imports +import pytest +from qtpy.QtCore import QTimer +from qtpy.QtWidgets import QMainWindow + +# Spyder imports +from spyder.config.manager import CONF + +# Local imports +from spyder_env_manager.spyder.config import CONF_DEFAULTS +from spyder_env_manager.spyder.plugin import SpyderEnvManager +from spyder_env_manager.spyder.widgets.main_widget import ( + SpyderEnvManagerWidget, + SpyderEnvManagerWidgetActions, +) +from spyder_env_manager.spyder.widgets.helper_widgets import ( + CustomParametersDialog, +) + +# Constants +OPERATION_TIMEOUT = 120000 +IMPORT_FILE_PATH = str(Path(__file__).parent / "data" / "import_env.yml") + + +# ---- Fixtures +# ------------------------------------------------------------------------ +class MainMock(QMainWindow): + def __init__(self): + super().__init__() + self.switcher = Mock() + self.main = self + self.resize(640, 480) + + def get_plugin(self, plugin_name, error=True): + return Mock() + + +@pytest.fixture +def spyder_env_manager_conf(tmp_path, qtbot, monkeypatch): + # Mocking mainwindow and get_config + window = MainMock() + backends_root_path = tmp_path / "backends" + backends_root_path.mkdir(parents=True) + + def get_conf(self, option, default=None, section=None): + if option == "environments_path": + return str(backends_root_path) + else: + try: + _, config_default_values = CONF_DEFAULTS[0] + return config_default_values[option] + except KeyError: + return None + + monkeypatch.setattr(SpyderEnvManagerWidget, "get_conf", get_conf) + + # Setup plugin + plugin = SpyderEnvManager(parent=window, configuration=CONF) + window.setCentralWidget(plugin.get_widget()) + window.show() + + yield plugin + + # Wait for pending operations and close + qtbot.waitUntil( + lambda: plugin.get_widget().actions_enabled, + timeout=OPERATION_TIMEOUT, + ) + plugin.get_widget().close() + window.close() + + +@pytest.fixture +def spyder_env_manager(tmp_path, qtbot, monkeypatch): + # Mocking mainwindow, get_config and CONF + window = MainMock() + backends_root_path = tmp_path / "backends" + backends_root_path.mkdir(parents=True) + + def get_conf(self, option, default=None, section=None): + if option == "environments_path": + return str(backends_root_path) + else: + try: + _, config_default_values = CONF_DEFAULTS[0] + return config_default_values[option] + except KeyError: + return None + + monkeypatch.setattr(SpyderEnvManagerWidget, "get_conf", get_conf) + + # Setup plugin + plugin = SpyderEnvManager(parent=window, configuration=Mock()) + window.setCentralWidget(plugin.get_widget()) + window.show() + + yield plugin + + # Wait for pending operations and close + qtbot.waitUntil( + lambda: plugin.get_widget().actions_enabled, + timeout=OPERATION_TIMEOUT, + ) + plugin.get_widget().close() + window.close() + + +# ---- Tests +# ------------------------------------------------------------------------ +def test_plugin_initial_state(spyder_env_manager): + """ + Check plugin initialization and that actions and widgets have the + correct state when initialized. + """ + widget = spyder_env_manager.get_widget() + + # Check for widgets initialization + assert widget.select_environment.currentData() is None + assert widget.select_environment.isEnabled() + assert widget.stack_layout.currentWidget() == widget.infowidget + + # Check widget actions + disabled_actions_ids = [ + SpyderEnvManagerWidgetActions.InstallPackage, + SpyderEnvManagerWidgetActions.DeleteEnvironment, + SpyderEnvManagerWidgetActions.ExportEnvironment, + SpyderEnvManagerWidgetActions.ToggleExcludeDependency, + SpyderEnvManagerWidgetActions.ToggleEnvironmentAsCustomInterpreter, + ] + for action_id, action in widget.get_actions().items(): + if action_id in disabled_actions_ids: + assert not action.isEnabled() + else: + assert action.isEnabled() + + +def test_environment_creation_and_deletion(spyder_env_manager, qtbot, caplog): + """Test creating and deleting an environment.""" + caplog.set_level(logging.DEBUG) + widget = spyder_env_manager.get_widget() + + # Create environment + def handle_environment_creation_dialog(): + dialog = widget.findChild(CustomParametersDialog) + dialog.lineedit_string.setText("test_env") + dialog.combobox_edit.setCurrentText("3.8.16") + dialog.accept() + + QTimer.singleShot(100, handle_environment_creation_dialog) + widget._message_new_environment() + + qtbot.waitUntil( + lambda: widget.stack_layout.currentWidget() == widget.packages_table, + timeout=OPERATION_TIMEOUT, + ) + assert widget.select_environment.currentText() == "test_env" + qtbot.waitUntil( + lambda: widget.packages_table.source_model.rowCount() == 2, + timeout=OPERATION_TIMEOUT, + ) + + # Delete environment + widget._run_action_for_env( + dialog=None, action=SpyderEnvManagerWidgetActions.DeleteEnvironment + ) + + qtbot.waitUntil( + lambda: widget.stack_layout.currentWidget() == widget.infowidget, + timeout=OPERATION_TIMEOUT, + ) + assert widget.select_environment.currentData() is None + + +def test_environment_import(spyder_env_manager, qtbot, caplog): + """Test importing an environment from a file.""" + caplog.set_level(logging.DEBUG) + widget = spyder_env_manager.get_widget() + + def handle_environment_import_dialog(): + dialog = widget.findChild(CustomParametersDialog) + dialog.lineedit_string.setText("test_env_import") + dialog.file_combobox.combobox.lineEdit().setText(IMPORT_FILE_PATH) + dialog.accept() + + QTimer.singleShot(100, handle_environment_import_dialog) + widget._message_import_environment() + + qtbot.waitUntil( + lambda: widget.stack_layout.currentWidget() == widget.packages_table, + timeout=OPERATION_TIMEOUT, + ) + assert widget.select_environment.currentText() == "test_env_import" + qtbot.waitUntil( + lambda: widget.packages_table.source_model.rowCount() == 3, + timeout=OPERATION_TIMEOUT, + ) + + +def test_environment_package_installation(spyder_env_manager, qtbot, caplog): + """Test creating an environment and installing a package on it.""" + caplog.set_level(logging.DEBUG) + widget = spyder_env_manager.get_widget() + + # Create environment + create_dialog = Mock() + create_dialog.combobox = combobox_mock = Mock() + combobox_mock.currentText = Mock(return_value="conda-like") + create_dialog.lineedit_string = lineedit_string_mock = Mock() + lineedit_string_mock.text = Mock(return_value="test_env") + create_dialog.combobox_edit = combobox_edit_mock = Mock() + combobox_edit_mock.currentText = Mock(return_value="3.9.16") + + assert create_dialog.combobox.currentText() == "conda-like" + assert create_dialog.lineedit_string.text() == "test_env" + assert create_dialog.combobox_edit.currentText() == "3.9.16" + + widget._run_action_for_env( + dialog=create_dialog, action=SpyderEnvManagerWidgetActions.NewEnvironment + ) + + qtbot.waitUntil( + lambda: widget.stack_layout.currentWidget() == widget.packages_table, + timeout=OPERATION_TIMEOUT, + ) + assert widget.select_environment.currentText() == "test_env" + qtbot.waitUntil( + lambda: widget.packages_table.source_model.rowCount() == 2, + timeout=OPERATION_TIMEOUT, + ) + + # Install package in environment + install_dialog = Mock() + install_dialog.lineedit_string = lineedit_string_mock = Mock() + lineedit_string_mock.text = Mock(return_value="packaging") + install_dialog.combobox = combobox_mock = Mock() + combobox_mock.currentText = Mock(return_value="==") + install_dialog.lineedit_version = lineedit_version_mock = Mock() + lineedit_version_mock.text = Mock(return_value="22.0") + + assert install_dialog.lineedit_string.text() == "packaging" + assert install_dialog.combobox.currentText() == "==" + assert install_dialog.lineedit_version.text() == "22.0" + + widget._run_action_for_env( + dialog=install_dialog, action=SpyderEnvManagerWidgetActions.InstallPackage + ) + + qtbot.waitUntil( + lambda: widget.packages_table.source_model.rowCount() == 3, + timeout=OPERATION_TIMEOUT, + ) + assert widget.packages_table.get_package_info(0)["name"] == "packaging" + assert widget.packages_table.get_package_info(0)["version"] == "22.0"