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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1495 @@
1
+ import contextlib
2
+ import importlib.metadata
3
+ import os
4
+ import sys
5
+ import webbrowser
6
+ from functools import partial
7
+ from pathlib import Path
8
+ from typing import Dict, List, Literal, NamedTuple, Optional, Sequence, Tuple
9
+
10
+ import napari.plugins
11
+ import napari.resources
12
+ import npe2
13
+ from napari._qt.qt_resources import QColoredSVGIcon, get_current_stylesheet
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.utils import normalized_name
18
+ from napari.settings import get_settings
19
+ from napari.utils.misc import (
20
+ parse_version,
21
+ running_as_constructor_app,
22
+ )
23
+ from napari.utils.notifications import show_info, show_warning
24
+ from napari.utils.translations import trans
25
+ from qtpy.QtCore import QPoint, QSize, Qt, QTimer, Signal, Slot
26
+ from qtpy.QtGui import QAction, QFont, QKeySequence, QMovie, QShortcut
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.npe2api import (
48
+ cache_clear,
49
+ iter_napari_plugin_info,
50
+ )
51
+ from napari_plugin_manager.qt_package_installer import (
52
+ InstallerActions,
53
+ InstallerQueue,
54
+ InstallerTools,
55
+ ProcessFinishedData,
56
+ )
57
+ from napari_plugin_manager.qt_widgets import ClickableLabel
58
+ from napari_plugin_manager.utils import is_conda_package
59
+
60
+ # Scaling factor for each list widget item when expanding.
61
+ CONDA = 'Conda'
62
+ PYPI = 'PyPI'
63
+ ON_BUNDLE = running_as_constructor_app()
64
+ IS_NAPARI_CONDA_INSTALLED = is_conda_package('napari')
65
+ STYLES_PATH = Path(__file__).parent / 'styles.qss'
66
+
67
+
68
+ def _show_message(widget):
69
+ message = trans._(
70
+ 'When installing/uninstalling npe2 plugins, '
71
+ 'you must restart napari for UI changes to take effect.'
72
+ )
73
+ if widget.isVisible():
74
+ button = widget.action_button
75
+ warn_dialog = WarnPopup(text=message)
76
+ global_point = widget.action_button.mapToGlobal(
77
+ button.rect().topRight()
78
+ )
79
+ global_point = QPoint(
80
+ global_point.x() - button.width(), global_point.y()
81
+ )
82
+ warn_dialog.move(global_point)
83
+ warn_dialog.exec_()
84
+
85
+
86
+ class ProjectInfoVersions(NamedTuple):
87
+ metadata: npe2.PackageMetadata
88
+ display_name: str
89
+ pypi_versions: List[str]
90
+ conda_versions: List[str]
91
+
92
+
93
+ class PluginListItem(QFrame):
94
+ """An entry in the plugin dialog. This will include the package name, summary,
95
+ author, source, version, and buttons to update, install/uninstall, etc."""
96
+
97
+ # item, package_name, action_name, version, installer_choice
98
+ actionRequested = Signal(
99
+ QListWidgetItem, str, InstallerActions, str, InstallerTools
100
+ )
101
+
102
+ def __init__(
103
+ self,
104
+ item: QListWidgetItem,
105
+ package_name: str,
106
+ display_name: str,
107
+ version: str = '',
108
+ url: str = '',
109
+ summary: str = '',
110
+ author: str = '',
111
+ license: str = "UNKNOWN", # noqa: A002
112
+ *,
113
+ plugin_name: Optional[str] = None,
114
+ parent: QWidget = None,
115
+ enabled: bool = True,
116
+ installed: bool = False,
117
+ npe_version=1,
118
+ versions_conda: Optional[List[str]] = None,
119
+ versions_pypi: Optional[List[str]] = None,
120
+ prefix=None,
121
+ ) -> None:
122
+ super().__init__(parent)
123
+ self.prefix = prefix
124
+ self.item = item
125
+ self.url = url
126
+ self.name = package_name
127
+ self.npe_version = npe_version
128
+ self._version = version
129
+ self._versions_conda = versions_conda
130
+ self._versions_pypi = versions_pypi
131
+ self.setup_ui(enabled)
132
+
133
+ if package_name == display_name:
134
+ name = package_name
135
+ else:
136
+ name = f"{display_name} <small>({package_name})</small>"
137
+
138
+ self.plugin_name.setText(name)
139
+
140
+ if len(versions_pypi) > 0:
141
+ self._populate_version_dropdown(PYPI)
142
+ else:
143
+ self._populate_version_dropdown(CONDA)
144
+
145
+ mod_version = version.replace('.', '․') # noqa: RUF001
146
+ self.version.setWordWrap(True)
147
+ self.version.setText(mod_version)
148
+ self.version.setToolTip(version)
149
+
150
+ if summary:
151
+ self.summary.setText(summary)
152
+
153
+ if author:
154
+ self.package_author.setText(author)
155
+
156
+ self.package_author.setWordWrap(True)
157
+ self.cancel_btn.setVisible(False)
158
+
159
+ self._handle_npe2_plugin(npe_version)
160
+ self._set_installed(installed, package_name)
161
+ self._populate_version_dropdown(self.get_installer_source())
162
+
163
+ def _set_installed(self, installed: bool, package_name):
164
+ if installed:
165
+ if is_conda_package(package_name):
166
+ self.source.setText(CONDA)
167
+
168
+ self.enabled_checkbox.show()
169
+ self.action_button.setText(trans._("Uninstall"))
170
+ self.action_button.setObjectName("remove_button")
171
+ self.info_choice_wdg.hide()
172
+ self.install_info_button.addWidget(self.info_widget)
173
+ self.info_widget.show()
174
+ else:
175
+ self.enabled_checkbox.hide()
176
+ self.action_button.setText(trans._("Install"))
177
+ self.action_button.setObjectName("install_button")
178
+ self.info_widget.hide()
179
+ self.install_info_button.addWidget(self.info_choice_wdg)
180
+ self.info_choice_wdg.show()
181
+
182
+ def _handle_npe2_plugin(self, npe_version):
183
+ if npe_version in (None, 1):
184
+ return
185
+
186
+ opacity = 0.4 if npe_version == 'shim' else 1
187
+ text = trans._('npe1 (adapted)') if npe_version == 'shim' else 'npe2'
188
+ icon = QColoredSVGIcon.from_resources('logo_silhouette')
189
+ self.set_status(
190
+ icon.colored(color='#33F0FF', opacity=opacity).pixmap(20, 20), text
191
+ )
192
+
193
+ def set_status(self, icon=None, text=''):
194
+ """Set the status icon and text. next to the package name."""
195
+ if icon:
196
+ self.status_icon.setPixmap(icon)
197
+
198
+ if text:
199
+ self.status_label.setText(text)
200
+
201
+ self.status_icon.setVisible(bool(icon))
202
+ self.status_label.setVisible(bool(text))
203
+
204
+ def set_busy(
205
+ self,
206
+ text: str,
207
+ action_name: Optional[
208
+ Literal['install', 'uninstall', 'cancel', 'upgrade']
209
+ ] = None,
210
+ ):
211
+ """Updates status text and what buttons are visible when any button is pushed.
212
+
213
+ Parameters
214
+ ----------
215
+ text: str
216
+ The new string to be displayed as the status.
217
+ action_name: str
218
+ The action of the button pressed.
219
+
220
+ """
221
+ self.item_status.setText(text)
222
+ if action_name == 'upgrade':
223
+ self.cancel_btn.setVisible(True)
224
+ self.action_button.setVisible(False)
225
+ elif action_name in {'uninstall', 'install'}:
226
+ self.action_button.setVisible(False)
227
+ self.cancel_btn.setVisible(True)
228
+ elif action_name == 'cancel':
229
+ self.action_button.setVisible(True)
230
+ self.action_button.setDisabled(False)
231
+ self.cancel_btn.setVisible(False)
232
+ else: # pragma: no cover
233
+ raise ValueError(f"Not supported {action_name}")
234
+
235
+ def setup_ui(self, enabled=True):
236
+ """Define the layout of the PluginListItem"""
237
+ # Enabled checkbox
238
+ self.enabled_checkbox = QCheckBox(self)
239
+ self.enabled_checkbox.setChecked(enabled)
240
+ self.enabled_checkbox.setToolTip(trans._("enable/disable"))
241
+ self.enabled_checkbox.setText("")
242
+ self.enabled_checkbox.stateChanged.connect(self._on_enabled_checkbox)
243
+
244
+ sizePolicy = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
245
+ sizePolicy.setHorizontalStretch(0)
246
+ sizePolicy.setVerticalStretch(0)
247
+ sizePolicy.setHeightForWidth(
248
+ self.enabled_checkbox.sizePolicy().hasHeightForWidth()
249
+ )
250
+ self.enabled_checkbox.setSizePolicy(sizePolicy)
251
+ self.enabled_checkbox.setMinimumSize(QSize(20, 0))
252
+
253
+ # Plugin name
254
+ self.plugin_name = ClickableLabel(self) # To style content
255
+ font_plugin_name = QFont()
256
+ font_plugin_name.setPointSize(15)
257
+ font_plugin_name.setUnderline(True)
258
+ self.plugin_name.setFont(font_plugin_name)
259
+
260
+ # Status
261
+ self.status_icon = QLabel(self)
262
+ self.status_icon.setVisible(False)
263
+ self.status_label = QLabel(self)
264
+ self.status_label.setVisible(False)
265
+
266
+ if self.url and self.url != 'UNKNOWN':
267
+ # Do not want to highlight on hover unless there is a website.
268
+ self.plugin_name.setObjectName('plugin_name_web')
269
+ else:
270
+ self.plugin_name.setObjectName('plugin_name')
271
+
272
+ sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum)
273
+ sizePolicy.setHorizontalStretch(0)
274
+ sizePolicy.setVerticalStretch(0)
275
+ sizePolicy.setHeightForWidth(
276
+ self.plugin_name.sizePolicy().hasHeightForWidth()
277
+ )
278
+ self.plugin_name.setSizePolicy(sizePolicy)
279
+
280
+ # Warning icon
281
+ icon = QColoredSVGIcon.from_resources("warning")
282
+ self.warning_tooltip = QtToolTipLabel(self)
283
+
284
+ # TODO: This color should come from the theme but the theme needs
285
+ # to provide the right color. Default warning should be orange, not
286
+ # red. Code example:
287
+ # theme_name = get_settings().appearance.theme
288
+ # napari.utils.theme.get_theme(theme_name, as_dict=False).warning.as_hex()
289
+ self.warning_tooltip.setPixmap(
290
+ icon.colored(color="#E3B617").pixmap(15, 15)
291
+ )
292
+ self.warning_tooltip.setVisible(False)
293
+
294
+ # Item status
295
+ self.item_status = QLabel(self)
296
+ self.item_status.setObjectName("small_italic_text")
297
+ self.item_status.setSizePolicy(sizePolicy)
298
+
299
+ # Summary
300
+ self.summary = QElidingLabel(parent=self)
301
+ self.summary.setObjectName('summary_text')
302
+ self.summary.setWordWrap(True)
303
+
304
+ font_summary = QFont()
305
+ font_summary.setPointSize(10)
306
+ self.summary.setFont(font_summary)
307
+
308
+ sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
309
+ sizePolicy.setHorizontalStretch(1)
310
+ sizePolicy.setVerticalStretch(0)
311
+ self.summary.setSizePolicy(sizePolicy)
312
+ self.summary.setContentsMargins(0, -2, 0, -2)
313
+
314
+ # Package author
315
+ self.package_author = QElidingLabel(self)
316
+ self.package_author.setObjectName('author_text')
317
+ self.package_author.setWordWrap(True)
318
+ self.package_author.setSizePolicy(sizePolicy)
319
+
320
+ # Update button
321
+ self.update_btn = QPushButton('Update', self)
322
+ self.update_btn.setObjectName("install_button")
323
+ self.update_btn.setVisible(False)
324
+ self.update_btn.clicked.connect(self._update_requested)
325
+ sizePolicy.setRetainSizeWhenHidden(True)
326
+ sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
327
+ self.update_btn.setSizePolicy(sizePolicy)
328
+ self.update_btn.clicked.connect(self._update_requested)
329
+
330
+ # Action Button
331
+ self.action_button = QPushButton(self)
332
+ self.action_button.setFixedWidth(70)
333
+ sizePolicy1 = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
334
+ self.action_button.setSizePolicy(sizePolicy1)
335
+ self.action_button.clicked.connect(self._action_requested)
336
+
337
+ # Cancel
338
+ self.cancel_btn = QPushButton("Cancel", self)
339
+ self.cancel_btn.setObjectName("remove_button")
340
+ self.cancel_btn.setSizePolicy(sizePolicy)
341
+ self.cancel_btn.setFixedWidth(70)
342
+ self.cancel_btn.clicked.connect(self._cancel_requested)
343
+
344
+ # Collapsible button
345
+ coll_icon = QColoredSVGIcon.from_resources('right_arrow').colored(
346
+ color='white',
347
+ )
348
+ exp_icon = QColoredSVGIcon.from_resources('down_arrow').colored(
349
+ color='white',
350
+ )
351
+ self.install_info_button = QCollapsible(
352
+ "Installation Info", collapsedIcon=coll_icon, expandedIcon=exp_icon
353
+ )
354
+ self.install_info_button.setLayoutDirection(
355
+ Qt.RightToLeft
356
+ ) # Make icon appear on the right
357
+ self.install_info_button.setObjectName("install_info_button")
358
+ self.install_info_button.setFixedWidth(180)
359
+ self.install_info_button.content().layout().setContentsMargins(
360
+ 0, 0, 0, 0
361
+ )
362
+ self.install_info_button.content().setContentsMargins(0, 0, 0, 0)
363
+ self.install_info_button.content().layout().setSpacing(0)
364
+ self.install_info_button.layout().setContentsMargins(0, 0, 0, 0)
365
+ self.install_info_button.layout().setSpacing(2)
366
+ self.install_info_button.setSizePolicy(sizePolicy)
367
+
368
+ # Information widget for available packages
369
+ self.info_choice_wdg = QWidget(self)
370
+ self.info_choice_wdg.setObjectName('install_choice')
371
+
372
+ self.source_choice_text = QLabel('Source:')
373
+ self.version_choice_text = QLabel('Version:')
374
+ self.source_choice_dropdown = QComboBox()
375
+ self.version_choice_dropdown = QComboBox()
376
+
377
+ if IS_NAPARI_CONDA_INSTALLED and self._versions_conda:
378
+ self.source_choice_dropdown.addItem(CONDA)
379
+
380
+ if self._versions_pypi:
381
+ self.source_choice_dropdown.addItem(PYPI)
382
+
383
+ source = self.get_installer_source()
384
+ self.source_choice_dropdown.setCurrentText(source)
385
+ self._populate_version_dropdown(source)
386
+ self.source_choice_dropdown.currentTextChanged.connect(
387
+ self._populate_version_dropdown
388
+ )
389
+
390
+ # Information widget for installed packages
391
+ self.info_widget = QWidget(self)
392
+ self.info_widget.setLayoutDirection(Qt.LeftToRight)
393
+ self.info_widget.setObjectName("info_widget")
394
+ self.info_widget.setFixedWidth(180)
395
+
396
+ self.source_text = QLabel('Source:')
397
+ self.source = QLabel(PYPI)
398
+ self.version_text = QLabel('Version:')
399
+ self.version = QElidingLabel()
400
+ self.version.setWordWrap(True)
401
+
402
+ info_layout = QGridLayout()
403
+ info_layout.setContentsMargins(0, 0, 0, 0)
404
+ info_layout.setVerticalSpacing(0)
405
+ info_layout.addWidget(self.source_text, 0, 0)
406
+ info_layout.addWidget(self.source, 1, 0)
407
+ info_layout.addWidget(self.version_text, 0, 1)
408
+ info_layout.addWidget(self.version, 1, 1)
409
+ self.info_widget.setLayout(info_layout)
410
+
411
+ # Error indicator
412
+ self.error_indicator = QPushButton()
413
+ self.error_indicator.setObjectName("warning_icon")
414
+ self.error_indicator.setCursor(Qt.CursorShape.PointingHandCursor)
415
+ self.error_indicator.hide()
416
+
417
+ # region - Layout
418
+ # -----------------------------------------------------------------
419
+ layout = QHBoxLayout()
420
+ layout.setSpacing(2)
421
+ layout_left = QVBoxLayout()
422
+ layout_right = QVBoxLayout()
423
+ layout_top = QHBoxLayout()
424
+ layout_bottom = QHBoxLayout()
425
+ layout_bottom.setSpacing(4)
426
+
427
+ layout_left.addWidget(
428
+ self.enabled_checkbox, alignment=Qt.AlignmentFlag.AlignTop
429
+ )
430
+
431
+ layout_right.addLayout(layout_top, 1)
432
+ layout_right.addLayout(layout_bottom, 100)
433
+
434
+ layout.addLayout(layout_left)
435
+ layout.addLayout(layout_right)
436
+
437
+ self.setLayout(layout)
438
+
439
+ layout_top.addWidget(self.plugin_name)
440
+ layout_top.addWidget(self.status_icon)
441
+ layout_top.addWidget(self.status_label)
442
+ layout_top.addWidget(self.item_status)
443
+ layout_top.addStretch()
444
+
445
+ layout_bottom.addWidget(
446
+ self.summary, alignment=Qt.AlignmentFlag.AlignTop, stretch=3
447
+ )
448
+ layout_bottom.addWidget(
449
+ self.package_author, alignment=Qt.AlignmentFlag.AlignTop, stretch=1
450
+ )
451
+ layout_bottom.addWidget(
452
+ self.update_btn, alignment=Qt.AlignmentFlag.AlignTop
453
+ )
454
+ layout_bottom.addWidget(
455
+ self.install_info_button, alignment=Qt.AlignmentFlag.AlignTop
456
+ )
457
+ layout_bottom.addWidget(
458
+ self.action_button, alignment=Qt.AlignmentFlag.AlignTop
459
+ )
460
+ layout_bottom.addWidget(
461
+ self.cancel_btn, alignment=Qt.AlignmentFlag.AlignTop
462
+ )
463
+
464
+ info_layout = QGridLayout()
465
+ info_layout.setContentsMargins(0, 0, 0, 0)
466
+ info_layout.setVerticalSpacing(0)
467
+ info_layout.addWidget(self.source_choice_text, 0, 0, 1, 1)
468
+ info_layout.addWidget(self.source_choice_dropdown, 1, 0, 1, 1)
469
+ info_layout.addWidget(self.version_choice_text, 0, 1, 1, 1)
470
+ info_layout.addWidget(self.version_choice_dropdown, 1, 1, 1, 1)
471
+
472
+ # endregion - Layout
473
+
474
+ self.info_choice_wdg.setLayout(info_layout)
475
+ self.info_choice_wdg.setLayoutDirection(Qt.LeftToRight)
476
+ self.info_choice_wdg.setObjectName("install_choice_widget")
477
+ self.info_choice_wdg.hide()
478
+
479
+ def _populate_version_dropdown(self, source: Literal["PyPI", "Conda"]):
480
+ """Display the versions available after selecting a source: pypi or conda."""
481
+ if source == PYPI:
482
+ versions = self._versions_pypi
483
+ else:
484
+ versions = self._versions_conda
485
+ self.version_choice_dropdown.clear()
486
+ for version in versions:
487
+ self.version_choice_dropdown.addItem(version)
488
+
489
+ def _on_enabled_checkbox(self, state: int):
490
+ """Called with `state` when checkbox is clicked."""
491
+ enabled = bool(state)
492
+ plugin_name = self.plugin_name.text()
493
+ pm2 = npe2.PluginManager.instance()
494
+ if plugin_name in pm2:
495
+ pm2.enable(plugin_name) if state else pm2.disable(plugin_name)
496
+ return
497
+
498
+ for (
499
+ npe1_name,
500
+ _,
501
+ distname,
502
+ ) in napari.plugins.plugin_manager.iter_available():
503
+ if distname and (normalized_name(distname) == plugin_name):
504
+ napari.plugins.plugin_manager.set_blocked(
505
+ npe1_name, not enabled
506
+ )
507
+ return
508
+
509
+ def _cancel_requested(self):
510
+ version = self.version_choice_dropdown.currentText()
511
+ tool = self.get_installer_tool()
512
+ self.actionRequested.emit(
513
+ self.item, self.name, InstallerActions.CANCEL, version, tool
514
+ )
515
+
516
+ def _action_requested(self):
517
+ version = self.version_choice_dropdown.currentText()
518
+ tool = self.get_installer_tool()
519
+ action = (
520
+ InstallerActions.INSTALL
521
+ if self.action_button.objectName() == 'install_button'
522
+ else InstallerActions.UNINSTALL
523
+ )
524
+ self.actionRequested.emit(self.item, self.name, action, version, tool)
525
+
526
+ def _update_requested(self):
527
+ version = self.version_choice_dropdown.currentText()
528
+ tool = self.get_installer_tool()
529
+ self.actionRequested.emit(
530
+ self.item, self.name, InstallerActions.UPGRADE, version, tool
531
+ )
532
+
533
+ def show_warning(self, message: str = ""):
534
+ """Show warning icon and tooltip."""
535
+ self.warning_tooltip.setVisible(bool(message))
536
+ self.warning_tooltip.setToolTip(message)
537
+
538
+ def get_installer_source(self):
539
+ return (
540
+ CONDA
541
+ if self.source_choice_dropdown.currentText() == CONDA
542
+ or is_conda_package(self.name)
543
+ else PYPI
544
+ )
545
+
546
+ def get_installer_tool(self):
547
+ return (
548
+ InstallerTools.CONDA
549
+ if self.source_choice_dropdown.currentText() == CONDA
550
+ or is_conda_package(self.name, prefix=self.prefix)
551
+ else InstallerTools.PIP
552
+ )
553
+
554
+
555
+ class QPluginList(QListWidget):
556
+
557
+ _SORT_ORDER_PREFIX = '0-'
558
+
559
+ def __init__(self, parent: QWidget, installer: InstallerQueue) -> None:
560
+ super().__init__(parent)
561
+ self.installer = installer
562
+ self._remove_list = []
563
+ self._data = []
564
+ self._initial_height = None
565
+
566
+ self.setSortingEnabled(True)
567
+
568
+ def count_visible(self) -> int:
569
+ """Return the number of visible items.
570
+
571
+ Visible items are the result of the normal `count` method minus
572
+ any hidden items.
573
+ """
574
+ hidden = 0
575
+ count = self.count()
576
+ for i in range(count):
577
+ item = self.item(i)
578
+ hidden += item.isHidden()
579
+
580
+ return count - hidden
581
+
582
+ @Slot(tuple)
583
+ def addItem(
584
+ self,
585
+ project_info: ProjectInfoVersions,
586
+ installed=False,
587
+ plugin_name=None,
588
+ enabled=True,
589
+ npe_version=None,
590
+ ):
591
+ pkg_name = project_info.metadata.name
592
+ # don't add duplicates
593
+ if (
594
+ self.findItems(pkg_name, Qt.MatchFlag.MatchFixedString)
595
+ and not plugin_name
596
+ ):
597
+ return
598
+
599
+ # including summary here for sake of filtering below.
600
+ searchable_text = f"{pkg_name} {project_info.display_name} {project_info.metadata.summary}"
601
+ item = QListWidgetItem(searchable_text, self)
602
+ item.version = project_info.metadata.version
603
+ super().addItem(item)
604
+ widg = PluginListItem(
605
+ item=item,
606
+ package_name=pkg_name,
607
+ display_name=project_info.display_name,
608
+ version=project_info.metadata.version,
609
+ url=project_info.metadata.home_page,
610
+ summary=project_info.metadata.summary,
611
+ author=project_info.metadata.author,
612
+ license=project_info.metadata.license,
613
+ parent=self,
614
+ plugin_name=plugin_name,
615
+ enabled=enabled,
616
+ installed=installed,
617
+ npe_version=npe_version,
618
+ versions_conda=project_info.conda_versions,
619
+ versions_pypi=project_info.pypi_versions,
620
+ )
621
+ item.widget = widg
622
+ item.npe_version = npe_version
623
+ item.setSizeHint(widg.sizeHint())
624
+ self.setItemWidget(item, widg)
625
+
626
+ if project_info.metadata.home_page:
627
+ widg.plugin_name.clicked.connect(
628
+ partial(webbrowser.open, project_info.metadata.home_page)
629
+ )
630
+
631
+ widg.actionRequested.connect(self.handle_action)
632
+ item.setSizeHint(item.widget.size())
633
+ if self._initial_height is None:
634
+ self._initial_height = item.widget.size().height()
635
+
636
+ widg.install_info_button.setDuration(0)
637
+ widg.install_info_button.toggled.connect(
638
+ lambda: self._resize_pluginlistitem(item)
639
+ )
640
+
641
+ def removeItem(self, name):
642
+ count = self.count()
643
+ for i in range(count):
644
+ item = self.item(i)
645
+ if item.widget.name == name:
646
+ self.takeItem(i)
647
+ break
648
+
649
+ def refreshItem(self, name, version=None):
650
+ count = self.count()
651
+ for i in range(count):
652
+ item = self.item(i)
653
+ if item.widget.name == name:
654
+ if version is not None:
655
+ item.version = version
656
+ mod_version = version.replace('.', '․') # noqa: RUF001
657
+ item.widget.version.setText(mod_version)
658
+ item.widget.version.setToolTip(version)
659
+ item.widget.set_busy('', InstallerActions.CANCEL)
660
+ if item.text().startswith(self._SORT_ORDER_PREFIX):
661
+ item.setText(item.text()[len(self._SORT_ORDER_PREFIX) :])
662
+ break
663
+
664
+ def _resize_pluginlistitem(self, item):
665
+ """Resize the plugin list item, especially after toggling QCollapsible."""
666
+ if item.widget.install_info_button.isExpanded():
667
+ item.widget.setFixedHeight(self._initial_height + 35)
668
+ else:
669
+ item.widget.setFixedHeight(self._initial_height)
670
+
671
+ item.setSizeHint(QSize(0, item.widget.height()))
672
+
673
+ def handle_action(
674
+ self,
675
+ item: QListWidgetItem,
676
+ pkg_name: str,
677
+ action_name: InstallerActions,
678
+ version: Optional[str] = None,
679
+ installer_choice: Optional[str] = None,
680
+ ):
681
+ """Determine which action is called (install, uninstall, update, cancel).
682
+ Update buttons appropriately and run the action."""
683
+ widget = item.widget
684
+ tool = installer_choice or widget.get_installer_tool()
685
+ self._remove_list.append((pkg_name, item))
686
+ self._warn_dialog = None
687
+ if not item.text().startswith(self._SORT_ORDER_PREFIX):
688
+ item.setText(f"{self._SORT_ORDER_PREFIX}{item.text()}")
689
+
690
+ # TODO: NPE version unknown before installing
691
+ if (
692
+ widget.npe_version != 1
693
+ and action_name == InstallerActions.UNINSTALL
694
+ ):
695
+ _show_message(widget)
696
+
697
+ if action_name == InstallerActions.INSTALL:
698
+ if version:
699
+ pkg_name += (
700
+ f"=={item.widget.version_choice_dropdown.currentText()}"
701
+ )
702
+ widget.set_busy(trans._("installing..."), action_name)
703
+
704
+ job_id = self.installer.install(
705
+ tool=tool,
706
+ pkgs=[pkg_name],
707
+ # origins="TODO",
708
+ )
709
+ widget.setProperty("current_job_id", job_id)
710
+ if self._warn_dialog:
711
+ self._warn_dialog.exec_()
712
+ self.scrollToTop()
713
+
714
+ if action_name == InstallerActions.UPGRADE:
715
+ if hasattr(item, 'latest_version'):
716
+ pkg_name += f"=={item.latest_version}"
717
+
718
+ widget.set_busy(trans._("updating..."), action_name)
719
+ widget.update_btn.setDisabled(True)
720
+ widget.action_button.setDisabled(True)
721
+
722
+ job_id = self.installer.upgrade(
723
+ tool=tool,
724
+ pkgs=[pkg_name],
725
+ # origins="TODO",
726
+ )
727
+ widget.setProperty("current_job_id", job_id)
728
+ if self._warn_dialog:
729
+ self._warn_dialog.exec_()
730
+ self.scrollToTop()
731
+
732
+ elif action_name == InstallerActions.UNINSTALL:
733
+ widget.set_busy(trans._("uninstalling..."), action_name)
734
+ widget.update_btn.setDisabled(True)
735
+ job_id = self.installer.uninstall(
736
+ tool=tool,
737
+ pkgs=[pkg_name],
738
+ # origins="TODO",
739
+ # upgrade=False,
740
+ )
741
+ widget.setProperty("current_job_id", job_id)
742
+ if self._warn_dialog:
743
+ self._warn_dialog.exec_()
744
+ self.scrollToTop()
745
+ elif action_name == InstallerActions.CANCEL:
746
+ widget.set_busy(trans._("cancelling..."), action_name)
747
+ try:
748
+ job_id = widget.property("current_job_id")
749
+ self.installer.cancel(job_id)
750
+ finally:
751
+ widget.setProperty("current_job_id", None)
752
+
753
+ def set_data(self, data):
754
+ self._data = data
755
+
756
+ def is_running(self):
757
+ return self.count() != len(self._data)
758
+
759
+ def packages(self):
760
+ return [self.item(idx).widget.name for idx in range(self.count())]
761
+
762
+ @Slot(npe2.PackageMetadata, bool)
763
+ def tag_outdated(self, metadata: npe2.PackageMetadata, is_available: bool):
764
+ """Determines if an installed plugin is up to date with the latest version.
765
+ If it is not, the latest version will be displayed on the update button.
766
+ """
767
+ if not is_available:
768
+ return
769
+
770
+ for item in self.findItems(
771
+ metadata.name, Qt.MatchFlag.MatchStartsWith
772
+ ):
773
+ current = item.version
774
+ latest = metadata.version
775
+ is_marked_outdated = getattr(item, 'outdated', False)
776
+ if parse_version(current) >= parse_version(latest):
777
+ # currently is up to date
778
+ if is_marked_outdated:
779
+ # previously marked as outdated, need to update item
780
+ # `outdated` state and hide item widget `update_btn`
781
+ item.outdated = False
782
+ widg = self.itemWidget(item)
783
+ widg.update_btn.setVisible(False)
784
+ continue
785
+ if is_marked_outdated:
786
+ # already tagged it
787
+ continue
788
+
789
+ item.outdated = True
790
+ item.latest_version = latest
791
+ widg = self.itemWidget(item)
792
+ widg.update_btn.setVisible(True)
793
+ widg.update_btn.setText(
794
+ trans._("update (v{latest})", latest=latest)
795
+ )
796
+
797
+ def tag_unavailable(self, metadata: npe2.PackageMetadata):
798
+ """
799
+ Tag list items as unavailable for install with conda-forge.
800
+
801
+ This will disable the item and the install button and add a warning
802
+ icon with a hover tooltip.
803
+ """
804
+ for item in self.findItems(
805
+ metadata.name, Qt.MatchFlag.MatchStartsWith
806
+ ):
807
+ widget = self.itemWidget(item)
808
+ widget.show_warning(
809
+ trans._(
810
+ "Plugin not yet available for installation within the bundle application"
811
+ )
812
+ )
813
+ widget.setObjectName("unavailable")
814
+ widget.style().unpolish(widget)
815
+ widget.style().polish(widget)
816
+ widget.action_button.setEnabled(False)
817
+ widget.warning_tooltip.setVisible(True)
818
+
819
+ def filter(self, text: str, starts_with_chars: int = 1):
820
+ """Filter items to those containing `text`."""
821
+ if text:
822
+ # PySide has some issues, so we compare using id
823
+ # See: https://bugreports.qt.io/browse/PYSIDE-74
824
+ flag = (
825
+ Qt.MatchFlag.MatchStartsWith
826
+ if len(text) <= starts_with_chars
827
+ else Qt.MatchFlag.MatchContains
828
+ )
829
+ if len(text) <= starts_with_chars:
830
+ flag = Qt.MatchFlag.MatchStartsWith
831
+ queries = (text, f'napari-{text}')
832
+ else:
833
+ flag = Qt.MatchFlag.MatchContains
834
+ queries = (text,)
835
+
836
+ shown = {
837
+ id(it)
838
+ for query in queries
839
+ for it in self.findItems(query, flag)
840
+ }
841
+ for i in range(self.count()):
842
+ item = self.item(i)
843
+ item.setHidden(id(item) not in shown)
844
+ else:
845
+ for i in range(self.count()):
846
+ item = self.item(i)
847
+ item.setHidden(False)
848
+
849
+
850
+ class QtPluginDialog(QDialog):
851
+ def __init__(self, parent=None, prefix=None) -> None:
852
+ super().__init__(parent)
853
+
854
+ self._parent = parent
855
+ if (
856
+ parent is not None
857
+ and getattr(parent, '_plugin_dialog', None) is None
858
+ ):
859
+ self._parent._plugin_dialog = self
860
+
861
+ self.already_installed = set()
862
+ self.available_set = set()
863
+ self._prefix = prefix
864
+ self._first_open = True
865
+ self._plugin_queue = [] # Store plugin data to be added
866
+ self._plugin_data = [] # Store all plugin data
867
+ self._filter_texts = []
868
+ self._filter_idxs_cache = set()
869
+ self._filter_timer = QTimer(self)
870
+ self.worker = None
871
+
872
+ # timer to avoid triggering a filter for every keystroke
873
+ self._filter_timer.setInterval(140) # ms
874
+ self._filter_timer.timeout.connect(self.filter)
875
+ self._filter_timer.setSingleShot(True)
876
+ self._plugin_data_map = {}
877
+ self._add_items_timer = QTimer(self)
878
+
879
+ # Timer to avoid race conditions and incorrect count of plugins when
880
+ # refreshing multiple times in a row. After click we disable the
881
+ # `Refresh` button and re-enable it after 3 seconds.
882
+ self._refresh_timer = QTimer(self)
883
+ self._refresh_timer.setInterval(3000) # ms
884
+ self._refresh_timer.setSingleShot(True)
885
+ self._refresh_timer.timeout.connect(self._enable_refresh_button)
886
+
887
+ # Add items in batches with a pause to avoid blocking the UI
888
+ self._add_items_timer.setInterval(61) # ms
889
+ self._add_items_timer.timeout.connect(self._add_items)
890
+
891
+ self.installer = InstallerQueue(parent=self, prefix=prefix)
892
+ self.setWindowTitle(trans._('Plugin Manager'))
893
+ self._setup_ui()
894
+ self.installer.set_output_widget(self.stdout_text)
895
+ self.installer.started.connect(self._on_installer_start)
896
+ self.installer.processFinished.connect(self._on_process_finished)
897
+ self.installer.allFinished.connect(self._on_installer_all_finished)
898
+ self.setAcceptDrops(True)
899
+
900
+ if (
901
+ parent is not None and parent._plugin_dialog is self
902
+ ) or parent is None:
903
+ self.refresh()
904
+ self._setup_shortcuts()
905
+
906
+ # region - Private methods
907
+ # ------------------------------------------------------------------------
908
+ def _enable_refresh_button(self):
909
+ self.refresh_button.setEnabled(True)
910
+
911
+ def _quit(self):
912
+ self.close()
913
+ with contextlib.suppress(AttributeError):
914
+ self._parent.close(quit_app=True, confirm_need=True)
915
+
916
+ def _setup_shortcuts(self):
917
+ self._quit_action = QAction(trans._('Exit'), self)
918
+ self._quit_action.setShortcut('Ctrl+Q')
919
+ self._quit_action.setMenuRole(QAction.QuitRole)
920
+ self._quit_action.triggered.connect(self._quit)
921
+ self.addAction(self._quit_action)
922
+
923
+ self._close_shortcut = QShortcut(
924
+ QKeySequence(Qt.CTRL | Qt.Key_W), self
925
+ )
926
+ self._close_shortcut.activated.connect(self.close)
927
+ get_settings().appearance.events.theme.connect(self._update_theme)
928
+
929
+ def _update_theme(self, event):
930
+ stylesheet = get_current_stylesheet([STYLES_PATH])
931
+ self.setStyleSheet(stylesheet)
932
+
933
+ def _on_installer_start(self):
934
+ """Updates dialog buttons and status when installing a plugin."""
935
+ self.cancel_all_btn.setVisible(True)
936
+ self.working_indicator.show()
937
+ self.process_success_indicator.hide()
938
+ self.process_error_indicator.hide()
939
+ self.refresh_button.setDisabled(True)
940
+
941
+ def _on_process_finished(self, process_finished_data: ProcessFinishedData):
942
+ action = process_finished_data['action']
943
+ exit_code = process_finished_data['exit_code']
944
+ pkg_names = [
945
+ pkg.split('==')[0] for pkg in process_finished_data['pkgs']
946
+ ]
947
+ if action == InstallerActions.INSTALL:
948
+ if exit_code == 0:
949
+ for pkg_name in pkg_names:
950
+ if pkg_name in self.available_set:
951
+ self.available_set.remove(pkg_name)
952
+
953
+ self.available_list.removeItem(pkg_name)
954
+ self._add_installed(pkg_name)
955
+ self._tag_outdated_plugins()
956
+ else:
957
+ for pkg_name in pkg_names:
958
+ self.available_list.refreshItem(pkg_name)
959
+ elif action == InstallerActions.UNINSTALL:
960
+ if exit_code == 0:
961
+ for pkg_name in pkg_names:
962
+ if pkg_name in self.already_installed:
963
+ self.already_installed.remove(pkg_name)
964
+
965
+ self.installed_list.removeItem(pkg_name)
966
+ self._add_to_available(pkg_name)
967
+ else:
968
+ for pkg_name in pkg_names:
969
+ self.installed_list.refreshItem(pkg_name)
970
+ elif action == InstallerActions.UPGRADE:
971
+ pkg_info = [
972
+ (pkg.split('==')[0], pkg.split('==')[1])
973
+ for pkg in process_finished_data['pkgs']
974
+ ]
975
+ for pkg_name, pkg_version in pkg_info:
976
+ self.installed_list.refreshItem(pkg_name, version=pkg_version)
977
+ self._tag_outdated_plugins()
978
+ elif action in [InstallerActions.CANCEL, InstallerActions.CANCEL_ALL]:
979
+ for pkg_name in pkg_names:
980
+ self.installed_list.refreshItem(pkg_name)
981
+ self.available_list.refreshItem(pkg_name)
982
+ self._tag_outdated_plugins()
983
+
984
+ self.working_indicator.hide()
985
+ if exit_code:
986
+ self.process_error_indicator.show()
987
+ else:
988
+ self.process_success_indicator.show()
989
+
990
+ def _on_installer_all_finished(self, exit_codes):
991
+ self.working_indicator.hide()
992
+ self.cancel_all_btn.setVisible(False)
993
+ self.close_btn.setDisabled(False)
994
+ self.refresh_button.setDisabled(False)
995
+
996
+ if not self.isVisible():
997
+ if sum(exit_codes) > 0:
998
+ show_warning(
999
+ trans._('Plugin Manager: process completed with errors\n')
1000
+ )
1001
+ else:
1002
+ show_info(trans._('Plugin Manager: process completed\n'))
1003
+
1004
+ def _add_to_available(self, pkg_name):
1005
+ self._add_items_timer.stop()
1006
+ self._plugin_queue.insert(0, self._plugin_data_map[pkg_name])
1007
+ self._add_items_timer.start()
1008
+ self._update_plugin_count()
1009
+
1010
+ def _add_to_installed(self, distname, enabled, npe_version=1):
1011
+ norm_name = normalized_name(distname or '')
1012
+ if distname:
1013
+ try:
1014
+ meta = importlib.metadata.metadata(distname)
1015
+
1016
+ except importlib.metadata.PackageNotFoundError:
1017
+ return # a race condition has occurred and the package is uninstalled by another thread
1018
+ if len(meta) == 0:
1019
+ # will not add builtins.
1020
+ return
1021
+ self.already_installed.add(norm_name)
1022
+ else:
1023
+ meta = {}
1024
+
1025
+ self.installed_list.addItem(
1026
+ ProjectInfoVersions(
1027
+ npe2.PackageMetadata(
1028
+ metadata_version="1.0",
1029
+ name=norm_name,
1030
+ version=meta.get('version', ''),
1031
+ summary=meta.get('summary', ''),
1032
+ home_page=meta.get('Home-page', ''),
1033
+ author=meta.get('author', ''),
1034
+ license=meta.get('license', ''),
1035
+ ),
1036
+ norm_name,
1037
+ [],
1038
+ [],
1039
+ ),
1040
+ installed=True,
1041
+ enabled=enabled,
1042
+ npe_version=npe_version,
1043
+ )
1044
+
1045
+ def _add_installed(self, pkg_name=None):
1046
+ pm2 = npe2.PluginManager.instance()
1047
+ pm2.discover()
1048
+ for manifest in pm2.iter_manifests():
1049
+ distname = normalized_name(manifest.name or '')
1050
+ if distname in self.already_installed or distname == 'napari':
1051
+ continue
1052
+ enabled = not pm2.is_disabled(manifest.name)
1053
+ # if it's an Npe1 adaptor, call it v1
1054
+ npev = 'shim' if manifest.npe1_shim else 2
1055
+ if distname == pkg_name or pkg_name is None:
1056
+ self._add_to_installed(distname, enabled, npe_version=npev)
1057
+
1058
+ napari.plugins.plugin_manager.discover() # since they might not be loaded yet
1059
+ for (
1060
+ plugin_name,
1061
+ _,
1062
+ distname,
1063
+ ) in napari.plugins.plugin_manager.iter_available():
1064
+ # not showing these in the plugin dialog
1065
+ if plugin_name in ('napari_plugin_engine',):
1066
+ continue
1067
+ if normalized_name(distname or '') in self.already_installed:
1068
+ continue
1069
+ if normalized_name(distname or '') == pkg_name or pkg_name is None:
1070
+ self._add_to_installed(
1071
+ distname,
1072
+ not napari.plugins.plugin_manager.is_blocked(plugin_name),
1073
+ )
1074
+ self._update_plugin_count()
1075
+
1076
+ for i in range(self.installed_list.count()):
1077
+ item = self.installed_list.item(i)
1078
+ widget = item.widget
1079
+ if widget.name == pkg_name:
1080
+ self.installed_list.scrollToItem(item)
1081
+ self.installed_list.setCurrentItem(item)
1082
+ if widget.npe_version != 1:
1083
+ _show_message(widget)
1084
+ break
1085
+
1086
+ def _fetch_available_plugins(self, clear_cache: bool = False):
1087
+ get_settings()
1088
+
1089
+ if clear_cache:
1090
+ cache_clear()
1091
+
1092
+ self.worker = create_worker(iter_napari_plugin_info)
1093
+ self.worker.yielded.connect(self._handle_yield)
1094
+ self.worker.started.connect(self.working_indicator.show)
1095
+ self.worker.finished.connect(self.working_indicator.hide)
1096
+ self.worker.finished.connect(self._add_items_timer.start)
1097
+ self.worker.start()
1098
+
1099
+ pm2 = npe2.PluginManager.instance()
1100
+ pm2.discover()
1101
+
1102
+ def _setup_ui(self):
1103
+ """Defines the layout for the PluginDialog."""
1104
+ self.resize(900, 600)
1105
+ vlay_1 = QVBoxLayout(self)
1106
+ self.h_splitter = QSplitter(self)
1107
+ vlay_1.addWidget(self.h_splitter)
1108
+ self.h_splitter.setOrientation(Qt.Orientation.Horizontal)
1109
+ self.v_splitter = QSplitter(self.h_splitter)
1110
+ self.v_splitter.setOrientation(Qt.Orientation.Vertical)
1111
+ self.v_splitter.setMinimumWidth(500)
1112
+
1113
+ installed = QWidget(self.v_splitter)
1114
+ lay = QVBoxLayout(installed)
1115
+ lay.setContentsMargins(0, 2, 0, 2)
1116
+ self.installed_label = QLabel(trans._("Installed Plugins"))
1117
+ self.packages_filter = QLineEdit()
1118
+ self.packages_filter.setPlaceholderText(trans._("filter..."))
1119
+ self.packages_filter.setMaximumWidth(350)
1120
+ self.packages_filter.setClearButtonEnabled(True)
1121
+ self.packages_filter.textChanged.connect(self._filter_timer.start)
1122
+
1123
+ self.refresh_button = QPushButton(trans._('Refresh'), self)
1124
+ self.refresh_button.setObjectName("refresh_button")
1125
+ self.refresh_button.setToolTip(
1126
+ trans._(
1127
+ 'This will clear and refresh the available and installed plugins lists.'
1128
+ )
1129
+ )
1130
+ self.refresh_button.clicked.connect(self._refresh_and_clear_cache)
1131
+
1132
+ mid_layout = QVBoxLayout()
1133
+ horizontal_mid_layout = QHBoxLayout()
1134
+ horizontal_mid_layout.addWidget(self.packages_filter)
1135
+ horizontal_mid_layout.addStretch()
1136
+ horizontal_mid_layout.addWidget(self.refresh_button)
1137
+ mid_layout.addLayout(horizontal_mid_layout)
1138
+ # mid_layout.addWidget(self.packages_filter)
1139
+ mid_layout.addWidget(self.installed_label)
1140
+ lay.addLayout(mid_layout)
1141
+
1142
+ self.installed_list = QPluginList(installed, self.installer)
1143
+ lay.addWidget(self.installed_list)
1144
+
1145
+ uninstalled = QWidget(self.v_splitter)
1146
+ lay = QVBoxLayout(uninstalled)
1147
+ lay.setContentsMargins(0, 2, 0, 2)
1148
+ self.avail_label = QLabel(trans._("Available Plugins"))
1149
+ mid_layout = QHBoxLayout()
1150
+ mid_layout.addWidget(self.avail_label)
1151
+ mid_layout.addStretch()
1152
+ lay.addLayout(mid_layout)
1153
+ self.available_list = QPluginList(uninstalled, self.installer)
1154
+ lay.addWidget(self.available_list)
1155
+
1156
+ self.stdout_text = QTextEdit(self.v_splitter)
1157
+ self.stdout_text.setReadOnly(True)
1158
+ self.stdout_text.setObjectName("plugin_manager_process_status")
1159
+ self.stdout_text.hide()
1160
+
1161
+ buttonBox = QHBoxLayout()
1162
+ self.working_indicator = QLabel(trans._("loading ..."), self)
1163
+ sp = self.working_indicator.sizePolicy()
1164
+ sp.setRetainSizeWhenHidden(True)
1165
+ self.working_indicator.setSizePolicy(sp)
1166
+ self.process_error_indicator = QLabel(self)
1167
+ self.process_error_indicator.setObjectName("error_label")
1168
+ self.process_error_indicator.hide()
1169
+ self.process_success_indicator = QLabel(self)
1170
+ self.process_success_indicator.setObjectName("success_label")
1171
+ self.process_success_indicator.hide()
1172
+ load_gif = str(Path(napari.resources.__file__).parent / "loading.gif")
1173
+ mov = QMovie(load_gif)
1174
+ mov.setScaledSize(QSize(18, 18))
1175
+ self.working_indicator.setMovie(mov)
1176
+ mov.start()
1177
+
1178
+ visibility_direct_entry = not running_as_constructor_app()
1179
+ self.direct_entry_edit = QLineEdit(self)
1180
+ self.direct_entry_edit.installEventFilter(self)
1181
+ self.direct_entry_edit.setPlaceholderText(
1182
+ trans._('install by name/url, or drop file...')
1183
+ )
1184
+ self.direct_entry_edit.setVisible(visibility_direct_entry)
1185
+ self.direct_entry_btn = QPushButton(trans._("Install"), self)
1186
+ self.direct_entry_btn.setVisible(visibility_direct_entry)
1187
+ self.direct_entry_btn.clicked.connect(self._install_packages)
1188
+
1189
+ self.show_status_btn = QPushButton(trans._("Show Status"), self)
1190
+ self.show_status_btn.setFixedWidth(100)
1191
+
1192
+ self.cancel_all_btn = QPushButton(trans._("cancel all actions"), self)
1193
+ self.cancel_all_btn.setObjectName("remove_button")
1194
+ self.cancel_all_btn.setVisible(False)
1195
+ self.cancel_all_btn.clicked.connect(self.installer.cancel_all)
1196
+
1197
+ self.close_btn = QPushButton(trans._("Close"), self)
1198
+ self.close_btn.clicked.connect(self.accept)
1199
+ self.close_btn.setObjectName("close_button")
1200
+
1201
+ buttonBox.addWidget(self.show_status_btn)
1202
+ buttonBox.addWidget(self.working_indicator)
1203
+ buttonBox.addWidget(self.direct_entry_edit)
1204
+ buttonBox.addWidget(self.direct_entry_btn)
1205
+ if not visibility_direct_entry:
1206
+ buttonBox.addStretch()
1207
+ buttonBox.addWidget(self.process_success_indicator)
1208
+ buttonBox.addWidget(self.process_error_indicator)
1209
+ buttonBox.addSpacing(20)
1210
+ buttonBox.addWidget(self.cancel_all_btn)
1211
+ buttonBox.addSpacing(20)
1212
+ buttonBox.addWidget(self.close_btn)
1213
+ buttonBox.setContentsMargins(0, 0, 4, 0)
1214
+ vlay_1.addLayout(buttonBox)
1215
+
1216
+ self.show_status_btn.setCheckable(True)
1217
+ self.show_status_btn.setChecked(False)
1218
+ self.show_status_btn.toggled.connect(self.toggle_status)
1219
+
1220
+ self.v_splitter.setStretchFactor(1, 2)
1221
+ self.h_splitter.setStretchFactor(0, 2)
1222
+
1223
+ self.packages_filter.setFocus()
1224
+
1225
+ def _update_plugin_count(self):
1226
+ """Update count labels for both installed and available plugin lists.
1227
+ Displays also amount of visible plugins out of total when filtering.
1228
+ """
1229
+ installed_count = self.installed_list.count()
1230
+ installed_count_visible = self.installed_list.count_visible()
1231
+ if installed_count == installed_count_visible:
1232
+ self.installed_label.setText(
1233
+ trans._(
1234
+ "Installed Plugins ({amount})",
1235
+ amount=installed_count,
1236
+ )
1237
+ )
1238
+ else:
1239
+ self.installed_label.setText(
1240
+ trans._(
1241
+ "Installed Plugins ({count}/{amount})",
1242
+ count=installed_count_visible,
1243
+ amount=installed_count,
1244
+ )
1245
+ )
1246
+
1247
+ available_count = len(self._plugin_data) - self.installed_list.count()
1248
+ available_count = available_count if available_count >= 0 else 0
1249
+ available_count_visible = self.available_list.count_visible()
1250
+ if available_count == available_count_visible:
1251
+ self.avail_label.setText(
1252
+ trans._(
1253
+ "Available Plugins ({amount})",
1254
+ amount=available_count,
1255
+ )
1256
+ )
1257
+ else:
1258
+ self.avail_label.setText(
1259
+ trans._(
1260
+ "Available Plugins ({count}/{amount})",
1261
+ count=available_count_visible,
1262
+ amount=available_count,
1263
+ )
1264
+ )
1265
+
1266
+ def _install_packages(
1267
+ self,
1268
+ packages: Sequence[str] = (),
1269
+ ):
1270
+ if not packages:
1271
+ _packages = self.direct_entry_edit.text()
1272
+ packages = (
1273
+ [_packages] if os.path.exists(_packages) else _packages.split()
1274
+ )
1275
+ self.direct_entry_edit.clear()
1276
+
1277
+ if packages:
1278
+ self.installer.install(InstallerTools.PIP, packages)
1279
+
1280
+ def _tag_outdated_plugins(self):
1281
+ """Tag installed plugins that might be outdated."""
1282
+ for pkg_name in self.installed_list.packages():
1283
+ _data = self._plugin_data_map.get(pkg_name)
1284
+ if _data is not None:
1285
+ metadata, is_available_in_conda, _ = _data
1286
+ self.installed_list.tag_outdated(
1287
+ metadata, is_available_in_conda
1288
+ )
1289
+
1290
+ def _add_items(self):
1291
+ """
1292
+ Add items to the lists by `batch_size` using a timer to add a pause
1293
+ and prevent freezing the UI.
1294
+ """
1295
+ if len(self._plugin_queue) == 0:
1296
+ if (
1297
+ self.installed_list.count() + self.available_list.count()
1298
+ == len(self._plugin_data)
1299
+ and self.available_list.count() != 0
1300
+ ):
1301
+ self._add_items_timer.stop()
1302
+ if not self.isVisible():
1303
+ show_info(
1304
+ trans._(
1305
+ 'Plugin Manager: All available plugins loaded\n'
1306
+ )
1307
+ )
1308
+
1309
+ return
1310
+
1311
+ batch_size = 2
1312
+ for _ in range(batch_size):
1313
+ data = self._plugin_queue.pop(0)
1314
+ metadata, is_available_in_conda, extra_info = data
1315
+ display_name = extra_info.get('display_name', metadata.name)
1316
+ if metadata.name in self.already_installed:
1317
+ self.installed_list.tag_outdated(
1318
+ metadata, is_available_in_conda
1319
+ )
1320
+ else:
1321
+ if metadata.name not in self.available_set:
1322
+ self.available_set.add(metadata.name)
1323
+ self.available_list.addItem(
1324
+ ProjectInfoVersions(
1325
+ metadata,
1326
+ display_name,
1327
+ extra_info['pypi_versions'],
1328
+ extra_info['conda_versions'],
1329
+ )
1330
+ )
1331
+ if ON_BUNDLE and not is_available_in_conda:
1332
+ self.available_list.tag_unavailable(metadata)
1333
+
1334
+ if len(self._plugin_queue) == 0:
1335
+ self._tag_outdated_plugins()
1336
+ break
1337
+
1338
+ if not self._filter_timer.isActive():
1339
+ self.filter(None, skip=True)
1340
+
1341
+ def _handle_yield(self, data: Tuple[npe2.PackageMetadata, bool, Dict]):
1342
+ """Output from a worker process.
1343
+
1344
+ Includes information about the plugin, including available versions on conda and pypi.
1345
+
1346
+ The data is stored but the actual items are added via a timer in the `_add_items`
1347
+ method to prevent the UI from freezing by adding all items at once.
1348
+ """
1349
+ self._plugin_data.append(data)
1350
+ self._plugin_queue.append(data)
1351
+ self._filter_texts = [
1352
+ f"{i[0].name} {i[-1].get('display_name', '')} {i[0].summary}".lower()
1353
+ for i in self._plugin_data
1354
+ ]
1355
+ metadata, _, _ = data
1356
+ self._plugin_data_map[metadata.name] = data
1357
+ self.available_list.set_data(self._plugin_data)
1358
+
1359
+ def _search_in_available(self, text):
1360
+ idxs = []
1361
+ for idx, item in enumerate(self._filter_texts):
1362
+ if text.lower() in item and idx not in self._filter_idxs_cache:
1363
+ idxs.append(idx)
1364
+ self._filter_idxs_cache.add(idx)
1365
+
1366
+ return idxs
1367
+
1368
+ def _refresh_and_clear_cache(self):
1369
+ self.refresh(clear_cache=True)
1370
+
1371
+ # endregion - Private methods
1372
+
1373
+ # region - Qt overrides
1374
+ # ------------------------------------------------------------------------
1375
+ def closeEvent(self, event):
1376
+ if self._parent is not None:
1377
+ plugin_dialog = getattr(self._parent, '_plugin_dialog', self)
1378
+ if self != plugin_dialog:
1379
+ self.destroy(True, True)
1380
+ super().closeEvent(event)
1381
+ else:
1382
+ plugin_dialog.hide()
1383
+ else:
1384
+ super().closeEvent(event)
1385
+
1386
+ def dragEnterEvent(self, event):
1387
+ event.accept()
1388
+
1389
+ def dropEvent(self, event):
1390
+ md = event.mimeData()
1391
+ if md.hasUrls():
1392
+ files = [url.toLocalFile() for url in md.urls()]
1393
+ self.direct_entry_edit.setText(files[0])
1394
+ return True
1395
+
1396
+ return super().dropEvent(event)
1397
+
1398
+ def exec_(self):
1399
+ plugin_dialog = getattr(self._parent, '_plugin_dialog', self)
1400
+ if plugin_dialog != self:
1401
+ self.close()
1402
+
1403
+ plugin_dialog.setModal(True)
1404
+ plugin_dialog.show()
1405
+
1406
+ if self._first_open:
1407
+ self._update_theme(None)
1408
+ self._first_open = False
1409
+
1410
+ def hideEvent(self, event):
1411
+ self.packages_filter.clear()
1412
+ self.toggle_status(False)
1413
+ super().hideEvent(event)
1414
+
1415
+ # endregion - Qt overrides
1416
+
1417
+ # region - Public methods
1418
+ # ------------------------------------------------------------------------
1419
+ def filter(self, text: Optional[str] = None, skip=False) -> None:
1420
+ """Filter by text or set current text as filter."""
1421
+ if text is None:
1422
+ text = self.packages_filter.text()
1423
+ else:
1424
+ self.packages_filter.setText(text)
1425
+
1426
+ if not skip and self.available_list.is_running() and len(text) >= 1:
1427
+ items = [
1428
+ self._plugin_data[idx]
1429
+ for idx in self._search_in_available(text)
1430
+ ]
1431
+ if items:
1432
+ for item in items:
1433
+ if item in self._plugin_queue:
1434
+ self._plugin_queue.remove(item)
1435
+
1436
+ self._plugin_queue = items + self._plugin_queue
1437
+
1438
+ self.installed_list.filter(text)
1439
+ self.available_list.filter(text)
1440
+ self._update_plugin_count()
1441
+
1442
+ def refresh(self, clear_cache: bool = False):
1443
+ self.refresh_button.setDisabled(True)
1444
+
1445
+ if self.worker is not None:
1446
+ self.worker.quit()
1447
+
1448
+ if self._add_items_timer.isActive():
1449
+ self._add_items_timer.stop()
1450
+
1451
+ self._filter_texts = []
1452
+ self._plugin_queue = []
1453
+ self._plugin_data = []
1454
+ self._plugin_data_map = {}
1455
+
1456
+ self.installed_list.clear()
1457
+ self.available_list.clear()
1458
+ self.already_installed = set()
1459
+ self.available_set = set()
1460
+
1461
+ self._add_installed()
1462
+ self._fetch_available_plugins(clear_cache=clear_cache)
1463
+
1464
+ self._refresh_timer.start()
1465
+
1466
+ def toggle_status(self, show=None):
1467
+ show = not self.stdout_text.isVisible() if show is None else show
1468
+ if show:
1469
+ self.show_status_btn.setText(trans._("Hide Status"))
1470
+ self.stdout_text.show()
1471
+ else:
1472
+ self.show_status_btn.setText(trans._("Show Status"))
1473
+ self.stdout_text.hide()
1474
+
1475
+ def set_prefix(self, prefix):
1476
+ self._prefix = prefix
1477
+ self.installer._prefix = prefix
1478
+ for idx in range(self.available_list.count()):
1479
+ item = self.available_list.item(idx)
1480
+ item.widget.prefix = prefix
1481
+
1482
+ for idx in range(self.installed_list.count()):
1483
+ item = self.installed_list.item(idx)
1484
+ item.widget.prefix = prefix
1485
+
1486
+ # endregion - Public methods
1487
+
1488
+
1489
+ if __name__ == "__main__":
1490
+ from qtpy.QtWidgets import QApplication
1491
+
1492
+ app = QApplication([])
1493
+ widget = QtPluginDialog()
1494
+ widget.exec_()
1495
+ sys.exit(app.exec_())