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