napari-plugin-manager 0.1.0a2__py3-none-any.whl → 0.1.1__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,21 @@ 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') or (
18
+ sys.version_info[:2] > (3, 10) and platform.system() == "Linux"
19
+ ):
17
20
  pytest.skip(
18
- "Known PySide2 x Python 3.11 incompatibility: "
19
- "TypeError: 'PySide2.QtCore.Qt.Alignment' object cannot be interpreted as an integer",
21
+ "Known PySide2 x Python incompatibility: "
22
+ "... object cannot be interpreted as an integer",
20
23
  allow_module_level=True,
21
24
  )
22
25
 
26
+ from napari_plugin_manager import qt_plugin_dialog
27
+ from napari_plugin_manager.qt_package_installer import InstallerActions
28
+
23
29
  N_MOCKED_PLUGINS = 2
24
30
 
25
31
 
@@ -33,11 +39,10 @@ def _iter_napari_pypi_plugin_info(
33
39
  This will mock napari.plugins.pypi.iter_napari_plugin_info` for pypi.
34
40
 
35
41
  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").
42
+ list (the bottom one).
39
43
  """
40
44
  # This mock `base_data`` will be the same for both fake plugins.
45
+ packages = ['pyzenhub', 'requests', 'my-plugin', 'my-test-old-plugin-1']
41
46
  base_data = {
42
47
  "metadata_version": "1.0",
43
48
  "version": "0.1.0",
@@ -46,28 +51,31 @@ def _iter_napari_pypi_plugin_info(
46
51
  "author": "test author",
47
52
  "license": "UNKNOWN",
48
53
  }
49
- for i in range(N_MOCKED_PLUGINS):
50
- yield npe2.PackageMetadata(name=f"test-name-{i}", **base_data), bool(
54
+ for i in range(len(packages)):
55
+ yield npe2.PackageMetadata(name=f"{packages[i]}", **base_data), bool(
51
56
  i
52
57
  ), {
53
58
  "home_page": 'www.mywebsite.com',
54
- "pypi_versions": ['3'],
55
- "conda_versions": ['4.5'],
59
+ "pypi_versions": ['2.31.0'],
60
+ "conda_versions": ['2.32.1'],
61
+ 'display_name': packages[i].upper(),
56
62
  }
57
63
 
58
64
 
59
65
  class PluginsMock:
60
66
  def __init__(self):
61
67
  self.plugins = {
62
- 'test-name-0': True,
63
- 'test-name-1': True,
68
+ 'requests': True,
69
+ 'pyzenhub': True,
64
70
  'my-plugin': True,
65
71
  }
66
72
 
67
73
 
68
74
  class OldPluginsMock:
69
75
  def __init__(self):
70
- self.plugins = [('test-1', False, 'test-1')]
76
+ self.plugins = [
77
+ ('my-test-old-plugin-1', False, 'my-test-old-plugin-1')
78
+ ]
71
79
  self.enabled = [True]
72
80
 
73
81
 
@@ -85,6 +93,9 @@ class WarnPopupMock:
85
93
  def __init__(self, text):
86
94
  self._is_visible = False
87
95
 
96
+ def show(self):
97
+ self._is_visible = True
98
+
88
99
  def exec_(self):
89
100
  self._is_visible = True
90
101
 
@@ -97,6 +108,9 @@ class WarnPopupMock:
97
108
  def close(self):
98
109
  self._is_visible = False
99
110
 
111
+ def width(self):
112
+ return 100
113
+
100
114
 
101
115
  @pytest.fixture(params=[True, False], ids=["constructor", "no-constructor"])
102
116
  def plugin_dialog(
@@ -177,7 +191,10 @@ def plugin_dialog(
177
191
  monkeypatch.setattr(
178
192
  qt_plugin_dialog, "running_as_constructor_app", lambda: request.param
179
193
  )
180
-
194
+ monkeypatch.setattr(
195
+ qt_plugin_dialog, "IS_NAPARI_CONDA_INSTALLED", request.param
196
+ )
197
+ monkeypatch.setattr(qt_plugin_dialog, "ON_BUNDLE", request.param)
181
198
  monkeypatch.setattr(
182
199
  napari.plugins, 'plugin_manager', OldPluginManagerMock()
183
200
  )
@@ -187,6 +204,7 @@ def plugin_dialog(
187
204
  monkeypatch.setattr(npe2, 'PluginManager', PluginManagerMock())
188
205
 
189
206
  widget = qt_plugin_dialog.QtPluginDialog()
207
+ # monkeypatch.setattr(widget, '_tag_outdated_plugins', lambda: None)
190
208
  widget.show()
191
209
  qtbot.waitUntil(widget.isVisible, timeout=300)
192
210
 
@@ -201,17 +219,15 @@ def plugin_dialog(
201
219
  assert not widget._add_items_timer.isActive()
202
220
 
203
221
 
204
- def test_filter_not_available_plugins(plugin_dialog):
222
+ def test_filter_not_available_plugins(request, plugin_dialog):
205
223
  """
206
224
  Check that the plugins listed under available plugins are
207
225
  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
226
  """
227
+ if "no-constructor" in request.node.name:
228
+ pytest.skip(
229
+ reason="This test is only relevant for constructor-based installs"
230
+ )
215
231
  item = plugin_dialog.available_list.item(0)
216
232
  widget = plugin_dialog.available_list.itemWidget(item)
217
233
  if widget:
@@ -231,14 +247,14 @@ def test_filter_available_plugins(plugin_dialog):
231
247
  """
232
248
  plugin_dialog.filter("")
233
249
  assert plugin_dialog.available_list.count() == 2
234
- assert plugin_dialog.available_list._count_visible() == 2
250
+ assert plugin_dialog.available_list.count_visible() == 2
235
251
 
236
252
  plugin_dialog.filter("no-match@123")
237
- assert plugin_dialog.available_list._count_visible() == 0
253
+ assert plugin_dialog.available_list.count_visible() == 0
238
254
 
239
255
  plugin_dialog.filter("")
240
- plugin_dialog.filter("test-name-0")
241
- assert plugin_dialog.available_list._count_visible() == 1
256
+ plugin_dialog.filter("requests")
257
+ assert plugin_dialog.available_list.count_visible() == 1
242
258
 
243
259
 
244
260
  def test_filter_installed_plugins(plugin_dialog):
@@ -247,10 +263,10 @@ def test_filter_installed_plugins(plugin_dialog):
247
263
  list (the top one).
248
264
  """
249
265
  plugin_dialog.filter("")
250
- assert plugin_dialog.installed_list._count_visible() >= 0
266
+ assert plugin_dialog.installed_list.count_visible() >= 0
251
267
 
252
268
  plugin_dialog.filter("no-match@123")
253
- assert plugin_dialog.installed_list._count_visible() == 0
269
+ assert plugin_dialog.installed_list.count_visible() == 0
254
270
 
255
271
 
256
272
  def test_visible_widgets(request, plugin_dialog):
@@ -258,26 +274,29 @@ def test_visible_widgets(request, plugin_dialog):
258
274
  Test that the direct entry button and textbox are visible
259
275
  """
260
276
  if "no-constructor" not in request.node.name:
261
- # the plugin_dialog fixture has this id
262
- # skip for 'constructor' variant
263
- pytest.skip()
277
+ pytest.skip(
278
+ reason="Tested functionality not available in constructor-based installs"
279
+ )
264
280
  assert plugin_dialog.direct_entry_edit.isVisible()
265
281
  assert plugin_dialog.direct_entry_btn.isVisible()
266
282
 
267
283
 
268
- def test_version_dropdown(qtbot, plugin_dialog):
284
+ def test_version_dropdown(plugin_dialog, qtbot):
269
285
  """
270
286
  Test that when the source drop down is changed, it displays the other versions properly.
271
287
  """
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"
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"
277
296
 
278
297
 
279
298
  def test_plugin_list_count_items(plugin_dialog):
280
- assert plugin_dialog.installed_list._count_visible() == 2
299
+ assert plugin_dialog.installed_list.count_visible() == 2
281
300
 
282
301
 
283
302
  def test_plugin_list_handle_action(plugin_dialog, qtbot):
@@ -285,7 +304,7 @@ def test_plugin_list_handle_action(plugin_dialog, qtbot):
285
304
  with patch.object(qt_plugin_dialog.PluginListItem, "set_busy") as mock:
286
305
  plugin_dialog.installed_list.handle_action(
287
306
  item,
288
- 'test-name-1',
307
+ 'my-test-old-plugin-1',
289
308
  InstallerActions.UPGRADE,
290
309
  )
291
310
  mock.assert_called_with(
@@ -295,16 +314,17 @@ def test_plugin_list_handle_action(plugin_dialog, qtbot):
295
314
  with patch.object(qt_plugin_dialog.WarnPopup, "exec_") as mock:
296
315
  plugin_dialog.installed_list.handle_action(
297
316
  item,
298
- 'test-name-1',
317
+ 'my-test-old-plugin-1',
299
318
  InstallerActions.UNINSTALL,
300
319
  )
301
320
  assert mock.called
302
321
 
303
322
  item = plugin_dialog.available_list.item(0)
304
323
  with patch.object(qt_plugin_dialog.PluginListItem, "set_busy") as mock:
324
+
305
325
  plugin_dialog.available_list.handle_action(
306
326
  item,
307
- 'test-name-1',
327
+ 'my-test-old-plugin-1',
308
328
  InstallerActions.INSTALL,
309
329
  version='3',
310
330
  )
@@ -313,17 +333,10 @@ def test_plugin_list_handle_action(plugin_dialog, qtbot):
313
333
  )
314
334
 
315
335
  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
336
+ item, 'my-test-old-plugin-1', InstallerActions.CANCEL, version='3'
320
337
  )
338
+ mock.assert_called_with("", InstallerActions.CANCEL)
321
339
 
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
340
  qtbot.waitUntil(lambda: not plugin_dialog.worker.is_running)
328
341
 
329
342
 
@@ -347,8 +360,12 @@ def test_on_enabled_checkbox(plugin_dialog, qtbot, plugins, old_plugins):
347
360
  assert old_plugins.enabled[0] is False
348
361
 
349
362
 
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."""
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
+ """
352
369
 
353
370
  # The plugin is being added to the available plugins list. When the dialog is being built
354
371
  # this one will be listed as available, and it will be found as already installed.
@@ -360,15 +377,209 @@ def test_add_items_outdated(plugin_dialog):
360
377
  True,
361
378
  {
362
379
  "home_page": 'www.mywebsite.com',
363
- "pypi_versions": ['0.4.0'],
364
- "conda_versions": ['0.4.0'],
380
+ "pypi_versions": ['0.4.0', '0.1.0'],
381
+ "conda_versions": ['0.4.0', '0.1.0'],
365
382
  },
366
383
  )
367
-
368
- plugin_dialog._plugin_data = [new_plugin]
369
-
384
+ plugin_dialog._plugin_data_map["my-plugin"] = new_plugin
385
+ plugin_dialog._plugin_queue = [new_plugin]
370
386
  plugin_dialog._add_items()
371
387
  item = plugin_dialog.installed_list.item(0)
372
388
  widget = plugin_dialog.installed_list.itemWidget(item)
373
-
389
+ initial_version = "0.1.0"
390
+ mod_initial_version = initial_version.replace('.', '․') # noqa: RUF001
374
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
+ @pytest.mark.skipif(
551
+ qtpy.API_NAME.lower().startswith('pyside'), reason='pyside specific bug'
552
+ )
553
+ def test_direct_entry_installs(qtbot, tmp_virtualenv, plugin_dialog, request):
554
+ if "[constructor]" in request.node.name:
555
+ pytest.skip(
556
+ reason="This test is only relevant for constructor-based installs"
557
+ )
558
+
559
+ plugin_dialog.set_prefix(str(tmp_virtualenv))
560
+ with qtbot.waitSignal(
561
+ plugin_dialog.installer.processFinished, timeout=60_000
562
+ ) as blocker:
563
+ plugin_dialog.direct_entry_edit.setText('requests')
564
+ plugin_dialog.direct_entry_btn.click()
565
+
566
+ process_finished_data = blocker.args[0]
567
+ assert process_finished_data['action'] == InstallerActions.INSTALL
568
+ assert process_finished_data['pkgs'][0].startswith("requests")
569
+ qtbot.wait(5000)
570
+
571
+
572
+ def test_shortcut_close(plugin_dialog, qtbot):
573
+ qtbot.keyClicks(
574
+ plugin_dialog, 'W', modifier=Qt.KeyboardModifier.ControlModifier
575
+ )
576
+ qtbot.wait(200)
577
+ assert not plugin_dialog.isVisible()
578
+
579
+
580
+ def test_shortcut_quit(plugin_dialog, qtbot):
581
+ qtbot.keyClicks(
582
+ plugin_dialog, 'Q', modifier=Qt.KeyboardModifier.ControlModifier
583
+ )
584
+ qtbot.wait(200)
585
+ 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.1'
16
+ __version_tuple__ = version_tuple = (0, 1, 1)
@@ -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()