napari-plugin-manager 0.1.3__py3-none-any.whl → 0.1.4__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,1816 @@
1
+ import contextlib
2
+ import importlib.metadata
3
+ import os
4
+ import webbrowser
5
+ from functools import partial
6
+ from typing import (
7
+ Any,
8
+ Dict,
9
+ List,
10
+ Literal,
11
+ NamedTuple,
12
+ Optional,
13
+ Protocol,
14
+ Sequence,
15
+ Tuple,
16
+ )
17
+
18
+ from packaging.version import parse as parse_version
19
+ from qtpy.QtCore import QSize, Qt, QTimer, Signal, Slot
20
+ from qtpy.QtGui import (
21
+ QAction,
22
+ QActionGroup,
23
+ QFont,
24
+ QIcon,
25
+ QKeySequence,
26
+ QMovie,
27
+ QShortcut,
28
+ )
29
+ from qtpy.QtWidgets import (
30
+ QCheckBox,
31
+ QComboBox,
32
+ QDialog,
33
+ QFrame,
34
+ QGridLayout,
35
+ QHBoxLayout,
36
+ QLabel,
37
+ QLineEdit,
38
+ QListWidget,
39
+ QListWidgetItem,
40
+ QMenu,
41
+ QPushButton,
42
+ QSizePolicy,
43
+ QSplitter,
44
+ QTextEdit,
45
+ QToolButton,
46
+ QVBoxLayout,
47
+ QWidget,
48
+ )
49
+ from superqt import QCollapsible, QElidingLabel
50
+
51
+ from napari_plugin_manager.base_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
+ CONDA = 'Conda'
61
+ PYPI = 'PyPI'
62
+
63
+
64
+ class PackageMetadataProtocol(Protocol):
65
+ """
66
+ Protocol class defining the minimum atributtes/properties needed for package metadata.
67
+
68
+ This class is meant for type checking purposes as well as to provide a type to use with
69
+ with the Qt `Slot` decorator.
70
+ """
71
+
72
+ @property
73
+ def metadata_version(self) -> str:
74
+ """Metadata version the package metadata class aims to support."""
75
+
76
+ @property
77
+ def name(self) -> str:
78
+ """Name of the package being represented."""
79
+
80
+ @property
81
+ def version(self) -> str:
82
+ """Version of the package being represented."""
83
+
84
+ @property
85
+ def summary(self) -> str:
86
+ """Summary of the package being represented."""
87
+
88
+ @property
89
+ def home_page(self) -> str:
90
+ """Home page URL of the package being represented."""
91
+
92
+ @property
93
+ def author(self) -> str:
94
+ """Author information of the package being represented."""
95
+
96
+ @property
97
+ def license(self) -> str:
98
+ """License information of the package being represented."""
99
+
100
+
101
+ class BasePackageMetadata(NamedTuple):
102
+ """Base class implementing the bare minimum to follow the `PackageMetadataProtocol` protocol class."""
103
+
104
+ metadata_version: str
105
+ name: str
106
+ version: str
107
+ summary: str
108
+ home_page: str
109
+ author: str
110
+ license: str
111
+
112
+
113
+ class BaseProjectInfoVersions(NamedTuple):
114
+ metadata: BasePackageMetadata
115
+ display_name: str
116
+ pypi_versions: List[str]
117
+ conda_versions: List[str]
118
+
119
+
120
+ class BasePluginListItem(QFrame):
121
+ """
122
+ An entry in the plugin dialog.
123
+
124
+ This will include the package name, summary,
125
+ author, source, version, and buttons to update, install/uninstall, etc.
126
+
127
+ Make sure to implement all the methods that raise `NotImplementedError` over a subclass.
128
+ Details are available in each method docstring.
129
+ """
130
+
131
+ # This should be set to the name of package that handles plugins
132
+ # e.g `napari` for napari
133
+ BASE_PACKAGE_NAME = ''
134
+
135
+ # item, package_name, action_name, version, installer_choice
136
+ actionRequested = Signal(QListWidgetItem, str, object, str, object)
137
+
138
+ def __init__(
139
+ self,
140
+ item: QListWidgetItem,
141
+ package_name: str,
142
+ display_name: str,
143
+ version: str = '',
144
+ url: str = '',
145
+ summary: str = '',
146
+ author: str = '',
147
+ license: str = "UNKNOWN", # noqa: A002
148
+ *,
149
+ plugin_name: Optional[str] = None,
150
+ parent: QWidget = None,
151
+ enabled: bool = True,
152
+ installed: bool = False,
153
+ plugin_api_version=1,
154
+ versions_conda: Optional[List[str]] = None,
155
+ versions_pypi: Optional[List[str]] = None,
156
+ prefix=None,
157
+ ) -> None:
158
+ super().__init__(parent)
159
+ self.prefix = prefix
160
+ self.item = item
161
+ self.url = url
162
+ self.name = package_name
163
+ self.plugin_api_version = plugin_api_version
164
+ self._version = version
165
+ self._versions_conda = versions_conda
166
+ self._versions_pypi = versions_pypi
167
+ self.setup_ui(enabled)
168
+
169
+ if package_name == display_name:
170
+ name = package_name
171
+ else:
172
+ name = f"{display_name} <small>({package_name})</small>"
173
+
174
+ self.plugin_name.setText(name)
175
+
176
+ if len(versions_pypi) > 0:
177
+ self._populate_version_dropdown(PYPI)
178
+ else:
179
+ self._populate_version_dropdown(CONDA)
180
+
181
+ mod_version = version.replace('.', '․') # noqa: RUF001
182
+ self.version.setWordWrap(True)
183
+ self.version.setText(mod_version)
184
+ self.version.setToolTip(version)
185
+
186
+ if summary:
187
+ self.summary.setText(summary)
188
+
189
+ if author:
190
+ self.package_author.setText(author)
191
+
192
+ self.package_author.setWordWrap(True)
193
+ self.cancel_btn.setVisible(False)
194
+
195
+ self._handle_plugin_api_version(plugin_api_version)
196
+ self._set_installed(installed, package_name)
197
+ self._populate_version_dropdown(self.get_installer_source())
198
+
199
+ def _warning_icon(self) -> QIcon:
200
+ """
201
+ Warning icon to be used.
202
+
203
+ Returns
204
+ -------
205
+ The icon (`QIcon` instance) defined as the warning icon for plugin item.
206
+ """
207
+ raise NotImplementedError
208
+
209
+ def _collapsed_icon(self) -> QIcon:
210
+ """
211
+ Icon to be used to indicate the plugin item info collapsible section can be collapsed.
212
+
213
+ Returns
214
+ -------
215
+ The icon (`QIcon` instance) defined as the warning icon for plugin item
216
+ info section.
217
+ """
218
+ raise NotImplementedError
219
+
220
+ def _expanded_icon(self) -> QIcon:
221
+ """
222
+ Icon to be used to indicate the plugin item info collapsible section
223
+ can be expanded.
224
+
225
+ Returns
226
+ -------
227
+ The icon (`QIcon` instance) defined as the expanded icon for plugin item
228
+ info section.
229
+ """
230
+ raise NotImplementedError
231
+
232
+ def _warning_tooltip(self) -> QWidget:
233
+ """
234
+ Widget to be used to indicate the plugin item warning information.
235
+
236
+ Returns
237
+ -------
238
+ The widget (`QWidget` instance/`QWidget` subclass instance that supports setting a pixmap i.e has
239
+ a `setPixmap` method - e.g a `QLabel`) used to show warning information.
240
+ """
241
+ raise NotImplementedError
242
+
243
+ def _trans(self, text: str, **kwargs) -> str:
244
+ """
245
+ Translate the given text.
246
+
247
+ Parameters
248
+ ----------
249
+ text : str
250
+ The singular string to translate.
251
+ **kwargs : dict, optional
252
+ Any additional arguments to use when formatting the string.
253
+
254
+ Returns
255
+ -------
256
+ The translated string.
257
+ """
258
+ raise NotImplementedError
259
+
260
+ def _is_main_app_conda_package(self):
261
+ return is_conda_package(self.BASE_PACKAGE_NAME)
262
+
263
+ def _set_installed(self, installed: bool, package_name):
264
+ if installed:
265
+ if is_conda_package(package_name):
266
+ self.source.setText(CONDA)
267
+
268
+ self.enabled_checkbox.show()
269
+ self.action_button.setText(self._trans("Uninstall"))
270
+ self.action_button.setObjectName("remove_button")
271
+ self.info_choice_wdg.hide()
272
+ self.install_info_button.addWidget(self.info_widget)
273
+ self.info_widget.show()
274
+ else:
275
+ self.enabled_checkbox.hide()
276
+ self.action_button.setText(self._trans("Install"))
277
+ self.action_button.setObjectName("install_button")
278
+ self.info_widget.hide()
279
+ self.install_info_button.addWidget(self.info_choice_wdg)
280
+ self.info_choice_wdg.show()
281
+
282
+ def _handle_plugin_api_version(self, plugin_api_version) -> None:
283
+ """
284
+ Customize a plugin item before it is finished being setup.
285
+
286
+ An example usage could be calling the `set_status` method to define a
287
+ an icon and text that the plugin should show depending on the plugin
288
+ API version implementation.
289
+
290
+ Parameters
291
+ ----------
292
+ plugin_api_version : Any
293
+ The value of the API version the plugin uses.
294
+ """
295
+ raise NotImplementedError
296
+
297
+ def set_status(self, icon=None, text=''):
298
+ """Set the status icon and text next to the package name."""
299
+ if icon:
300
+ self.status_icon.setPixmap(icon)
301
+
302
+ if text:
303
+ self.status_label.setText(text)
304
+
305
+ self.status_icon.setVisible(bool(icon))
306
+ self.status_label.setVisible(bool(text))
307
+
308
+ def set_busy(
309
+ self,
310
+ text: str,
311
+ action_name: Optional[
312
+ Literal['install', 'uninstall', 'cancel', 'upgrade']
313
+ ] = None,
314
+ ):
315
+ """Updates status text and what buttons are visible when any button is pushed.
316
+
317
+ Parameters
318
+ ----------
319
+ text: str
320
+ The new string to be displayed as the status.
321
+ action_name: str
322
+ The action of the button pressed.
323
+
324
+ """
325
+ self.item_status.setText(text)
326
+ if action_name == 'upgrade':
327
+ self.cancel_btn.setVisible(True)
328
+ self.action_button.setVisible(False)
329
+ elif action_name in {'uninstall', 'install'}:
330
+ self.action_button.setVisible(False)
331
+ self.cancel_btn.setVisible(True)
332
+ elif action_name == 'cancel':
333
+ self.action_button.setVisible(True)
334
+ self.action_button.setDisabled(False)
335
+ self.cancel_btn.setVisible(False)
336
+ else: # pragma: no cover
337
+ raise ValueError(f"Not supported {action_name}")
338
+
339
+ def is_busy(self):
340
+ return bool(self.item_status.text())
341
+
342
+ def setup_ui(self, enabled=True):
343
+ """Define the layout of the PluginListItem"""
344
+ # Enabled checkbox
345
+ self.enabled_checkbox = QCheckBox(self)
346
+ self.enabled_checkbox.setChecked(enabled)
347
+ self.enabled_checkbox.setToolTip(self._trans("enable/disable"))
348
+ self.enabled_checkbox.setText("")
349
+ self.enabled_checkbox.stateChanged.connect(self._on_enabled_checkbox)
350
+
351
+ sizePolicy = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
352
+ sizePolicy.setHorizontalStretch(0)
353
+ sizePolicy.setVerticalStretch(0)
354
+ sizePolicy.setHeightForWidth(
355
+ self.enabled_checkbox.sizePolicy().hasHeightForWidth()
356
+ )
357
+ self.enabled_checkbox.setSizePolicy(sizePolicy)
358
+ self.enabled_checkbox.setMinimumSize(QSize(20, 0))
359
+
360
+ # Plugin name
361
+ self.plugin_name = ClickableLabel(self) # To style content
362
+ font_plugin_name = QFont()
363
+ font_plugin_name.setPointSize(15)
364
+ font_plugin_name.setUnderline(True)
365
+ self.plugin_name.setFont(font_plugin_name)
366
+
367
+ # Status
368
+ self.status_icon = QLabel(self)
369
+ self.status_icon.setVisible(False)
370
+ self.status_label = QLabel(self)
371
+ self.status_label.setVisible(False)
372
+
373
+ if self.url and self.url != 'UNKNOWN':
374
+ # Do not want to highlight on hover unless there is a website.
375
+ self.plugin_name.setObjectName('plugin_name_web')
376
+ else:
377
+ self.plugin_name.setObjectName('plugin_name')
378
+
379
+ sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum)
380
+ sizePolicy.setHorizontalStretch(0)
381
+ sizePolicy.setVerticalStretch(0)
382
+ sizePolicy.setHeightForWidth(
383
+ self.plugin_name.sizePolicy().hasHeightForWidth()
384
+ )
385
+ self.plugin_name.setSizePolicy(sizePolicy)
386
+
387
+ # Warning icon
388
+ icon = self._warning_icon()
389
+ self.warning_tooltip = self._warning_tooltip()
390
+
391
+ self.warning_tooltip.setPixmap(icon.pixmap(15, 15))
392
+ self.warning_tooltip.setVisible(False)
393
+
394
+ # Item status
395
+ self.item_status = QLabel(self)
396
+ self.item_status.setObjectName("small_italic_text")
397
+ self.item_status.setSizePolicy(sizePolicy)
398
+
399
+ # Summary
400
+ self.summary = QElidingLabel(parent=self)
401
+ self.summary.setObjectName('summary_text')
402
+ self.summary.setWordWrap(True)
403
+
404
+ font_summary = QFont()
405
+ font_summary.setPointSize(10)
406
+ self.summary.setFont(font_summary)
407
+
408
+ sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
409
+ sizePolicy.setHorizontalStretch(1)
410
+ sizePolicy.setVerticalStretch(0)
411
+ self.summary.setSizePolicy(sizePolicy)
412
+ self.summary.setContentsMargins(0, -2, 0, -2)
413
+
414
+ # Package author
415
+ self.package_author = QElidingLabel(self)
416
+ self.package_author.setObjectName('author_text')
417
+ self.package_author.setWordWrap(True)
418
+ self.package_author.setSizePolicy(sizePolicy)
419
+
420
+ # Update button
421
+ self.update_btn = QPushButton('Update', self)
422
+ self.update_btn.setObjectName("install_button")
423
+ self.update_btn.setVisible(False)
424
+ self.update_btn.clicked.connect(self._update_requested)
425
+ sizePolicy.setRetainSizeWhenHidden(True)
426
+ sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
427
+ self.update_btn.setSizePolicy(sizePolicy)
428
+ self.update_btn.clicked.connect(self._update_requested)
429
+
430
+ # Action Button
431
+ self.action_button = QPushButton(self)
432
+ self.action_button.setFixedWidth(70)
433
+ sizePolicy1 = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
434
+ self.action_button.setSizePolicy(sizePolicy1)
435
+ self.action_button.clicked.connect(self._action_requested)
436
+
437
+ # Cancel
438
+ self.cancel_btn = QPushButton("Cancel", self)
439
+ self.cancel_btn.setObjectName("remove_button")
440
+ self.cancel_btn.setSizePolicy(sizePolicy)
441
+ self.cancel_btn.setFixedWidth(70)
442
+ self.cancel_btn.clicked.connect(self._cancel_requested)
443
+
444
+ # Collapsible button
445
+ coll_icon = self._collapsed_icon()
446
+ exp_icon = self._expanded_icon()
447
+
448
+ self.install_info_button = QCollapsible(
449
+ "Installation Info", collapsedIcon=coll_icon, expandedIcon=exp_icon
450
+ )
451
+ self.install_info_button.setLayoutDirection(
452
+ Qt.RightToLeft
453
+ ) # Make icon appear on the right
454
+ self.install_info_button.setObjectName("install_info_button")
455
+ self.install_info_button.setFixedWidth(180)
456
+ self.install_info_button.content().layout().setContentsMargins(
457
+ 0, 0, 0, 0
458
+ )
459
+ self.install_info_button.content().setContentsMargins(0, 0, 0, 0)
460
+ self.install_info_button.content().layout().setSpacing(0)
461
+ self.install_info_button.layout().setContentsMargins(0, 0, 0, 0)
462
+ self.install_info_button.layout().setSpacing(2)
463
+ self.install_info_button.setSizePolicy(sizePolicy)
464
+
465
+ # Information widget for available packages
466
+ self.info_choice_wdg = QWidget(self)
467
+ self.info_choice_wdg.setObjectName('install_choice')
468
+
469
+ self.source_choice_text = QLabel('Source:')
470
+ self.version_choice_text = QLabel('Version:')
471
+ self.source_choice_dropdown = QComboBox()
472
+ self.version_choice_dropdown = QComboBox()
473
+
474
+ if self._is_main_app_conda_package() and self._versions_conda:
475
+ self.source_choice_dropdown.addItem(CONDA)
476
+
477
+ if self._versions_pypi:
478
+ self.source_choice_dropdown.addItem(PYPI)
479
+
480
+ source = self.get_installer_source()
481
+ self.source_choice_dropdown.setCurrentText(source)
482
+ self._populate_version_dropdown(source)
483
+ self.source_choice_dropdown.currentTextChanged.connect(
484
+ self._populate_version_dropdown
485
+ )
486
+
487
+ # Information widget for installed packages
488
+ self.info_widget = QWidget(self)
489
+ self.info_widget.setLayoutDirection(Qt.LeftToRight)
490
+ self.info_widget.setObjectName("info_widget")
491
+ self.info_widget.setFixedWidth(180)
492
+
493
+ self.source_text = QLabel('Source:')
494
+ self.source = QLabel(PYPI)
495
+ self.version_text = QLabel('Version:')
496
+ self.version = QElidingLabel()
497
+ self.version.setWordWrap(True)
498
+
499
+ info_layout = QGridLayout()
500
+ info_layout.setContentsMargins(0, 0, 0, 0)
501
+ info_layout.setVerticalSpacing(0)
502
+ info_layout.addWidget(self.source_text, 0, 0)
503
+ info_layout.addWidget(self.source, 1, 0)
504
+ info_layout.addWidget(self.version_text, 0, 1)
505
+ info_layout.addWidget(self.version, 1, 1)
506
+ self.info_widget.setLayout(info_layout)
507
+
508
+ # Error indicator
509
+ self.error_indicator = QPushButton()
510
+ self.error_indicator.setObjectName("warning_icon")
511
+ self.error_indicator.setCursor(Qt.CursorShape.PointingHandCursor)
512
+ self.error_indicator.hide()
513
+
514
+ # region - Layout
515
+ # -----------------------------------------------------------------
516
+ layout = QHBoxLayout()
517
+ layout.setSpacing(2)
518
+ layout_left = QVBoxLayout()
519
+ layout_right = QVBoxLayout()
520
+ layout_top = QHBoxLayout()
521
+ layout_bottom = QHBoxLayout()
522
+ layout_bottom.setSpacing(4)
523
+
524
+ layout_left.addWidget(
525
+ self.enabled_checkbox, alignment=Qt.AlignmentFlag.AlignTop
526
+ )
527
+
528
+ layout_right.addLayout(layout_top, 1)
529
+ layout_right.addLayout(layout_bottom, 100)
530
+
531
+ layout.addLayout(layout_left)
532
+ layout.addLayout(layout_right)
533
+
534
+ self.setLayout(layout)
535
+
536
+ layout_top.addWidget(self.plugin_name)
537
+ layout_top.addWidget(self.status_icon)
538
+ layout_top.addWidget(self.status_label)
539
+ layout_top.addWidget(self.item_status)
540
+ layout_top.addStretch()
541
+
542
+ layout_bottom.addWidget(
543
+ self.summary, alignment=Qt.AlignmentFlag.AlignTop, stretch=3
544
+ )
545
+ layout_bottom.addWidget(
546
+ self.package_author, alignment=Qt.AlignmentFlag.AlignTop, stretch=1
547
+ )
548
+ layout_bottom.addWidget(
549
+ self.update_btn, alignment=Qt.AlignmentFlag.AlignTop
550
+ )
551
+ layout_bottom.addWidget(
552
+ self.install_info_button, alignment=Qt.AlignmentFlag.AlignTop
553
+ )
554
+ layout_bottom.addWidget(
555
+ self.action_button, alignment=Qt.AlignmentFlag.AlignTop
556
+ )
557
+ layout_bottom.addWidget(
558
+ self.cancel_btn, alignment=Qt.AlignmentFlag.AlignTop
559
+ )
560
+
561
+ info_layout = QGridLayout()
562
+ info_layout.setContentsMargins(0, 0, 0, 0)
563
+ info_layout.setVerticalSpacing(0)
564
+ info_layout.addWidget(self.source_choice_text, 0, 0, 1, 1)
565
+ info_layout.addWidget(self.source_choice_dropdown, 1, 0, 1, 1)
566
+ info_layout.addWidget(self.version_choice_text, 0, 1, 1, 1)
567
+ info_layout.addWidget(self.version_choice_dropdown, 1, 1, 1, 1)
568
+
569
+ # endregion - Layout
570
+
571
+ self.info_choice_wdg.setLayout(info_layout)
572
+ self.info_choice_wdg.setLayoutDirection(Qt.LeftToRight)
573
+ self.info_choice_wdg.setObjectName("install_choice_widget")
574
+ self.info_choice_wdg.hide()
575
+
576
+ def _populate_version_dropdown(self, source: Literal["PyPI", "Conda"]):
577
+ """Display the versions available after selecting a source: pypi or conda."""
578
+ if source == PYPI:
579
+ versions = self._versions_pypi
580
+ else:
581
+ versions = self._versions_conda
582
+ self.version_choice_dropdown.clear()
583
+ for version in versions:
584
+ self.version_choice_dropdown.addItem(version)
585
+
586
+ def _on_enabled_checkbox(self, state: Qt.CheckState) -> None:
587
+ """
588
+ Enable/disable the plugin item.
589
+
590
+ Called with `state` (`Qt.CheckState` value) when checkbox is clicked.
591
+ An implementation of this method could call a plugin manager in charge of
592
+ enabling/disabling plugins.
593
+
594
+ Note that the plugin can be identified with the `plugin_name` attribute.
595
+
596
+ Parameters
597
+ ----------
598
+ state : int | Qt.CheckState
599
+ Current state the enable checkbox has.
600
+ """
601
+ raise NotImplementedError
602
+
603
+ def _action_validation(self, tool, action) -> bool:
604
+ """
605
+ Validate if the current action should be done or not.
606
+
607
+ As an example you could warn that a package from PyPI is going
608
+ to be installed.
609
+
610
+ Returns
611
+ -------
612
+ This should return a `bool`, `True` if the action should proceed, `False`
613
+ otherwise.
614
+ """
615
+ raise NotImplementedError
616
+
617
+ def _cancel_requested(self):
618
+ version = self.version_choice_dropdown.currentText()
619
+ tool = self.get_installer_tool()
620
+ self.actionRequested.emit(
621
+ self.item, self.name, InstallerActions.CANCEL, version, tool
622
+ )
623
+
624
+ def _action_requested(self):
625
+ version = self.version_choice_dropdown.currentText()
626
+ tool = self.get_installer_tool()
627
+ action = (
628
+ InstallerActions.INSTALL
629
+ if self.action_button.objectName() == 'install_button'
630
+ else InstallerActions.UNINSTALL
631
+ )
632
+ if self._action_validation(tool, action):
633
+ self.actionRequested.emit(
634
+ self.item, self.name, action, version, tool
635
+ )
636
+
637
+ def _update_requested(self):
638
+ version = self.version_choice_dropdown.currentText()
639
+ tool = self.get_installer_tool()
640
+ self.actionRequested.emit(
641
+ self.item, self.name, InstallerActions.UPGRADE, version, tool
642
+ )
643
+
644
+ def show_warning(self, message: str = ""):
645
+ """Show warning icon and tooltip."""
646
+ self.warning_tooltip.setVisible(bool(message))
647
+ self.warning_tooltip.setToolTip(message)
648
+
649
+ def get_installer_source(self):
650
+ return (
651
+ CONDA
652
+ if self.source_choice_dropdown.currentText() == CONDA
653
+ or is_conda_package(self.name)
654
+ else PYPI
655
+ )
656
+
657
+ def get_installer_tool(self):
658
+ return (
659
+ InstallerTools.CONDA
660
+ if self.source_choice_dropdown.currentText() == CONDA
661
+ or is_conda_package(self.name, prefix=self.prefix)
662
+ else InstallerTools.PIP
663
+ )
664
+
665
+
666
+ class BaseQPluginList(QListWidget):
667
+ """
668
+ A list of plugins.
669
+
670
+ Make sure to implement all the methods that raise `NotImplementedError` over a subclass.
671
+ Details are available in each method docstring.
672
+ """
673
+
674
+ _SORT_ORDER_PREFIX = '0-'
675
+ PLUGIN_LIST_ITEM_CLASS = BasePluginListItem
676
+
677
+ def __init__(
678
+ self, parent: QWidget, installer: InstallerQueue, package_name: str
679
+ ) -> None:
680
+ super().__init__(parent)
681
+ self.installer = installer
682
+ self._package_name = package_name
683
+ self._remove_list = []
684
+ self._data = []
685
+ self._initial_height = None
686
+
687
+ self.setSortingEnabled(True)
688
+
689
+ def _trans(self, text: str, **kwargs) -> str:
690
+ """
691
+ Translates the given text.
692
+
693
+ Parameters
694
+ ----------
695
+ text : str
696
+ The singular string to translate.
697
+ **kwargs : dict, optional
698
+ Any additional arguments to use when formatting the string.
699
+
700
+ Returns
701
+ -------
702
+ The translated string.
703
+ """
704
+ raise NotImplementedError
705
+
706
+ def count_visible(self) -> int:
707
+ """Return the number of visible items.
708
+
709
+ Visible items are the result of the normal `count` method minus
710
+ any hidden items.
711
+ """
712
+ hidden = 0
713
+ count = self.count()
714
+ for i in range(count):
715
+ item = self.item(i)
716
+ hidden += item.isHidden()
717
+
718
+ return count - hidden
719
+
720
+ @Slot(tuple)
721
+ def addItem(
722
+ self,
723
+ project_info: BaseProjectInfoVersions,
724
+ installed=False,
725
+ plugin_name=None,
726
+ enabled=True,
727
+ plugin_api_version=None,
728
+ ):
729
+ pkg_name = project_info.metadata.name
730
+ # don't add duplicates
731
+ if (
732
+ self.findItems(pkg_name, Qt.MatchFlag.MatchFixedString)
733
+ and not plugin_name
734
+ ):
735
+ return
736
+
737
+ # including summary here for sake of filtering below.
738
+ searchable_text = f"{pkg_name} {project_info.display_name} {project_info.metadata.summary}"
739
+ item = QListWidgetItem(searchable_text, self)
740
+ item.version = project_info.metadata.version
741
+ super().addItem(item)
742
+ widg = self.PLUGIN_LIST_ITEM_CLASS(
743
+ item=item,
744
+ package_name=pkg_name,
745
+ display_name=project_info.display_name,
746
+ version=project_info.metadata.version,
747
+ url=project_info.metadata.home_page,
748
+ summary=project_info.metadata.summary,
749
+ author=project_info.metadata.author,
750
+ license=project_info.metadata.license,
751
+ parent=self,
752
+ plugin_name=plugin_name,
753
+ enabled=enabled,
754
+ installed=installed,
755
+ plugin_api_version=plugin_api_version,
756
+ versions_conda=project_info.conda_versions,
757
+ versions_pypi=project_info.pypi_versions,
758
+ )
759
+ item.widget = widg
760
+ item.plugin_api_version = plugin_api_version
761
+ item.setSizeHint(widg.sizeHint())
762
+ self.setItemWidget(item, widg)
763
+
764
+ if project_info.metadata.home_page:
765
+ widg.plugin_name.clicked.connect(
766
+ partial(webbrowser.open, project_info.metadata.home_page)
767
+ )
768
+
769
+ widg.actionRequested.connect(self.handle_action)
770
+ item.setSizeHint(item.widget.size())
771
+ if self._initial_height is None:
772
+ self._initial_height = item.widget.size().height()
773
+
774
+ widg.install_info_button.setDuration(0)
775
+ widg.install_info_button.toggled.connect(
776
+ lambda: self._resize_pluginlistitem(item)
777
+ )
778
+
779
+ def removeItem(self, name):
780
+ count = self.count()
781
+ for i in range(count):
782
+ item = self.item(i)
783
+ if item.widget.name == name:
784
+ self.takeItem(i)
785
+ break
786
+
787
+ def refreshItem(self, name, version=None):
788
+ count = self.count()
789
+ for i in range(count):
790
+ item = self.item(i)
791
+ if item.widget.name == name:
792
+ if version is not None:
793
+ item.version = version
794
+ mod_version = version.replace('.', '․') # noqa: RUF001
795
+ item.widget.version.setText(mod_version)
796
+ item.widget.version.setToolTip(version)
797
+ item.widget.set_busy('', InstallerActions.CANCEL)
798
+ if item.text().startswith(self._SORT_ORDER_PREFIX):
799
+ item.setText(item.text()[len(self._SORT_ORDER_PREFIX) :])
800
+ break
801
+
802
+ def _resize_pluginlistitem(self, item):
803
+ """Resize the plugin list item, especially after toggling QCollapsible."""
804
+ if item.widget.install_info_button.isExpanded():
805
+ item.widget.setFixedHeight(self._initial_height + 35)
806
+ else:
807
+ item.widget.setFixedHeight(self._initial_height)
808
+
809
+ item.setSizeHint(QSize(0, item.widget.height()))
810
+
811
+ def _before_handle_action(
812
+ self, widget: BasePluginListItem, action_name: InstallerActions
813
+ ) -> None:
814
+ """
815
+ Hook to add custom logic before handling an action.
816
+
817
+ It can be used for example to show a message before an action is going to take
818
+ place, for example a warning message before installing/uninstalling a plugin.
819
+
820
+ Parameters
821
+ ----------
822
+ widget : BasePluginListItem
823
+ Plugin item widget that the action to be done is going to affect.
824
+ action_name : InstallerActions
825
+ Action that will be done to the plugin.
826
+ """
827
+ raise NotImplementedError
828
+
829
+ def handle_action(
830
+ self,
831
+ item: QListWidgetItem,
832
+ pkg_name: str,
833
+ action_name: InstallerActions,
834
+ version: Optional[str] = None,
835
+ installer_choice: Optional[str] = None,
836
+ ):
837
+ """Determine which action is called (install, uninstall, update, cancel).
838
+ Update buttons appropriately and run the action."""
839
+ widget = item.widget
840
+ tool = installer_choice or widget.get_installer_tool()
841
+ self._remove_list.append((pkg_name, item))
842
+ self._warn_dialog = None
843
+ if not item.text().startswith(self._SORT_ORDER_PREFIX):
844
+ item.setText(f"{self._SORT_ORDER_PREFIX}{item.text()}")
845
+
846
+ self._before_handle_action(widget, action_name)
847
+
848
+ if action_name == InstallerActions.INSTALL:
849
+ if version:
850
+ pkg_name += (
851
+ f"=={item.widget.version_choice_dropdown.currentText()}"
852
+ )
853
+ widget.set_busy(self._trans("installing..."), action_name)
854
+
855
+ job_id = self.installer.install(
856
+ tool=tool,
857
+ pkgs=[pkg_name],
858
+ # origins="TODO",
859
+ )
860
+ widget.setProperty("current_job_id", job_id)
861
+ if self._warn_dialog:
862
+ self._warn_dialog.exec_()
863
+ self.scrollToTop()
864
+
865
+ if action_name == InstallerActions.UPGRADE:
866
+ if hasattr(item, 'latest_version'):
867
+ pkg_name += f"=={item.latest_version}"
868
+
869
+ widget.set_busy(self._trans("updating..."), action_name)
870
+ widget.update_btn.setDisabled(True)
871
+ widget.action_button.setDisabled(True)
872
+
873
+ job_id = self.installer.upgrade(
874
+ tool=tool,
875
+ pkgs=[pkg_name],
876
+ # origins="TODO",
877
+ )
878
+ widget.setProperty("current_job_id", job_id)
879
+ if self._warn_dialog:
880
+ self._warn_dialog.exec_()
881
+ self.scrollToTop()
882
+
883
+ elif action_name == InstallerActions.UNINSTALL:
884
+ widget.set_busy(self._trans("uninstalling..."), action_name)
885
+ widget.update_btn.setDisabled(True)
886
+ job_id = self.installer.uninstall(
887
+ tool=tool,
888
+ pkgs=[pkg_name],
889
+ # origins="TODO",
890
+ # upgrade=False,
891
+ )
892
+ widget.setProperty("current_job_id", job_id)
893
+ if self._warn_dialog:
894
+ self._warn_dialog.exec_()
895
+ self.scrollToTop()
896
+ elif action_name == InstallerActions.CANCEL:
897
+ widget.set_busy(self._trans("cancelling..."), action_name)
898
+ try:
899
+ job_id = widget.property("current_job_id")
900
+ self.installer.cancel(job_id)
901
+ finally:
902
+ widget.setProperty("current_job_id", None)
903
+
904
+ def set_data(self, data):
905
+ self._data = data
906
+
907
+ def is_running(self):
908
+ return self.count() != len(self._data)
909
+
910
+ def packages(self):
911
+ return [self.item(idx).widget.name for idx in range(self.count())]
912
+
913
+ @Slot(PackageMetadataProtocol, bool)
914
+ def tag_outdated(
915
+ self, metadata: PackageMetadataProtocol, is_available: bool
916
+ ):
917
+ """Determines if an installed plugin is up to date with the latest version.
918
+ If it is not, the latest version will be displayed on the update button.
919
+ """
920
+ if not is_available:
921
+ return
922
+
923
+ for item in self.findItems(
924
+ metadata.name, Qt.MatchFlag.MatchStartsWith
925
+ ):
926
+ current = item.version
927
+ latest = metadata.version
928
+ is_marked_outdated = getattr(item, 'outdated', False)
929
+ if parse_version(current) >= parse_version(latest):
930
+ # currently is up to date
931
+ if is_marked_outdated:
932
+ # previously marked as outdated, need to update item
933
+ # `outdated` state and hide item widget `update_btn`
934
+ item.outdated = False
935
+ widg = self.itemWidget(item)
936
+ widg.update_btn.setVisible(False)
937
+ continue
938
+ if is_marked_outdated:
939
+ # already tagged it
940
+ continue
941
+
942
+ item.outdated = True
943
+ item.latest_version = latest
944
+ widg = self.itemWidget(item)
945
+ widg.update_btn.setVisible(True)
946
+ widg.update_btn.setText(
947
+ self._trans("update (v{latest})", latest=latest)
948
+ )
949
+
950
+ def tag_unavailable(self, metadata: PackageMetadataProtocol):
951
+ """
952
+ Tag list items as unavailable for install with conda-forge.
953
+
954
+ This will disable the item and the install button and add a warning
955
+ icon with a hover tooltip.
956
+ """
957
+ for item in self.findItems(
958
+ metadata.name, Qt.MatchFlag.MatchStartsWith
959
+ ):
960
+ widget = self.itemWidget(item)
961
+ widget.show_warning(
962
+ self._trans(
963
+ "Plugin not yet available for installation within the bundle application"
964
+ )
965
+ )
966
+ widget.setObjectName("unavailable")
967
+ widget.style().unpolish(widget)
968
+ widget.style().polish(widget)
969
+ widget.action_button.setEnabled(False)
970
+ widget.warning_tooltip.setVisible(True)
971
+
972
+ def filter(self, text: str, starts_with_chars: int = 1):
973
+ """Filter items to those containing `text`."""
974
+ if text:
975
+ # PySide has some issues, so we compare using id
976
+ # See: https://bugreports.qt.io/browse/PYSIDE-74
977
+ flag = (
978
+ Qt.MatchFlag.MatchStartsWith
979
+ if len(text) <= starts_with_chars
980
+ else Qt.MatchFlag.MatchContains
981
+ )
982
+ if len(text) <= starts_with_chars:
983
+ flag = Qt.MatchFlag.MatchStartsWith
984
+ queries = (text, f'{self._package_name}-{text}')
985
+ else:
986
+ flag = Qt.MatchFlag.MatchContains
987
+ queries = (text,)
988
+
989
+ shown = {
990
+ id(it)
991
+ for query in queries
992
+ for it in self.findItems(query, flag)
993
+ }
994
+ for i in range(self.count()):
995
+ item = self.item(i)
996
+ item.setHidden(
997
+ id(item) not in shown and not item.widget.is_busy()
998
+ )
999
+ else:
1000
+ for i in range(self.count()):
1001
+ item = self.item(i)
1002
+ item.setHidden(False)
1003
+
1004
+ def hideAll(self):
1005
+ for i in range(self.count()):
1006
+ item = self.item(i)
1007
+ item.setHidden(not item.widget.is_busy())
1008
+
1009
+
1010
+ class BaseQtPluginDialog(QDialog):
1011
+ """
1012
+ A plugins dialog.
1013
+
1014
+ The dialog shows two list of plugins:
1015
+ * A list for the already installed plugins and
1016
+ * A list for the plugins that could be installed
1017
+
1018
+ It also counts with a space to show output related with the actions being done
1019
+ (installing/uninstalling/updating a plugin).
1020
+
1021
+ Make sure to implement all the methods that raise `NotImplementedError` over a subclass.
1022
+ Details are available in each method docstring.
1023
+ """
1024
+
1025
+ PACKAGE_METADATA_CLASS = BasePackageMetadata
1026
+ PROJECT_INFO_VERSION_CLASS = BaseProjectInfoVersions
1027
+ PLUGIN_LIST_CLASS = BaseQPluginList
1028
+ INSTALLER_QUEUE_CLASS = InstallerQueue
1029
+ BASE_PACKAGE_NAME = ''
1030
+ MAX_PLUGIN_SEARCH_ITEMS = 35
1031
+
1032
+ finished = Signal()
1033
+
1034
+ def __init__(self, parent=None, prefix=None) -> None:
1035
+ super().__init__(parent)
1036
+
1037
+ self._parent = parent
1038
+ if (
1039
+ parent is not None
1040
+ and getattr(parent, '_plugin_dialog', None) is None
1041
+ ):
1042
+ self._parent._plugin_dialog = self
1043
+
1044
+ self._plugins_found = 0
1045
+ self.already_installed = set()
1046
+ self.available_set = set()
1047
+ self._prefix = prefix
1048
+ self._first_open = True
1049
+ self._plugin_queue = [] # Store plugin data to be added
1050
+ self._plugin_data = [] # Store all plugin data
1051
+ self._filter_texts = []
1052
+ self._filter_idxs_cache = set()
1053
+ self.worker = None
1054
+ self._plugin_data_map = {}
1055
+ self._add_items_timer = QTimer(self)
1056
+
1057
+ # Timer to avoid race conditions and incorrect count of plugins when
1058
+ # refreshing multiple times in a row. After click we disable the
1059
+ # `Refresh` button and re-enable it after 3 seconds.
1060
+ self._refresh_timer = QTimer(self)
1061
+ self._refresh_timer.setInterval(3000) # ms
1062
+ self._refresh_timer.setSingleShot(True)
1063
+ self._refresh_timer.timeout.connect(self._enable_refresh_button)
1064
+
1065
+ # Add items in batches with a pause to avoid blocking the UI
1066
+ self._add_items_timer.setInterval(61) # ms
1067
+ self._add_items_timer.timeout.connect(self._add_items)
1068
+
1069
+ self.installer = self.INSTALLER_QUEUE_CLASS(parent=self, prefix=prefix)
1070
+ self.setWindowTitle(self._trans('Plugin Manager'))
1071
+ self._setup_ui()
1072
+ self.installer.set_output_widget(self.stdout_text)
1073
+ self.installer.started.connect(self._on_installer_start)
1074
+ self.installer.processFinished.connect(self._on_process_finished)
1075
+ self.installer.allFinished.connect(self._on_installer_all_finished)
1076
+ self.setAcceptDrops(True)
1077
+
1078
+ if (
1079
+ parent is not None and parent._plugin_dialog is self
1080
+ ) or parent is None:
1081
+ self.refresh()
1082
+ self._setup_shortcuts()
1083
+ self._setup_theme_update()
1084
+
1085
+ # region - Private methods
1086
+ # ------------------------------------------------------------------------
1087
+ def _enable_refresh_button(self):
1088
+ self.refresh_button.setEnabled(True)
1089
+
1090
+ def _quit(self):
1091
+ self.close()
1092
+ with contextlib.suppress(AttributeError):
1093
+ self._parent.close(quit_app=True, confirm_need=True)
1094
+
1095
+ def _setup_shortcuts(self):
1096
+ self._refresh_styles_action = QAction(
1097
+ self._trans('Refresh Styles'), self
1098
+ )
1099
+ self._refresh_styles_action.setShortcut('Ctrl+R')
1100
+ self._refresh_styles_action.triggered.connect(self._update_theme)
1101
+ self.addAction(self._refresh_styles_action)
1102
+
1103
+ self._quit_action = QAction(self._trans('Exit'), self)
1104
+ self._quit_action.setShortcut('Ctrl+Q')
1105
+ self._quit_action.setMenuRole(QAction.QuitRole)
1106
+ self._quit_action.triggered.connect(self._quit)
1107
+ self.addAction(self._quit_action)
1108
+
1109
+ self._close_shortcut = QShortcut(QKeySequence('Ctrl+W'), self)
1110
+ self._close_shortcut.activated.connect(self.close)
1111
+
1112
+ def _setup_theme_update(self) -> None:
1113
+ """
1114
+ Setup any initial style that should be applied to the plugin dialog.
1115
+
1116
+ To be used along side `_update_theme`. For example, this could be implemented
1117
+ in a way that the `_update_theme` method gets called when a signal is emitted.
1118
+ """
1119
+ raise NotImplementedError
1120
+
1121
+ def _update_theme(self, event: Any) -> None:
1122
+ """
1123
+ Update the plugin dialog theme.
1124
+
1125
+ To be used along side `_setup_theme_update`. This method should end up calling
1126
+ `setStyleSheet` to change the style of the dialog.
1127
+
1128
+ Parameters
1129
+ ----------
1130
+ event : Any
1131
+ Object with information about the theme/style change.
1132
+ """
1133
+ raise NotImplementedError
1134
+
1135
+ def _on_installer_start(self):
1136
+ """Updates dialog buttons and status when installing a plugin."""
1137
+ self.cancel_all_btn.setVisible(True)
1138
+ self.working_indicator.show()
1139
+ self.process_success_indicator.hide()
1140
+ self.process_error_indicator.hide()
1141
+ self.refresh_button.setDisabled(True)
1142
+
1143
+ def _on_process_finished(self, process_finished_data: ProcessFinishedData):
1144
+ action = process_finished_data['action']
1145
+ exit_code = process_finished_data['exit_code']
1146
+ pkg_names = [
1147
+ pkg.split('==')[0] for pkg in process_finished_data['pkgs']
1148
+ ]
1149
+ if action == InstallerActions.INSTALL:
1150
+ if exit_code == 0:
1151
+ for pkg_name in pkg_names:
1152
+ if pkg_name in self.available_set:
1153
+ self.available_set.remove(pkg_name)
1154
+
1155
+ self.available_list.removeItem(pkg_name)
1156
+ self._add_installed(pkg_name)
1157
+ self._tag_outdated_plugins()
1158
+ else:
1159
+ for pkg_name in pkg_names:
1160
+ self.available_list.refreshItem(pkg_name)
1161
+ elif action == InstallerActions.UNINSTALL:
1162
+ if exit_code == 0:
1163
+ for pkg_name in pkg_names:
1164
+ if pkg_name in self.already_installed:
1165
+ self.already_installed.remove(pkg_name)
1166
+
1167
+ self.installed_list.removeItem(pkg_name)
1168
+ self._add_to_available(pkg_name)
1169
+ else:
1170
+ for pkg_name in pkg_names:
1171
+ self.installed_list.refreshItem(pkg_name)
1172
+ elif action == InstallerActions.UPGRADE:
1173
+ for pkg in process_finished_data['pkgs']:
1174
+ if '==' in pkg:
1175
+ pkg_name, pkg_version = (
1176
+ pkg.split('==')[0],
1177
+ pkg.split('==')[1],
1178
+ )
1179
+ self.installed_list.refreshItem(
1180
+ pkg_name, version=pkg_version
1181
+ )
1182
+ else:
1183
+ self.installed_list.refreshItem(pkg)
1184
+ self._tag_outdated_plugins()
1185
+ elif action in [InstallerActions.CANCEL, InstallerActions.CANCEL_ALL]:
1186
+ for pkg_name in pkg_names:
1187
+ self.installed_list.refreshItem(pkg_name)
1188
+ self.available_list.refreshItem(pkg_name)
1189
+ self._tag_outdated_plugins()
1190
+
1191
+ self.working_indicator.hide()
1192
+ if exit_code:
1193
+ self.process_error_indicator.show()
1194
+ else:
1195
+ self.process_success_indicator.show()
1196
+
1197
+ def _on_installer_all_finished(self, exit_codes):
1198
+ self.working_indicator.hide()
1199
+ self.cancel_all_btn.setVisible(False)
1200
+ self.close_btn.setDisabled(False)
1201
+ self.refresh_button.setDisabled(False)
1202
+
1203
+ if not self.isVisible():
1204
+ if sum(exit_codes) > 0:
1205
+ self._show_warning(
1206
+ self._trans(
1207
+ 'Plugin Manager: process completed with errors\n'
1208
+ )
1209
+ )
1210
+ else:
1211
+ self._show_info(
1212
+ self._trans('Plugin Manager: process completed\n')
1213
+ )
1214
+
1215
+ self.search()
1216
+
1217
+ def _add_to_installed(
1218
+ self, distname, enabled, norm_name, plugin_api_version=1
1219
+ ):
1220
+ if distname:
1221
+ try:
1222
+ meta = importlib.metadata.metadata(distname)
1223
+
1224
+ except importlib.metadata.PackageNotFoundError:
1225
+ return # a race condition has occurred and the package is uninstalled by another thread
1226
+ if len(meta) == 0:
1227
+ # will not add builtins.
1228
+ return
1229
+ self.already_installed.add(norm_name)
1230
+ else:
1231
+ meta = {}
1232
+
1233
+ self.installed_list.addItem(
1234
+ self.PROJECT_INFO_VERSION_CLASS(
1235
+ display_name=norm_name,
1236
+ pypi_versions=[],
1237
+ conda_versions=[],
1238
+ metadata=self.PACKAGE_METADATA_CLASS(
1239
+ metadata_version="1.0",
1240
+ name=norm_name,
1241
+ version=meta.get('version', ''),
1242
+ summary=meta.get('summary', ''),
1243
+ home_page=meta.get('Home-page', ''),
1244
+ author=meta.get('author', ''),
1245
+ license=meta.get('license', ''),
1246
+ ),
1247
+ ),
1248
+ installed=True,
1249
+ enabled=enabled,
1250
+ plugin_api_version=plugin_api_version,
1251
+ )
1252
+
1253
+ def _add_to_available(self, pkg_name):
1254
+ self._add_items_timer.stop()
1255
+ if self._plugin_queue is not None:
1256
+ self._plugin_queue.insert(0, self._plugin_data_map[pkg_name])
1257
+
1258
+ self._add_items_timer.start()
1259
+ self._update_plugin_count()
1260
+
1261
+ def _add_installed(self, pkg_name: Optional[str] = None) -> None:
1262
+ """
1263
+ Add plugins that are installed to the dialog.
1264
+
1265
+ This should call the `_add_to_installed` method to add each plugin item
1266
+ that should be shown as an installed plugin.
1267
+
1268
+ Parameters
1269
+ ----------
1270
+ pkg_name : str, optional
1271
+ The name of the package that needs to be shown as installed.
1272
+ The default is None. Without passing a package name the logic should
1273
+ fetch/get the info of all the installed plugins and add them to the dialog
1274
+ via the `_add_to_installed` method.
1275
+ """
1276
+ raise NotImplementedError
1277
+
1278
+ def _fetch_available_plugins(self, clear_cache: bool = False) -> None:
1279
+ """
1280
+ Fetch plugins available for installation.
1281
+
1282
+ This should call `_handle_yield` in order to queue the addition of plugins available
1283
+ for installation to the corresponding list (`self.available_list`).
1284
+
1285
+ Parameters
1286
+ ----------
1287
+ clear_cache : bool, optional
1288
+ If a cache is implemented, if the cache should be cleared or not.
1289
+ The default is False.
1290
+ """
1291
+ raise NotImplementedError
1292
+
1293
+ def _loading_gif(self) -> QMovie:
1294
+ """
1295
+ Animation to indicate something is loading.
1296
+
1297
+ Returns
1298
+ -------
1299
+ An instance of `QMovie` with a scaled size fo 18x18 that represents the animation to use
1300
+ when things are loading/an operation is being done.
1301
+ """
1302
+ raise NotImplementedError
1303
+
1304
+ def _on_bundle(self) -> bool:
1305
+ """
1306
+ If the current installation comes from a bundle/standalone approach or not.
1307
+
1308
+ Returns
1309
+ -------
1310
+ This should return a `bool`, `True` if under a bundle like installation, `False`
1311
+ otherwise.
1312
+ """
1313
+ raise NotImplementedError
1314
+
1315
+ def _show_info(self, info: str) -> None:
1316
+ """
1317
+ Shows a info message.
1318
+
1319
+ Parameters
1320
+ ----------
1321
+ info : str
1322
+ Info message to be shown.
1323
+ """
1324
+ raise NotImplementedError
1325
+
1326
+ def _show_warning(self, warning: str) -> None:
1327
+ """
1328
+ Shows a warning message.
1329
+
1330
+ Parameters
1331
+ ----------
1332
+ warning : str
1333
+ Warning message to be shown.
1334
+ """
1335
+ raise NotImplementedError
1336
+
1337
+ def _trans(self, text: str, **kwargs) -> str:
1338
+ """
1339
+ Translates the given text.
1340
+
1341
+ Parameters
1342
+ ----------
1343
+ text : str
1344
+ The singular string to translate.
1345
+ **kwargs : dict, optional
1346
+ Any additional arguments to use when formatting the string.
1347
+
1348
+ Returns
1349
+ -------
1350
+ The translated string
1351
+
1352
+ """
1353
+ raise NotImplementedError
1354
+
1355
+ def _is_main_app_conda_package(self):
1356
+ return is_conda_package(self.BASE_PACKAGE_NAME)
1357
+
1358
+ def _setup_ui(self):
1359
+ """Defines the layout for the PluginDialog."""
1360
+ self.resize(900, 600)
1361
+ vlay_1 = QVBoxLayout(self)
1362
+ self.h_splitter = QSplitter(self)
1363
+ vlay_1.addWidget(self.h_splitter)
1364
+ self.h_splitter.setOrientation(Qt.Orientation.Horizontal)
1365
+ self.v_splitter = QSplitter(self.h_splitter)
1366
+ self.v_splitter.setOrientation(Qt.Orientation.Vertical)
1367
+ self.v_splitter.setMinimumWidth(500)
1368
+
1369
+ installed = QWidget(self.v_splitter)
1370
+ lay = QVBoxLayout(installed)
1371
+ lay.setContentsMargins(0, 2, 0, 2)
1372
+ self.installed_label = QLabel(self._trans("Installed Plugins"))
1373
+ self.packages_search = QLineEdit()
1374
+ self.packages_search.setPlaceholderText(
1375
+ self._trans("Type here to start searching for plugins...")
1376
+ )
1377
+ self.packages_search.setToolTip(
1378
+ self._trans(
1379
+ "The search text will filter currently installed plugins "
1380
+ "while also being used to search for plugins on the {package_name} hub",
1381
+ package_name=self.BASE_PACKAGE_NAME,
1382
+ )
1383
+ )
1384
+ self.packages_search.setMaximumWidth(350)
1385
+ self.packages_search.setClearButtonEnabled(True)
1386
+ self.packages_search.textChanged.connect(self.search)
1387
+
1388
+ self.refresh_button = QPushButton(self._trans('Refresh'), self)
1389
+ self.refresh_button.setObjectName("refresh_button")
1390
+ self.refresh_button.setToolTip(
1391
+ self._trans(
1392
+ 'This will clear and refresh the available and installed plugins lists.'
1393
+ )
1394
+ )
1395
+ self.refresh_button.clicked.connect(self._refresh_and_clear_cache)
1396
+
1397
+ mid_layout = QVBoxLayout()
1398
+ horizontal_mid_layout = QHBoxLayout()
1399
+ horizontal_mid_layout.addWidget(self.packages_search)
1400
+ horizontal_mid_layout.addStretch()
1401
+ horizontal_mid_layout.addWidget(self.refresh_button)
1402
+ mid_layout.addLayout(horizontal_mid_layout)
1403
+ mid_layout.addWidget(self.installed_label)
1404
+ lay.addLayout(mid_layout)
1405
+
1406
+ self.installed_list = self.PLUGIN_LIST_CLASS(
1407
+ installed, self.installer, self.BASE_PACKAGE_NAME
1408
+ )
1409
+ lay.addWidget(self.installed_list)
1410
+
1411
+ uninstalled = QWidget(self.v_splitter)
1412
+ lay = QVBoxLayout(uninstalled)
1413
+ lay.setContentsMargins(0, 2, 0, 2)
1414
+ self.avail_label = QLabel(self._trans("Available Plugins"))
1415
+ mid_layout = QHBoxLayout()
1416
+ mid_layout.addWidget(self.avail_label)
1417
+ mid_layout.addStretch()
1418
+ lay.addLayout(mid_layout)
1419
+ self.available_list = self.PLUGIN_LIST_CLASS(
1420
+ uninstalled, self.installer, self.BASE_PACKAGE_NAME
1421
+ )
1422
+ lay.addWidget(self.available_list)
1423
+
1424
+ self.stdout_text = QTextEdit(self.v_splitter)
1425
+ self.stdout_text.setReadOnly(True)
1426
+ self.stdout_text.setObjectName("plugin_manager_process_status")
1427
+ self.stdout_text.hide()
1428
+
1429
+ buttonBox = QHBoxLayout()
1430
+ self.working_indicator = QLabel(self._trans("loading ..."), self)
1431
+ sp = self.working_indicator.sizePolicy()
1432
+ sp.setRetainSizeWhenHidden(True)
1433
+ self.working_indicator.setSizePolicy(sp)
1434
+ self.process_error_indicator = QLabel(self)
1435
+ self.process_error_indicator.setObjectName("error_label")
1436
+ self.process_error_indicator.hide()
1437
+ self.process_success_indicator = QLabel(self)
1438
+ self.process_success_indicator.setObjectName("success_label")
1439
+ self.process_success_indicator.hide()
1440
+ mov = self._loading_gif()
1441
+ self.working_indicator.setMovie(mov)
1442
+ mov.start()
1443
+
1444
+ visibility_direct_entry = not self._on_bundle()
1445
+ self.direct_entry_edit = QLineEdit(self)
1446
+ self.direct_entry_edit.installEventFilter(self)
1447
+ self.direct_entry_edit.returnPressed.connect(self._install_packages)
1448
+ self.direct_entry_edit.setVisible(visibility_direct_entry)
1449
+ self.direct_entry_btn = QToolButton(self)
1450
+ self.direct_entry_btn.setVisible(visibility_direct_entry)
1451
+ self.direct_entry_btn.clicked.connect(self._install_packages)
1452
+ self.direct_entry_btn.setText(self._trans("Install"))
1453
+
1454
+ self._action_conda = QAction(self._trans('Conda'), self)
1455
+ self._action_conda.setCheckable(True)
1456
+ self._action_conda.triggered.connect(self._update_direct_entry_text)
1457
+
1458
+ self._action_pypi = QAction(self._trans('pip'), self)
1459
+ self._action_pypi.setCheckable(True)
1460
+ self._action_pypi.triggered.connect(self._update_direct_entry_text)
1461
+
1462
+ self._action_group = QActionGroup(self)
1463
+ self._action_group.addAction(self._action_pypi)
1464
+ self._action_group.addAction(self._action_conda)
1465
+ self._action_group.setExclusive(True)
1466
+
1467
+ self._menu = QMenu(self)
1468
+ self._menu.addAction(self._action_conda)
1469
+ self._menu.addAction(self._action_pypi)
1470
+
1471
+ if self._is_main_app_conda_package():
1472
+ self.direct_entry_btn.setPopupMode(QToolButton.MenuButtonPopup)
1473
+ self._action_conda.setChecked(True)
1474
+ self.direct_entry_btn.setMenu(self._menu)
1475
+
1476
+ self.show_status_btn = QPushButton(self._trans("Show Status"), self)
1477
+ self.show_status_btn.setFixedWidth(100)
1478
+
1479
+ self.cancel_all_btn = QPushButton(
1480
+ self._trans("cancel all actions"), self
1481
+ )
1482
+ self.cancel_all_btn.setObjectName("remove_button")
1483
+ self.cancel_all_btn.setVisible(False)
1484
+ self.cancel_all_btn.clicked.connect(self.installer.cancel_all)
1485
+
1486
+ self.close_btn = QPushButton(self._trans("Close"), self)
1487
+ self.close_btn.clicked.connect(self.accept)
1488
+ self.close_btn.setObjectName("close_button")
1489
+
1490
+ buttonBox.addWidget(self.show_status_btn)
1491
+ buttonBox.addWidget(self.working_indicator)
1492
+ buttonBox.addWidget(self.direct_entry_edit)
1493
+ buttonBox.addWidget(self.direct_entry_btn)
1494
+ if not visibility_direct_entry:
1495
+ buttonBox.addStretch()
1496
+ buttonBox.addWidget(self.process_success_indicator)
1497
+ buttonBox.addWidget(self.process_error_indicator)
1498
+ buttonBox.addSpacing(20)
1499
+ buttonBox.addWidget(self.cancel_all_btn)
1500
+ buttonBox.addSpacing(20)
1501
+ buttonBox.addWidget(self.close_btn)
1502
+ buttonBox.setContentsMargins(0, 0, 4, 0)
1503
+ vlay_1.addLayout(buttonBox)
1504
+
1505
+ self.show_status_btn.setCheckable(True)
1506
+ self.show_status_btn.setChecked(False)
1507
+ self.show_status_btn.toggled.connect(self.toggle_status)
1508
+
1509
+ self.v_splitter.setStretchFactor(1, 2)
1510
+ self.h_splitter.setStretchFactor(0, 2)
1511
+
1512
+ self.packages_search.setFocus()
1513
+ self._update_direct_entry_text()
1514
+
1515
+ def _update_direct_entry_text(self):
1516
+ tool = (
1517
+ str(InstallerTools.CONDA)
1518
+ if self._action_conda.isChecked()
1519
+ else str(InstallerTools.PIP)
1520
+ )
1521
+ self.direct_entry_edit.setPlaceholderText(
1522
+ self._trans(
1523
+ "install with '{tool}' by name/url, or drop file...", tool=tool
1524
+ )
1525
+ )
1526
+
1527
+ def _update_plugin_count(self):
1528
+ """Update count labels for both installed and available plugin lists.
1529
+ Displays also amount of visible plugins out of total when filtering.
1530
+ """
1531
+ installed_count = self.installed_list.count()
1532
+ installed_count_visible = self.installed_list.count_visible()
1533
+ if installed_count == installed_count_visible:
1534
+ self.installed_label.setText(
1535
+ self._trans(
1536
+ "Installed Plugins ({amount})",
1537
+ amount=installed_count,
1538
+ )
1539
+ )
1540
+ else:
1541
+ self.installed_label.setText(
1542
+ self._trans(
1543
+ "Installed Plugins ({count}/{amount})",
1544
+ count=installed_count_visible,
1545
+ amount=installed_count,
1546
+ )
1547
+ )
1548
+
1549
+ available_count = len(self._plugin_data) - self.installed_list.count()
1550
+ available_count = available_count if available_count >= 0 else 0
1551
+
1552
+ if self._plugins_found == 0:
1553
+ self.avail_label.setText(
1554
+ self._trans(
1555
+ "{amount} plugins available on the napari hub",
1556
+ amount=available_count,
1557
+ )
1558
+ )
1559
+ elif self._plugins_found > self.MAX_PLUGIN_SEARCH_ITEMS:
1560
+ self.avail_label.setText(
1561
+ self._trans(
1562
+ "Found {found} out of {amount} plugins on the napari hub. Displaying the first {max_count}...",
1563
+ found=self._plugins_found,
1564
+ amount=available_count,
1565
+ max_count=self.MAX_PLUGIN_SEARCH_ITEMS,
1566
+ )
1567
+ )
1568
+ else:
1569
+ self.avail_label.setText(
1570
+ self._trans(
1571
+ "Found {found} out of {amount} plugins on the napari hub",
1572
+ found=self._plugins_found,
1573
+ amount=available_count,
1574
+ )
1575
+ )
1576
+
1577
+ def _install_packages(
1578
+ self,
1579
+ packages: Sequence[str] = (),
1580
+ ):
1581
+ if not packages:
1582
+ _packages = self.direct_entry_edit.text()
1583
+ packages = (
1584
+ [_packages] if os.path.exists(_packages) else _packages.split()
1585
+ )
1586
+ self.direct_entry_edit.clear()
1587
+
1588
+ if packages:
1589
+ tool = (
1590
+ InstallerTools.CONDA
1591
+ if self._action_conda.isChecked()
1592
+ else InstallerTools.PIP
1593
+ )
1594
+ self.installer.install(tool, packages)
1595
+
1596
+ def _tag_outdated_plugins(self):
1597
+ """Tag installed plugins that might be outdated."""
1598
+ for pkg_name in self.installed_list.packages():
1599
+ _data = self._plugin_data_map.get(pkg_name)
1600
+ if _data is not None:
1601
+ metadata, is_available_in_conda, _ = _data
1602
+ self.installed_list.tag_outdated(
1603
+ metadata, is_available_in_conda
1604
+ )
1605
+
1606
+ def _add_items(self):
1607
+ """
1608
+ Add items to the lists by `batch_size` using a timer to add a pause
1609
+ and prevent freezing the UI.
1610
+ """
1611
+ if (
1612
+ len(self._plugin_queue) == 0
1613
+ or self.available_list.count_visible()
1614
+ >= self.MAX_PLUGIN_SEARCH_ITEMS
1615
+ ):
1616
+ if (
1617
+ self.installed_list.count() + self.available_list.count()
1618
+ == len(self._plugin_data)
1619
+ and self.available_list.count() != 0
1620
+ ):
1621
+ self._add_items_timer.stop()
1622
+ if not self.isVisible():
1623
+ self._show_info(
1624
+ self._trans(
1625
+ 'Plugin Manager: All available plugins loaded\n'
1626
+ )
1627
+ )
1628
+
1629
+ return
1630
+
1631
+ batch_size = 2
1632
+ for _ in range(batch_size):
1633
+ data = self._plugin_queue.pop(0)
1634
+ metadata, is_available_in_conda, extra_info = data
1635
+ display_name = extra_info.get('display_name', metadata.name)
1636
+ if metadata.name in self.already_installed:
1637
+ self.installed_list.tag_outdated(
1638
+ metadata, is_available_in_conda
1639
+ )
1640
+ else:
1641
+ if metadata.name not in self.available_set:
1642
+ self.available_set.add(metadata.name)
1643
+ self.available_list.addItem(
1644
+ self.PROJECT_INFO_VERSION_CLASS(
1645
+ display_name=display_name,
1646
+ pypi_versions=extra_info['pypi_versions'],
1647
+ conda_versions=extra_info['conda_versions'],
1648
+ metadata=metadata,
1649
+ )
1650
+ )
1651
+ if self._on_bundle() and not is_available_in_conda:
1652
+ self.available_list.tag_unavailable(metadata)
1653
+
1654
+ if len(self._plugin_queue) == 0:
1655
+ self._tag_outdated_plugins()
1656
+ break
1657
+
1658
+ self._update_plugin_count()
1659
+
1660
+ def _handle_yield(self, data: Tuple[PackageMetadataProtocol, bool, Dict]):
1661
+ """Output from a worker process.
1662
+
1663
+ Includes information about the plugin, including available versions on conda and pypi.
1664
+
1665
+ The data is stored but the actual items are added via a timer in the `_add_items`
1666
+ method to prevent the UI from freezing by adding all items at once.
1667
+ """
1668
+ self._plugin_data.append(data)
1669
+ self._filter_texts = [
1670
+ f"{i[0].name} {i[-1].get('display_name', '')} {i[0].summary}".lower()
1671
+ for i in self._plugin_data
1672
+ ]
1673
+ metadata, _, _ = data
1674
+ self._plugin_data_map[metadata.name] = data
1675
+ self.available_list.set_data(self._plugin_data)
1676
+ self._update_plugin_count()
1677
+
1678
+ def _search_in_available(self, text):
1679
+ idxs = []
1680
+ for idx, item in enumerate(self._filter_texts):
1681
+ if text.lower().strip() in item:
1682
+ idxs.append(idx)
1683
+ self._filter_idxs_cache.add(idx)
1684
+
1685
+ return idxs
1686
+
1687
+ def _refresh_and_clear_cache(self):
1688
+ self.refresh(clear_cache=True)
1689
+
1690
+ # endregion - Private methods
1691
+
1692
+ # region - Qt overrides
1693
+ # ------------------------------------------------------------------------
1694
+ def closeEvent(self, event):
1695
+ if self._parent is not None:
1696
+ plugin_dialog = getattr(self._parent, '_plugin_dialog', self)
1697
+ if self != plugin_dialog:
1698
+ self.destroy(True, True)
1699
+ super().closeEvent(event)
1700
+ else:
1701
+ plugin_dialog.hide()
1702
+ else:
1703
+ super().closeEvent(event)
1704
+
1705
+ def dragEnterEvent(self, event):
1706
+ event.accept()
1707
+
1708
+ def dropEvent(self, event):
1709
+ md = event.mimeData()
1710
+ if md.hasUrls():
1711
+ files = [url.toLocalFile() for url in md.urls()]
1712
+ self.direct_entry_edit.setText(files[0])
1713
+ return True
1714
+
1715
+ return super().dropEvent(event)
1716
+
1717
+ def exec_(self):
1718
+ plugin_dialog = getattr(self._parent, '_plugin_dialog', self)
1719
+ if plugin_dialog != self:
1720
+ self.close()
1721
+
1722
+ plugin_dialog.setModal(True)
1723
+ plugin_dialog.show()
1724
+
1725
+ if self._first_open:
1726
+ self._update_theme(None)
1727
+ self._first_open = False
1728
+
1729
+ def hideEvent(self, event):
1730
+ self.packages_search.clear()
1731
+ self.toggle_status(False)
1732
+ super().hideEvent(event)
1733
+
1734
+ # endregion - Qt overrides
1735
+
1736
+ # region - Public methods
1737
+ # ------------------------------------------------------------------------
1738
+ def search(self, text: Optional[str] = None, skip=False) -> None:
1739
+ """Filter by text or set current text as filter."""
1740
+ if text is None:
1741
+ text = self.packages_search.text()
1742
+ else:
1743
+ self.packages_search.setText(text)
1744
+
1745
+ if len(text.strip()) == 0:
1746
+ self.installed_list.filter('')
1747
+ self.available_list.hideAll()
1748
+ self._plugin_queue = None
1749
+ self._add_items_timer.stop()
1750
+ self._plugins_found = 0
1751
+ else:
1752
+ items = [
1753
+ self._plugin_data[idx]
1754
+ for idx in self._search_in_available(text)
1755
+ ]
1756
+ # Go over list and remove any not found
1757
+ self.installed_list.filter(text.strip().lower())
1758
+ self.available_list.filter(text.strip().lower())
1759
+
1760
+ if items:
1761
+ self._add_items_timer.stop()
1762
+ self._plugin_queue = items
1763
+ self._plugins_found = len(items)
1764
+ self._add_items_timer.start()
1765
+ else:
1766
+ self._plugin_queue = None
1767
+ self._add_items_timer.stop()
1768
+ self._plugins_found = 0
1769
+
1770
+ self._update_plugin_count()
1771
+
1772
+ def refresh(self, clear_cache: bool = False):
1773
+ self.refresh_button.setDisabled(True)
1774
+
1775
+ if self.worker is not None:
1776
+ self.worker.quit()
1777
+
1778
+ if self._add_items_timer.isActive():
1779
+ self._add_items_timer.stop()
1780
+
1781
+ self._filter_texts = []
1782
+ self._plugin_queue = []
1783
+ self._plugin_data = []
1784
+ self._plugin_data_map = {}
1785
+
1786
+ self.installed_list.clear()
1787
+ self.available_list.clear()
1788
+ self.already_installed = set()
1789
+ self.available_set = set()
1790
+
1791
+ self._add_installed()
1792
+ self._fetch_available_plugins(clear_cache=clear_cache)
1793
+
1794
+ self._refresh_timer.start()
1795
+
1796
+ def toggle_status(self, show=None):
1797
+ show = not self.stdout_text.isVisible() if show is None else show
1798
+ if show:
1799
+ self.show_status_btn.setText(self._trans("Hide Status"))
1800
+ self.stdout_text.show()
1801
+ else:
1802
+ self.show_status_btn.setText(self._trans("Show Status"))
1803
+ self.stdout_text.hide()
1804
+
1805
+ def set_prefix(self, prefix):
1806
+ self._prefix = prefix
1807
+ self.installer._prefix = prefix
1808
+ for idx in range(self.available_list.count()):
1809
+ item = self.available_list.item(idx)
1810
+ item.widget.prefix = prefix
1811
+
1812
+ for idx in range(self.installed_list.count()):
1813
+ item = self.installed_list.item(idx)
1814
+ item.widget.prefix = prefix
1815
+
1816
+ # endregion - Public methods