napari-plugin-manager 0.1.0a2__py3-none-any.whl → 0.1.2__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.
@@ -1,4 +1,6 @@
1
1
  import importlib.metadata
2
+ import os
3
+ import platform
2
4
  import sys
3
5
  from typing import Generator, Optional, Tuple
4
6
  from unittest.mock import patch
@@ -9,17 +11,19 @@ import pytest
9
11
  import qtpy
10
12
  from napari.plugins._tests.test_npe2 import mock_pm # noqa
11
13
  from napari.utils.translations import trans
14
+ from qtpy.QtCore import QMimeData, QPointF, Qt, QUrl
15
+ from qtpy.QtGui import QDropEvent
12
16
 
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
+ if qtpy.API_NAME == 'PySide2' and sys.version_info[:2] > (3, 10):
17
18
  pytest.skip(
18
- "Known PySide2 x Python 3.11 incompatibility: "
19
- "TypeError: 'PySide2.QtCore.Qt.Alignment' object cannot be interpreted as an integer",
19
+ "Known PySide2 x Python incompatibility: "
20
+ "... object cannot be interpreted as an integer",
20
21
  allow_module_level=True,
21
22
  )
22
23
 
24
+ from napari_plugin_manager import qt_plugin_dialog
25
+ from napari_plugin_manager.qt_package_installer import InstallerActions
26
+
23
27
  N_MOCKED_PLUGINS = 2
24
28
 
25
29
 
