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