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