napari-plugin-manager 0.1.4__py3-none-any.whl → 0.1.5__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.
@@ -346,7 +346,7 @@ def test_constraints_are_in_sync():
346
346
 
347
347
  name_re = re.compile(r"([a-z0-9_\-]+).*")
348
348
  for conda_constraint, pip_constraint in zip(
349
- conda_constraints, pip_constraints
349
+ conda_constraints, pip_constraints, strict=False
350
350
  ):
351
351
  conda_name = name_re.match(conda_constraint).group(1)
352
352
  pip_name = name_re.match(pip_constraint).group(1)
@@ -1,3 +1,5 @@
1
+ from urllib.error import HTTPError, URLError
2
+
1
3
  from flaky import flaky
2
4
 
3
5
  from napari_plugin_manager.npe2api import (
@@ -26,20 +28,26 @@ def test_plugin_summaries():
26
28
  "pypi_versions",
27
29
  "conda_versions",
28
30
  ]
29
- data = plugin_summaries()
30
- test_data = dict(data[0])
31
- for key in keys:
32
- assert key in test_data
33
- test_data.pop(key)
31
+ try:
32
+ data = plugin_summaries()
33
+ test_data = dict(data[0])
34
+ for key in keys:
35
+ assert key in test_data
36
+ test_data.pop(key)
34
37
 
35
- assert not test_data
38
+ assert not test_data
39
+ except (HTTPError, URLError):
40
+ pass
36
41
 
37
42
 
38
43
  def test_conda_map():
39
44
  pkgs = ["napari-svg"]
40
- data = conda_map()
41
- for pkg in pkgs:
42
- assert pkg in data
45
+ try:
46
+ data = conda_map()
47
+ for pkg in pkgs:
48
+ assert pkg in data
49
+ except (HTTPError, URLError):
50
+ pass
43
51
 
44
52
 
45
53
  def test_iter_napari_plugin_info():
@@ -1,8 +1,8 @@
1
1
  import importlib.metadata
2
2
  import os
3
3
  import sys
4
- from typing import Generator, Optional, Tuple
5
- from unittest.mock import patch
4
+ from collections.abc import Generator
5
+ from unittest.mock import MagicMock, call, patch
6
6
 
7
7
  import napari.plugins
8
8
  import npe2
@@ -10,9 +10,12 @@ import pytest
10
10
  import qtpy
11
11
  from napari.plugins._tests.test_npe2 import mock_pm # noqa
12
12
  from napari.utils.translations import trans
13
- from qtpy.QtCore import QMimeData, QPointF, Qt, QUrl
13
+ from qtpy.QtCore import QMimeData, QPointF, Qt, QTimer, QUrl
14
14
  from qtpy.QtGui import QDropEvent
15
- from qtpy.QtWidgets import QMessageBox
15
+ from qtpy.QtWidgets import (
16
+ QApplication,
17
+ QMessageBox,
18
+ )
16
19
 
17
20
  if qtpy.API_NAME == 'PySide2' and sys.version_info[:2] > (3, 10):
18
21
  pytest.skip(
@@ -21,8 +24,11 @@ if qtpy.API_NAME == 'PySide2' and sys.version_info[:2] > (3, 10):
21
24
  allow_module_level=True,
22
25
  )
23
26
 
24
- from napari_plugin_manager import qt_plugin_dialog
25
- from napari_plugin_manager.base_qt_package_installer import InstallerActions
27
+ from napari_plugin_manager import base_qt_plugin_dialog, qt_plugin_dialog
28
+ from napari_plugin_manager.base_qt_package_installer import (
29
+ InstallerActions,
30
+ InstallerTools,
31
+ )
26
32
 
27
33
  N_MOCKED_PLUGINS = 2
28
34
 
@@ -30,7 +36,7 @@ N_MOCKED_PLUGINS = 2
30
36
  def _iter_napari_pypi_plugin_info(
31
37
  conda_forge: bool = True,
32
38
  ) -> Generator[
33
- Tuple[Optional[npe2.PackageMetadata], bool], None, None
39
+ tuple[npe2.PackageMetadata | None, bool], None, None
34
40
  ]: # pragma: no cover (this function is used in thread and codecov has a problem with the collection of coverage in such cases)
35
41
  """Mock the pypi method to collect available plugins.
36
42
 
@@ -87,29 +93,6 @@ def plugins(qtbot):
87
93
  return PluginsMock()
88
94
 
89
95
 
90
- class WarnPopupMock:
91
- def __init__(self, text):
92
- self._is_visible = False
93
-
94
- def show(self):
95
- self._is_visible = True
96
-
97
- def exec_(self):
98
- self._is_visible = True
99
-
100
- def move(self, pos):
101
- return False
102
-
103
- def isVisible(self):
104
- return self._is_visible
105
-
106
- def close(self):
107
- self._is_visible = False
108
-
109
- def width(self):
110
- return 100
111
-
112
-
113
96
  @pytest.fixture(params=[True, False], ids=["constructor", "no-constructor"])
114
97
  def plugin_dialog(
115
98
  request,
@@ -120,6 +103,10 @@ def plugin_dialog(
120
103
  old_plugins,
121
104
  ):
122
105
  """Fixture that provides a plugin dialog for a normal napari install."""
106
+ from napari.settings import get_settings
107
+
108
+ original_setting = get_settings().plugins.use_npe2_adaptor
109
+ get_settings().plugins.use_npe2_adaptor = False
123
110
 
124
111
  class PluginManagerMock:
125
112
  def instance(self):
@@ -138,7 +125,7 @@ def plugin_dialog(
138
125
  def is_disabled(self, name):
139
126
  return False
140
127
 
141
- def discover(self):
128
+ def discover(self, include_npe1=False):
142
129
  return ['plugin']
143
130
 
144
131
  def enable(self, plugin):
@@ -182,7 +169,6 @@ def plugin_dialog(
182
169
  "iter_napari_plugin_info",
183
170
  _iter_napari_pypi_plugin_info,
184
171
  )
185
- monkeypatch.setattr(qt_plugin_dialog, 'WarnPopup', WarnPopupMock)
186
172
 
187
173
  # This is patching `napari.utils.misc.running_as_constructor_app` function
188
174
  # to mock a normal napari install.
@@ -212,6 +198,7 @@ def plugin_dialog(
212
198
  widget.hide()
213
199
  widget._add_items_timer.stop()
214
200
  assert not widget._add_items_timer.isActive()
201
+ get_settings().plugins.use_npe2_adaptor = original_setting
215
202
 
216
203
 
217
204
  def test_filter_not_available_plugins(request, plugin_dialog, qtbot):
@@ -275,7 +262,7 @@ def test_visible_widgets(request, plugin_dialog):
275
262
  """
276
263
  Test that the direct entry button and textbox are visible
