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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1086 @@
1
+ import importlib.metadata
2
+ import os
3
+ import re
4
+ import sys
5
+ from enum import Enum, auto
6
+ from functools import partial
7
+ from pathlib import Path
8
+ from typing import Dict, List, Literal, Optional, Sequence, Tuple
9
+
10
+ import napari.plugins
11
+ import napari.resources
12
+ import npe2
13
+ from napari._qt.qt_resources import QColoredSVGIcon
14
+ from napari._qt.qthreading import create_worker
15
+ from napari._qt.widgets.qt_message_popup import WarnPopup
16
+ from napari._qt.widgets.qt_tooltip import QtToolTipLabel
17
+ from napari.plugins.npe2api import iter_napari_plugin_info
18
+ from napari.plugins.utils import normalized_name
19
+ from napari.settings import get_settings
20
+ from napari.utils.misc import (
21
+ parse_version,
22
+ running_as_constructor_app,
23
+ )
24
+ from napari.utils.translations import trans
25
+ from qtpy.QtCore import QEvent, QPoint, QSize, Qt, QTimer, Slot
26
+ from qtpy.QtGui import QFont, QMovie
27
+ from qtpy.QtWidgets import (
28
+ QCheckBox,
29
+ QComboBox,
30
+ QDialog,
31
+ QFrame,
32
+ QGridLayout,
33
+ QHBoxLayout,
34
+ QLabel,
35
+ QLineEdit,
36
+ QListWidget,
37
+ QListWidgetItem,
38
+ QPushButton,
39
+ QSizePolicy,
40
+ QSplitter,
41
+ QTextEdit,
42
+ QVBoxLayout,
43
+ QWidget,
44
+ )
45
+ from superqt import QCollapsible, QElidingLabel
46
+
47
+ from napari_plugin_manager.qt_package_installer import (
48
+ InstallerActions,
49
+ InstallerQueue,
50
+ InstallerTools,
51
+ )
52
+
53
+ # TODO: add error icon and handle pip install errors
54
+
55
+ # Scaling factor for each list widget item when expanding.
56
+ SCALE = 1.6
57
+
58
+ CONDA = 'Conda'
59
+ PYPI = 'PyPI'
60
+
61
+
62
+ def is_conda_package(pkg: str):
63
+ """Determines if plugin was installed through conda.
64
+
65
+ Returns
66
+ -------
67
+ bool: True if a conda package, False if not
68
+ """
69
+
70
+ # Installed conda packages within a conda installation and environment can be identified as files
71
+ # with the template `<package-name>-<version>-<build-string>.json` saved within a `conda-meta` folder within
72
+ # the given environment of interest.
73
+
74
+ conda_meta_dir = Path(sys.prefix) / 'conda-meta'
75
+ return any(
76
+ re.match(rf"{pkg}-[^-]+-[^-]+.json", p.name)
77
+ for p in conda_meta_dir.glob(f"{pkg}-*-*.json")
78
+ )
79
+
80
+
81
+ class PluginListItem(QFrame):
82
+ """An entry in the plugin dialog. This will include the package name, summary,
83
+ author, source, version, and buttons to update, install/uninstall, etc."""
84
+
85
+ def __init__(
86
+ self,
87
+ package_name: str,
88
+ version: str = '',
89
+ url: str = '',
90
+ summary: str = '',
91
+ author: str = '',
92
+ license: str = "UNKNOWN", # noqa: A002
93
+ *,
94
+ plugin_name: str = None,
95
+ parent: QWidget = None,
96
+ enabled: bool = True,
97
+ installed: bool = False,
98
+ npe_version=1,
99
+ versions_conda: List[str] = None,
100
+ versions_pypi: List[str] = None,
101
+ ) -> None:
102
+ super().__init__(parent)
103
+ self.url = url
104
+ self._versions_conda = versions_conda
105
+ self._versions_pypi = versions_pypi
106
+ self.setup_ui(enabled)
107
+ self.plugin_name.setText(package_name)
108
+
109
+ if len(versions_pypi) > 0:
110
+ self._populate_version_dropdown(PYPI)
111
+ else:
112
+ self._populate_version_dropdown(CONDA)
113
+
114
+ self.package_name.setText(version)
115
+ if summary:
116
+ self.summary.setText(summary + '<br />')
117
+ if author:
118
+ self.package_author.setText(author)
119
+ self.package_author.setWordWrap(True)
120
+ self.cancel_btn.setVisible(False)
121
+
122
+ self._handle_npe2_plugin(npe_version)
123
+
124
+ if installed:
125
+ if is_conda_package(package_name):
126
+ self.source.setText(CONDA)
127
+ self.enabled_checkbox.show()
128
+ self.action_button.setText(trans._("Uninstall"))
129
+ self.action_button.setObjectName("remove_button")
130
+ self.info_choice_wdg.hide()
131
+ self.install_info_button.addWidget(self.info_widget)
132
+ self.info_widget.show()
133
+ else:
134
+ self.enabled_checkbox.hide()
135
+ self.action_button.setText(trans._("Install"))
136
+ self.action_button.setObjectName("install_button")
137
+ self.info_widget.hide()
138
+ self.install_info_button.addWidget(self.info_choice_wdg)
139
+ self.install_info_button.setFixedWidth(170)
140
+
141
+ self.info_choice_wdg.show()
142
+
143
+ def _handle_npe2_plugin(self, npe_version):
144
+ if npe_version in (None, 1):
145
+ return
146
+ opacity = 0.4 if npe_version == 'shim' else 1
147
+ lbl = trans._('npe1 (adapted)') if npe_version == 'shim' else 'npe2'
148
+ npe2_icon = QLabel(self)
149
+ icon = QColoredSVGIcon.from_resources('logo_silhouette')
150
+ npe2_icon.setPixmap(
151
+ icon.colored(color='#33F0FF', opacity=opacity).pixmap(20, 20)
152
+ )
153
+ self.row1.insertWidget(2, QLabel(lbl))
154
+ self.row1.insertWidget(2, npe2_icon)
155
+
156
+ def set_busy(
157
+ self,
158
+ text: str,
159
+ action_name: Literal[
160
+ "install", "uninstall", "cancel", "upgrade"
161
+ ] = None,
162
+ ):
163
+ """Updates status text and what buttons are visible when any button is pushed.
164
+
165
+ Parameters
166
+ ----------
167
+ text: str
168
+ The new string to be displayed as the status.
169
+ action_name: str
170
+ The action of the button pressed.
171
+
172
+ """
173
+ self.item_status.setText(text)
174
+ if action_name == 'upgrade':
175
+ self.cancel_btn.setVisible(True)
176
+ self.action_button.setVisible(False)
177
+ elif action_name in {'uninstall', 'install'}:
178
+ self.action_button.setVisible(False)
179
+ self.cancel_btn.setVisible(True)
180
+ elif action_name == 'cancel':
181
+ self.action_button.setVisible(True)
182
+ self.action_button.setDisabled(False)
183
+ self.cancel_btn.setVisible(False)
184
+ else: # pragma: no cover
185
+ raise ValueError(f"Not supported {action_name}")
186
+
187
+ def setup_ui(self, enabled=True):
188
+ """Define the layout of the PluginListItem"""
189
+
190
+ self.v_lay = QVBoxLayout(self)
191
+ self.v_lay.setContentsMargins(-1, 6, -1, 6)
192
+ self.v_lay.setSpacing(0)
193
+ self.row1 = QHBoxLayout()
194
+ self.row1.setSpacing(6)
195
+ self.enabled_checkbox = QCheckBox(self)
196
+ self.enabled_checkbox.setChecked(enabled)
197
+ self.enabled_checkbox.stateChanged.connect(self._on_enabled_checkbox)
198
+ self.enabled_checkbox.setToolTip(trans._("enable/disable"))
199
+ sizePolicy = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
200
+ sizePolicy.setHorizontalStretch(0)
201
+ sizePolicy.setVerticalStretch(0)
202
+ sizePolicy.setHeightForWidth(
203
+ self.enabled_checkbox.sizePolicy().hasHeightForWidth()
204
+ )
205
+ self.enabled_checkbox.setSizePolicy(sizePolicy)
206
+ self.enabled_checkbox.setMinimumSize(QSize(20, 0))
207
+ self.enabled_checkbox.setText("")
208
+ self.row1.addWidget(self.enabled_checkbox)
209
+ self.plugin_name = QPushButton(self)
210
+ # Do not want to highlight on hover unless there is a website.
211
+ if self.url and self.url != 'UNKNOWN':
212
+ self.plugin_name.setObjectName('plugin_name_web')
213
+ else:
214
+ self.plugin_name.setObjectName('plugin_name')
215
+
216
+ sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum)
217
+ sizePolicy.setHorizontalStretch(0)
218
+ sizePolicy.setVerticalStretch(0)
219
+ sizePolicy.setHeightForWidth(
220
+ self.plugin_name.sizePolicy().hasHeightForWidth()
221
+ )
222
+ self.plugin_name.setSizePolicy(sizePolicy)
223
+ font15 = QFont()
224
+ font15.setPointSize(15)
225
+ font15.setUnderline(True)
226
+ self.plugin_name.setFont(font15)
227
+ self.row1.addWidget(self.plugin_name)
228
+
229
+ icon = QColoredSVGIcon.from_resources("warning")
230
+ self.warning_tooltip = QtToolTipLabel(self)
231
+ # TODO: This color should come from the theme but the theme needs
232
+ # to provide the right color. Default warning should be orange, not
233
+ # red. Code example:
234
+ # theme_name = get_settings().appearance.theme
235
+ # napari.utils.theme.get_theme(theme_name, as_dict=False).warning.as_hex()
236
+ self.warning_tooltip.setPixmap(
237
+ icon.colored(color="#E3B617").pixmap(15, 15)
238
+ )
239
+ self.warning_tooltip.setVisible(False)
240
+ self.row1.addWidget(self.warning_tooltip)
241
+
242
+ self.item_status = QLabel(self)
243
+ self.item_status.setObjectName("small_italic_text")
244
+ self.item_status.setSizePolicy(sizePolicy)
245
+ self.row1.addWidget(self.item_status)
246
+ self.row1.addStretch()
247
+ self.v_lay.addLayout(self.row1)
248
+
249
+ self.row2 = QGridLayout()
250
+ self.error_indicator = QPushButton()
251
+ self.error_indicator.setObjectName("warning_icon")
252
+ self.error_indicator.setCursor(Qt.CursorShape.PointingHandCursor)
253
+ self.error_indicator.hide()
254
+ self.row2.addWidget(
255
+ self.error_indicator,
256
+ 0,
257
+ 0,
258
+ 1,
259
+ 1,
260
+ alignment=Qt.AlignmentFlag.AlignTop,
261
+ )
262
+ self.row2.setSpacing(4)
263
+ self.summary = QElidingLabel(parent=self)
264
+ self.summary.setObjectName('summary_text')
265
+ self.summary.setWordWrap(True)
266
+
267
+ sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
268
+
269
+ sizePolicy.setHorizontalStretch(1)
270
+ sizePolicy.setVerticalStretch(0)
271
+ self.summary.setSizePolicy(sizePolicy)
272
+ self.row2.addWidget(
273
+ self.summary, 0, 1, 1, 3, alignment=Qt.AlignmentFlag.AlignTop
274
+ )
275
+
276
+ self.package_author = QElidingLabel(self)
277
+ self.package_author.setObjectName('author_text')
278
+ self.package_author.setWordWrap(True)
279
+ self.package_author.setSizePolicy(sizePolicy)
280
+ self.row2.addWidget(
281
+ self.package_author,
282
+ 0,
283
+ 4,
284
+ 1,
285
+ 2,
286
+ alignment=Qt.AlignmentFlag.AlignTop,
287
+ )
288
+
289
+ self.update_btn = QPushButton('Update', self)
290
+ sizePolicy.setRetainSizeWhenHidden(True)
291
+ self.update_btn.setSizePolicy(sizePolicy)
292
+ self.update_btn.setObjectName("install_button")
293
+ self.update_btn.setVisible(False)
294
+
295
+ self.row2.addWidget(
296
+ self.update_btn, 0, 6, 1, 1, alignment=Qt.AlignmentFlag.AlignTop
297
+ )
298
+
299
+ self.info_choice_wdg = QWidget(self)
300
+ self.info_choice_wdg.setObjectName('install_choice')
301
+ coll_icon = QColoredSVGIcon.from_resources('right_arrow').colored(
302
+ color='white',
303
+ )
304
+ exp_icon = QColoredSVGIcon.from_resources('down_arrow').colored(
305
+ color='white',
306
+ )
307
+ self.install_info_button = QCollapsible(
308
+ "Installation Info", collapsedIcon=coll_icon, expandedIcon=exp_icon
309
+ )
310
+ self.install_info_button.setObjectName("install_info_button")
311
+
312
+ # To make the icon appear on the right
313
+ self.install_info_button.setLayoutDirection(Qt.RightToLeft)
314
+
315
+ # Remove any extra margins
316
+ self.install_info_button.content().layout().setContentsMargins(
317
+ 0, 0, 0, 0
318
+ )
319
+ self.install_info_button.content().setContentsMargins(0, 0, 0, 0)
320
+ self.install_info_button.content().layout().setSpacing(0)
321
+ self.install_info_button.layout().setContentsMargins(0, 0, 0, 0)
322
+ self.install_info_button.layout().setSpacing(2)
323
+ self.install_info_button.setSizePolicy(sizePolicy)
324
+
325
+ self.source_choice_text = QLabel('Source:')
326
+ self.version_choice_text = QLabel('Version:')
327
+ self.source_choice_dropdown = QComboBox()
328
+
329
+ if len(self._versions_pypi) is not None:
330
+ self.source_choice_dropdown.addItem(PYPI)
331
+
332
+ if len(self._versions_conda) is not None:
333
+ self.source_choice_dropdown.addItem(CONDA)
334
+
335
+ self.source_choice_dropdown.currentTextChanged.connect(
336
+ self._populate_version_dropdown
337
+ )
338
+ self.version_choice_dropdown = QComboBox()
339
+ self.row2.addWidget(
340
+ self.install_info_button,
341
+ 0,
342
+ 7,
343
+ 1,
344
+ 1,
345
+ alignment=Qt.AlignmentFlag.AlignTop,
346
+ )
347
+
348
+ info_layout = QGridLayout()
349
+ info_layout.setContentsMargins(0, 0, 0, 0)
350
+ info_layout.setVerticalSpacing(0)
351
+ info_layout.addWidget(self.source_choice_text, 0, 0, 1, 1)
352
+ info_layout.addWidget(self.source_choice_dropdown, 1, 0, 1, 1)
353
+ info_layout.addWidget(self.version_choice_text, 0, 1, 1, 1)
354
+ info_layout.addWidget(self.version_choice_dropdown, 1, 1, 1, 1)
355
+ self.info_choice_wdg.setLayout(info_layout)
356
+ self.info_choice_wdg.setLayoutDirection(Qt.LeftToRight)
357
+ self.info_choice_wdg.setObjectName("install_choice_widget")
358
+ self.info_choice_wdg.hide()
359
+
360
+ self.cancel_btn = QPushButton("Cancel", self)
361
+ self.cancel_btn.setSizePolicy(sizePolicy)
362
+ self.cancel_btn.setObjectName("remove_button")
363
+ self.row2.addWidget(
364
+ self.cancel_btn, 0, 8, 1, 1, alignment=Qt.AlignmentFlag.AlignTop
365
+ )
366
+
367
+ self.action_button = QPushButton(self)
368
+ self.action_button.setFixedWidth(70)
369
+ sizePolicy1 = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
370
+ self.action_button.setSizePolicy(sizePolicy1)
371
+ self.row2.addWidget(
372
+ self.action_button, 0, 8, 1, 1, alignment=Qt.AlignmentFlag.AlignTop
373
+ )
374
+
375
+ self.v_lay.addLayout(self.row2)
376
+
377
+ self.info_widget = QWidget(self)
378
+ self.info_widget.setLayoutDirection(Qt.LeftToRight)
379
+ self.info_widget.setObjectName("info_widget")
380
+ info_layout = QGridLayout()
381
+ info_layout.setContentsMargins(0, 0, 0, 0)
382
+ info_layout.setVerticalSpacing(0)
383
+ self.version_text = QLabel('Version:')
384
+ self.package_name = QLabel()
385
+ self.source_text = QLabel('Source:')
386
+ self.source = QLabel(PYPI)
387
+
388
+ info_layout.addWidget(self.source_text, 0, 0)
389
+ info_layout.addWidget(self.source, 1, 0)
390
+ info_layout.addWidget(self.version_text, 0, 1)
391
+ info_layout.addWidget(self.package_name, 1, 1)
392
+
393
+ self.install_info_button.setFixedWidth(150)
394
+ self.install_info_button.layout().setContentsMargins(0, 0, 0, 0)
395
+ self.info_widget.setLayout(info_layout)
396
+
397
+ def _populate_version_dropdown(self, source: Literal["PyPI", "Conda"]):
398
+ """Display the versions available after selecting a source: pypi or conda."""
399
+ if source == PYPI:
400
+ versions = self._versions_pypi
401
+ else:
402
+ versions = self._versions_conda
403
+ self.version_choice_dropdown.clear()
404
+ for version in versions:
405
+ self.version_choice_dropdown.addItem(version)
406
+
407
+ def _on_enabled_checkbox(self, state: int):
408
+ """Called with `state` when checkbox is clicked."""
409
+ enabled = bool(state)
410
+ plugin_name = self.plugin_name.text()
411
+ pm2 = npe2.PluginManager.instance()
412
+ if plugin_name in pm2:
413
+ pm2.enable(plugin_name) if state else pm2.disable(plugin_name)
414
+ return
415
+
416
+ for (
417
+ npe1_name,
418
+ _,
419
+ distname,
420
+ ) in napari.plugins.plugin_manager.iter_available():
421
+ if distname and (normalized_name(distname) == plugin_name):
422
+ napari.plugins.plugin_manager.set_blocked(
423
+ npe1_name, not enabled
424
+ )
425
+ return
426
+
427
+ def show_warning(self, message: str = ""):
428
+ """Show warning icon and tooltip."""
429
+ self.warning_tooltip.setVisible(bool(message))
430
+ self.warning_tooltip.setToolTip(message)
431
+
432
+
433
+ class QPluginList(QListWidget):
434
+ def __init__(self, parent: QWidget, installer: InstallerQueue) -> None:
435
+ super().__init__(parent)
436
+ self.installer = installer
437
+ self.setSortingEnabled(True)
438
+ self._remove_list = []
439
+
440
+ def _count_visible(self) -> int:
441
+ """Return the number of visible items.
442
+
443
+ Visible items are the result of the normal `count` method minus
444
+ any hidden items.
445
+ """
446
+ hidden = 0
447
+ count = self.count()
448
+ for i in range(count):
449
+ item = self.item(i)
450
+ hidden += item.isHidden()
451
+
452
+ return count - hidden
453
+
454
+ @Slot(tuple)
455
+ def addItem(
456
+ self,
457
+ project_info_versions: Tuple[
458
+ npe2.PackageMetadata, List[str], List[str]
459
+ ],
460
+ installed=False,
461
+ plugin_name=None,
462
+ enabled=True,
463
+ npe_version=None,
464
+ ):
465
+ project_info, versions_pypi, versions_conda = project_info_versions
466
+
467
+ pkg_name = project_info.name
468
+ # don't add duplicates
469
+ if (
470
+ self.findItems(pkg_name, Qt.MatchFlag.MatchFixedString)
471
+ and not plugin_name
472
+ ):
473
+ return
474
+
475
+ # including summary here for sake of filtering below.
476
+ searchable_text = f"{pkg_name} {project_info.summary}"
477
+ item = QListWidgetItem(searchable_text, self)
478
+ item.version = project_info.version
479
+ super().addItem(item)
480
+ widg = PluginListItem(
481
+ package_name=pkg_name,
482
+ version=project_info.version,
483
+ url=project_info.home_page,
484
+ summary=project_info.summary,
485
+ author=project_info.author,
486
+ license=project_info.license,
487
+ parent=self,
488
+ plugin_name=plugin_name,
489
+ enabled=enabled,
490
+ installed=installed,
491
+ npe_version=npe_version,
492
+ versions_conda=versions_conda,
493
+ versions_pypi=versions_pypi,
494
+ )
495
+ item.widget = widg
496
+ item.npe_version = npe_version
497
+ action_name = 'uninstall' if installed else 'install'
498
+ item.setSizeHint(widg.sizeHint())
499
+ self.setItemWidget(item, widg)
500
+
501
+ if project_info.home_page:
502
+ import webbrowser
503
+
504
+ # FIXME: Partial may lead to leak memory when connecting to Qt signals.
505
+ widg.plugin_name.clicked.connect(
506
+ partial(webbrowser.open, project_info.home_page)
507
+ )
508
+
509
+ # FIXME: Partial may lead to leak memory when connecting to Qt signals.
510
+ widg.action_button.clicked.connect(
511
+ partial(
512
+ self.handle_action,
513
+ item,
514
+ pkg_name,
515
+ action_name,
516
+ version=widg.version_choice_dropdown.currentText(),
517
+ installer_choice=widg.source_choice_dropdown.currentText(),
518
+ )
519
+ )
520
+
521
+ widg.update_btn.clicked.connect(
522
+ partial(
523
+ self.handle_action,
524
+ item,
525
+ pkg_name,
526
+ InstallerActions.UPGRADE,
527
+ )
528
+ )
529
+ widg.cancel_btn.clicked.connect(
530
+ partial(
531
+ self.handle_action, item, pkg_name, InstallerActions.CANCEL
532
+ )
533
+ )
534
+
535
+ item.setSizeHint(item.widget.size())
536
+ widg.install_info_button.setDuration(0)
537
+ widg.install_info_button.toggled.connect(
538
+ lambda: self._resize_pluginlistitem(item)
539
+ )
540
+
541
+ def _resize_pluginlistitem(self, item):
542
+ """Resize the plugin list item, especially after toggling QCollapsible."""
543
+ height = item.widget.height()
544
+ if item.widget.install_info_button.isExpanded():
545
+ item.widget.setFixedHeight(int(height * SCALE))
546
+ else:
547
+ item.widget.setFixedHeight(int(height / SCALE))
548
+ item.setSizeHint(item.widget.size())
549
+
550
+ def handle_action(
551
+ self,
552
+ item: QListWidgetItem,
553
+ pkg_name: str,
554
+ action_name: InstallerActions,
555
+ version: str = None,
556
+ installer_choice: Optional[str] = None,
557
+ ):
558
+ """Determine which action is called (install, uninstall, update, cancel).
559
+ Update buttons appropriately and run the action."""
560
+ tool = (
561
+ InstallerTools.CONDA
562
+ if item.widget.source_choice_dropdown.currentText() == CONDA
563
+ or is_conda_package(pkg_name)
564
+ else InstallerTools.PIP
565
+ )
566
+
567
+ widget = item.widget
568
+ item.setText(f"0-{item.text()}")
569
+ self._remove_list.append((pkg_name, item))
570
+ self._warn_dialog = None
571
+ # TODO: NPE version unknown before installing
572
+ if item.npe_version != 1 and action_name == InstallerActions.UNINSTALL:
573
+ # show warning pop up dialog
574
+ message = trans._(
575
+ 'When installing/uninstalling npe2 plugins, you must '
576
+ 'restart napari for UI changes to take effect.'
577
+ )
578
+ self._warn_dialog = WarnPopup(text=message)
579
+
580
+ delta_x = 75
581
+ global_point = widget.action_button.mapToGlobal(
582
+ widget.action_button.rect().topLeft()
583
+ )
584
+ global_point = QPoint(global_point.x() - delta_x, global_point.y())
585
+ self._warn_dialog.move(global_point)
586
+
587
+ if action_name == InstallerActions.INSTALL:
588
+ if version:
589
+ pkg_name += (
590
+ f"=={item.widget.version_choice_dropdown.currentText()}"
591
+ )
592
+ widget.set_busy(trans._("installing..."), action_name)
593
+
594
+ job_id = self.installer.install(
595
+ tool=tool,
596
+ pkgs=[pkg_name],
597
+ # origins="TODO",
598
+ )
599
+ if self._warn_dialog:
600
+ self._warn_dialog.exec_()
601
+ self.scrollToTop()
602
+
603
+ if action_name == InstallerActions.UPGRADE:
604
+ if hasattr(item, 'latest_version'):
605
+ pkg_name += f"=={item.latest_version}"
606
+
607
+ widget.set_busy(trans._("updating..."), action_name)
608
+ widget.action_button.setDisabled(True)
609
+
610
+ job_id = self.installer.upgrade(
611
+ tool=tool,
612
+ pkgs=[pkg_name],
613
+ # origins="TODO",
614
+ )
615
+ if self._warn_dialog:
616
+ self._warn_dialog.exec_()
617
+ self.scrollToTop()
618
+
619
+ elif action_name == InstallerActions.UNINSTALL:
620
+ widget.set_busy(trans._("uninstalling..."), action_name)
621
+ widget.update_btn.setDisabled(True)
622
+ job_id = self.installer.uninstall(
623
+ tool=tool,
624
+ pkgs=[pkg_name],
625
+ # origins="TODO",
626
+ # upgrade=False,
627
+ )
628
+ widget.setProperty("current_job_id", job_id)
629
+ if self._warn_dialog:
630
+ self._warn_dialog.exec_()
631
+ self.scrollToTop()
632
+ elif action_name == InstallerActions.CANCEL:
633
+ widget.set_busy(trans._("cancelling..."), action_name)
634
+ try:
635
+ job_id = widget.property("current_job_id")
636
+ self.installer.cancel(job_id)
637
+ finally:
638
+ widget.setProperty("current_job_id", None)
639
+
640
+ @Slot(npe2.PackageMetadata, bool)
641
+ def tag_outdated(
642
+ self, project_info: npe2.PackageMetadata, is_available: bool
643
+ ):
644
+ """Determines if an installed plugin is up to date with the latest version.
645
+ If it is not, the latest version will be displayed on the update button.
646
+ """
647
+ if not is_available:
648
+ return
649
+
650
+ for item in self.findItems(
651
+ project_info.name, Qt.MatchFlag.MatchStartsWith
652
+ ):
653
+ current = item.version
654
+ latest = project_info.version
655
+ if parse_version(current) >= parse_version(latest):
656
+ continue
657
+ if hasattr(item, 'outdated'):
658
+ # already tagged it
659
+ continue
660
+
661
+ item.outdated = True
662
+ item.latest_version = latest
663
+ widg = self.itemWidget(item)
664
+ widg.update_btn.setVisible(True)
665
+ widg.update_btn.setText(
666
+ trans._("update (v{latest})", latest=latest)
667
+ )
668
+
669
+ def tag_unavailable(self, project_info: npe2.PackageMetadata):
670
+ """
671
+ Tag list items as unavailable for install with conda-forge.
672
+
673
+ This will disable the item and the install button and add a warning
674
+ icon with a hover tooltip.
675
+ """
676
+ for item in self.findItems(
677
+ project_info.name, Qt.MatchFlag.MatchStartsWith
678
+ ):
679
+ widget = self.itemWidget(item)
680
+ widget.show_warning(
681
+ trans._(
682
+ "Plugin not yet available for installation within the bundle application"
683
+ )
684
+ )
685
+ widget.setObjectName("unavailable")
686
+ widget.style().unpolish(widget)
687
+ widget.style().polish(widget)
688
+ widget.action_button.setEnabled(False)
689
+ widget.warning_tooltip.setVisible(True)
690
+
691
+ def filter(self, text: str):
692
+ """Filter items to those containing `text`."""
693
+ if text:
694
+ # PySide has some issues, so we compare using id
695
+ # See: https://bugreports.qt.io/browse/PYSIDE-74
696
+ shown = [
697
+ id(it)
698
+ for it in self.findItems(text, Qt.MatchFlag.MatchContains)
699
+ ]
700
+ for i in range(self.count()):
701
+ item = self.item(i)
702
+ item.setHidden(id(item) not in shown)
703
+ else:
704
+ for i in range(self.count()):
705
+ item = self.item(i)
706
+ item.setHidden(False)
707
+
708
+
709
+ class RefreshState(Enum):
710
+ REFRESHING = auto()
711
+ OUTDATED = auto()
712
+ DONE = auto()
713
+
714
+
715
+ class QtPluginDialog(QDialog):
716
+ def __init__(self, parent=None) -> None:
717
+ super().__init__(parent)
718
+ self.refresh_state = RefreshState.DONE
719
+ self.already_installed = set()
720
+ self.available_set = set()
721
+
722
+ self._plugin_data = [] # Store plugin data while populating lists
723
+ self.all_plugin_data = [] # Store all plugin data
724
+ self._add_items_timer = QTimer(self)
725
+ # Add items in batches to avoid blocking the UI
726
+ self._add_items_timer.setInterval(100)
727
+ self._add_items_timer.timeout.connect(self._add_items)
728
+ self._add_items_timer.timeout.connect(self._update_count_in_label)
729
+
730
+ self.installer = InstallerQueue()
731
+ self.setWindowTitle(trans._('Plugin Manager'))
732
+ self.setup_ui()
733
+ self.setWindowTitle('Plugin Manager')
734
+ self.installer.set_output_widget(self.stdout_text)
735
+ self.installer.started.connect(self._on_installer_start)
736
+ self.installer.finished.connect(self._on_installer_done)
737
+ self.refresh()
738
+
739
+ def _on_installer_start(self):
740
+ """Updates dialog buttons and status when installing a plugin."""
741
+ self.cancel_all_btn.setVisible(True)
742
+ self.working_indicator.show()
743
+ self.process_success_indicator.hide()
744
+ self.process_error_indicator.hide()
745
+ self.close_btn.setDisabled(True)
746
+
747
+ def _on_installer_done(self, exit_code):
748
+ """Updates buttons and status when plugin is done installing."""
749
+ self.working_indicator.hide()
750
+ if exit_code:
751
+ self.process_error_indicator.show()
752
+ else:
753
+ self.process_success_indicator.show()
754
+ self.cancel_all_btn.setVisible(False)
755
+ self.close_btn.setDisabled(False)
756
+ self.refresh()
757
+
758
+ def closeEvent(self, event):
759
+ self._add_items_timer.stop()
760
+ if self.close_btn.isEnabled():
761
+ super().closeEvent(event)
762
+ event.ignore()
763
+
764
+ def refresh(self):
765
+ if self.refresh_state != RefreshState.DONE:
766
+ self.refresh_state = RefreshState.OUTDATED
767
+ return
768
+ self.refresh_state = RefreshState.REFRESHING
769
+ self.installed_list.clear()
770
+ self.available_list.clear()
771
+
772
+ self.already_installed = set()
773
+ self.available_set = set()
774
+
775
+ def _add_to_installed(distname, enabled, npe_version=1):
776
+ norm_name = normalized_name(distname or '')
777
+ if distname:
778
+ try:
779
+ meta = importlib.metadata.metadata(distname)
780
+
781
+ except importlib.metadata.PackageNotFoundError:
782
+ self.refresh_state = RefreshState.OUTDATED
783
+ return # a race condition has occurred and the package is uninstalled by another thread
784
+ if len(meta) == 0:
785
+ # will not add builtins.
786
+ return
787
+ self.already_installed.add(norm_name)
788
+ else:
789
+ meta = {}
790
+
791
+ self.installed_list.addItem(
792
+ (
793
+ npe2.PackageMetadata(
794
+ metadata_version="1.0",
795
+ name=norm_name,
796
+ version=meta.get('version', ''),
797
+ summary=meta.get('summary', ''),
798
+ home_page=meta.get('Home-page', ''),
799
+ author=meta.get('author', ''),
800
+ license=meta.get('license', ''),
801
+ ),
802
+ [],
803
+ [],
804
+ ),
805
+ installed=True,
806
+ enabled=enabled,
807
+ npe_version=npe_version,
808
+ )
809
+
810
+ pm2 = npe2.PluginManager.instance()
811
+ discovered = pm2.discover()
812
+ for manifest in pm2.iter_manifests():
813
+ distname = normalized_name(manifest.name or '')
814
+ if distname in self.already_installed or distname == 'napari':
815
+ continue
816
+ enabled = not pm2.is_disabled(manifest.name)
817
+ # if it's an Npe1 adaptor, call it v1
818
+ npev = 'shim' if manifest.npe1_shim else 2
819
+ _add_to_installed(distname, enabled, npe_version=npev)
820
+
821
+ napari.plugins.plugin_manager.discover() # since they might not be loaded yet
822
+ for (
823
+ plugin_name,
824
+ _,
825
+ distname,
826
+ ) in napari.plugins.plugin_manager.iter_available():
827
+ # not showing these in the plugin dialog
828
+ if plugin_name in ('napari_plugin_engine',):
829
+ continue
830
+ if normalized_name(distname or '') in self.already_installed:
831
+ continue
832
+ _add_to_installed(
833
+ distname,
834
+ not napari.plugins.plugin_manager.is_blocked(plugin_name),
835
+ )
836
+
837
+ self.installed_label.setText(
838
+ trans._(
839
+ "Installed Plugins ({amount})",
840
+ amount=len(self.already_installed),
841
+ )
842
+ )
843
+
844
+ # fetch available plugins
845
+ get_settings()
846
+
847
+ self.worker = create_worker(iter_napari_plugin_info)
848
+
849
+ self.worker.yielded.connect(self._handle_yield)
850
+ self.worker.finished.connect(self.working_indicator.hide)
851
+ self.worker.finished.connect(self._end_refresh)
852
+ self.worker.start()
853
+ self._add_items_timer.start()
854
+
855
+ if discovered:
856
+ message = trans._(
857
+ 'When installing/uninstalling npe2 plugins, '
858
+ 'you must restart napari for UI changes to take effect.'
859
+ )
860
+ self._warn_dialog = WarnPopup(text=message)
861
+ global_point = self.process_error_indicator.mapToGlobal(
862
+ self.process_error_indicator.rect().topLeft()
863
+ )
864
+ global_point = QPoint(global_point.x(), global_point.y() - 75)
865
+ self._warn_dialog.move(global_point)
866
+ self._warn_dialog.exec_()
867
+
868
+ def setup_ui(self):
869
+ """Defines the layout for the PluginDialog."""
870
+
871
+ self.resize(950, 640)
872
+ vlay_1 = QVBoxLayout(self)
873
+ self.h_splitter = QSplitter(self)
874
+ vlay_1.addWidget(self.h_splitter)
875
+ self.h_splitter.setOrientation(Qt.Orientation.Horizontal)
876
+ self.v_splitter = QSplitter(self.h_splitter)
877
+ self.v_splitter.setOrientation(Qt.Orientation.Vertical)
878
+ self.v_splitter.setMinimumWidth(500)
879
+
880
+ installed = QWidget(self.v_splitter)
881
+ lay = QVBoxLayout(installed)
882
+ lay.setContentsMargins(0, 2, 0, 2)
883
+ self.installed_label = QLabel(trans._("Installed Plugins"))
884
+ self.packages_filter = QLineEdit()
885
+ self.packages_filter.setPlaceholderText(trans._("filter..."))
886
+ self.packages_filter.setMaximumWidth(350)
887
+ self.packages_filter.setClearButtonEnabled(True)
888
+ mid_layout = QVBoxLayout()
889
+ mid_layout.addWidget(self.packages_filter)
890
+ mid_layout.addWidget(self.installed_label)
891
+ lay.addLayout(mid_layout)
892
+
893
+ self.installed_list = QPluginList(installed, self.installer)
894
+ self.packages_filter.textChanged.connect(self.installed_list.filter)
895
+ lay.addWidget(self.installed_list)
896
+
897
+ uninstalled = QWidget(self.v_splitter)
898
+ lay = QVBoxLayout(uninstalled)
899
+ lay.setContentsMargins(0, 2, 0, 2)
900
+ self.avail_label = QLabel(trans._("Available Plugins"))
901
+ mid_layout = QHBoxLayout()
902
+ mid_layout.addWidget(self.avail_label)
903
+ mid_layout.addStretch()
904
+ lay.addLayout(mid_layout)
905
+ self.available_list = QPluginList(uninstalled, self.installer)
906
+ self.packages_filter.textChanged.connect(self.available_list.filter)
907
+ lay.addWidget(self.available_list)
908
+
909
+ self.stdout_text = QTextEdit(self.v_splitter)
910
+ self.stdout_text.setReadOnly(True)
911
+ self.stdout_text.setObjectName("plugin_manager_process_status")
912
+ self.stdout_text.hide()
913
+
914
+ buttonBox = QHBoxLayout()
915
+ self.working_indicator = QLabel(trans._("loading ..."), self)
916
+ sp = self.working_indicator.sizePolicy()
917
+ sp.setRetainSizeWhenHidden(True)
918
+ self.working_indicator.setSizePolicy(sp)
919
+ self.process_error_indicator = QLabel(self)
920
+ self.process_error_indicator.setObjectName("error_label")
921
+ self.process_error_indicator.hide()
922
+ self.process_success_indicator = QLabel(self)
923
+ self.process_success_indicator.setObjectName("success_label")
924
+ self.process_success_indicator.hide()
925
+ load_gif = str(Path(napari.resources.__file__).parent / "loading.gif")
926
+ mov = QMovie(load_gif)
927
+ mov.setScaledSize(QSize(18, 18))
928
+ self.working_indicator.setMovie(mov)
929
+ mov.start()
930
+
931
+ visibility_direct_entry = not running_as_constructor_app()
932
+ self.direct_entry_edit = QLineEdit(self)
933
+ self.direct_entry_edit.installEventFilter(self)
934
+ self.direct_entry_edit.setPlaceholderText(
935
+ trans._('install by name/url, or drop file...')
936
+ )
937
+ self.direct_entry_edit.setVisible(visibility_direct_entry)
938
+ self.direct_entry_btn = QPushButton(trans._("Install"), self)
939
+ self.direct_entry_btn.setVisible(visibility_direct_entry)
940
+ self.direct_entry_btn.clicked.connect(self._install_packages)
941
+
942
+ self.show_status_btn = QPushButton(trans._("Show Status"), self)
943
+ self.show_status_btn.setFixedWidth(100)
944
+
945
+ self.cancel_all_btn = QPushButton(trans._("cancel all actions"), self)
946
+ self.cancel_all_btn.setObjectName("remove_button")
947
+ self.cancel_all_btn.setVisible(False)
948
+ self.cancel_all_btn.clicked.connect(self.installer.cancel)
949
+
950
+ self.close_btn = QPushButton(trans._("Close"), self)
951
+ self.close_btn.clicked.connect(self.accept)
952
+ self.close_btn.setObjectName("close_button")
953
+ buttonBox.addWidget(self.show_status_btn)
954
+ buttonBox.addWidget(self.working_indicator)
955
+ buttonBox.addWidget(self.direct_entry_edit)
956
+ buttonBox.addWidget(self.direct_entry_btn)
957
+ if not visibility_direct_entry:
958
+ buttonBox.addStretch()
959
+ buttonBox.addWidget(self.process_success_indicator)
960
+ buttonBox.addWidget(self.process_error_indicator)
961
+ buttonBox.addSpacing(20)
962
+ buttonBox.addWidget(self.cancel_all_btn)
963
+ buttonBox.addSpacing(20)
964
+ buttonBox.addWidget(self.close_btn)
965
+ buttonBox.setContentsMargins(0, 0, 4, 0)
966
+ vlay_1.addLayout(buttonBox)
967
+
968
+ self.show_status_btn.setCheckable(True)
969
+ self.show_status_btn.setChecked(False)
970
+ self.show_status_btn.toggled.connect(self._toggle_status)
971
+
972
+ self.v_splitter.setStretchFactor(1, 2)
973
+ self.h_splitter.setStretchFactor(0, 2)
974
+
975
+ self.packages_filter.setFocus()
976
+
977
+ def _update_count_in_label(self):
978
+ """Counts all available but not installed plugins. Updates value."""
979
+
980
+ count = self.available_list.count()
981
+ self.avail_label.setText(
982
+ trans._("Available Plugins ({count})", count=count)
983
+ )
984
+
985
+ def _end_refresh(self):
986
+ refresh_state = self.refresh_state
987
+ self.refresh_state = RefreshState.DONE
988
+ if refresh_state == RefreshState.OUTDATED:
989
+ self.refresh()
990
+
991
+ def eventFilter(self, watched, event):
992
+ if event.type() == QEvent.DragEnter:
993
+ # we need to accept this event explicitly to be able
994
+ # to receive QDropEvents!
995
+ event.accept()
996
+ if event.type() == QEvent.Drop:
997
+ md = event.mimeData()
998
+ if md.hasUrls():
999
+ files = [url.toLocalFile() for url in md.urls()]
1000
+ self.direct_entry_edit.setText(files[0])
1001
+ return True
1002
+ return super().eventFilter(watched, event)
1003
+
1004
+ def _toggle_status(self, show):
1005
+ if show:
1006
+ self.show_status_btn.setText(trans._("Hide Status"))
1007
+ self.stdout_text.show()
1008
+ else:
1009
+ self.show_status_btn.setText(trans._("Show Status"))
1010
+ self.stdout_text.hide()
1011
+
1012
+ def _install_packages(
1013
+ self,
1014
+ packages: Sequence[str] = (),
1015
+ versions: Optional[Sequence[str]] = None,
1016
+ ):
1017
+ if not packages:
1018
+ _packages = self.direct_entry_edit.text()
1019
+ packages = (
1020
+ [_packages] if os.path.exists(_packages) else _packages.split()
1021
+ )
1022
+ self.direct_entry_edit.clear()
1023
+ if packages:
1024
+ self.installer.install(
1025
+ packages,
1026
+ versions=versions,
1027
+ )
1028
+
1029
+ def _add_items(self):
1030
+ """Add items to the lists one by one using a timer to prevent freezing the UI."""
1031
+ if len(self._plugin_data) == 0:
1032
+ if (
1033
+ self.installed_list.count() + self.available_list.count()
1034
+ == len(self.all_plugin_data)
1035
+ ):
1036
+ self._add_items_timer.stop()
1037
+ return
1038
+
1039
+ data = self._plugin_data.pop(0)
1040
+ project_info, is_available, extra_info = data
1041
+ if project_info.name in self.already_installed:
1042
+ self.installed_list.tag_outdated(project_info, is_available)
1043
+ else:
1044
+ if project_info.name not in self.available_set:
1045
+ self.available_set.add(project_info.name)
1046
+ self.available_list.addItem(
1047
+ (
1048
+ project_info,
1049
+ extra_info['pypi_versions'],
1050
+ extra_info['conda_versions'],
1051
+ )
1052
+ )
1053
+ if not is_available:
1054
+ self.available_list.tag_unavailable(project_info)
1055
+
1056
+ self.filter()
1057
+
1058
+ def _handle_yield(self, data: Tuple[npe2.PackageMetadata, bool, Dict]):
1059
+ """Output from a worker process.
1060
+
1061
+ Includes information about the plugin, including available versions on conda and pypi.
1062
+
1063
+ The data is stored but the actual items are added via a timer in the `_add_items`
1064
+ method to prevent the UI from freezing by adding all items at once.
1065
+ """
1066
+ self._plugin_data.append(data)
1067
+ self.all_plugin_data.append(data)
1068
+
1069
+ def filter(self, text: Optional[str] = None) -> None:
1070
+ """Filter by text or set current text as filter."""
1071
+ if text is None:
1072
+ text = self.packages_filter.text()
1073
+ else:
1074
+ self.packages_filter.setText(text)
1075
+
1076
+ self.installed_list.filter(text)
1077
+ self.available_list.filter(text)
1078
+
1079
+
1080
+ if __name__ == "__main__":
1081
+ from qtpy.QtWidgets import QApplication
1082
+
1083
+ app = QApplication([])
1084
+ w = QtPluginDialog()
1085
+ w.show()
1086
+ app.exec_()