napari-plugin-manager 0.1.0a0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
File without changes
@@ -0,0 +1,18 @@
1
+ import pytest
2
+ from qtpy.QtWidgets import QDialog, QInputDialog, QMessageBox
3
+
4
+
5
+ @pytest.fixture(autouse=True)
6
+ def _block_message_box(monkeypatch, request):
7
+ def raise_on_call(*_, **__):
8
+ raise RuntimeError("exec_ call") # pragma: no cover
9
+
10
+ monkeypatch.setattr(QMessageBox, "exec_", raise_on_call)
11
+ monkeypatch.setattr(QMessageBox, "critical", raise_on_call)
12
+ monkeypatch.setattr(QMessageBox, "information", raise_on_call)
13
+ monkeypatch.setattr(QMessageBox, "question", raise_on_call)
14
+ monkeypatch.setattr(QMessageBox, "warning", raise_on_call)
15
+ monkeypatch.setattr(QInputDialog, "getText", raise_on_call)
16
+ # QDialogs can be allowed via a marker; only raise if not decorated
17
+ if "enabledialog" not in request.keywords:
18
+ monkeypatch.setattr(QDialog, "exec_", raise_on_call)
@@ -0,0 +1,226 @@
1
+ import re
2
+ import sys
3
+ import time
4
+ from pathlib import Path
5
+ from types import MethodType
6
+ from typing import TYPE_CHECKING
7
+
8
+ import pytest
9
+ from qtpy.QtCore import QProcessEnvironment
10
+
11
+ from napari_plugin_manager.qt_package_installer import (
12
+ AbstractInstallerTool,
13
+ CondaInstallerTool,
14
+ InstallerQueue,
15
+ InstallerTools,
16
+ PipInstallerTool,
17
+ )
18
+
19
+ if TYPE_CHECKING:
20
+ from virtualenv.run import Session
21
+
22
+
23
+ @pytest.fixture
24
+ def tmp_virtualenv(tmp_path) -> 'Session':
25
+ virtualenv = pytest.importorskip('virtualenv')
26
+
27
+ cmd = [str(tmp_path), '--no-setuptools', '--no-wheel', '--activators', '']
28
+ return virtualenv.cli_run(cmd)
29
+
30
+
31
+ @pytest.fixture
32
+ def tmp_conda_env(tmp_path):
33
+ import subprocess
34
+
35
+ try:
36
+ subprocess.check_output(
37
+ [
38
+ CondaInstallerTool.executable(),
39
+ 'create',
40
+ '-yq',
41
+ '-p',
42
+ str(tmp_path),
43
+ '--override-channels',
44
+ '-c',
45
+ 'conda-forge',
46
+ f'python={sys.version_info.major}.{sys.version_info.minor}',
47
+ ],
48
+ stderr=subprocess.STDOUT,
49
+ text=True,
50
+ timeout=300,
51
+ )
52
+ except subprocess.CalledProcessError as exc:
53
+ print(exc.output)
54
+ raise
55
+
56
+ return tmp_path
57
+
58
+
59
+ def test_pip_installer_tasks(qtbot, tmp_virtualenv: 'Session', monkeypatch):
60
+ installer = InstallerQueue()
61
+ monkeypatch.setattr(
62
+ PipInstallerTool, "executable", lambda *a: tmp_virtualenv.creator.exe
63
+ )
64
+ with qtbot.waitSignal(installer.allFinished, timeout=20000):
65
+ installer.install(
66
+ tool=InstallerTools.PIP,
67
+ pkgs=['pip-install-test'],
68
+ )
69
+ installer.install(
70
+ tool=InstallerTools.PIP,
71
+ pkgs=['typing-extensions'],
72
+ )
73
+ job_id = installer.install(
74
+ tool=InstallerTools.PIP,
75
+ pkgs=['requests'],
76
+ )
77
+ assert isinstance(job_id, int)
78
+ installer.cancel(job_id)
79
+
80
+ assert not installer.hasJobs()
81
+
82
+ pkgs = 0
83
+ for pth in tmp_virtualenv.creator.libs:
84
+ if (pth / 'pip_install_test').exists():
85
+ pkgs += 1
86
+ if (pth / 'typing_extensions.py').exists():
87
+ pkgs += 1
88
+ if (pth / 'requests').exists():
89
+ raise AssertionError('requests got installed')
90
+
91
+ assert pkgs >= 2, 'package was not installed'
92
+
93
+ with qtbot.waitSignal(installer.allFinished, timeout=10000):
94
+ job_id = installer.uninstall(
95
+ tool=InstallerTools.PIP,
96
+ pkgs=['pip-install-test'],
97
+ )
98
+
99
+ for pth in tmp_virtualenv.creator.libs:
100
+ assert not (
101
+ pth / 'pip_install_test'
102
+ ).exists(), 'pip_install_test still installed'
103
+
104
+ assert not installer.hasJobs()
105
+
106
+
107
+ def _assert_exit_code_not_zero(
108
+ self, exit_code=None, exit_status=None, error=None
109
+ ):
110
+ errors = []
111
+ if exit_code == 0:
112
+ errors.append("- 'exit_code' should have been non-zero!")
113
+ if error is not None:
114
+ errors.append("- 'error' should have been None!")
115
+ if errors:
116
+ raise AssertionError("\n".join(errors))
117
+ return self._on_process_done_original(exit_code, exit_status, error)
118
+
119
+
120
+ class _NonExistingTool(AbstractInstallerTool):
121
+ def executable(self):
122
+ return f"this-tool-does-not-exist-{hash(time.time())}"
123
+
124
+ def arguments(self):
125
+ return ()
126
+
127
+ def environment(self, env=None):
128
+ return QProcessEnvironment.systemEnvironment()
129
+
130
+
131
+ def _assert_error_used(self, exit_code=None, exit_status=None, error=None):
132
+ errors = []
133
+ if error is None:
134
+ errors.append("- 'error' should have been populated!")
135
+ if exit_code is not None:
136
+ errors.append("- 'exit_code' should not have been populated!")
137
+ if errors:
138
+ raise AssertionError("\n".join(errors))
139
+ return self._on_process_done_original(exit_code, exit_status, error)
140
+
141
+
142
+ def test_installer_failures(qtbot, tmp_virtualenv: 'Session', monkeypatch):
143
+ installer = InstallerQueue()
144
+ monkeypatch.setattr(
145
+ PipInstallerTool, "executable", lambda *a: tmp_virtualenv.creator.exe
146
+ )
147
+
148
+ # CHECK 1) Errors should trigger finished and allFinished too
149
+ with qtbot.waitSignal(installer.allFinished, timeout=10000):
150
+ installer.install(
151
+ tool=InstallerTools.PIP,
152
+ pkgs=[f'this-package-does-not-exist-{hash(time.time())}'],
153
+ )
154
+
155
+ # Keep a reference before we monkey patch stuff
156
+ installer._on_process_done_original = installer._on_process_done
157
+
158
+ # CHECK 2) Non-existing packages should return non-zero
159
+ monkeypatch.setattr(
160
+ installer,
161
+ "_on_process_done",
162
+ MethodType(_assert_exit_code_not_zero, installer),
163
+ )
164
+ with qtbot.waitSignal(installer.allFinished, timeout=10000):
165
+ installer.install(
166
+ tool=InstallerTools.PIP,
167
+ pkgs=[f'this-package-does-not-exist-{hash(time.time())}'],
168
+ )
169
+
170
+ # CHECK 3) Non-existing tools should fail to start
171
+ monkeypatch.setattr(
172
+ installer,
173
+ "_on_process_done",
174
+ MethodType(_assert_error_used, installer),
175
+ )
176
+ monkeypatch.setattr(installer, "_get_tool", lambda *a: _NonExistingTool)
177
+ with qtbot.waitSignal(installer.allFinished, timeout=10000):
178
+ installer.install(
179
+ tool=_NonExistingTool,
180
+ pkgs=[f'this-package-does-not-exist-{hash(time.time())}'],
181
+ )
182
+
183
+
184
+ @pytest.mark.skipif(
185
+ not CondaInstallerTool.available(), reason="Conda is not available."
186
+ )
187
+ def test_conda_installer(qtbot, tmp_conda_env: Path):
188
+ installer = InstallerQueue()
189
+
190
+ with qtbot.waitSignal(installer.allFinished, timeout=600_000):
191
+ installer.install(
192
+ tool=InstallerTools.CONDA,
193
+ pkgs=['typing-extensions'],
194
+ prefix=tmp_conda_env,
195
+ )
196
+
197
+ conda_meta = tmp_conda_env / "conda-meta"
198
+ glob_pat = "typing-extensions-*.json"
199
+
200
+ assert not installer.hasJobs()
201
+ assert list(conda_meta.glob(glob_pat))
202
+
203
+ with qtbot.waitSignal(installer.allFinished, timeout=600_000):
204
+ installer.uninstall(
205
+ tool=InstallerTools.CONDA,
206
+ pkgs=['typing-extensions'],
207
+ prefix=tmp_conda_env,
208
+ )
209
+
210
+ assert not installer.hasJobs()
211
+ assert not list(conda_meta.glob(glob_pat))
212
+
213
+
214
+ def test_constraints_are_in_sync():
215
+ conda_constraints = sorted(CondaInstallerTool.constraints())
216
+ pip_constraints = sorted(PipInstallerTool.constraints())
217
+
218
+ assert len(conda_constraints) == len(pip_constraints)
219
+
220
+ name_re = re.compile(r"([a-z0-9_\-]+).*")
221
+ for conda_constraint, pip_constraint in zip(
222
+ conda_constraints, pip_constraints
223
+ ):
224
+ conda_name = name_re.match(conda_constraint).group(1)
225
+ pip_name = name_re.match(pip_constraint).group(1)
226
+ assert conda_name == pip_name
@@ -0,0 +1,374 @@
1
+ import importlib.metadata
2
+ import sys
3
+ from typing import Generator, Optional, Tuple
4
+ from unittest.mock import patch
5
+
6
+ import napari.plugins
7
+ import npe2
8
+ import pytest
9
+ import qtpy
10
+ from napari.plugins._tests.test_npe2 import mock_pm # noqa
11
+ from napari.utils.translations import trans
12
+
13
+ from napari_plugin_manager import qt_plugin_dialog
14
+ from napari_plugin_manager.qt_package_installer import InstallerActions
15
+
16
+ if qtpy.API_NAME == 'PySide2' and sys.version_info[:2] == (3, 11):
17
+ pytest.skip(
18
+ "Known PySide2 x Python 3.11 incompatibility: "
19
+ "TypeError: 'PySide2.QtCore.Qt.Alignment' object cannot be interpreted as an integer",
20
+ allow_module_level=True,
21
+ )
22
+
23
+ N_MOCKED_PLUGINS = 2
24
+
25
+
26
+ def _iter_napari_pypi_plugin_info(
27
+ conda_forge: bool = True,
28
+ ) -> Generator[
29
+ Tuple[Optional[npe2.PackageMetadata], bool], None, None
30
+ ]: # pragma: no cover (this function is used in thread and codecov has a problem with the collection of coverage in such cases)
31
+ """Mock the pypi method to collect available plugins.
32
+
33
+ This will mock napari.plugins.pypi.iter_napari_plugin_info` for pypi.
34
+
35
+ It will return two fake plugins that will populate the available plugins
36
+ list (the bottom one). The first plugin will not be available on
37
+ conda-forge so will be greyed out ("test-name-0"). The second plugin will
38
+ be available on conda-forge so will be enabled ("test-name-1").
39
+ """
40
+ # This mock `base_data`` will be the same for both fake plugins.
41
+ base_data = {
42
+ "metadata_version": "1.0",
43
+ "version": "0.1.0",
44
+ "summary": "some test package",
45
+ "home_page": "http://napari.org",
46
+ "author": "test author",
47
+ "license": "UNKNOWN",
48
+ }
49
+ for i in range(N_MOCKED_PLUGINS):
50
+ yield npe2.PackageMetadata(name=f"test-name-{i}", **base_data), bool(
51
+ i
52
+ ), {
53
+ "home_page": 'www.mywebsite.com',
54
+ "pypi_versions": ['3'],
55
+ "conda_versions": ['4.5'],
56
+ }
57
+
58
+
59
+ class PluginsMock:
60
+ def __init__(self):
61
+ self.plugins = {
62
+ 'test-name-0': True,
63
+ 'test-name-1': True,
64
+ 'my-plugin': True,
65
+ }
66
+
67
+
68
+ class OldPluginsMock:
69
+ def __init__(self):
70
+ self.plugins = [('test-1', False, 'test-1')]
71
+ self.enabled = [True]
72
+
73
+
74
+ @pytest.fixture
75
+ def old_plugins(qtbot):
76
+ return OldPluginsMock()
77
+
78
+
79
+ @pytest.fixture
80
+ def plugins(qtbot):
81
+ return PluginsMock()
82
+
83
+
84
+ class WarnPopupMock:
85
+ def __init__(self, text):
86
+ self._is_visible = False
87
+
88
+ def exec_(self):
89
+ self._is_visible = True
90
+
91
+ def move(self, pos):
92
+ return False
93
+
94
+ def isVisible(self):
95
+ return self._is_visible
96
+
97
+ def close(self):
98
+ self._is_visible = False
99
+
100
+
101
+ @pytest.fixture(params=[True, False], ids=["constructor", "no-constructor"])
102
+ def plugin_dialog(
103
+ request,
104
+ qtbot,
105
+ monkeypatch,
106
+ mock_pm, # noqa
107
+ plugins,
108
+ old_plugins,
109
+ ):
110
+ """Fixture that provides a plugin dialog for a normal napari install."""
111
+
112
+ class PluginManagerMock:
113
+ def instance(self):
114
+ return PluginManagerInstanceMock(plugins)
115
+
116
+ class PluginManagerInstanceMock:
117
+ def __init__(self, plugins):
118
+ self.plugins = plugins.plugins
119
+
120
+ def __iter__(self):
121
+ yield from self.plugins
122
+
123
+ def iter_manifests(self):
124
+ yield from [mock_pm.get_manifest('my-plugin')]
125
+
126
+ def is_disabled(self, name):
127
+ return False
128
+
129
+ def discover(self):
130
+ return ['plugin']
131
+
132
+ def enable(self, plugin):
133
+ self.plugins[plugin] = True
134
+ return
135
+
136
+ def disable(self, plugin):
137
+ self.plugins[plugin] = False
138
+ return
139
+
140
+ def mock_metadata(name):
141
+ meta = {
142
+ 'version': '0.1.0',
143
+ 'summary': '',
144
+ 'Home-page': '',
145
+ 'author': '',
146
+ 'license': '',
147
+ }
148
+ return meta
149
+
150
+ class OldPluginManagerMock:
151
+ def __init__(self):
152
+ self.plugins = old_plugins.plugins
153
+ self.enabled = old_plugins.enabled
154
+
155
+ def iter_available(self):
156
+ return self.plugins
157
+
158
+ def discover(self):
159
+ return None
160
+
161
+ def is_blocked(self, plugin):
162
+ return self.plugins[0][1]
163
+
164
+ def set_blocked(self, plugin, blocked):
165
+ self.enabled[0] = not blocked
166
+ return
167
+
168
+ monkeypatch.setattr(
169
+ qt_plugin_dialog,
170
+ "iter_napari_plugin_info",
171
+ _iter_napari_pypi_plugin_info,
172
+ )
173
+ monkeypatch.setattr(qt_plugin_dialog, 'WarnPopup', WarnPopupMock)
174
+
175
+ # This is patching `napari.utils.misc.running_as_constructor_app` function
176
+ # to mock a normal napari install.
177
+ monkeypatch.setattr(
178
+ qt_plugin_dialog, "running_as_constructor_app", lambda: request.param
179
+ )
180
+
181
+ monkeypatch.setattr(
182
+ napari.plugins, 'plugin_manager', OldPluginManagerMock()
183
+ )
184
+
185
+ monkeypatch.setattr(importlib.metadata, 'metadata', mock_metadata)
186
+
187
+ monkeypatch.setattr(npe2, 'PluginManager', PluginManagerMock())
188
+
189
+ widget = qt_plugin_dialog.QtPluginDialog()
190
+ widget.show()
191
+ qtbot.waitUntil(widget.isVisible, timeout=300)
192
+
193
+ def available_list_populated():
194
+ return widget.available_list.count() == N_MOCKED_PLUGINS
195
+
196
+ qtbot.waitUntil(available_list_populated, timeout=3000)
197
+ qtbot.add_widget(widget)
198
+ yield widget
199
+ widget.hide()
200
+ widget._add_items_timer.stop()
201
+ assert not widget._add_items_timer.isActive()
202
+
203
+
204
+ def test_filter_not_available_plugins(plugin_dialog):
205
+ """
206
+ Check that the plugins listed under available plugins are
207
+ enabled and disabled accordingly.
208
+
209
+ The first plugin ("test-name-0") is not available on conda-forge and
210
+ should be disabled, and show a tooltip warning.
211
+
212
+ The second plugin ("test-name-1") is available on conda-forge and
213
+ should be enabled without the tooltip warning.
214
+ """
215
+ item = plugin_dialog.available_list.item(0)
216
+ widget = plugin_dialog.available_list.itemWidget(item)
217
+ if widget:
218
+ assert not widget.action_button.isEnabled()
219
+ assert widget.warning_tooltip.isVisible()
220
+
221
+ item = plugin_dialog.available_list.item(1)
222
+ widget = plugin_dialog.available_list.itemWidget(item)
223
+ assert widget.action_button.isEnabled()
224
+ assert not widget.warning_tooltip.isVisible()
225
+
226
+
227
+ def test_filter_available_plugins(plugin_dialog):
228
+ """
229
+ Test the dialog is correctly filtering plugins in the available plugins
230
+ list (the bottom one).
231
+ """
232
+ plugin_dialog.filter("")
233
+ assert plugin_dialog.available_list.count() == 2
234
+ assert plugin_dialog.available_list._count_visible() == 2
235
+
236
+ plugin_dialog.filter("no-match@123")
237
+ assert plugin_dialog.available_list._count_visible() == 0
238
+
239
+ plugin_dialog.filter("")
240
+ plugin_dialog.filter("test-name-0")
241
+ assert plugin_dialog.available_list._count_visible() == 1
242
+
243
+
244
+ def test_filter_installed_plugins(plugin_dialog):
245
+ """
246
+ Test the dialog is correctly filtering plugins in the installed plugins
247
+ list (the top one).
248
+ """
249
+ plugin_dialog.filter("")
250
+ assert plugin_dialog.installed_list._count_visible() >= 0
251
+
252
+ plugin_dialog.filter("no-match@123")
253
+ assert plugin_dialog.installed_list._count_visible() == 0
254
+
255
+
256
+ def test_visible_widgets(request, plugin_dialog):
257
+ """
258
+ Test that the direct entry button and textbox are visible
259
+ """
260
+ if "no-constructor" not in request.node.name:
261
+ # the plugin_dialog fixture has this id
262
+ # skip for 'constructor' variant
263
+ pytest.skip()
264
+ assert plugin_dialog.direct_entry_edit.isVisible()
265
+ assert plugin_dialog.direct_entry_btn.isVisible()
266
+
267
+
268
+ def test_version_dropdown(qtbot, plugin_dialog):
269
+ """
270
+ Test that when the source drop down is changed, it displays the other versions properly.
271
+ """
272
+ widget = plugin_dialog.available_list.item(1).widget
273
+ assert widget.version_choice_dropdown.currentText() == "3"
274
+ # switch from PyPI source to conda one.
275
+ widget.source_choice_dropdown.setCurrentIndex(1)
276
+ assert widget.version_choice_dropdown.currentText() == "4.5"
277
+
278
+
279
+ def test_plugin_list_count_items(plugin_dialog):
280
+ assert plugin_dialog.installed_list._count_visible() == 2
281
+
282
+
283
+ def test_plugin_list_handle_action(plugin_dialog, qtbot):
284
+ item = plugin_dialog.installed_list.item(0)
285
+ with patch.object(qt_plugin_dialog.PluginListItem, "set_busy") as mock:
286
+ plugin_dialog.installed_list.handle_action(
287
+ item,
288
+ 'test-name-1',
289
+ InstallerActions.UPGRADE,
290
+ )
291
+ mock.assert_called_with(
292
+ trans._("updating..."), InstallerActions.UPGRADE
293
+ )
294
+
295
+ with patch.object(qt_plugin_dialog.WarnPopup, "exec_") as mock:
296
+ plugin_dialog.installed_list.handle_action(
297
+ item,
298
+ 'test-name-1',
299
+ InstallerActions.UNINSTALL,
300
+ )
301
+ assert mock.called
302
+
303
+ item = plugin_dialog.available_list.item(0)
304
+ with patch.object(qt_plugin_dialog.PluginListItem, "set_busy") as mock:
305
+ plugin_dialog.available_list.handle_action(
306
+ item,
307
+ 'test-name-1',
308
+ InstallerActions.INSTALL,
309
+ version='3',
310
+ )
311
+ mock.assert_called_with(
312
+ trans._("installing..."), InstallerActions.INSTALL
313
+ )
314
+
315
+ plugin_dialog.available_list.handle_action(
316
+ item, 'test-name-1', InstallerActions.CANCEL, version='3'
317
+ )
318
+ mock.assert_called_with(
319
+ trans._("cancelling..."), InstallerActions.CANCEL
320
+ )
321
+
322
+ # Wait for refresh timer, state and worker to be done
323
+ qtbot.waitUntil(
324
+ lambda: not plugin_dialog._add_items_timer.isActive()
325
+ and plugin_dialog.refresh_state == qt_plugin_dialog.RefreshState.DONE
326
+ )
327
+ qtbot.waitUntil(lambda: not plugin_dialog.worker.is_running)
328
+
329
+
330
+ def test_on_enabled_checkbox(plugin_dialog, qtbot, plugins, old_plugins):
331
+ # checks npe2 lines
332
+ item = plugin_dialog.installed_list.item(0)
333
+ widget = plugin_dialog.installed_list.itemWidget(item)
334
+
335
+ assert plugins.plugins['my-plugin'] is True
336
+ with qtbot.waitSignal(widget.enabled_checkbox.stateChanged, timeout=500):
337
+ widget.enabled_checkbox.setChecked(False)
338
+ assert plugins.plugins['my-plugin'] is False
339
+
340
+ # checks npe1 lines
341
+ item = plugin_dialog.installed_list.item(1)
342
+ widget = plugin_dialog.installed_list.itemWidget(item)
343
+
344
+ assert old_plugins.enabled[0] is True
345
+ with qtbot.waitSignal(widget.enabled_checkbox.stateChanged, timeout=500):
346
+ widget.enabled_checkbox.setChecked(False)
347
+ assert old_plugins.enabled[0] is False
348
+
349
+
350
+ def test_add_items_outdated(plugin_dialog):
351
+ """Test that a plugin is tagged as outdated (a newer version is available), the update button becomes visible."""
352
+
353
+ # The plugin is being added to the available plugins list. When the dialog is being built
354
+ # this one will be listed as available, and it will be found as already installed.
355
+ # Then, it will check if the installed version is a lower version than the one available.
356
+ # In this case, my-plugin is installed with version 0.1.0, so the one we are trying to install
357
+ # is newer, so the update button should pop up.
358
+ new_plugin = (
359
+ npe2.PackageMetadata(name="my-plugin", version="0.4.0"),
360
+ True,
361
+ {
362
+ "home_page": 'www.mywebsite.com',
363
+ "pypi_versions": ['0.4.0'],
364
+ "conda_versions": ['0.4.0'],
365
+ },
366
+ )
367
+
368
+ plugin_dialog._plugin_data = [new_plugin]
369
+
370
+ plugin_dialog._add_items()
371
+ item = plugin_dialog.installed_list.item(0)
372
+ widget = plugin_dialog.installed_list.itemWidget(item)
373
+
374
+ assert widget.update_btn.isVisible()
@@ -0,0 +1,4 @@
1
+ # file generated by setuptools_scm
2
+ # don't change, don't track in version control
3
+ __version__ = version = '0.1.0a0'
4
+ __version_tuple__ = version_tuple = (0, 1, 0)