277
264
  """
278
- if "no-constructor" not in request.node.name:
265
+ if "constructor" in request.node.name:
279
266
  pytest.skip(
280
267
  reason="Tested functionality not available in constructor-based installs"
281
268
  )
@@ -314,14 +301,6 @@ def test_plugin_list_handle_action(plugin_dialog, qtbot):
314
301
  trans._("updating..."), InstallerActions.UPGRADE
315
302
  )
316
303
 
317
- with patch.object(qt_plugin_dialog.WarnPopup, "exec_") as mock:
318
- plugin_dialog.installed_list.handle_action(
319
- item,
320
- 'my-test-old-plugin-1',
321
- InstallerActions.UNINSTALL,
322
- )
323
- assert mock.called
324
-
325
304
  plugin_dialog.search("requests")
326
305
  qtbot.wait(500)
327
306
  item = plugin_dialog.available_list.item(0)
@@ -334,7 +313,7 @@ def test_plugin_list_handle_action(plugin_dialog, qtbot):
334
313
  InstallerActions.INSTALL,
335
314
  version='3',
336
315
  )
337
- mock.assert_called_with(
316
+ mock.assert_called_once_with(
338
317
  trans._("installing..."), InstallerActions.INSTALL
339
318
  )
340
319
 
@@ -344,11 +323,36 @@ def test_plugin_list_handle_action(plugin_dialog, qtbot):
344
323
  InstallerActions.CANCEL,
345
324
  version='3',
346
325
  )
347
- mock.assert_called_with("", InstallerActions.CANCEL)
326
+ assert mock.call_count >= 2
327
+ assert mock.call_args_list[1] == call(
328
+ "cancelling...", InstallerActions.CANCEL
329
+ )
348
330
 
349
331
  qtbot.waitUntil(lambda: not plugin_dialog.worker.is_running)
350
332
 
351
333
 
334
+ def test_plugin_install_restart_warning(plugin_dialog, monkeypatch):
335
+ dialog_mock = MagicMock()
336
+ monkeypatch.setattr(
337
+ base_qt_plugin_dialog, 'RestartWarningDialog', dialog_mock
338
+ )
339
+ plugin_dialog.exec_()
340
+ plugin_dialog.already_installed.add('brand-new-plugin')
341
+ plugin_dialog.hide()
342
+ dialog_mock.assert_called_once()
343
+
344
+
345
+ def test_plugin_uninstall_restart_warning(plugin_dialog, monkeypatch):
346
+ dialog_mock = MagicMock()
347
+ monkeypatch.setattr(
348
+ base_qt_plugin_dialog, 'RestartWarningDialog', dialog_mock
349
+ )
350
+ plugin_dialog.exec_()
351
+ plugin_dialog.already_installed.remove('my-plugin')
352
+ plugin_dialog.hide()
353
+ dialog_mock.assert_called_once()
354
+
355
+
352
356
  def test_on_enabled_checkbox(plugin_dialog, qtbot, plugins, old_plugins):
353
357
  # checks npe2 lines
354
358
  item = plugin_dialog.installed_list.item(0)
@@ -463,10 +467,15 @@ def test_drop_event(plugin_dialog, tmp_path):
463
467
  assert plugin_dialog.direct_entry_edit.text() == str(path_1)
464
468
 
465
469
 
470
+ @pytest.mark.skipif(
471
+ 'napari_latest' in os.getenv('TOX_ENV_NAME')
472
+ and 'PySide2' in os.getenv('TOX_ENV_NAME'),
473
+ reason='PySide2 flaky with latest released napari',
474
+ )
466
475
  def test_installs(qtbot, tmp_virtualenv, plugin_dialog, request):
467
476
  if "[constructor]" in request.node.name:
468
477
  pytest.skip(
469
- reason="This test is only relevant for constructor-based installs"
478
+ reason="This test is only relevant for non-constructor-based installs"
470
479
  )
471
480
 
472
481
  plugin_dialog.set_prefix(str(tmp_virtualenv))
@@ -490,12 +499,23 @@ def test_installs(qtbot, tmp_virtualenv, plugin_dialog, request):
490
499
  [QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.Ok],
491
500
  )
492
501
  def test_install_pypi_constructor(
493
- qtbot, tmp_virtualenv, plugin_dialog, request, message_return
502
+ qtbot, tmp_virtualenv, plugin_dialog, request, message_return, monkeypatch
494
503
  ):
495
504
  if "no-constructor" in request.node.name:
496
505
  pytest.skip(
497
- reason="This test is only relevant for constructor-based installs"
506
+ reason="This test is to test pip in constructor-based installs"
498
507
  )
508
+ # ensure pip is the installer tool, so that the warning will trigger
509
+ monkeypatch.setattr(
510
+ qt_plugin_dialog.PluginListItem,
511
+ 'get_installer_tool',
512
+ lambda self: InstallerTools.PIP,
513
+ )
514
+ monkeypatch.setattr(
515
+ qt_plugin_dialog.PluginListItem,
516
+ 'get_installer_source',
517
+ lambda self: "PIP",
518
+ )
499
519
 
500
520
  plugin_dialog.set_prefix(str(tmp_virtualenv))
501
521
  plugin_dialog.search('requests')
@@ -518,7 +538,7 @@ def test_install_pypi_constructor(
518
538
  def test_cancel(qtbot, tmp_virtualenv, plugin_dialog, request):
519
539
  if "[constructor]" in request.node.name:
520
540
  pytest.skip(
521
- reason="This test is only relevant for constructor-based installs"
541
+ reason="This test is only relevant for non-constructor-based installs"
522
542
  )
523
543
 
524
544
  plugin_dialog.set_prefix(str(tmp_virtualenv))
@@ -542,7 +562,7 @@ def test_cancel(qtbot, tmp_virtualenv, plugin_dialog, request):
542
562
  def test_cancel_all(qtbot, tmp_virtualenv, plugin_dialog, request):
543
563
  if "[constructor]" in request.node.name:
544
564
  pytest.skip(
545
- reason="This test is only relevant for constructor-based installs"
565
+ reason="This test is only relevant for non-constructor-based installs"
546
566
  )
547
567
 
548
568
  plugin_dialog.set_prefix(str(tmp_virtualenv))
@@ -569,7 +589,7 @@ def test_cancel_all(qtbot, tmp_virtualenv, plugin_dialog, request):
569
589
  def test_direct_entry_installs(qtbot, tmp_virtualenv, plugin_dialog, request):
570
590
  if "[constructor]" in request.node.name:
571
591
  pytest.skip(
572
- reason="This test is only relevant for constructor-based installs"
592
+ reason="The tested functionality is not available in constructor-based installs"
573
593
  )
574
594
 
575
595
  plugin_dialog.set_prefix(str(tmp_virtualenv))
@@ -605,3 +625,46 @@ def test_shortcut_quit(plugin_dialog, qtbot):
605
625
  )
606
626
  qtbot.wait(500)
607
627
  assert not plugin_dialog.isVisible()
628
+
629
+
630
+ @pytest.mark.skipif(
631
+ not sys.platform.startswith('linux'), reason="Test works only on linux"
632
+ )
633
+ def test_export_plugins_button(plugin_dialog):
634
+ def _timer():
635
+ dialog = QApplication.activeModalWidget()
636
+ dialog.reject()
637
+
638
+ timer = QTimer()
639
+ timer.setSingleShot(True)
640
+ timer.timeout.connect(_timer)
641
+ timer.start(4_000)
642
+ plugin_dialog.export_button.click()
643
+
644
+
645
+ def test_export_plugins(plugin_dialog, tmp_path):
646
+ plugins_file = 'plugins.txt'
647
+ plugin_dialog.export_plugins(str(tmp_path / plugins_file))
648
+ assert (tmp_path / plugins_file).exists()
649
+
650
+
651
+ @pytest.mark.skipif(
652
+ not sys.platform.startswith('linux'), reason="Test works only on linux"
653
+ )
654
+ def test_import_plugins_button(plugin_dialog):
655
+ def _timer():
656
+ dialog = QApplication.activeModalWidget()
657
+ dialog.reject()
658
+
659
+ timer = QTimer()
660
+ timer.setSingleShot(True)
661
+ timer.timeout.connect(_timer)
662
+ timer.start(4_000)
663
+ plugin_dialog.import_button.click()
664
+
665
+
666
+ def test_import_plugins(plugin_dialog, tmp_path, qtbot):
667
+ path = tmp_path / 'plugins.txt'
668
+ path.write_text('requests\npyzenhub\n')
669
+ with qtbot.waitSignal(plugin_dialog.installer.allFinished, timeout=60_000):
670
+ plugin_dialog.import_plugins(str(path))
@@ -1,8 +1,13 @@
1
- # file generated by setuptools_scm
1
+ # file generated by setuptools-scm
2
2
  # don't change, don't track in version control
3
+
4
+ __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
5
+
3
6
  TYPE_CHECKING = False
4
7
  if TYPE_CHECKING:
5
- from typing import Tuple, Union
8
+ from typing import Tuple
9
+ from typing import Union
10
+
6
11
  VERSION_TUPLE = Tuple[Union[int, str], ...]
7
12
  else:
8
13
  VERSION_TUPLE = object
@@ -12,5 +17,5 @@ __version__: str
12
17
  __version_tuple__: VERSION_TUPLE
13
18
  version_tuple: VERSION_TUPLE
14
19
 
15
- __version__ = version = '0.1.4'
16
- __version_tuple__ = version_tuple = (0, 1, 4)
20
+ __version__ = version = '0.1.5'
21
+ __version_tuple__ = version_tuple = (0, 1, 5)
@@ -1,18 +1,21 @@
1
- """
2
- A tool-agnostic installation logic for the plugin manager.
1
+ """Package tool-agnostic installation logic for the plugin manager.
3
2
 
4
3
  The main object is `InstallerQueue`, a `QProcess` subclass
5
- with the notion of a job queue. The queued jobs are represented
6
- by a `deque` of `*InstallerTool` dataclasses that contain the
7
- executable path, arguments and environment modifications.
4
+ with the notion of a job queue.
5
+
6
+ The queued jobs are represented by a `deque` of `*InstallerTool` dataclasses
7
+ that contain the executable path, arguments and environment modifications.
8
+
8
9
  Available actions for each tool are `install`, `uninstall`
9
10
  and `cancel`.
10
11
  """
