diff --git a/napari_plugin_manager/_tests/test_qt_plugin_dialog.py b/napari_plugin_manager/_tests/test_qt_plugin_dialog.py index 08071616..3c1a8865 100644 --- a/napari_plugin_manager/_tests/test_qt_plugin_dialog.py +++ b/napari_plugin_manager/_tests/test_qt_plugin_dialog.py @@ -12,6 +12,7 @@ from napari.utils.translations import trans from qtpy.QtCore import QMimeData, QPointF, Qt, QUrl from qtpy.QtGui import QDropEvent +from qtpy.QtWidgets import QMessageBox if qtpy.API_NAME == 'PySide2' and sys.version_info[:2] > (3, 10): pytest.skip( @@ -484,6 +485,35 @@ def test_installs(qtbot, tmp_virtualenv, plugin_dialog, request): qtbot.wait(5000) +@pytest.mark.parametrize( + "message_return", + [QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.Ok], +) +def test_install_pypi_constructor( + qtbot, tmp_virtualenv, plugin_dialog, request, message_return +): + if "no-constructor" in request.node.name: + pytest.skip( + reason="This test is only relevant for constructor-based installs" + ) + + plugin_dialog.set_prefix(str(tmp_virtualenv)) + plugin_dialog.search('requests') + qtbot.wait(500) + item = plugin_dialog.available_list.item(0) + widget = plugin_dialog.available_list.itemWidget(item) + with patch.object(qt_plugin_dialog.QMessageBox, "exec_") as mock: + mock.return_value = message_return + if message_return == QMessageBox.StandardButton.Ok: + with qtbot.waitSignal( + plugin_dialog.installer.processFinished, timeout=60_000 + ): + widget.action_button.click() + else: + widget.action_button.click() + assert mock.called + + def test_cancel(qtbot, tmp_virtualenv, plugin_dialog, request): if "[constructor]" in request.node.name: pytest.skip( diff --git a/napari_plugin_manager/base_qt_plugin_dialog.py b/napari_plugin_manager/base_qt_plugin_dialog.py index 816eab81..7b9b09c8 100644 --- a/napari_plugin_manager/base_qt_plugin_dialog.py +++ b/napari_plugin_manager/base_qt_plugin_dialog.py @@ -600,6 +600,20 @@ def _on_enabled_checkbox(self, state: Qt.CheckState) -> None: """ raise NotImplementedError + def _action_validation(self, tool, action) -> bool: + """ + Validate if the current action should be done or not. + + As an example you could warn that a package from PyPI is going + to be installed. + + Returns + ------- + This should return a `bool`, `True` if the action should proceed, `False` + otherwise. + """ + raise NotImplementedError + def _cancel_requested(self): version = self.version_choice_dropdown.currentText() tool = self.get_installer_tool() @@ -615,7 +629,10 @@ def _action_requested(self): if self.action_button.objectName() == 'install_button' else InstallerActions.UNINSTALL ) - self.actionRequested.emit(self.item, self.name, action, version, tool) + if self._action_validation(tool, action): + self.actionRequested.emit( + self.item, self.name, action, version, tool + ) def _update_requested(self): version = self.version_choice_dropdown.currentText() diff --git a/napari_plugin_manager/qt_plugin_dialog.py b/napari_plugin_manager/qt_plugin_dialog.py index f1f04371..9b5dd795 100644 --- a/napari_plugin_manager/qt_plugin_dialog.py +++ b/napari_plugin_manager/qt_plugin_dialog.py @@ -19,6 +19,7 @@ from qtpy.QtGui import ( QMovie, ) +from qtpy.QtWidgets import QCheckBox, QMessageBox from napari_plugin_manager.base_qt_plugin_dialog import ( BasePluginListItem, @@ -32,10 +33,13 @@ ) from napari_plugin_manager.qt_package_installer import ( InstallerActions, + InstallerTools, ) +from napari_plugin_manager.utils import is_conda_package # Scaling factor for each list widget item when expanding. STYLES_PATH = Path(__file__).parent / 'styles.qss' +DISMISS_WARN_PYPI_INSTALL_DLG = False def _show_message(widget): @@ -127,6 +131,48 @@ def _on_enabled_checkbox(self, state: int): ) return + def _warn_pypi_install(self): + return running_as_constructor_app() or is_conda_package( + 'napari' + ) # or True + + def _action_validation(self, tool, action): + global DISMISS_WARN_PYPI_INSTALL_DLG + if ( + tool == InstallerTools.PIP + and action == InstallerActions.INSTALL + and self._warn_pypi_install() + and not DISMISS_WARN_PYPI_INSTALL_DLG + ): + warn_msgbox = QMessageBox(self) + warn_msgbox.setWindowTitle( + self._trans('PyPI installation on bundle/conda') + ) + warn_msgbox.setText( + self._trans( + 'Installing from PyPI does not take into account existing installed packages, ' + 'so it can break existing installations. ' + 'If this happens the only solution is to reinstall the bundle/create a new conda environment.\n\n' + 'Are you sure you want to install from PyPI?' + ) + ) + warn_checkbox = QCheckBox( + self._trans( + "Don't show this message again in the current session" + ) + ) + warn_msgbox.setCheckBox(warn_checkbox) + warn_msgbox.setIcon(QMessageBox.Icon.Warning) + warn_msgbox.setStandardButtons( + QMessageBox.StandardButton.Ok + | QMessageBox.StandardButton.Cancel + ) + button_clicked = warn_msgbox.exec_() + DISMISS_WARN_PYPI_INSTALL_DLG = warn_checkbox.isChecked() + if button_clicked != QMessageBox.StandardButton.Ok: + return False + return True + class QPluginList(BaseQPluginList):