napari-plugin-manager 0.1.3__py3-none-any.whl → 0.1.5__py3-none-any.whl

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