11
12
 
12
13
  import contextlib
14
+ import logging
13
15
  import os
14
16
  import sys
15
17
  from collections import deque
18
+ from collections.abc import Sequence
16
19
  from dataclasses import dataclass
17
20
  from enum import auto
18
21
  from functools import lru_cache
@@ -20,7 +23,7 @@ from logging import getLogger
20
23
  from pathlib import Path
21
24
  from subprocess import call
22
25
  from tempfile import gettempdir
23
- from typing import Deque, Optional, Sequence, Tuple, TypedDict
26
+ from typing import TypedDict
24
27
 
25
28
  from napari.plugins import plugin_manager
26
29
  from napari.plugins.npe2api import _user_agent
@@ -30,12 +33,15 @@ from npe2 import PluginManager
30
33
  from qtpy.QtCore import QObject, QProcess, QProcessEnvironment, Signal
31
34
  from qtpy.QtWidgets import QTextEdit
32
35
 
36
+ # Alias for int type to represent a job idenfifier in the installer queue
33
37
  JobId = int
38
+
34
39
  log = getLogger(__name__)
35
40
 
36
41
 
37
42
  class InstallerActions(StringEnum):
38
43
  "Available actions for the plugin manager"
44
+
39
45
  INSTALL = auto()
40
46
  UNINSTALL = auto()
41
47
  CANCEL = auto()
@@ -44,24 +50,29 @@ class InstallerActions(StringEnum):
44
50
 
45
51
 
46
52
  class ProcessFinishedData(TypedDict):
53
+ """Data about a finished process."""
54
+
47
55
  exit_code: int
48
56
  exit_status: int
49
57
  action: InstallerActions
50
- pkgs: Tuple[str, ...]
58
+ pkgs: tuple[str, ...]
51
59
 
52
60
 
53
61
  class InstallerTools(StringEnum):
54
- "Available tools for InstallerQueue jobs"
62
+ "Installer tools selectable by InstallerQueue jobs"
63
+
55
64
  CONDA = auto()
56
65
  PIP = auto()
57
66
 
58
67
 
59
68
  @dataclass(frozen=True)
60
69
  class AbstractInstallerTool:
70
+ """Abstract base class for installer tools."""
71
+
61
72
  action: InstallerActions
62
- pkgs: Tuple[str, ...]
63
- origins: Tuple[str, ...] = ()
64
- prefix: Optional[str] = None
73
+ pkgs: tuple[str, ...]
74
+ origins: tuple[str, ...] = ()
75
+ prefix: str | None = None
65
76
  process: QProcess = None
66
77
 
67
78
  @property
@@ -104,16 +115,25 @@ class AbstractInstallerTool:
104
115
 
105
116
 
106
117
  class PipInstallerTool(AbstractInstallerTool):
118
+ """Pip installer tool for the plugin manager.
119
+
120
+ This class is used to install and uninstall packages using pip.
121
+ """
122
+
107
123
  @classmethod
108
124
  def available(cls):
125
+ """Check if pip is available."""
109
126
  return call([cls.executable(), "-m", "pip", "--version"]) == 0
110
127
 
111
- def arguments(self) -> Tuple[str, ...]:
128
+ def arguments(self) -> tuple[str, ...]:
129
+ """Compose arguments for the pip command."""
112
130
  args = ['-m', 'pip']
131
+
113
132
  if self.action == InstallerActions.INSTALL:
114
133
  args += ['install', '-c', self._constraints_file()]
115
134
  for origin in self.origins:
116
135
  args += ['--extra-index-url', origin]
136
+
117
137
  elif self.action == InstallerActions.UPGRADE:
118
138
  args += [
119
139
  'install',
@@ -123,14 +143,19 @@ class PipInstallerTool(AbstractInstallerTool):
123
143
  ]
124
144
  for origin in self.origins:
125
145
  args += ['--extra-index-url', origin]
146
+
126
147
  elif self.action == InstallerActions.UNINSTALL:
127
148
  args += ['uninstall', '-y']
149
+
128
150
  else:
129
151
  raise ValueError(f"Action '{self.action}' not supported!")
130
- if 10 <= log.getEffectiveLevel() < 30: # DEBUG level
152
+
153
+ if log.getEffectiveLevel() < 30: # DEBUG and INFOlevel
131
154
  args.append('-vvv')
155
+
132
156
  if self.prefix is not None:
133
157
  args.extend(['--prefix', str(self.prefix)])
158
+
134
159
  return (*args, *self.pkgs)
135
160
 
