napari-plugin-manager 0.1.0__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.
@@ -0,0 +1,563 @@
1
+ import importlib.metadata
2
+ import os
3
+ import platform
4
+ import sys
5
+ from typing import Generator, Optional, Tuple
6
+ from unittest.mock import patch
7
+
8
+ import napari.plugins
9
+ import npe2
10
+ import pytest
11
+ import qtpy
12
+ from napari.plugins._tests.test_npe2 import mock_pm # noqa
13
+ from napari.utils.translations import trans
14
+ from qtpy.QtCore import QMimeData, QPointF, Qt, QUrl
15
+ from qtpy.QtGui import QDropEvent
16
+
17
+ if (qtpy.API_NAME == 'PySide2') or (
18
+ sys.version_info[:2] > (3, 10) and platform.system() == "Linux"
19
+ ):
20
+ pytest.skip(
21
+ "Known PySide2 x Python incompatibility: "
22
+ "... object cannot be interpreted as an integer",
23
+ allow_module_level=True,
24
+ )
25
+
26
+ from napari_plugin_manager import qt_plugin_dialog
27
+ from napari_plugin_manager.qt_package_installer import InstallerActions
28
+
29
+ N_MOCKED_PLUGINS = 2
30
+
31
+
32
+ def _iter_napari_pypi_plugin_info(
33
+ conda_forge: bool = True,
34
+ ) -> Generator[
35
+ Tuple[Optional[npe2.PackageMetadata], bool], None, None
36
+ ]: # pragma: no cover (this function is used in thread and codecov has a problem with the collection of coverage in such cases)
37
+ """Mock the pypi method to collect available plugins.
38
+
39
+ This will mock napari.plugins.pypi.iter_napari_plugin_info` for pypi.
40
+
41
+ It will return two fake plugins that will populate the available plugins
42
+ list (the bottom one).
43
+ """
44
+ # This mock `base_data`` will be the same for both fake plugins.
45
+ packages = ['pyzenhub', 'requests', 'my-plugin', 'my-test-old-plugin-1']
46
+ base_data = {
47
+ "metadata_version": "1.0",
48
+ "version": "0.1.0",
49
+ "summary": "some test package",
50
+ "home_page": "http://napari.org",
51
+ "author": "test author",
52
+ "license": "UNKNOWN",
53
+ }
54
+ for i in range(len(packages)):
55
+ yield npe2.PackageMetadata(name=f"{packages[i]}", **base_data), bool(
56
+ i
57
+ ), {
58
+ "home_page": 'www.mywebsite.com',
59
+ "pypi_versions": ['2.31.0'],
60
+ "conda_versions": ['2.32.1'],
61
+ 'display_name': packages[i].upper(),
62
+ }
63
+
64
+
65
+ class PluginsMock:
66
+ def __init__(self):
67
+ self.plugins = {
68
+ 'requests': True,
69
+ 'pyzenhub': True,
70
+ 'my-plugin': True,
71
+ }
72
+
73
+
74
+ class OldPluginsMock:
75
+ def __init__(self):
76
+ self.plugins = [
77
+ ('my-test-old-plugin-1', False, 'my-test-old-plugin-1')
78
+ ]
79
+ self.enabled = [True]
80
+
81
+
82
+ @pytest.fixture
83
+ def old_plugins(qtbot):
84
+ return OldPluginsMock()
85
+
86
+
87
+ @pytest.fixture
88
+ def plugins(qtbot):
89
+ return PluginsMock()
90
+
91
+
92
+ class WarnPopupMock:
93
+ def __init__(self, text):
94
+ self._is_visible = False
95
+
96
+ def show(self):
97
+ self._is_visible = True
98
+
99
+ def exec_(self):
100
+ self._is_visible = True
101
+
102
+ def move(self, pos):
103
+ return False
104
+
105
+ def isVisible(self):
106
+ return self._is_visible
107
+
108
+ def close(self):
109
+ self._is_visible = False
110
+
111
+ def width(self):
112
+ return 100
113
+
114
+
115
+ @pytest.fixture(params=[True, False], ids=["constructor", "no-constructor"])
116
+ def plugin_dialog(
117
+ request,
118
+ qtbot,
119
+ monkeypatch,
120
+ mock_pm, # noqa
121
+ plugins,
122
+ old_plugins,
123
+ ):
124
+ """Fixture that provides a plugin dialog for a normal napari install."""
125
+
126
+ class PluginManagerMock:
127
+ def instance(self):
128
+ return PluginManagerInstanceMock(plugins)
129
+
130
+ class PluginManagerInstanceMock:
131
+ def __init__(self, plugins):
132
+ self.plugins = plugins.plugins
133
+
134
+ def __iter__(self):
135
+ yield from self.plugins
136
+
137
+ def iter_manifests(self):
138
+ yield from [mock_pm.get_manifest('my-plugin')]
139
+
140
+ def is_disabled(self, name):
141
+ return False
142
+
143
+ def discover(self):
144
+ return ['plugin']
145
+
146
+ def enable(self, plugin):
147
+ self.plugins[plugin] = True
148
+ return
149
+
150
+ def disable(self, plugin):
151
+ self.plugins[plugin] = False
152
+ return
153
+
154
+ def mock_metadata(name):
155
+ meta = {
156
+ 'version': '0.1.0',
157
+ 'summary': '',
158
+ 'Home-page': '',
159
+ 'author': '',
160
+ 'license': '',
161
+ }
162
+ return meta
163
+
164
+ class OldPluginManagerMock:
165
+ def __init__(self):
166
+ self.plugins = old_plugins.plugins
167
+ self.enabled = old_plugins.enabled
168
+
169
+ def iter_available(self):
170
+ return self.plugins
171
+
172
+ def discover(self):
173
+ return None
174
+
175
+ def is_blocked(self, plugin):
176
+ return self.plugins[0][1]
177
+
178
+ def set_blocked(self, plugin, blocked):
179
+ self.enabled[0] = not blocked
180
+ return
181
+
182
+ monkeypatch.setattr(
183
+ qt_plugin_dialog,
184
+ "iter_napari_plugin_info",
185
+ _iter_napari_pypi_plugin_info,
186
+ )
187
+ monkeypatch.setattr(qt_plugin_dialog, 'WarnPopup', WarnPopupMock)
188
+
189
+ # This is patching `napari.utils.misc.running_as_constructor_app` function
190
+ # to mock a normal napari install.
191
+ monkeypatch.setattr(
192
+ qt_plugin_dialog, "running_as_constructor_app", lambda: request.param
193
+ )
194
+ monkeypatch.setattr(
195
+ qt_plugin_dialog, "IS_NAPARI_CONDA_INSTALLED", request.param
196
+ )
197
+ monkeypatch.setattr(qt_plugin_dialog, "ON_BUNDLE", request.param)
198
+ monkeypatch.setattr(
199
+ napari.plugins, 'plugin_manager', OldPluginManagerMock()
200
+ )
201
+
202
+ monkeypatch.setattr(importlib.metadata, 'metadata', mock_metadata)
203
+
204
+ monkeypatch.setattr(npe2, 'PluginManager', PluginManagerMock())
205
+
206
+ widget = qt_plugin_dialog.QtPluginDialog()
207
+ # monkeypatch.setattr(widget, '_tag_outdated_plugins', lambda: None)
208
+ widget.show()
209
+ qtbot.waitUntil(widget.isVisible, timeout=300)
210
+
211
+ def available_list_populated():
212
+ return widget.available_list.count() == N_MOCKED_PLUGINS
213
+
214
+ qtbot.waitUntil(available_list_populated, timeout=3000)
215
+ qtbot.add_widget(widget)
216
+ yield widget
217
+ widget.hide()
218
+ widget._add_items_timer.stop()
219
+ assert not widget._add_items_timer.isActive()
220
+
221
+
222
+ def test_filter_not_available_plugins(request, plugin_dialog):
223
+ """
224
+ Check that the plugins listed under available plugins are
225
+ enabled and disabled accordingly.
226
+ """
227
+ if "no-constructor" in request.node.name:
228
+ pytest.skip(
229
+ reason="This test is only relevant for constructor-based installs"
230
+ )
231
+ item = plugin_dialog.available_list.item(0)
232
+ widget = plugin_dialog.available_list.itemWidget(item)
233
+ if widget:
234
+ assert not widget.action_button.isEnabled()
235
+ assert widget.warning_tooltip.isVisible()
236
+
237
+ item = plugin_dialog.available_list.item(1)
238
+ widget = plugin_dialog.available_list.itemWidget(item)
239
+ assert widget.action_button.isEnabled()
240
+ assert not widget.warning_tooltip.isVisible()
241
+
242
+
243
+ def test_filter_available_plugins(plugin_dialog):
244
+ """
245
+ Test the dialog is correctly filtering plugins in the available plugins
246
+ list (the bottom one).
247
+ """
248
+ plugin_dialog.filter("")
249
+ assert plugin_dialog.available_list.count() == 2
250
+ assert plugin_dialog.available_list.count_visible() == 2
251
+
252
+ plugin_dialog.filter("no-match@123")
253
+ assert plugin_dialog.available_list.count_visible() == 0
254
+
255
+ plugin_dialog.filter("")
256
+ plugin_dialog.filter("requests")
257
+ assert plugin_dialog.available_list.count_visible() == 1
258
+
259
+
260
+ def test_filter_installed_plugins(plugin_dialog):
261
+ """
262
+ Test the dialog is correctly filtering plugins in the installed plugins
263
+ list (the top one).
264
+ """
265
+ plugin_dialog.filter("")
266
+ assert plugin_dialog.installed_list.count_visible() >= 0
267
+
268
+ plugin_dialog.filter("no-match@123")
269
+ assert plugin_dialog.installed_list.count_visible() == 0
270
+
271
+
272
+ def test_visible_widgets(request, plugin_dialog):
273
+ """
274
+ Test that the direct entry button and textbox are visible
275
+ """
276
+ if "no-constructor" not in request.node.name:
277
+ pytest.skip(
278
+ reason="Tested functionality not available in constructor-based installs"
279
+ )
280
+ assert plugin_dialog.direct_entry_edit.isVisible()
281
+ assert plugin_dialog.direct_entry_btn.isVisible()
282
+
283
+
284
+ def test_version_dropdown(plugin_dialog, qtbot):
285
+ """
286
+ Test that when the source drop down is changed, it displays the other versions properly.
287
+ """
288
+ # qtbot.wait(10000)
289
+ widget = plugin_dialog.available_list.item(0).widget
290
+ count = widget.version_choice_dropdown.count()
291
+ if count == 2:
292
+ assert widget.version_choice_dropdown.currentText() == "2.31.0"
293
+ # switch from PyPI source to conda one.
294
+ widget.source_choice_dropdown.setCurrentIndex(1)
295
+ assert widget.version_choice_dropdown.currentText() == "2.32.1"
296
+
297
+
298
+ def test_plugin_list_count_items(plugin_dialog):
299
+ assert plugin_dialog.installed_list.count_visible() == 2
300
+
301
+
302
+ def test_plugin_list_handle_action(plugin_dialog, qtbot):
303
+ item = plugin_dialog.installed_list.item(0)
304
+ with patch.object(qt_plugin_dialog.PluginListItem, "set_busy") as mock:
305
+ plugin_dialog.installed_list.handle_action(
306
+ item,
307
+ 'my-test-old-plugin-1',
308
+ InstallerActions.UPGRADE,
309
+ )
310
+ mock.assert_called_with(
311
+ trans._("updating..."), InstallerActions.UPGRADE
312
+ )
313
+
314
+ with patch.object(qt_plugin_dialog.WarnPopup, "exec_") as mock:
315
+ plugin_dialog.installed_list.handle_action(
316
+ item,
317
+ 'my-test-old-plugin-1',
318
+ InstallerActions.UNINSTALL,
319
+ )
320
+ assert mock.called
321
+
322
+ item = plugin_dialog.available_list.item(0)
323
+ with patch.object(qt_plugin_dialog.PluginListItem, "set_busy") as mock:
324
+
325
+ plugin_dialog.available_list.handle_action(
326
+ item,
327
+ 'my-test-old-plugin-1',
328
+ InstallerActions.INSTALL,
329
+ version='3',
330
+ )
331
+ mock.assert_called_with(
332
+ trans._("installing..."), InstallerActions.INSTALL
333
+ )
334
+
335
+ plugin_dialog.available_list.handle_action(
336
+ item, 'my-test-old-plugin-1', InstallerActions.CANCEL, version='3'
337
+ )
338
+ mock.assert_called_with("", InstallerActions.CANCEL)
339
+
340
+ qtbot.waitUntil(lambda: not plugin_dialog.worker.is_running)
341
+
342
+
343
+ def test_on_enabled_checkbox(plugin_dialog, qtbot, plugins, old_plugins):
344
+ # checks npe2 lines
345
+ item = plugin_dialog.installed_list.item(0)
346
+ widget = plugin_dialog.installed_list.itemWidget(item)
347
+
348
+ assert plugins.plugins['my-plugin'] is True
349
+ with qtbot.waitSignal(widget.enabled_checkbox.stateChanged, timeout=500):
350
+ widget.enabled_checkbox.setChecked(False)
351
+ assert plugins.plugins['my-plugin'] is False
352
+
353
+ # checks npe1 lines
354
+ item = plugin_dialog.installed_list.item(1)
355
+ widget = plugin_dialog.installed_list.itemWidget(item)
356
+
357
+ assert old_plugins.enabled[0] is True
358
+ with qtbot.waitSignal(widget.enabled_checkbox.stateChanged, timeout=500):
359
+ widget.enabled_checkbox.setChecked(False)
360
+ assert old_plugins.enabled[0] is False
361
+
362
+
363
+ def test_add_items_outdated_and_update(plugin_dialog, qtbot):
364
+ """
365
+ Test that a plugin is tagged as outdated (a newer version is available), the update button becomes visible.
366
+
367
+ Also check that after doing an update the update button gets hidden.
368
+ """
369
+
370
+ # The plugin is being added to the available plugins list. When the dialog is being built
371
+ # this one will be listed as available, and it will be found as already installed.
372
+ # Then, it will check if the installed version is a lower version than the one available.
373
+ # In this case, my-plugin is installed with version 0.1.0, so the one we are trying to install
374
+ # is newer, so the update button should pop up.
375
+ new_plugin = (
376
+ npe2.PackageMetadata(name="my-plugin", version="0.4.0"),
377
+ True,
378
+ {
379
+ "home_page": 'www.mywebsite.com',
380
+ "pypi_versions": ['0.4.0', '0.1.0'],
381
+ "conda_versions": ['0.4.0', '0.1.0'],
382
+ },
383
+ )
384
+ plugin_dialog._plugin_data_map["my-plugin"] = new_plugin
385
+ plugin_dialog._plugin_queue = [new_plugin]
386
+ plugin_dialog._add_items()
387
+ item = plugin_dialog.installed_list.item(0)
388
+ widget = plugin_dialog.installed_list.itemWidget(item)
389
+ initial_version = "0.1.0"
390
+ mod_initial_version = initial_version.replace('.', '․') # noqa: RUF001
391
+ assert widget.update_btn.isVisible()
392
+ assert widget.version.text() == mod_initial_version
393
+ assert widget.version.toolTip() == initial_version
394
+
395
+ # Trigger process finished handler to simulated that an update was done
396
+ plugin_dialog._on_process_finished(
397
+ {
398
+ 'exit_code': 1,
399
+ 'exit_status': 0,
400
+ 'action': InstallerActions.UPGRADE,
401
+ 'pkgs': ['my-plugin==0.4.0'],
402
+ }
403
+ )
404
+ updated_version = "0.4.0"
405
+ mod_updated_version = updated_version.replace('.', '․') # noqa: RUF001
406
+ assert not widget.update_btn.isVisible()
407
+ assert widget.version.text() == mod_updated_version
408
+ assert widget.version.toolTip() == updated_version
409
+
410
+
411
+ @pytest.mark.skipif(
412
+ qtpy.API_NAME.lower().startswith('pyside')
413
+ and sys.version_info[:2] > (3, 10)
414
+ and platform.system() == "Darwin",
415
+ reason='pyside specific bug',
416
+ )
417
+ def test_refresh(qtbot, plugin_dialog):
418
+ with qtbot.waitSignal(plugin_dialog._add_items_timer.timeout, timeout=500):
419
+ plugin_dialog.refresh(clear_cache=False)
420
+
421
+ with qtbot.waitSignal(plugin_dialog._add_items_timer.timeout, timeout=500):
422
+ plugin_dialog.refresh(clear_cache=True)
423
+
424
+ with qtbot.waitSignal(plugin_dialog._add_items_timer.timeout, timeout=500):
425
+ plugin_dialog._refresh_and_clear_cache()
426
+
427
+
428
+ @pytest.mark.skipif(
429
+ qtpy.API_NAME.lower().startswith('pyside')
430
+ and sys.version_info[:2] > (3, 10)
431
+ and platform.system() == "Darwin",
432
+ reason='pyside specific bug',
433
+ )
434
+ def test_toggle_status(plugin_dialog):
435
+ plugin_dialog.toggle_status(True)
436
+ assert plugin_dialog.stdout_text.isVisible()
437
+ plugin_dialog.toggle_status(False)
438
+ assert not plugin_dialog.stdout_text.isVisible()
439
+
440
+
441
+ @pytest.mark.skipif(
442
+ qtpy.API_NAME.lower().startswith('pyside')
443
+ and sys.version_info[:2] > (3, 10)
444
+ and platform.system() == "Darwin",
445
+ reason='pyside specific bug',
446
+ )
447
+ def test_exec(plugin_dialog):
448
+ plugin_dialog.exec_()
449
+
450
+
451
+ @pytest.mark.skipif(
452
+ qtpy.API_NAME.lower().startswith('pyside')
453
+ and sys.version_info[:2] > (3, 10)
454
+ and platform.system() == "Darwin",
455
+ reason='pyside specific bug',
456
+ )
457
+ def test_search_in_available(plugin_dialog):
458
+ idxs = plugin_dialog._search_in_available("test")
459
+ assert idxs == [0, 1, 2, 3]
460
+ idxs = plugin_dialog._search_in_available("*&%$")
461
+ assert idxs == []
462
+
463
+
464
+ def test_drop_event(plugin_dialog, tmp_path):
465
+ path_1 = tmp_path / "example-1.txt"
466
+ path_2 = tmp_path / "example-2.txt"
467
+ url_prefix = 'file:///' if os.name == 'nt' else 'file://'
468
+ data = QMimeData()
469
+ data.setUrls(
470
+ [QUrl(f'{url_prefix}{path_1}'), QUrl(f'{url_prefix}{path_2}')]
471
+ )
472
+ event = QDropEvent(
473
+ QPointF(5.0, 5.0), Qt.CopyAction, data, Qt.LeftButton, Qt.NoModifier
474
+ )
475
+ plugin_dialog.dropEvent(event)
476
+ assert plugin_dialog.direct_entry_edit.text() == str(path_1)
477
+
478
+
479
+ @pytest.mark.skipif(
480
+ qtpy.API_NAME.lower().startswith('pyside'), reason='pyside specific bug'
481
+ )
482
+ def test_installs(qtbot, tmp_virtualenv, plugin_dialog, request):
483
+ if "[constructor]" in request.node.name:
484
+ pytest.skip(
485
+ reason="This test is only relevant for constructor-based installs"
486
+ )
487
+
488
+ plugin_dialog.set_prefix(str(tmp_virtualenv))
489
+ item = plugin_dialog.available_list.item(1)
490
+ widget = plugin_dialog.available_list.itemWidget(item)
491
+ with qtbot.waitSignal(
492
+ plugin_dialog.installer.processFinished, timeout=60_000
493
+ ) as blocker:
494
+ widget.action_button.click()
495
+
496
+ process_finished_data = blocker.args[0]
497
+ assert process_finished_data['action'] == InstallerActions.INSTALL
498
+ assert process_finished_data['pkgs'][0].startswith("requests")
499
+ qtbot.wait(5000)
500
+
501
+
502
+ @pytest.mark.skipif(
503
+ qtpy.API_NAME.lower().startswith('pyside'), reason='pyside specific bug'
504
+ )
505
+ def test_cancel(qtbot, tmp_virtualenv, plugin_dialog, request):
506
+ if "[constructor]" in request.node.name:
507
+ pytest.skip(
508
+ reason="This test is only relevant for constructor-based installs"
509
+ )
510
+
511
+ plugin_dialog.set_prefix(str(tmp_virtualenv))
512
+ item = plugin_dialog.available_list.item(1)
513
+ widget = plugin_dialog.available_list.itemWidget(item)
514
+ with qtbot.waitSignal(
515
+ plugin_dialog.installer.processFinished, timeout=60_000
516
+ ) as blocker:
517
+ widget.action_button.click()
518
+ widget.cancel_btn.click()
519
+
520
+ process_finished_data = blocker.args[0]
521
+ assert process_finished_data['action'] == InstallerActions.CANCEL
522
+ assert process_finished_data['pkgs'][0].startswith("requests")
523
+ assert plugin_dialog.available_list.count() == 2
524
+ assert plugin_dialog.installed_list.count() == 2
525
+
526
+
527
+ @pytest.mark.skipif(
528
+ qtpy.API_NAME.lower().startswith('pyside'), reason='pyside specific bug'
529
+ )
530
+ def test_cancel_all(qtbot, tmp_virtualenv, plugin_dialog, request):
531
+ if "[constructor]" in request.node.name:
532
+ pytest.skip(
533
+ reason="This test is only relevant for constructor-based installs"
534
+ )
535
+
536
+ plugin_dialog.set_prefix(str(tmp_virtualenv))
537
+ item_1 = plugin_dialog.available_list.item(0)
538
+ item_2 = plugin_dialog.available_list.item(1)
539
+ widget_1 = plugin_dialog.available_list.itemWidget(item_1)
540
+ widget_2 = plugin_dialog.available_list.itemWidget(item_2)
541
+ with qtbot.waitSignal(plugin_dialog.installer.allFinished, timeout=60_000):
542
+ widget_1.action_button.click()
543
+ widget_2.action_button.click()
544
+ plugin_dialog.cancel_all_btn.click()
545
+
546
+ assert plugin_dialog.available_list.count() == 2
547
+ assert plugin_dialog.installed_list.count() == 2
548
+
549
+
550
+ def test_shortcut_close(plugin_dialog, qtbot):
551
+ qtbot.keyClicks(
552
+ plugin_dialog, 'W', modifier=Qt.KeyboardModifier.ControlModifier
553
+ )
554
+ qtbot.wait(200)
555
+ assert not plugin_dialog.isVisible()
556
+
557
+
558
+ def test_shortcut_quit(plugin_dialog, qtbot):
559
+ qtbot.keyClicks(
560
+ plugin_dialog, 'Q', modifier=Qt.KeyboardModifier.ControlModifier
561
+ )
562
+ qtbot.wait(200)
563
+ assert not plugin_dialog.isVisible()
@@ -0,0 +1,27 @@
1
+ import sys
2
+ from unittest.mock import patch
3
+
4
+ import pytest
5
+
6
+ from napari_plugin_manager.utils import is_conda_package
7
+
8
+
9
+ @pytest.mark.parametrize(
10
+ "pkg_name,expected",
11
+ [
12
+ ("some-package", True),
13
+ ("some-other-package", False),
14
+ ("some-package-other", False),
15
+ ("other-some-package", False),
16
+ ("package", False),
17
+ ("some", False),
18
+ ],
19
+ )
20
+ def test_is_conda_package(pkg_name, expected, tmp_path):
21
+ mocked_conda_meta = tmp_path / 'conda-meta'
22
+ mocked_conda_meta.mkdir()
23
+ mocked_package = mocked_conda_meta / 'some-package-0.1.1-0.json'
24
+ mocked_package.touch()
25
+
26
+ with patch.object(sys, 'prefix', tmp_path):
27
+ assert is_conda_package(pkg_name) is expected
@@ -0,0 +1,16 @@
1
+ # file generated by setuptools_scm
2
+ # don't change, don't track in version control
3
+ TYPE_CHECKING = False
4
+ if TYPE_CHECKING:
5
+ from typing import Tuple, Union
6
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
7
+ else:
8
+ VERSION_TUPLE = object
9
+
10
+ version: str
11
+ __version__: str
12
+ __version_tuple__: VERSION_TUPLE
13
+ version_tuple: VERSION_TUPLE
14
+
15
+ __version__ = version = '0.1.0'
16
+ __version_tuple__ = version_tuple = (0, 1, 0)