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