136
161
  def environment(
@@ -148,8 +173,17 @@ class PipInstallerTool(AbstractInstallerTool):
148
173
 
149
174
 
150
175
  class CondaInstallerTool(AbstractInstallerTool):
176
+ """Conda installer tool for the plugin manager.
177
+
178
+ This class is used to install and uninstall packages using conda or conda-like executable.
179
+ """
180
+
151
181
  @classmethod
152
182
  def executable(cls):
183
+ """Find a path to the executable.
184
+
185
+ This method assumes that if no environment variable is set that conda is available in the PATH.
186
+ """
153
187
  bat = ".bat" if os.name == "nt" else ""
154
188
  for path in (
155
189
  Path(os.environ.get('MAMBA_EXE', '')),
@@ -158,26 +192,33 @@ class CondaInstallerTool(AbstractInstallerTool):
158
192
  Path(os.environ.get('CONDA', '')) / 'condabin' / f'conda{bat}',
159
193
  ):
160
194
  if path.is_file():
195
+ # return the path to the executable
161
196
  return str(path)
162
- return f'conda{bat}' # cross our fingers 'conda' is in PATH
197
+ # Otherwise, we assume that conda is available in the PATH
198
+ return f'conda{bat}'
163
199
 
164
200
  @classmethod
165
201
  def available(cls):
202
+ """Check if the executable is available by checking if it can output its version."""
166
203
  executable = cls.executable()
167
204
  try:
168
205
  return call([executable, "--version"]) == 0
169
206
  except FileNotFoundError: # pragma: no cover
170
207
  return False
171
208
 
172
- def arguments(self) -> Tuple[str, ...]:
209
+ def arguments(self) -> tuple[str, ...]:
210
+ """Compose arguments for the conda command."""
173
211
  prefix = self.prefix or self._default_prefix()
212
+
174
213
  if self.action == InstallerActions.UPGRADE:
175
214
  args = ['update', '-y', '--prefix', prefix]
176
215
  else:
177
216
  args = [self.action.value, '-y', '--prefix', prefix]
217
+
178
218
  args.append('--override-channels')
179
219
  for channel in (*self.origins, *self._default_channels()):
180
220
  args.extend(["-c", channel])
221
+
181
222
  return (*args, *self.pkgs)
182
223
 
183
224
  def environment(
@@ -205,6 +246,7 @@ class CondaInstallerTool(AbstractInstallerTool):
205
246
  def _add_constraints_to_env(
206
247
  self, env: QProcessEnvironment
207
248
  ) -> QProcessEnvironment:
249
+ """Add constraints to the environment."""
208
250
  PINNED = 'CONDA_PINNED_PACKAGES'
209
251
  constraints = self.constraints()
210
252
  if env.contains(PINNED):
@@ -213,9 +255,11 @@ class CondaInstallerTool(AbstractInstallerTool):
213
255
  return env
214
256
 
215
257
  def _default_channels(self):
258
+ """Default channels for conda installations."""
216
259
  return ('conda-forge',)
217
260
 
218
261
  def _default_prefix(self):
262
+ """Default prefix for conda installations."""
219
263
  if (Path(sys.prefix) / "conda-meta").is_dir():
220
264
  return sys.prefix
221
265
  raise ValueError("Prefix has not been specified!")
@@ -244,10 +288,10 @@ class InstallerQueue(QObject):
244
288
  BASE_PACKAGE_NAME = ''
245
289
 
246
290
  def __init__(
247
- self, parent: Optional[QObject] = None, prefix: Optional[str] = None
291
+ self, parent: QObject | None = None, prefix: str | None = None
248
292
  ) -> None:
249
293
  super().__init__(parent)
250
- self._queue: Deque[AbstractInstallerTool] = deque()
294
+ self._queue: deque[AbstractInstallerTool] = deque()
251
295
  self._current_process: QProcess = None
252
296
  self._prefix = prefix
253
297
  self._output_widget = None
@@ -259,11 +303,13 @@ class InstallerQueue(QObject):
259
303
  tool: InstallerTools,
260
304
  pkgs: Sequence[str],
261
305
  *,
262
- prefix: Optional[str] = None,
306
+ prefix: str | None = None,
263
307
  origins: Sequence[str] = (),
264
308
  **kwargs,
265
309
  ) -> JobId:
266
- """Install packages in `pkgs` into `prefix` using `tool` with additional
310
+ """Install packages in the installer queue.
311
+
312
+ This installs packages in `pkgs` into `prefix` using `tool` with additional
267
313
  `origins` as source for `pkgs`.
268
314
 
269
315
  Parameters
@@ -280,7 +326,7 @@ class InstallerQueue(QObject):
280
326
  Returns
281
327
  -------
282
328
  JobId : int
283
- ID that can be used to cancel the process.
329
+ An ID to reference the job. Use to cancel the process.
284
330
  """
285
331
  item = self._build_queue_item(
286
332
  tool=tool,
@@ -298,11 +344,13 @@ class InstallerQueue(QObject):
298
344
  tool: InstallerTools,
299
345
  pkgs: Sequence[str],
300
346
  *,
301
- prefix: Optional[str] = None,
347
+ prefix: str | None = None,
302
348
  origins: Sequence[str] = (),
303
349
  **kwargs,
304
350
  ) -> JobId:
305
- """Upgrade packages in `pkgs` into `prefix` using `tool` with additional
351
+ """Upgrade packages in the installer queue.
352
+
353
+ Upgrade in `pkgs` into `prefix` using `tool` with additional
306
354
  `origins` as source for `pkgs`.
307
355
 
308
356
  Parameters
@@ -319,7 +367,7 @@ class InstallerQueue(QObject):
319
367
  Returns
320
368
  -------
321
369
  JobId : int
322
- ID that can be used to cancel the process.
370
+ An ID to reference the job. Use to cancel the process.
323
371
  """
324
372
  item = self._build_queue_item(
325
373
  tool=tool,
@@ -337,10 +385,12 @@ class InstallerQueue(QObject):
337
385
  tool: InstallerTools,
338
386
  pkgs: Sequence[str],
339
387
  *,
340
- prefix: Optional[str] = None,
388
+ prefix: str | None = None,
341
389
  **kwargs,
342
390
  ) -> JobId:
343
- """Uninstall packages in `pkgs` from `prefix` using `tool`.
391
+ """Uninstall packages in the installer queue.
392
+
393
+ Uninstall packages in `pkgs` from `prefix` using `tool`.
344
394
 
345
395
  Parameters
346
396
  ----------
@@ -354,7 +404,7 @@ class InstallerQueue(QObject):
354
404
  Returns
355
405
  -------
356
406
  JobId : int
357
- ID that can be used to cancel the process.
407
+ An ID to reference the job. Use to cancel the process.
358
408
  """
359
409
  item = self._build_queue_item(
360
410
  tool=tool,
@@ -367,8 +417,10 @@ class InstallerQueue(QObject):
367
417
  return self._queue_item(item)
368
418
 
369
419
  def cancel(self, job_id: JobId):
370
- """Cancel `job_id` if it is running. If `job_id` does not exist int the queue,
371
- a ValueError is raised.
420
+ """Cancel a job.
421
+
422
+ Cancel the process, if it is running, referenced by `job_id`.
423
+ If `job_id` does not exist in the queue, a ValueError is raised.
372
424
 
373
425
  Parameters
374
426
  ----------
@@ -391,7 +443,7 @@ class InstallerQueue(QObject):
391
443
 
392
444
  self._end_process(item.process)
393
445
  else:
394
- # still pending, just remove from queue
446
+ # job is still pending, just remove it from the queue
395
447
  self._queue.remove(item)
396
448
 
397
449
  self.processFinished.emit(
@@ -402,6 +454,7 @@ class InstallerQueue(QObject):
402
454
  'pkgs': item.pkgs,
403
455
  }
404
456
  )
457
+ # continue processing the queue
405
458
  self._process_queue()
406
459
  return
407
460
 
@@ -415,7 +468,7 @@ class InstallerQueue(QObject):
415
468
  raise ValueError(msg)
416
469
 
417
470
  def cancel_all(self):
418
- """Terminate all process in the queue and emit the `processFinished` signal."""
471
+ """Terminate all processes in the queue and emit the `processFinished` signal."""
419
472
  all_pkgs = []
420
473
  for item in deque(self._queue):
421
474
  all_pkgs.extend(item.pkgs)
@@ -462,6 +515,7 @@ class InstallerQueue(QObject):
462
515
  return len(self._queue)
463
516
 
464
517
  def set_output_widget(self, output_widget: QTextEdit):
518
+ """Set the output widget for text output."""
465
519
  if output_widget:
466
520
  self._output_widget = output_widget
467
521
 
@@ -492,7 +546,7 @@ class InstallerQueue(QObject):
492
546
  tool: InstallerTools,
493
547
  action: InstallerActions,
494
548
  pkgs: Sequence[str],
495
- prefix: Optional[str] = None,
549
+ prefix: str | None = None,
496
550
  origins: Sequence[str] = (),
497
551
  **kwargs,
498
552
  ) -> AbstractInstallerTool:
@@ -581,9 +635,9 @@ class InstallerQueue(QObject):
581
635
 
582
636
  def _on_process_done(
583
637
  self,
584
- exit_code: Optional[int] = None,
585
- exit_status: Optional[QProcess.ExitStatus] = None,
586
- error: Optional[QProcess.ProcessError] = None,
638
+ exit_code: int | None = None,
639
+ exit_status: QProcess.ExitStatus | None = None,
640
+ error: QProcess.ProcessError | None = None,
587
641
  ):
588
642
  item = None
589
643
  with contextlib.suppress(IndexError):
@@ -616,9 +670,15 @@ class InstallerQueue(QObject):
616
670
 
617
671
  def _on_stdout_ready(self):
618
672
  if self._current_process is not None:
619
- text = (
620
- self._current_process.readAllStandardOutput().data().decode()
621
- )
673
+ try:
674
+ text = (
675
+ self._current_process.readAllStandardOutput()
676
+ .data()
677
+ .decode()
678
+ )
679
+ except UnicodeDecodeError:
680
+ logging.exception("Could not decode stdout")
681
+ return
622
682
  if text:
623
683
  self._log(text)
624
684
 
@@ -2,20 +2,17 @@ import contextlib
2
2
  import importlib.metadata
3
3
  import os
4
4
  import webbrowser
5
+ from collections.abc import Sequence
5
6
  from functools import partial
6
7
  from typing import (
7
8
  Any,
8
- Dict,
9
- List,
10
9
  Literal,
11
10
  NamedTuple,
12
- Optional,
13
11
  Protocol,
14
- Sequence,
15
- Tuple,
16
12
  )
17
13
 
18
14
  from packaging.version import parse as parse_version
15
+ from qtpy.compat import getopenfilename, getsavefilename
19
16
  from qtpy.QtCore import QSize, Qt, QTimer, Signal, Slot
20
17
  from qtpy.QtGui import (
21
18
  QAction,
@@ -54,6 +51,7 @@ from napari_plugin_manager.base_qt_package_installer import (
54
51
  InstallerTools,
55
52
  ProcessFinishedData,
56
53
  )
54
+ from napari_plugin_manager.qt_warning_dialog import RestartWarningDialog
57
55
  from napari_plugin_manager.qt_widgets import ClickableLabel
58
56
  from napari_plugin_manager.utils import is_conda_package
59
57
 
@@ -113,8 +111,8 @@ class BasePackageMetadata(NamedTuple):
113
111
  class BaseProjectInfoVersions(NamedTuple):
114
112
  metadata: BasePackageMetadata
115
113
  display_name: str
116
- pypi_versions: List[str]
117
- conda_versions: List[str]
114
+ pypi_versions: list[str]
115
+ conda_versions: list[str]
118
116
 
119
117
 
120
118
  class BasePluginListItem(QFrame):
@@ -146,13 +144,13 @@ class BasePluginListItem(QFrame):
146
144
  author: str = '',
147
145
  license: str = "UNKNOWN", # noqa: A002
148
146
  *,
149
- plugin_name: Optional[str] = None,
147
+ plugin_name: str | None = None,
150
148
  parent: QWidget = None,
151
149
  enabled: bool = True,
152
150
  installed: bool = False,
153
151
  plugin_api_version=1,
154
- versions_conda: Optional[List[str]] = None,
155
- versions_pypi: Optional[List[str]] = None,
152
+ versions_conda: list[str] | None = None,
153
+ versions_pypi: list[str] | None = None,
156
154
  prefix=None,
157
155
  ) -> None:
158
156
  super().__init__(parent)
@@ -308,9 +306,9 @@ class BasePluginListItem(QFrame):
308
306
  def set_busy(
309
307
  self,
310
308
  text: str,
311
- action_name: Optional[
312
- Literal['install', 'uninstall', 'cancel', 'upgrade']
313
- ] = None,
309
+ action_name: (
310
+ Literal['install', 'uninstall', 'cancel', 'upgrade'] | None
311
+ ) = None,
314
312
  ):
315
313
  """Updates status text and what buttons are visible when any button is pushed.
316
314
 
@@ -831,8 +829,8 @@ class BaseQPluginList(QListWidget):
831
829
  item: QListWidgetItem,
832
830
  pkg_name: str,
833
831
  action_name: InstallerActions,
834
- version: Optional[str] = None,
835
- installer_choice: Optional[str] = None,
832
+ version: str | None = None,
833
+ installer_choice: str | None = None,
836
834
  ):