@@ -33,11 +37,10 @@ def _iter_napari_pypi_plugin_info(
33
37
  This will mock napari.plugins.pypi.iter_napari_plugin_info` for pypi.
34
38
 
35
39
  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").
40
+ list (the bottom one).
39
41
  """
40
42
  # This mock `base_data`` will be the same for both fake plugins.
43
+ packages = ['pyzenhub', 'requests', 'my-plugin', 'my-test-old-plugin-1']
41
44
  base_data = {
42
45
  "metadata_version": "1.0",
43
46
  "version": "0.1.0",
@@ -46,28 +49,31 @@ def _iter_napari_pypi_plugin_info(
46
49
  "author": "test author",
47
50
  "license": "UNKNOWN",
48
51
  }
49
- for i in range(N_MOCKED_PLUGINS):
50
- yield npe2.PackageMetadata(name=f"test-name-{i}", **base_data), bool(
52
+ for i in range(len(packages)):
53
+ yield npe2.PackageMetadata(name=f"{packages[i]}", **base_data), bool(
51
54
  i
52
55
  ), {
53
56
  "home_page": 'www.mywebsite.com',
54
- "pypi_versions": ['3'],
55
- "conda_versions": ['4.5'],
57
+ "pypi_versions": ['2.31.0'],
58
+ "conda_versions": ['2.32.1'],
59
+ 'display_name': packages[i].upper(),
56
60
  }
57
61
 
58
62
 
59
63
  class PluginsMock:
60
64
  def __init__(self):
61
65
  self.plugins = {
62
- 'test-name-0': True,
63
- 'test-name-1': True,
66
+ 'requests': True,
67
+ 'pyzenhub': True,
64
68
  'my-plugin': True,
65
69
  }
66
70
 
67
71
 
68
72
  class OldPluginsMock:
69
73
  def __init__(self):
70
- self.plugins = [('test-1', False, 'test-1')]
74
+ self.plugins = [
75
+ ('my-test-old-plugin-1', False, 'my-test-old-plugin-1')
76
+ ]
71
77
  self.enabled = [True]
72
78
 
73
79
 
@@ -85,6 +91,9 @@ class WarnPopupMock:
85
91
  def __init__(self, text):
86
92
  self._is_visible = False
87
93
 
94
+ def show(self):
95
+ self._is_visible = True
96
+
88
97
  def exec_(self):
89
98
  self._is_visible = True
90
99
 
@@ -97,6 +106,9 @@ class WarnPopupMock:
97
106
  def close(self):
98
107
  self._is_visible = False
99
108
 
109
+ def width(self):
110
+ return 100
111
+
100
112
 
101
113
  @pytest.fixture(params=[True, False], ids=["constructor", "no-constructor"])
102
114
  def plugin_dialog(
@@ -177,7 +189,10 @@ def plugin_dialog(
177
189
  monkeypatch.setattr(
178
190
  qt_plugin_dialog, "running_as_constructor_app", lambda: request.param
179
191
  )
180
-
192
+ monkeypatch.setattr(
193
+ qt_plugin_dialog, "IS_NAPARI_CONDA_INSTALLED", request.param
194
+ )
195
+ monkeypatch.setattr(qt_plugin_dialog, "ON_BUNDLE", request.param)
181
196
  monkeypatch.setattr(
182
197
  napari.plugins, 'plugin_manager', OldPluginManagerMock()
183
198
  )
@@ -187,6 +202,7 @@ def plugin_dialog(
187
202
  monkeypatch.setattr(npe2, 'PluginManager', PluginManagerMock())
188
203
 
189
204
  widget = qt_plugin_dialog.QtPluginDialog()
205
+ # monkeypatch.setattr(widget, '_tag_outdated_plugins', lambda: None)
190
206
  widget.show()
191
207
  qtbot.waitUntil(widget.isVisible, timeout=300)
192
208
 
@@ -201,17 +217,15 @@ def plugin_dialog(
201
217
  assert not widget._add_items_timer.isActive()
202
218
 
203
219
 
204
- def test_filter_not_available_plugins(plugin_dialog):
220
+ def test_filter_not_available_plugins(request, plugin_dialog):
205
221
  """
206
222
  Check that the plugins listed under available plugins are
207
223
  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
224
  """
225
+ if "no-constructor" in request.node.name:
226
+ pytest.skip(
227
+ reason="This test is only relevant for constructor-based installs"
228
+ )
215
229
  item = plugin_dialog.available_list.item(0)
216
230
  widget = plugin_dialog.available_list.itemWidget(item)
217
231
  if widget:
@@ -231,14 +245,14 @@ def test_filter_available_plugins(plugin_dialog):
231
245
  """
232
246
  plugin_dialog.filter("")
233
247
  assert plugin_dialog.available_list.count() == 2
234
- assert plugin_dialog.available_list._count_visible() == 2
248
+ assert plugin_dialog.available_list.count_visible() == 2
235
249
 
236
250
  plugin_dialog.filter("no-match@123")
237
- assert plugin_dialog.available_list._count_visible() == 0
251
+ assert plugin_dialog.available_list.count_visible() == 0
238
252
 
239
253
  plugin_dialog.filter("")
240
- plugin_dialog.filter("test-name-0")
241
- assert plugin_dialog.available_list._count_visible() == 1
254
+ plugin_dialog.filter("requests")
255
+ assert plugin_dialog.available_list.count_visible() == 1
242
256
 
243
257
 
244
258
  def test_filter_installed_plugins(plugin_dialog):
@@ -247,10 +261,10 @@ def test_filter_installed_plugins(plugin_dialog):
247
261
  list (the top one).
248
262
  """
249
263
  plugin_dialog.filter("")
250
- assert plugin_dialog.installed_list._count_visible() >= 0
264
+ assert plugin_dialog.installed_list.count_visible() >= 0
251
265
 
252
266
  plugin_dialog.filter("no-match@123")
253
- assert plugin_dialog.installed_list._count_visible() == 0
267
+ assert plugin_dialog.installed_list.count_visible() == 0
254
268
 
255
269
 
256
270
  def test_visible_widgets(request, plugin_dialog):
@@ -258,26 +272,29 @@ def test_visible_widgets(request, plugin_dialog):
258
272
  Test that the direct entry button and textbox are visible
259
273
  """
260
274
  if "no-constructor" not in request.node.name:
261
- # the plugin_dialog fixture has this id
262
- # skip for 'constructor' variant
263
- pytest.skip()
275
+ pytest.skip(
276
+ reason="Tested functionality not available in constructor-based installs"
277
+ )
264
278
  assert plugin_dialog.direct_entry_edit.isVisible()
265
279
  assert plugin_dialog.direct_entry_btn.isVisible()
266
280
 
267
281
 
268
- def test_version_dropdown(qtbot, plugin_dialog):
282
+ def test_version_dropdown(plugin_dialog, qtbot):
269
283
  """
270
284
  Test that when the source drop down is changed, it displays the other versions properly.
271
285
  """
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"
286
+ # qtbot.wait(10000)
287
+ widget = plugin_dialog.available_list.item(0).widget
288
+ count = widget.version_choice_dropdown.count()
289
+ if count == 2:
290
+ assert widget.version_choice_dropdown.currentText() == "2.31.0"
291
+ # switch from PyPI source to conda one.
292
+ widget.source_choice_dropdown.setCurrentIndex(1)
293
+ assert widget.version_choice_dropdown.currentText() == "2.32.1"
277
294
 
278
295
 
279
296
  def test_plugin_list_count_items(plugin_dialog):
280
- assert plugin_dialog.installed_list._count_visible() == 2
297
+ assert plugin_dialog.installed_list.count_visible() == 2
281
298
 
282
299
 
283
300
  def test_plugin_list_handle_action(plugin_dialog, qtbot):
@@ -285,7 +302,7 @@ def test_plugin_list_handle_action(plugin_dialog, qtbot):
285
302
  with patch.object(qt_plugin_dialog.PluginListItem, "set_busy") as mock:
286
303
  plugin_dialog.installed_list.handle_action(
287
304
  item,
288
- 'test-name-1',
305
+ 'my-test-old-plugin-1',
289
306
  InstallerActions.UPGRADE,
290
307
  )
291
308
  mock.assert_called_with(
@@ -295,16 +312,17 @@ def test_plugin_list_handle_action(plugin_dialog, qtbot):
295
312
  with patch.object(qt_plugin_dialog.WarnPopup, "exec_") as mock:
296
313
  plugin_dialog.installed_list.handle_action(
297
314
  item,
298
- 'test-name-1',
315
+ 'my-test-old-plugin-1',
299
316
  InstallerActions.UNINSTALL,
300
317
  )
301
318
  assert mock.called
302
319
 
303
320
  item = plugin_dialog.available_list.item(0)
304
321
  with patch.object(qt_plugin_dialog.PluginListItem, "set_busy") as mock:
322
+
305
323
  plugin_dialog.available_list.handle_action(
306
324
  item,
307
- 'test-name-1',
325
+ 'my-test-old-plugin-1',
308
326
  InstallerActions.INSTALL,
309
327
  version='3',
310
328
  )
@@ -313,17 +331,10 @@ def test_plugin_list_handle_action(plugin_dialog, qtbot):
313
331
  )
314
332
 
315
333
  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
334
+ item, 'my-test-old-plugin-1', InstallerActions.CANCEL, version='3'
320
335
  )
336
+ mock.assert_called_with("", InstallerActions.CANCEL)
321
337
 
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
338
  qtbot.waitUntil(lambda: not plugin_dialog.worker.is_running)
328
339
 
329
340
 
@@ -347,8 +358,12 @@ def test_on_enabled_checkbox(plugin_dialog, qtbot, plugins, old_plugins):
347
358
  assert old_plugins.enabled[0] is False
348
359
 
349
360
 
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."""
361
+ def test_add_items_outdated_and_update(plugin_dialog, qtbot):
362
+ """
363
+ Test that a plugin is tagged as outdated (a newer version is available), the update button becomes visible.
364
+
365
+ Also check that after doing an update the update button gets hidden.
366
+ """
352
367
 
353
368
  # The plugin is being added to the available plugins list. When the dialog is being built
354
369
  # this one will be listed as available, and it will be found as already installed.
@@ -360,15 +375,209 @@ def test_add_items_outdated(plugin_dialog):
360
375
  True,
361
376
  {
362
377
  "home_page": 'www.mywebsite.com',
363
- "pypi_versions": ['0.4.0'],
364
- "conda_versions": ['0.4.0'],
378
+ "pypi_versions": ['0.4.0', '0.1.0'],
379
+ "conda_versions": ['0.4.0', '0.1.0'],
365
380
  },
366
381
  )
367
-
368
- plugin_dialog._plugin_data = [new_plugin]
369
-
382
+ plugin_dialog._plugin_data_map["my-plugin"] = new_plugin
383
+ plugin_dialog._plugin_queue = [new_plugin]
370
384
  plugin_dialog._add_items()
371
385
  item = plugin_dialog.installed_list.item(0)
372
386
  widget = plugin_dialog.installed_list.itemWidget(item)
373
-
387
+ initial_version = "0.1.0"
388
+ mod_initial_version = initial_version.replace('.', '․') # noqa: RUF001
374
389
  assert widget.update_btn.isVisible()
390
+ assert widget.version.text() == mod_initial_version
391
+ assert widget.version.toolTip() == initial_version
392
+
393
+ # Trigger process finished handler to simulated that an update was done
394
+ plugin_dialog._on_process_finished(
395
+ {
396
+ 'exit_code': 1,
397
+ 'exit_status': 0,
398
+ 'action': InstallerActions.UPGRADE,
399
+ 'pkgs': ['my-plugin==0.4.0'],
400
+ }
401
+ )
402
+ updated_version = "0.4.0"
403
+ mod_updated_version = updated_version.replace('.', '․') # noqa: RUF001
404
+ assert not widget.update_btn.isVisible()
405
+ assert widget.version.text() == mod_updated_version
406
+ assert widget.version.toolTip() == updated_version
407
+
408
+
409
+ @pytest.mark.skipif(
410
+ qtpy.API_NAME.lower().startswith('pyside')
411
+ and sys.version_info[:2] > (3, 10)
412
+ and platform.system() == "Darwin",
413
+ reason='pyside specific bug',
414
+ )
415
+ def test_refresh(qtbot, plugin_dialog):
416
+ with qtbot.waitSignal(plugin_dialog._add_items_timer.timeout, timeout=500):
417
+ plugin_dialog.refresh(clear_cache=False)
418
+
419
+ with qtbot.waitSignal(plugin_dialog._add_items_timer.timeout, timeout=500):
420
+ plugin_dialog.refresh(clear_cache=True)
421
+
422
+ with qtbot.waitSignal(plugin_dialog._add_items_timer.timeout, timeout=500):
423
+ plugin_dialog._refresh_and_clear_cache()
424
+
425
+
426
+ @pytest.mark.skipif(
427
+ qtpy.API_NAME.lower().startswith('pyside')
428
+ and sys.version_info[:2] > (3, 10)
429
+ and platform.system() == "Darwin",
430
+ reason='pyside specific bug',
431
+ )
432
+ def test_toggle_status(plugin_dialog):
433
+ plugin_dialog.toggle_status(True)
434
+ assert plugin_dialog.stdout_text.isVisible()
435
+ plugin_dialog.toggle_status(False)
436
+ assert not plugin_dialog.stdout_text.isVisible()
437
+
438
+
439
+ @pytest.mark.skipif(
440
+ qtpy.API_NAME.lower().startswith('pyside')
441
+ and sys.version_info[:2] > (3, 10)
442
+ and platform.system() == "Darwin",
443
+ reason='pyside specific bug',
444
+ )
445
+ def test_exec(plugin_dialog):
446
+ plugin_dialog.exec_()
447
+
448
+
449
+ @pytest.mark.skipif(
450
+ qtpy.API_NAME.lower().startswith('pyside')
451
+ and sys.version_info[:2] > (3, 10)
452
+ and platform.system() == "Darwin",
453
+ reason='pyside specific bug',
454
+ )
455
+ def test_search_in_available(plugin_dialog):
456
+ idxs = plugin_dialog._search_in_available("test")
457
+ assert idxs == [0, 1, 2, 3]
458
+ idxs = plugin_dialog._search_in_available("*&%$")
459
+ assert idxs == []
460
+
461
+
462
+ def test_drop_event(plugin_dialog, tmp_path):
463
+ path_1 = tmp_path / "example-1.txt"
464
+ path_2 = tmp_path / "example-2.txt"
465
+ url_prefix = 'file:///' if os.name == 'nt' else 'file://'
466
+ data = QMimeData()
467
+ data.setUrls(
468
+ [QUrl(f'{url_prefix}{path_1}'), QUrl(f'{url_prefix}{path_2}')]
469
+ )
470
+ event = QDropEvent(
471
+ QPointF(5.0, 5.0), Qt.CopyAction, data, Qt.LeftButton, Qt.NoModifier
472
+ )
473
+ plugin_dialog.dropEvent(event)
474
+ assert plugin_dialog.direct_entry_edit.text() == str(path_1)
475
+
476
+
477
+ @pytest.mark.skipif(
478
+ qtpy.API_NAME.lower().startswith('pyside'), reason='pyside specific bug'
479
+ )
480
+ def test_installs(qtbot, tmp_virtualenv, plugin_dialog, request):
481
+ if "[constructor]" in request.node.name:
482
+ pytest.skip(
483
+ reason="This test is only relevant for constructor-based installs"
484
+ )
485
+
486
+ plugin_dialog.set_prefix(str(tmp_virtualenv))
487
+ item = plugin_dialog.available_list.item(1)
488
+ widget = plugin_dialog.available_list.itemWidget(item)
489
+ with qtbot.waitSignal(
490
+ plugin_dialog.installer.processFinished, timeout=60_000
491
+ ) as blocker:
492
+ widget.action_button.click()
493
+
494
+ process_finished_data = blocker.args[0]
495
+ assert process_finished_data['action'] == InstallerActions.INSTALL
496
+ assert process_finished_data['pkgs'][0].startswith("requests")
497
+ qtbot.wait(5000)
498
+
499
+
500
+ @pytest.mark.skipif(
501
+ qtpy.API_NAME.lower().startswith('pyside'), reason='pyside specific bug'
502
+ )
503
+ def test_cancel(qtbot, tmp_virtualenv, plugin_dialog, request):
504
+ if "[constructor]" in request.node.name:
505
+ pytest.skip(
506
+ reason="This test is only relevant for constructor-based installs"
507
+ )
508
+
509
+ plugin_dialog.set_prefix(str(tmp_virtualenv))
510
+ item = plugin_dialog.available_list.item(1)
511
+ widget = plugin_dialog.available_list.itemWidget(item)
512
+ with qtbot.waitSignal(
513
+ plugin_dialog.installer.processFinished, timeout=60_000
514
+ ) as blocker:
515
+ widget.action_button.click()
516
+ widget.cancel_btn.click()
517
+
518
+ process_finished_data = blocker.args[0]
519
+ assert process_finished_data['action'] == InstallerActions.CANCEL
520
+ assert process_finished_data['pkgs'][0].startswith("requests")
521
+ assert plugin_dialog.available_list.count() == 2
522
+ assert plugin_dialog.installed_list.count() == 2
523
+
524
+
525
+ @pytest.mark.skipif(
526
+ qtpy.API_NAME.lower().startswith('pyside'), reason='pyside specific bug'
527
+ )
528
+ def test_cancel_all(qtbot, tmp_virtualenv, plugin_dialog, request):
529
+ if "[constructor]" in request.node.name:
530
+ pytest.skip(
531
+ reason="This test is only relevant for constructor-based installs"
532
+ )
533
+
534
+ plugin_dialog.set_prefix(str(tmp_virtualenv))
535
+ item_1 = plugin_dialog.available_list.item(0)
536
+ item_2 = plugin_dialog.available_list.item(1)
537
+ widget_1 = plugin_dialog.available_list.itemWidget(item_1)
538
+ widget_2 = plugin_dialog.available_list.itemWidget(item_2)
539
+ with qtbot.waitSignal(plugin_dialog.installer.allFinished, timeout=60_000):
540
+ widget_1.action_button.click()
541
+ widget_2.action_button.click()
542
+ plugin_dialog.cancel_all_btn.click()
543
+
544
+ assert plugin_dialog.available_list.count() == 2
545
+ assert plugin_dialog.installed_list.count() == 2
546
+
547
+
548
+ @pytest.mark.skipif(
549
+ qtpy.API_NAME.lower().startswith('pyside'), reason='pyside specific bug'
550
+ )
551
+ def test_direct_entry_installs(qtbot, tmp_virtualenv, plugin_dialog, request):
552
+ if "[constructor]" in request.node.name:
553
+ pytest.skip(
554
+ reason="This test is only relevant for constructor-based installs"
555
+ )
556
+
557
+ plugin_dialog.set_prefix(str(tmp_virtualenv))
558
+ with qtbot.waitSignal(
559
+ plugin_dialog.installer.processFinished, timeout=60_000
560
+ ) as blocker:
561
+ plugin_dialog.direct_entry_edit.setText('requests')
562
+ plugin_dialog.direct_entry_btn.click()
563
+
564
+ process_finished_data = blocker.args[0]
565
+ assert process_finished_data['action'] == InstallerActions.INSTALL
566
+ assert process_finished_data['pkgs'][0].startswith("requests")
567
+ qtbot.wait(5000)
568
+
569
+
570
+ def test_shortcut_close(plugin_dialog, qtbot):
571
+ qtbot.keyClicks(
572
+ plugin_dialog, 'W', modifier=Qt.KeyboardModifier.ControlModifier
573
+ )
574
+ qtbot.wait(200)
575
+ assert not plugin_dialog.isVisible()
576
+
577
+
578
+ def test_shortcut_quit(plugin_dialog, qtbot):
579
+ qtbot.keyClicks(
580
+ plugin_dialog, 'Q', modifier=Qt.KeyboardModifier.ControlModifier
581
+ )
582
+ qtbot.wait(200)
583
+ 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
@@ -1,4 +1,16 @@
1
1
  # file generated by setuptools_scm
2
2
  # don't change, don't track in version control
3
- __version__ = version = '0.1.0a2'
4
- __version_tuple__ = version_tuple = (0, 1, 0)
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.2'
16
+ __version_tuple__ = version_tuple = (0, 1, 2)
@@ -0,0 +1,131 @@
1
+ """
2
+ These convenience functions will be useful for searching pypi for packages
3
+ that match the plugin naming convention, and retrieving related metadata.
4
+ """
5
+
6
+ import json
7
+ from collections.abc import Iterator
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ from functools import lru_cache
10
+ from typing import (
11
+ Optional,
12
+ TypedDict,
13
+ cast,
14
+ )
15
+ from urllib.error import HTTPError, URLError
16
+ from urllib.request import Request, urlopen
17
+
18
+ from napari.plugins.utils import normalized_name
19
+ from napari.utils.notifications import show_warning
20
+ from npe2 import PackageMetadata
21
+ from typing_extensions import NotRequired
22
+
23
+ PyPIname = str
24
+
25
+
26
+ @lru_cache
27
+ def _user_agent() -> str:
28
+ """Return a user agent string for use in http requests."""
29
+ import platform
30
+
31
+ from napari import __version__
32
+ from napari.utils import misc
33
+
34
+ if misc.running_as_constructor_app():
35
+ env = 'constructor'
36
+ elif misc.in_jupyter():
37
+ env = 'jupyter'
38
+ elif misc.in_ipython():
39
+ env = 'ipython'
40
+ else:
41
+ env = 'python'
42
+
43
+ parts = [
44
+ ('napari', __version__),
45
+ ('runtime', env),
46
+ (platform.python_implementation(), platform.python_version()),
47
+ (platform.system(), platform.release()),
48
+ ]
49
+ return ' '.join(f'{k}/{v}' for k, v in parts)
50
+
51
+
52
+ class _ShortSummaryDict(TypedDict):
53
+ """Objects returned at https://npe2api.vercel.app/api/extended_summary ."""
54
+
55
+ name: NotRequired[PyPIname]
56
+ version: str
57
+ summary: str
58
+ author: str
59
+ license: str
60
+ home_page: str
61
+
62
+
63
+ class SummaryDict(_ShortSummaryDict):
64
+ display_name: NotRequired[str]
65
+ pypi_versions: NotRequired[list[str]]
66
+ conda_versions: NotRequired[list[str]]
67
+
68
+
69
+ @lru_cache
70
+ def plugin_summaries() -> list[SummaryDict]:
71
+ """Return PackageMetadata object for all known napari plugins."""
72
+ url = 'https://npe2api.vercel.app/api/extended_summary'
73
+ with urlopen(Request(url, headers={'User-Agent': _user_agent()})) as resp:
74
+ return json.load(resp)
75
+
76
+
77
+ @lru_cache
78
+ def conda_map() -> dict[PyPIname, Optional[str]]:
79
+ """Return map of PyPI package name to conda_channel/package_name ()."""
80
+ url = 'https://npe2api.vercel.app/api/conda'
81
+ with urlopen(Request(url, headers={'User-Agent': _user_agent()})) as resp:
82
+ return json.load(resp)
83
+
84
+
85
+ def iter_napari_plugin_info() -> Iterator[tuple[PackageMetadata, bool, dict]]:
86
+ """Iterator of tuples of ProjectInfo, Conda availability for all napari plugins."""
87
+ try:
88
+ with ThreadPoolExecutor() as executor:
89
+ data = executor.submit(plugin_summaries)
90
+ _conda = executor.submit(conda_map)
91
+
92
+ conda = _conda.result()
93
+ data_set = data.result()
94
+ except (HTTPError, URLError):
95
+ show_warning(
96
+ 'Plugin manager: There seems to be an issue with network connectivity. '
97
+ 'Remote plugins cannot be installed, only local ones.\n'
98
+ )
99
+ return
100
+
101
+ conda_set = {normalized_name(x) for x in conda}
102
+ for info in data_set:
103
+ info_copy = dict(info)
104
+ info_copy.pop('display_name', None)
105
+ pypi_versions = info_copy.pop('pypi_versions')
106
+ conda_versions = info_copy.pop('conda_versions')
107
+ info_ = cast(_ShortSummaryDict, info_copy)
108
+
109
+ # TODO: use this better.
110
+ # this would require changing the api that qt_plugin_dialog expects to
111
+ # receive
112
+
113
+ # TODO: once the new version of npe2 is out, this can be refactored
114
+ # to all the metadata includes the conda and pypi versions.
115
+ extra_info = {
116
+ 'home_page': info_.get('home_page', ''),
117
+ 'display_name': info.get('display_name', ''),
118
+ 'pypi_versions': pypi_versions,
119
+ 'conda_versions': conda_versions,
120
+ }
121
+ info_['name'] = normalized_name(info_['name'])
122
+ meta = PackageMetadata(**info_) # type:ignore[call-arg]
123
+
124
+ yield meta, (info_['name'] in conda_set), extra_info
125
+
126
+
127
+ def cache_clear():
128
+ """Clear the cache for all cached functions in this module."""
129
+ plugin_summaries.cache_clear()
130
+ conda_map.cache_clear()
131
+ _user_agent.cache_clear()