diff --git a/README.md b/README.md index a2b72ad..4bd61aa 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,19 @@ You can cancel the process at any time by clicking the `Cancel` button of each p ![Screenshot of the napari-plugin-manager showing the process of updating a plugin](https://raw.githubusercontent.com/napari/napari-plugin-manager/main/images/update.png) +### Export/Import plugins + +You can export the list of install plugins by clicking on the `Export` button located on the top right +corner of the UI. This will prompt a dialog to select the location and name of the text file where +the installed plugin list will be saved. + +This file can be shared and then imported by clicking on the `Import` button located on the top right +corner of the UI. This will prompt a dialog to select the location of the text file to import. + +After selecting the file, the plugin will dialog will attempt to install all the listed plugins. + +![Screenshot of the napari-plugin-manager showing the process of import/export](https://raw.githubusercontent.com/napari/napari-plugin-manager/main/images/import-export.png) + ### Batch actions You don't need wait for one action to finish before you can start another one. You can add more diff --git a/images/import-export.png b/images/import-export.png new file mode 100644 index 0000000..059f7fc Binary files /dev/null and b/images/import-export.png differ diff --git a/napari_plugin_manager/_tests/test_npe2api.py b/napari_plugin_manager/_tests/test_npe2api.py index a981f44..0ae3e4f 100644 --- a/napari_plugin_manager/_tests/test_npe2api.py +++ b/napari_plugin_manager/_tests/test_npe2api.py @@ -1,3 +1,5 @@ +from urllib.error import HTTPError, URLError + from flaky import flaky from napari_plugin_manager.npe2api import ( @@ -26,20 +28,26 @@ def test_plugin_summaries(): "pypi_versions", "conda_versions", ] - data = plugin_summaries() - test_data = dict(data[0]) - for key in keys: - assert key in test_data - test_data.pop(key) + try: + data = plugin_summaries() + test_data = dict(data[0]) + for key in keys: + assert key in test_data + test_data.pop(key) - assert not test_data + assert not test_data + except (HTTPError, URLError): + pass def test_conda_map(): pkgs = ["napari-svg"] - data = conda_map() - for pkg in pkgs: - assert pkg in data + try: + data = conda_map() + for pkg in pkgs: + assert pkg in data + except (HTTPError, URLError): + pass def test_iter_napari_plugin_info(): diff --git a/napari_plugin_manager/_tests/test_qt_plugin_dialog.py b/napari_plugin_manager/_tests/test_qt_plugin_dialog.py index cb5623a..0fcae42 100644 --- a/napari_plugin_manager/_tests/test_qt_plugin_dialog.py +++ b/napari_plugin_manager/_tests/test_qt_plugin_dialog.py @@ -10,9 +10,12 @@ import qtpy from napari.plugins._tests.test_npe2 import mock_pm # noqa from napari.utils.translations import trans -from qtpy.QtCore import QMimeData, QPointF, Qt, QUrl +from qtpy.QtCore import QMimeData, QPointF, Qt, QTimer, QUrl from qtpy.QtGui import QDropEvent -from qtpy.QtWidgets import QMessageBox +from qtpy.QtWidgets import ( + QApplication, + QMessageBox, +) if qtpy.API_NAME == 'PySide2' and sys.version_info[:2] > (3, 10): pytest.skip( @@ -605,3 +608,46 @@ def test_shortcut_quit(plugin_dialog, qtbot): ) qtbot.wait(500) assert not plugin_dialog.isVisible() + + +@pytest.mark.skipif( + not sys.platform.startswith('linux'), reason="Test works only on linux" +) +def test_export_plugins_button(plugin_dialog): + def _timer(): + dialog = QApplication.activeModalWidget() + dialog.reject() + + timer = QTimer() + timer.setSingleShot(True) + timer.timeout.connect(_timer) + timer.start(4_000) + plugin_dialog.export_button.click() + + +def test_export_plugins(plugin_dialog, tmp_path): + plugins_file = 'plugins.txt' + plugin_dialog.export_plugins(str(tmp_path / plugins_file)) + assert (tmp_path / plugins_file).exists() + + +@pytest.mark.skipif( + not sys.platform.startswith('linux'), reason="Test works only on linux" +) +def test_import_plugins_button(plugin_dialog): + def _timer(): + dialog = QApplication.activeModalWidget() + dialog.reject() + + timer = QTimer() + timer.setSingleShot(True) + timer.timeout.connect(_timer) + timer.start(4_000) + plugin_dialog.import_button.click() + + +def test_import_plugins(plugin_dialog, tmp_path, qtbot): + path = tmp_path / 'plugins.txt' + path.write_text('requests\npyzenhub\n') + with qtbot.waitSignal(plugin_dialog.installer.allFinished, timeout=60_000): + plugin_dialog.import_plugins(str(path)) diff --git a/napari_plugin_manager/base_qt_plugin_dialog.py b/napari_plugin_manager/base_qt_plugin_dialog.py index 1ae8e39..22de176 100644 --- a/napari_plugin_manager/base_qt_plugin_dialog.py +++ b/napari_plugin_manager/base_qt_plugin_dialog.py @@ -16,6 +16,7 @@ ) from packaging.version import parse as parse_version +from qtpy.compat import getopenfilename, getsavefilename from qtpy.QtCore import QSize, Qt, QTimer, Signal, Slot from qtpy.QtGui import ( QAction, @@ -1385,6 +1386,16 @@ def _setup_ui(self): self.packages_search.setClearButtonEnabled(True) self.packages_search.textChanged.connect(self.search) + self.import_button = QPushButton(self._trans('Import'), self) + self.import_button.setObjectName("import_button") + self.import_button.setToolTip(self._trans('')) + self.import_button.clicked.connect(self._import_plugins) + + self.export_button = QPushButton(self._trans('Export'), self) + self.export_button.setObjectName("export_button") + self.export_button.setToolTip(self._trans('')) + self.export_button.clicked.connect(self._export_plugins) + self.refresh_button = QPushButton(self._trans('Refresh'), self) self.refresh_button.setObjectName("refresh_button") self.refresh_button.setToolTip( @@ -1398,6 +1409,8 @@ def _setup_ui(self): horizontal_mid_layout = QHBoxLayout() horizontal_mid_layout.addWidget(self.packages_search) horizontal_mid_layout.addStretch() + horizontal_mid_layout.addWidget(self.import_button) + horizontal_mid_layout.addWidget(self.export_button) horizontal_mid_layout.addWidget(self.refresh_button) mid_layout.addLayout(horizontal_mid_layout) mid_layout.addWidget(self.installed_label) @@ -1687,6 +1700,16 @@ def _search_in_available(self, text): def _refresh_and_clear_cache(self): self.refresh(clear_cache=True) + def _import_plugins(self): + fpath, _ = getopenfilename(filters="Text files (*.txt)") + if fpath: + self.import_plugins(fpath) + + def _export_plugins(self): + fpath, _ = getsavefilename(filters="Text files (*.txt)") + if fpath: + self.export_plugins(fpath) + # endregion - Private methods # region - Qt overrides @@ -1813,4 +1836,30 @@ def set_prefix(self, prefix): item = self.installed_list.item(idx) item.widget.prefix = prefix + def export_plugins(self, fpath: str) -> list[str]: + """Export installed plugins to a file.""" + plugins = [] + if self.installed_list.count(): + for idx in range(self.installed_list.count()): + item = self.installed_list.item(idx) + if item: + name = item.widget.name + version = item.widget._version # Make public attr? + plugins.append(f"{name}=={version}\n") + + with open(fpath, 'w') as f: + f.writelines(plugins) + + return plugins + + def import_plugins(self, fpath: str) -> None: + """Install plugins from file.""" + with open(fpath) as f: + plugins = f.read().split('\n') + + print(plugins) + + plugins = [p for p in plugins if p] + self._install_packages(plugins) + # endregion - Public methods