837
835
  """Determine which action is called (install, uninstall, update, cancel).
838
836
  Update buttons appropriately and run the action."""
@@ -843,8 +841,6 @@ class BaseQPluginList(QListWidget):
843
841
  if not item.text().startswith(self._SORT_ORDER_PREFIX):
844
842
  item.setText(f"{self._SORT_ORDER_PREFIX}{item.text()}")
845
843
 
846
- self._before_handle_action(widget, action_name)
847
-
848
844
  if action_name == InstallerActions.INSTALL:
849
845
  if version:
850
846
  pkg_name += (
@@ -1258,7 +1254,7 @@ class BaseQtPluginDialog(QDialog):
1258
1254
  self._add_items_timer.start()
1259
1255
  self._update_plugin_count()
1260
1256
 
1261
- def _add_installed(self, pkg_name: Optional[str] = None) -> None:
1257
+ def _add_installed(self, pkg_name: str | None = None) -> None:
1262
1258
  """
1263
1259
  Add plugins that are installed to the dialog.
1264
1260
 
@@ -1385,6 +1381,18 @@ class BaseQtPluginDialog(QDialog):
1385
1381
  self.packages_search.setClearButtonEnabled(True)
1386
1382
  self.packages_search.textChanged.connect(self.search)
1387
1383
 
1384
+ self.import_button = QPushButton(self._trans('Import'), self)
1385
+ self.import_button.setObjectName("import_button")
1386
+ self.import_button.setToolTip(self._trans('Import plugins from file'))
1387
+ self.import_button.clicked.connect(self._import_plugins)
1388
+
1389
+ self.export_button = QPushButton(self._trans('Export'), self)
1390
+ self.export_button.setObjectName("export_button")
1391
+ self.export_button.setToolTip(
1392
+ self._trans('Export installed plugins list')
1393
+ )
1394
+ self.export_button.clicked.connect(self._export_plugins)
1395
+
1388
1396
  self.refresh_button = QPushButton(self._trans('Refresh'), self)
1389
1397
  self.refresh_button.setObjectName("refresh_button")
1390
1398
  self.refresh_button.setToolTip(
@@ -1398,6 +1406,8 @@ class BaseQtPluginDialog(QDialog):
1398
1406
  horizontal_mid_layout = QHBoxLayout()
1399
1407
  horizontal_mid_layout.addWidget(self.packages_search)
1400
1408
  horizontal_mid_layout.addStretch()
1409
+ horizontal_mid_layout.addWidget(self.import_button)
1410
+ horizontal_mid_layout.addWidget(self.export_button)
1401
1411
  horizontal_mid_layout.addWidget(self.refresh_button)
1402
1412
  mid_layout.addLayout(horizontal_mid_layout)
1403
1413
  mid_layout.addWidget(self.installed_label)
@@ -1657,7 +1667,7 @@ class BaseQtPluginDialog(QDialog):
1657
1667
 
1658
1668
  self._update_plugin_count()
1659
1669
 
1660
- def _handle_yield(self, data: Tuple[PackageMetadataProtocol, bool, Dict]):
1670
+ def _handle_yield(self, data: tuple[PackageMetadataProtocol, bool, dict]):
1661
1671
  """Output from a worker process.
1662
1672
 
1663
1673
  Includes information about the plugin, including available versions on conda and pypi.
@@ -1687,6 +1697,16 @@ class BaseQtPluginDialog(QDialog):
1687
1697
  def _refresh_and_clear_cache(self):
1688
1698
  self.refresh(clear_cache=True)
1689
1699
 
1700
+ def _import_plugins(self):
1701
+ fpath, _ = getopenfilename(filters="Text files (*.txt)")
1702
+ if fpath:
1703
+ self.import_plugins(fpath)
1704
+
1705
+ def _export_plugins(self):
1706
+ fpath, _ = getsavefilename(filters="Text files (*.txt)")
1707
+ if fpath:
1708
+ self.export_plugins(fpath)
1709
+
1690
1710
  # endregion - Private methods
1691
1711
 
1692
1712
  # region - Qt overrides
@@ -1721,12 +1741,18 @@ class BaseQtPluginDialog(QDialog):
1721
1741
 
1722
1742
  plugin_dialog.setModal(True)
1723
1743
  plugin_dialog.show()
1744
+ plugin_dialog._installed_on_show = set(plugin_dialog.already_installed)
1724
1745
 
1725
1746
  if self._first_open:
1726
1747
  self._update_theme(None)
1727
1748
  self._first_open = False
1728
1749
 
1729
1750
  def hideEvent(self, event):
1751
+ if (
1752
+ hasattr(self, '_installed_on_show')
1753
+ and self._installed_on_show != self.already_installed
1754
+ ):
1755
+ RestartWarningDialog(self).exec_()
1730
1756
  self.packages_search.clear()
1731
1757
  self.toggle_status(False)
1732
1758
  super().hideEvent(event)
@@ -1735,7 +1761,7 @@ class BaseQtPluginDialog(QDialog):
1735
1761
 
1736
1762
  # region - Public methods
1737
1763
  # ------------------------------------------------------------------------
1738
- def search(self, text: Optional[str] = None, skip=False) -> None:
1764
+ def search(self, text: str | None = None, skip=False) -> None:
1739
1765
  """Filter by text or set current text as filter."""
1740
1766
  if text is None:
1741
1767
  text = self.packages_search.text()
@@ -1813,4 +1839,30 @@ class BaseQtPluginDialog(QDialog):
1813
1839
  item = self.installed_list.item(idx)
1814
1840
  item.widget.prefix = prefix
1815
1841
 
1842
+ def export_plugins(self, fpath: str) -> list[str]:
1843
+ """Export installed plugins to a file."""
1844
+ plugins = []
1845
+ if self.installed_list.count():
1846
+ for idx in range(self.installed_list.count()):
1847
+ item = self.installed_list.item(idx)
1848
+ if item:
1849
+ name = item.widget.name
1850
+ version = item.widget._version # Make public attr?
1851
+ plugins.append(f"{name}=={version}\n")
1852
+
1853
+ with open(fpath, 'w') as f:
1854
+ f.writelines(plugins)
1855
+
1856
+ return plugins
1857
+
1858
+ def import_plugins(self, fpath: str) -> None:
1859
+ """Install plugins from file."""
1860
+ with open(fpath) as f:
1861
+ plugins = f.read().split('\n')
1862
+
1863
+ print(plugins)
1864
+
1865
+ plugins = [p for p in plugins if p]
1866
+ self._install_packages(plugins)
1867
+
1816
1868
  # endregion - Public methods
@@ -8,7 +8,6 @@ from collections.abc import Iterator
8
8
  from concurrent.futures import ThreadPoolExecutor
9
9
  from functools import lru_cache
10
10
  from typing import (
11
- Optional,
12
11
  TypedDict,
13
12
  cast,
14
13
  )
@@ -75,7 +74,7 @@ def plugin_summaries() -> list[SummaryDict]:
75
74
 
76
75
 
77
76
  @lru_cache
78
- def conda_map() -> dict[PyPIname, Optional[str]]:
77
+ def conda_map() -> dict[PyPIname, str | None]:
79
78
  """Return map of PyPI package name to conda_channel/package_name ()."""
80
79
  url = 'https://npe2api.vercel.app/api/conda'
81
80
  with urlopen(Request(url, headers={'User-Agent': _user_agent()})) as resp:
@@ -10,10 +10,10 @@ by a `deque` of `*InstallerTool` dataclasses (`NapariPipInstallerTool` and
10
10
  import atexit
11
11
  import os
12
12
  import sys
13
+ from collections.abc import Sequence
13
14
  from functools import lru_cache
14
15
  from pathlib import Path
15
16
  from tempfile import NamedTemporaryFile
16
- from typing import Sequence
17
17
 
18
18
  from napari._version import version as _napari_version
19
19
  from napari._version import version_tuple as _napari_version_tuple
@@ -48,7 +48,7 @@ class NapariPipInstallerTool(PipInstallerTool):
48
48
  """
49
49
  Version constraints to limit unwanted changes in installation.
50
50
  """
51
- return [f"napari=={_napari_version}", "numpy<2"]
51
+ return [f"napari=={_napari_version}"]
52
52
 
53
53
  @classmethod
54
54
  @lru_cache(maxsize=0)
@@ -75,7 +75,7 @@ class NapariCondaInstallerTool(CondaInstallerTool):
75
75
  pin_level = 2 if is_dev else 3
76
76
  version = ".".join([str(x) for x in _napari_version_tuple[:pin_level]])
77
77
 
78
- return [f"napari={version}", "numpy<2.0a0"]
78
+ return [f"napari={version}"]
79
79
 
80
80
 
81
81
  class NapariInstallerQueue(InstallerQueue):
@@ -6,7 +6,6 @@ import napari.resources
6
6
  import npe2
7
7
  from napari._qt.qt_resources import QColoredSVGIcon, get_current_stylesheet
8
8
  from napari._qt.qthreading import create_worker
9
- from napari._qt.widgets.qt_message_popup import WarnPopup
10
9
  from napari._qt.widgets.qt_tooltip import QtToolTipLabel
11
10
  from napari.plugins.utils import normalized_name
12
11
  from napari.settings import get_settings
@@ -15,7 +14,7 @@ from napari.utils.misc import (
15
14
  )
16
15
  from napari.utils.notifications import show_info, show_warning
17
16
  from napari.utils.translations import trans
18
- from qtpy.QtCore import QPoint, QSize
17
+ from qtpy.QtCore import QSize
19
18
  from qtpy.QtGui import (
20
19
  QMovie,
21
20
  )
@@ -43,24 +42,6 @@ STYLES_PATH = Path(__file__).parent / 'styles.qss'
43
42
  DISMISS_WARN_PYPI_INSTALL_DLG = False
44
43
 
45
44
 
46
- def _show_message(widget):
47
- message = trans._(
48
- 'When installing/uninstalling npe2 plugins, '
49
- 'you must restart napari for UI changes to take effect.'
50
- )
51
- if widget.isVisible():
52
- button = widget.action_button
53
- warn_dialog = WarnPopup(text=message)
54
- global_point = widget.action_button.mapToGlobal(
55
- button.rect().topRight()
56
- )
57
- global_point = QPoint(
58
- global_point.x() - button.width(), global_point.y()
59
- )
60
- warn_dialog.move(global_point)
61
- warn_dialog.exec_()
62
-
63
-
64
45
  class ProjectInfoVersions(BaseProjectInfoVersions):
65
46
  metadata: npe2.PackageMetadata
66
47
 
@@ -182,13 +163,6 @@ class QPluginList(BaseQPluginList):
182
163
  def _trans(self, text, **kwargs):
183
164
  return trans._(text, **kwargs)
184
165
 
185
- def _before_handle_action(self, widget, action_name):
186
- if (
187
- widget.plugin_api_version != 1
188
- and action_name == InstallerActions.UNINSTALL
189
- ):
190
- _show_message(widget)
191
-
192
166
 
193
167
  class QtPluginDialog(BaseQtPluginDialog):
194
168
 
@@ -206,8 +180,9 @@ class QtPluginDialog(BaseQtPluginDialog):
206
180
  self.setStyleSheet(stylesheet)
207
181
 
208
182
  def _add_installed(self, pkg_name=None):
183
+ use_npe2_adaptor = get_settings().plugins.use_npe2_adaptor
209
184
  pm2 = npe2.PluginManager.instance()
210
- pm2.discover()
185
+ pm2.discover(include_npe1=use_npe2_adaptor)
211
186
  for manifest in pm2.iter_manifests():
212
187
  distname = normalized_name(manifest.name or '')
213
188
  if distname in self.already_installed or distname == 'napari':
@@ -220,26 +195,32 @@ class QtPluginDialog(BaseQtPluginDialog):
220
195
  distname, enabled, distname, plugin_api_version=npev
221
196
  )
222
197
 
223
- napari.plugins.plugin_manager.discover() # since they might not be loaded yet
224
- for (
225
- plugin_name,
226
- _,
227
- distname,
228
- ) in napari.plugins.plugin_manager.iter_available():
229
- # not showing these in the plugin dialog
230
- if plugin_name in (
231
- 'napari_plugin_engine',
232
- 'napari_plugin_manager',
233
- ):
234
- continue
235
- if normalized_name(distname or '') in self.already_installed:
236
- continue
237
- if normalized_name(distname or '') == pkg_name or pkg_name is None:
238
- self._add_to_installed(
239
- distname,
240
- not napari.plugins.plugin_manager.is_blocked(plugin_name),
241
- normalized_name(distname or ''),
242
- )
198
+ if not use_npe2_adaptor:
199
+ napari.plugins.plugin_manager.discover() # since they might not be loaded yet
200
+ for (
201
+ plugin_name,
202
+ _,
203
+ distname,
204
+ ) in napari.plugins.plugin_manager.iter_available():
205
+ # not showing these in the plugin dialog
206
+ if plugin_name in (
207
+ 'napari_plugin_engine',
208
+ 'napari_plugin_manager',
209
+ ):
210
+ continue
211
+ if normalized_name(distname or '') in self.already_installed:
212
+ continue
213
+ if (
214
+ normalized_name(distname or '') == pkg_name
215
+ or pkg_name is None
216
+ ):
217
+ self._add_to_installed(
218
+ distname,
219
+ not napari.plugins.plugin_manager.is_blocked(
220
+ plugin_name
221
+ ),
222
+ normalized_name(distname or ''),
223
+ )
243
224
  self._update_plugin_count()
244
225
 
245
226
  for i in range(self.installed_list.count()):
@@ -248,12 +229,11 @@ class QtPluginDialog(BaseQtPluginDialog):
248
229
  if widget.name == pkg_name:
249
230
  self.installed_list.scrollToItem(item)
250
231
  self.installed_list.setCurrentItem(item)
251
- if widget.plugin_api_version != 1:
252
- _show_message(widget)
253
232
  break
254
233
 
255
234
  def _fetch_available_plugins(self, clear_cache: bool = False):
256
- get_settings()
235
+ settings = get_settings()
236
+ use_npe2_adaptor = settings.plugins.use_npe2_adaptor
257
237
 
258
238
  if clear_cache:
259
239
  cache_clear()
@@ -267,7 +247,7 @@ class QtPluginDialog(BaseQtPluginDialog):
267
247
  self.worker.start()
268
248
 
269
249
  pm2 = npe2.PluginManager.instance()
270
- pm2.discover()
250
+ pm2.discover(include_npe1=use_npe2_adaptor)
271
251
 
272
252
  def _loading_gif(self):
273
253
  load_gif = str(Path(napari.resources.__file__).parent / "loading.gif")
@@ -0,0 +1,19 @@
1
+ from qtpy.QtWidgets import QDialog, QLabel, QPushButton, QVBoxLayout, QWidget
2
+
3
+
4
+ class RestartWarningDialog(QDialog):
5
+ def __init__(self, parent: QWidget) -> None:
6
+ super().__init__(parent)
7
+ self.setWindowTitle('Restart napari')
8
+ okay_btn = QPushButton('Okay')
9
+ self.restart_warning_text = """
10
+ Plugins have been installed or uninstalled. If you notice any
11
+ issues with plugin functionality, you may need to restart napari.
12
+ """
13
+
14
+ okay_btn.clicked.connect(self.accept)
15
+
16
+ layout = QVBoxLayout()
17
+ layout.addWidget(QLabel(self.restart_warning_text))
18
+ layout.addWidget(okay_btn)
19
+ self.setLayout(layout)
@@ -1,10 +1,9 @@
1
1
  import re
2
2
  import sys
3
3
  from pathlib import Path
4
- from typing import Optional
5
4
 
6
5
 
7
- def is_conda_package(pkg: str, prefix: Optional[str] = None) -> bool:
6
+ def is_conda_package(pkg: str, prefix: str | None = None) -> bool:
8
7
  """Determines if plugin was installed through conda.
9
8
 
10
9
  Returns
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: napari-plugin-manager
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: Install plugins for napari, in napari.
5
5
  Author-email: napari team <napari-steering-council@googlegroups.com>
6
6
  License: BSD 3-Clause License
@@ -43,9 +43,10 @@ Classifier: License :: OSI Approved :: BSD License
43
43
  Classifier: Programming Language :: C
44
44
  Classifier: Programming Language :: Python
45
45
  Classifier: Programming Language :: Python :: 3 :: Only
46
- Classifier: Programming Language :: Python :: 3.9
47
46
  Classifier: Programming Language :: Python :: 3.10
48
47
  Classifier: Programming Language :: Python :: 3.11
48
+ Classifier: Programming Language :: Python :: 3.12
49
+ Classifier: Programming Language :: Python :: 3.13
49
50
  Classifier: Topic :: Scientific/Engineering
50
51
  Classifier: Topic :: Scientific/Engineering :: Visualization
51
52
  Classifier: Topic :: Scientific/Engineering :: Information Analysis
@@ -55,7 +56,7 @@ Classifier: Operating System :: Microsoft :: Windows
55
56
  Classifier: Operating System :: POSIX
56
57
  Classifier: Operating System :: Unix
57
58
  Classifier: Operating System :: MacOS
58
- Requires-Python: >=3.8
59
+ Requires-Python: >=3.10
59
60
  Description-Content-Type: text/markdown
60
61
  License-File: LICENSE
61
62
  Requires-Dist: npe2
@@ -64,12 +65,12 @@ Requires-Dist: superqt
64
65
  Requires-Dist: pip
65
66
  Requires-Dist: packaging
66
67
  Provides-Extra: dev
67
- Requires-Dist: PyQt5; extra == "dev"
68
+ Requires-Dist: PyQt6; extra == "dev"
68
69
  Requires-Dist: pre-commit; extra == "dev"
69
70
  Provides-Extra: testing
71
+ Requires-Dist: coverage; extra == "testing"
70
72
  Requires-Dist: flaky; extra == "testing"
71
73
  Requires-Dist: pytest; extra == "testing"
72
- Requires-Dist: pytest-cov; extra == "testing"
73
74
  Requires-Dist: pytest-qt; extra == "testing"
74
75
  Requires-Dist: virtualenv; extra == "testing"
75
76
  Provides-Extra: docs
@@ -80,6 +81,7 @@ Requires-Dist: sphinx-copybutton; extra == "docs"
80
81
  Requires-Dist: sphinx-favicon; extra == "docs"
81
82
  Requires-Dist: myst-nb; extra == "docs"
82
83
  Requires-Dist: napari-sphinx-theme>=0.3.0; extra == "docs"
84
+ Dynamic: license-file
83
85
 
84
86
  # napari-plugin-manager
85
87
 
@@ -230,6 +232,24 @@ You can cancel the process at any time by clicking the `Cancel` button of each p
230
232
 
231
233
  ![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)
232
234
 
235
+ ### Export/Import plugins
236
+
237
+ You can export the list of install plugins by clicking on the `Export` button located on the top right
238
+ corner of the UI. This will prompt a dialog to select the location and name of the text file where
239
+ the installed plugin list will be saved.
240
+
241
+ The file is plain text and plugins are listed in the [requirements.txt](https://pip.pypa.io/en/stable/reference/requirements-file-format/) format:
242
+ ```
243
+ plugin_name==0.1.2
244
+ ```
245
+
246
+ This file can be shared and then imported by clicking on the `Import` button located on the top right
247
+ corner of the UI. This will prompt a dialog to select the location of the text file to import.
248
+
249
+ After selecting the file, the plugin manager will attempt to install all the listed plugins using the auto-detected default installer.
250
+
251
+ ![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)
252
+
233
253
  ### Batch actions
234
254
 
235
255
  You don't need wait for one action to finish before you can start another one. You can add more
@@ -0,0 +1,23 @@
1
+ napari_plugin_manager/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ napari_plugin_manager/_version.py,sha256=Y4jy4bEMmwl_qNPCmiMFnlQ2ofMoqyG37hp8uwI3m10,511
3
+ napari_plugin_manager/base_qt_package_installer.py,sha256=Tp2dt5BBT-5CsXXlQPIPxuXSDMvUgKy2kSgw2Y2G9Dk,21825
4
+ napari_plugin_manager/base_qt_plugin_dialog.py,sha256=gkrEkVf_WGJHjZFORMoCiw-gxCwFuTZ04Q0PGFK8gLk,65978
5
+ napari_plugin_manager/npe2api.py,sha256=bXmhwFkwKw_1DfnGLhWhEGaEAA3oYPFaw4eR4SX2Nyg,4075
6
+ napari_plugin_manager/qt_package_installer.py,sha256=j-pacW6wHVq3iJaZXsj6D-_VH25Fz-55r7clcd_CQvE,2804
7
+ napari_plugin_manager/qt_plugin_dialog.py,sha256=Ig4TtjCh9Z2Dx_QpiOsfLlgolPJHmJ5_7VuTU2DE0Ag,9607
8
+ napari_plugin_manager/qt_warning_dialog.py,sha256=ue4CeMptlBBkBctPg7qCayamrkm75iLqASSFUvwo_Bc,669
9
+ napari_plugin_manager/qt_widgets.py,sha256=O8t5CbN8r_16cQzshyjvhTEYdUcj7OX0-bfYIiN2uSs,356
10
+ napari_plugin_manager/styles.qss,sha256=9ODPba2IorJybWObWoEO9VGq4AO0IYlAa8brN14tgZU,7379
11
+ napari_plugin_manager/utils.py,sha256=V0QmCQNP2OwszoQ2n9Gnau9jH81rLKwfskL4ebex7EE,722
12
+ napari_plugin_manager/_tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ napari_plugin_manager/_tests/conftest.py,sha256=OvzenfBP2oIS6x8ksr9FhPXdsLV3Q_3Kzr6PRJe45Uc,1885
14
+ napari_plugin_manager/_tests/test_base_installer_process.py,sha256=Cv-nBnUeNAX6pYUE1zs38I9vGtCE-ahBN4q-xcBH-pw,561
15
+ napari_plugin_manager/_tests/test_installer_process.py,sha256=qPSDcYWPQ08gzM38av2tcE9XCtruHv-Mo6duQ0sZp-8,11614
16
+ napari_plugin_manager/_tests/test_npe2api.py,sha256=GRXucH7kWHt6thgueppHHWaToTvQG1PXH6UECFeVxcM,1225
17
+ napari_plugin_manager/_tests/test_qt_plugin_dialog.py,sha256=IRXodt3IrE7rXHZO0R69lhz4_2mMW3MOE9LLQ5tCxWY,21727
18
+ napari_plugin_manager/_tests/test_utils.py,sha256=7EilxmDkRjU6UO2AnaqyYovdAs18D0ZA5GCVGN62-3M,720
19
+ napari_plugin_manager-0.1.5.dist-info/licenses/LICENSE,sha256=8dAlKbOqTMYe9L-gL_kEx5Xr1Sd0AbaWQDUkpiOp3vI,1506
20
+ napari_plugin_manager-0.1.5.dist-info/METADATA,sha256=zjeelaebx95UsROBhCGhzqJ4JG_syOGxg7RMSgTbmGI,13810
21
+ napari_plugin_manager-0.1.5.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
22
+ napari_plugin_manager-0.1.5.dist-info/top_level.txt,sha256=pmCqLetuumhY1CKSuTZ5eQsitzazrSvc7V_mkugEPTY,22
23
+ napari_plugin_manager-0.1.5.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.7.0)
2
+ Generator: setuptools (78.1.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,22 +0,0 @@
1
- napari_plugin_manager/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- napari_plugin_manager/_version.py,sha256=9GTNkADgEYZ6fEjCvZZUdKyqxiPIgtskLFZNJz7nq_U,411
3
- napari_plugin_manager/base_qt_package_installer.py,sha256=XjfI_sqiCIkOLop4scpDio2sf7zIG-LwjrvPIuB4R7Q,20166
4
- napari_plugin_manager/base_qt_plugin_dialog.py,sha256=RtvkAnPGDkivjihlSAE2zi3rNP-4DjorFh9tSl9t5_k,63834
5
- napari_plugin_manager/npe2api.py,sha256=vEBKo1JBnPxRoVzMWedODXYNnD5OIfYJM-XeNWm6DsQ,4092
6
- napari_plugin_manager/qt_package_installer.py,sha256=AcEu3htlXK1FsYdicqtdTCkKF9TN2XPGAIYgEgVZTHM,2821
7
- napari_plugin_manager/qt_plugin_dialog.py,sha256=oWkaB06oW5SNDtg-XohnC5Aln7CBhzgeG-p6K5DfC8g,10124
8
- napari_plugin_manager/qt_widgets.py,sha256=O8t5CbN8r_16cQzshyjvhTEYdUcj7OX0-bfYIiN2uSs,356
9
- napari_plugin_manager/styles.qss,sha256=9ODPba2IorJybWObWoEO9VGq4AO0IYlAa8brN14tgZU,7379
10
- napari_plugin_manager/utils.py,sha256=wG_lGPaMmbfyH-q7oTWDYSI2iAKiZ3cqxyjlRlbvFJo,753
11
- napari_plugin_manager/_tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- napari_plugin_manager/_tests/conftest.py,sha256=OvzenfBP2oIS6x8ksr9FhPXdsLV3Q_3Kzr6PRJe45Uc,1885
13
- napari_plugin_manager/_tests/test_base_installer_process.py,sha256=Cv-nBnUeNAX6pYUE1zs38I9vGtCE-ahBN4q-xcBH-pw,561
14
- napari_plugin_manager/_tests/test_installer_process.py,sha256=-q3u6UqCccWbHXjtWROUikFFONNCW3PRVqXVJUno9pg,11600
15
- napari_plugin_manager/_tests/test_npe2api.py,sha256=i0W1CrWcRzjL1Rk2WELlelOiBiHbOS37QKpmyaRn1QA,1031
16
- napari_plugin_manager/_tests/test_qt_plugin_dialog.py,sha256=q6Cj19069KwYwivB-0_sB_64e4ICYoFR_PcjvDwvLts,19520
17
- napari_plugin_manager/_tests/test_utils.py,sha256=7EilxmDkRjU6UO2AnaqyYovdAs18D0ZA5GCVGN62-3M,720
18
- napari_plugin_manager-0.1.4.dist-info/LICENSE,sha256=8dAlKbOqTMYe9L-gL_kEx5Xr1Sd0AbaWQDUkpiOp3vI,1506
19
- napari_plugin_manager-0.1.4.dist-info/METADATA,sha256=Ld1YHwF15gQgkLpOfbbgqqdi19GbF4Oyjgb0cVpCWW0,12782
20
- napari_plugin_manager-0.1.4.dist-info/WHEEL,sha256=A3WOREP4zgxI0fKrHUG8DC8013e3dK3n7a6HDbcEIwE,91
21
- napari_plugin_manager-0.1.4.dist-info/top_level.txt,sha256=pmCqLetuumhY1CKSuTZ5eQsitzazrSvc7V_mkugEPTY,22
22
- napari_plugin_manager-0.1.4.dist-info/RECORD,,