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,689 @@
|
|
|
1
|
+
"""Package tool-agnostic installation logic for the plugin manager.
|
|
2
|
+
|
|
3
|
+
The main object is `InstallerQueue`, a `QProcess` subclass
|
|
4
|
+
with the notion of a job queue.
|
|
5
|
+
|
|
6
|
+
The queued jobs are represented by a `deque` of `*InstallerTool` dataclasses
|
|
7
|
+
that contain the executable path, arguments and environment modifications.
|
|
8
|
+
|
|
9
|
+
Available actions for each tool are `install`, `uninstall`
|
|
10
|
+
and `cancel`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from collections import deque
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from enum import auto
|
|
21
|
+
from functools import lru_cache
|
|
22
|
+
from logging import getLogger
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from subprocess import call
|
|
25
|
+
from tempfile import gettempdir
|
|
26
|
+
from typing import TypedDict
|
|
27
|
+
|
|
28
|
+
from napari.plugins import plugin_manager
|
|
29
|
+
from napari.plugins.npe2api import _user_agent
|
|
30
|
+
from napari.utils.misc import StringEnum
|
|
31
|
+
from napari.utils.translations import trans
|
|
32
|
+
from npe2 import PluginManager
|
|
33
|
+
from qtpy.QtCore import QObject, QProcess, QProcessEnvironment, Signal
|
|
34
|
+
from qtpy.QtWidgets import QTextEdit
|
|
35
|
+
|
|
36
|
+
# Alias for int type to represent a job idenfifier in the installer queue
|
|
37
|
+
JobId = int
|
|
38
|
+
|
|
39
|
+
log = getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class InstallerActions(StringEnum):
|
|
43
|
+
"Available actions for the plugin manager"
|
|
44
|
+
|
|
45
|
+
INSTALL = auto()
|
|
46
|
+
UNINSTALL = auto()
|
|
47
|
+
CANCEL = auto()
|
|
48
|
+
CANCEL_ALL = auto()
|
|
49
|
+
UPGRADE = auto()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ProcessFinishedData(TypedDict):
|
|
53
|
+
"""Data about a finished process."""
|
|
54
|
+
|
|
55
|
+
exit_code: int
|
|
56
|
+
exit_status: int
|
|
57
|
+
action: InstallerActions
|
|
58
|
+
pkgs: tuple[str, ...]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class InstallerTools(StringEnum):
|
|
62
|
+
"Installer tools selectable by InstallerQueue jobs"
|
|
63
|
+
|
|
64
|
+
CONDA = auto()
|
|
65
|
+
PIP = auto()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class AbstractInstallerTool:
|
|
70
|
+
"""Abstract base class for installer tools."""
|
|
71
|
+
|
|
72
|
+
action: InstallerActions
|
|
73
|
+
pkgs: tuple[str, ...]
|
|
74
|
+
origins: tuple[str, ...] = ()
|
|
75
|
+
prefix: str | None = None
|
|
76
|
+
process: QProcess = None
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def ident(self):
|
|
80
|
+
return hash(
|
|
81
|
+
(self.action, *self.pkgs, *self.origins, self.prefix, self.process)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# abstract method
|
|
85
|
+
@classmethod
|
|
86
|
+
def executable(cls):
|
|
87
|
+
"Path to the executable that will run the task"
|
|
88
|
+
raise NotImplementedError
|
|
89
|
+
|
|
90
|
+
# abstract method
|
|
91
|
+
def arguments(self):
|
|
92
|
+
"Arguments supplied to the executable"
|
|
93
|
+
raise NotImplementedError
|
|
94
|
+
|
|
95
|
+
# abstract method
|
|
96
|
+
def environment(
|
|
97
|
+
self, env: QProcessEnvironment = None
|
|
98
|
+
) -> QProcessEnvironment:
|
|
99
|
+
"Changes needed in the environment variables."
|
|
100
|
+
raise NotImplementedError
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def constraints() -> Sequence[str]:
|
|
104
|
+
"""
|
|
105
|
+
Version constraints to limit unwanted changes in installation.
|
|
106
|
+
"""
|
|
107
|
+
raise NotImplementedError
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def available(cls) -> bool:
|
|
111
|
+
"""
|
|
112
|
+
Check if the tool is available by performing a little test
|
|
113
|
+
"""
|
|
114
|
+
raise NotImplementedError
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class PipInstallerTool(AbstractInstallerTool):
|
|
118
|
+
"""Pip installer tool for the plugin manager.
|
|
119
|
+
|
|
120
|
+
This class is used to install and uninstall packages using pip.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def available(cls):
|
|
125
|
+
"""Check if pip is available."""
|
|
126
|
+
return call([cls.executable(), "-m", "pip", "--version"]) == 0
|
|
127
|
+
|
|
128
|
+
def arguments(self) -> tuple[str, ...]:
|
|
129
|
+
"""Compose arguments for the pip command."""
|
|
130
|
+
args = ['-m', 'pip']
|
|
131
|
+
|
|
132
|
+
if self.action == InstallerActions.INSTALL:
|
|
133
|
+
args += ['install', '-c', self._constraints_file()]
|
|
134
|
+
for origin in self.origins:
|
|
135
|
+
args += ['--extra-index-url', origin]
|
|
136
|
+
|
|
137
|
+
elif self.action == InstallerActions.UPGRADE:
|
|
138
|
+
args += [
|
|
139
|
+
'install',
|
|
140
|
+
'--upgrade',
|
|
141
|
+
'-c',
|
|
142
|
+
self._constraints_file(),
|
|
143
|
+
]
|
|
144
|
+
for origin in self.origins:
|
|
145
|
+
args += ['--extra-index-url', origin]
|
|
146
|
+
|
|
147
|
+
elif self.action == InstallerActions.UNINSTALL:
|
|
148
|
+
args += ['uninstall', '-y']
|
|
149
|
+
|
|
150
|
+
else:
|
|
151
|
+
raise ValueError(f"Action '{self.action}' not supported!")
|
|
152
|
+
|
|
153
|
+
if log.getEffectiveLevel() < 30: # DEBUG and INFOlevel
|
|
154
|
+
args.append('-vvv')
|
|
155
|
+
|
|
156
|
+
if self.prefix is not None:
|
|
157
|
+
args.extend(['--prefix', str(self.prefix)])
|
|
158
|
+
|
|
159
|
+
return (*args, *self.pkgs)
|
|
160
|
+
|
|
161
|
+
def environment(
|
|
162
|
+
self, env: QProcessEnvironment = None
|
|
163
|
+
) -> QProcessEnvironment:
|
|
164
|
+
if env is None:
|
|
165
|
+
env = QProcessEnvironment.systemEnvironment()
|
|
166
|
+
env.insert("PIP_USER_AGENT_USER_DATA", _user_agent())
|
|
167
|
+
return env
|
|
168
|
+
|
|
169
|
+
@classmethod
|
|
170
|
+
@lru_cache(maxsize=0)
|
|
171
|
+
def _constraints_file(cls) -> str:
|
|
172
|
+
raise NotImplementedError
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class CondaInstallerTool(AbstractInstallerTool):
|
|
176
|
+
"""Conda installer tool for the plugin manager.
|
|
177
|
+
|
|
178
|
+
This class is used to install and uninstall packages using conda or conda-like executable.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
@classmethod
|
|
182
|
+
def executable(cls):
|
|
183
|
+
"""Find a path to the executable.
|
|
184
|
+
|
|
185
|
+
This method assumes that if no environment variable is set that conda is available in the PATH.
|
|
186
|
+
"""
|
|
187
|
+
bat = ".bat" if os.name == "nt" else ""
|
|
188
|
+
for path in (
|
|
189
|
+
Path(os.environ.get('MAMBA_EXE', '')),
|
|
190
|
+
Path(os.environ.get('CONDA_EXE', '')),
|
|
191
|
+
# $CONDA is usually only available on GitHub Actions
|
|
192
|
+
Path(os.environ.get('CONDA', '')) / 'condabin' / f'conda{bat}',
|
|
193
|
+
):
|
|
194
|
+
if path.is_file():
|
|
195
|
+
# return the path to the executable
|
|
196
|
+
return str(path)
|
|
197
|
+
# Otherwise, we assume that conda is available in the PATH
|
|
198
|
+
return f'conda{bat}'
|
|
199
|
+
|
|
200
|
+
@classmethod
|
|
201
|
+
def available(cls):
|
|
202
|
+
"""Check if the executable is available by checking if it can output its version."""
|
|
203
|
+
executable = cls.executable()
|
|
204
|
+
try:
|
|
205
|
+
return call([executable, "--version"]) == 0
|
|
206
|
+
except FileNotFoundError: # pragma: no cover
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
def arguments(self) -> tuple[str, ...]:
|
|
210
|
+
"""Compose arguments for the conda command."""
|
|
211
|
+
prefix = self.prefix or self._default_prefix()
|
|
212
|
+
|
|
213
|
+
if self.action == InstallerActions.UPGRADE:
|
|
214
|
+
args = ['update', '-y', '--prefix', prefix]
|
|
215
|
+
else:
|
|
216
|
+
args = [self.action.value, '-y', '--prefix', prefix]
|
|
217
|
+
|
|
218
|
+
args.append('--override-channels')
|
|
219
|
+
for channel in (*self.origins, *self._default_channels()):
|
|
220
|
+
args.extend(["-c", channel])
|
|
221
|
+
|
|
222
|
+
return (*args, *self.pkgs)
|
|
223
|
+
|
|
224
|
+
def environment(
|
|
225
|
+
self, env: QProcessEnvironment = None
|
|
226
|
+
) -> QProcessEnvironment:
|
|
227
|
+
if env is None:
|
|
228
|
+
env = QProcessEnvironment.systemEnvironment()
|
|
229
|
+
self._add_constraints_to_env(env)
|
|
230
|
+
if 10 <= log.getEffectiveLevel() < 30: # DEBUG level
|
|
231
|
+
env.insert('CONDA_VERBOSITY', '3')
|
|
232
|
+
if os.name == "nt":
|
|
233
|
+
if not env.contains("TEMP"):
|
|
234
|
+
temp = gettempdir()
|
|
235
|
+
env.insert("TMP", temp)
|
|
236
|
+
env.insert("TEMP", temp)
|
|
237
|
+
if not env.contains("USERPROFILE"):
|
|
238
|
+
env.insert("HOME", os.path.expanduser("~"))
|
|
239
|
+
env.insert("USERPROFILE", os.path.expanduser("~"))
|
|
240
|
+
if sys.platform == 'darwin' and env.contains('PYTHONEXECUTABLE'):
|
|
241
|
+
# Fix for macOS when napari launched from terminal
|
|
242
|
+
# related to https://github.com/napari/napari/pull/5531
|
|
243
|
+
env.remove("PYTHONEXECUTABLE")
|
|
244
|
+
return env
|
|
245
|
+
|
|
246
|
+
def _add_constraints_to_env(
|
|
247
|
+
self, env: QProcessEnvironment
|
|
248
|
+
) -> QProcessEnvironment:
|
|
249
|
+
"""Add constraints to the environment."""
|
|
250
|
+
PINNED = 'CONDA_PINNED_PACKAGES'
|
|
251
|
+
constraints = self.constraints()
|
|
252
|
+
if env.contains(PINNED):
|
|
253
|
+
constraints.append(env.value(PINNED))
|
|
254
|
+
env.insert(PINNED, "&".join(constraints))
|
|
255
|
+
return env
|
|
256
|
+
|
|
257
|
+
def _default_channels(self):
|
|
258
|
+
"""Default channels for conda installations."""
|
|
259
|
+
return ('conda-forge',)
|
|
260
|
+
|
|
261
|
+
def _default_prefix(self):
|
|
262
|
+
"""Default prefix for conda installations."""
|
|
263
|
+
if (Path(sys.prefix) / "conda-meta").is_dir():
|
|
264
|
+
return sys.prefix
|
|
265
|
+
raise ValueError("Prefix has not been specified!")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class InstallerQueue(QObject):
|
|
269
|
+
"""Queue for installation and uninstallation tasks in the plugin manager."""
|
|
270
|
+
|
|
271
|
+
# emitted when all jobs are finished. Not to be confused with finished,
|
|
272
|
+
# which is emitted when each individual job is finished.
|
|
273
|
+
# Tuple of exit codes for each individual job
|
|
274
|
+
allFinished = Signal(tuple)
|
|
275
|
+
|
|
276
|
+
# emitted when each job finishes
|
|
277
|
+
# dict: ProcessFinishedData
|
|
278
|
+
processFinished = Signal(dict)
|
|
279
|
+
|
|
280
|
+
# emitted when each job starts
|
|
281
|
+
started = Signal()
|
|
282
|
+
|
|
283
|
+
# classes to manage pip and conda installations
|
|
284
|
+
PIP_INSTALLER_TOOL_CLASS = PipInstallerTool
|
|
285
|
+
CONDA_INSTALLER_TOOL_CLASS = CondaInstallerTool
|
|
286
|
+
# This should be set to the name of package that handles plugins
|
|
287
|
+
# e.g `napari` for napari
|
|
288
|
+
BASE_PACKAGE_NAME = ''
|
|
289
|
+
|
|
290
|
+
def __init__(
|
|
291
|
+
self, parent: QObject | None = None, prefix: str | None = None
|
|
292
|
+
) -> None:
|
|
293
|
+
super().__init__(parent)
|
|
294
|
+
self._queue: deque[AbstractInstallerTool] = deque()
|
|
295
|
+
self._current_process: QProcess = None
|
|
296
|
+
self._prefix = prefix
|
|
297
|
+
self._output_widget = None
|
|
298
|
+
self._exit_codes = []
|
|
299
|
+
|
|
300
|
+
# -------------------------- Public API ------------------------------
|
|
301
|
+
def install(
|
|
302
|
+
self,
|
|
303
|
+
tool: InstallerTools,
|
|
304
|
+
pkgs: Sequence[str],
|
|
305
|
+
*,
|
|
306
|
+
prefix: str | None = None,
|
|
307
|
+
origins: Sequence[str] = (),
|
|
308
|
+
**kwargs,
|
|
309
|
+
) -> JobId:
|
|
310
|
+
"""Install packages in the installer queue.
|
|
311
|
+
|
|
312
|
+
This installs packages in `pkgs` into `prefix` using `tool` with additional
|
|
313
|
+
`origins` as source for `pkgs`.
|
|
314
|
+
|
|
315
|
+
Parameters
|
|
316
|
+
----------
|
|
317
|
+
tool : InstallerTools
|
|
318
|
+
Which type of installation tool to use.
|
|
319
|
+
pkgs : Sequence[str]
|
|
320
|
+
List of packages to install.
|
|
321
|
+
prefix : Optional[str], optional
|
|
322
|
+
Optional prefix to install packages into.
|
|
323
|
+
origins : Optional[Sequence[str]], optional
|
|
324
|
+
Additional sources for packages to be downloaded from.
|
|
325
|
+
|
|
326
|
+
Returns
|
|
327
|
+
-------
|
|
328
|
+
JobId : int
|
|
329
|
+
An ID to reference the job. Use to cancel the process.
|
|
330
|
+
"""
|
|
331
|
+
item = self._build_queue_item(
|
|
332
|
+
tool=tool,
|
|
333
|
+
action=InstallerActions.INSTALL,
|
|
334
|
+
pkgs=pkgs,
|
|
335
|
+
prefix=prefix,
|
|
336
|
+
origins=origins,
|
|
337
|
+
process=self._create_process(),
|
|
338
|
+
**kwargs,
|
|
339
|
+
)
|
|
340
|
+
return self._queue_item(item)
|
|
341
|
+
|
|
342
|
+
def upgrade(
|
|
343
|
+
self,
|
|
344
|
+
tool: InstallerTools,
|
|
345
|
+
pkgs: Sequence[str],
|
|
346
|
+
*,
|
|
347
|
+
prefix: str | None = None,
|
|
348
|
+
origins: Sequence[str] = (),
|
|
349
|
+
**kwargs,
|
|
350
|
+
) -> JobId:
|
|
351
|
+
"""Upgrade packages in the installer queue.
|
|
352
|
+
|
|
353
|
+
Upgrade in `pkgs` into `prefix` using `tool` with additional
|
|
354
|
+
`origins` as source for `pkgs`.
|
|
355
|
+
|
|
356
|
+
Parameters
|
|
357
|
+
----------
|
|
358
|
+
tool : InstallerTools
|
|
359
|
+
Which type of installation tool to use.
|
|
360
|
+
pkgs : Sequence[str]
|
|
361
|
+
List of packages to install.
|
|
362
|
+
prefix : Optional[str], optional
|
|
363
|
+
Optional prefix to install packages into.
|
|
364
|
+
origins : Optional[Sequence[str]], optional
|
|
365
|
+
Additional sources for packages to be downloaded from.
|
|
366
|
+
|
|
367
|
+
Returns
|
|
368
|
+
-------
|
|
369
|
+
JobId : int
|
|
370
|
+
An ID to reference the job. Use to cancel the process.
|
|
371
|
+
"""
|
|
372
|
+
item = self._build_queue_item(
|
|
373
|
+
tool=tool,
|
|
374
|
+
action=InstallerActions.UPGRADE,
|
|
375
|
+
pkgs=pkgs,
|
|
376
|
+
prefix=prefix,
|
|
377
|
+
origins=origins,
|
|
378
|
+
process=self._create_process(),
|
|
379
|
+
**kwargs,
|
|
380
|
+
)
|
|
381
|
+
return self._queue_item(item)
|
|
382
|
+
|
|
383
|
+
def uninstall(
|
|
384
|
+
self,
|
|
385
|
+
tool: InstallerTools,
|
|
386
|
+
pkgs: Sequence[str],
|
|
387
|
+
*,
|
|
388
|
+
prefix: str | None = None,
|
|
389
|
+
**kwargs,
|
|
390
|
+
) -> JobId:
|
|
391
|
+
"""Uninstall packages in the installer queue.
|
|
392
|
+
|
|
393
|
+
Uninstall packages in `pkgs` from `prefix` using `tool`.
|
|
394
|
+
|
|
395
|
+
Parameters
|
|
396
|
+
----------
|
|
397
|
+
tool : InstallerTools
|
|
398
|
+
Which type of installation tool to use.
|
|
399
|
+
pkgs : Sequence[str]
|
|
400
|
+
List of packages to uninstall.
|
|
401
|
+
prefix : Optional[str], optional
|
|
402
|
+
Optional prefix from which to uninstall packages.
|
|
403
|
+
|
|
404
|
+
Returns
|
|
405
|
+
-------
|
|
406
|
+
JobId : int
|
|
407
|
+
An ID to reference the job. Use to cancel the process.
|
|
408
|
+
"""
|
|
409
|
+
item = self._build_queue_item(
|
|
410
|
+
tool=tool,
|
|
411
|
+
action=InstallerActions.UNINSTALL,
|
|
412
|
+
pkgs=pkgs,
|
|
413
|
+
prefix=prefix,
|
|
414
|
+
process=self._create_process(),
|
|
415
|
+
**kwargs,
|
|
416
|
+
)
|
|
417
|
+
return self._queue_item(item)
|
|
418
|
+
|
|
419
|
+
def cancel(self, job_id: JobId):
|
|
420
|
+
"""Cancel a job.
|
|
421
|
+
|
|
422
|
+
Cancel the process, if it is running, referenced by `job_id`.
|
|
423
|
+
If `job_id` does not exist in the queue, a ValueError is raised.
|
|
424
|
+
|
|
425
|
+
Parameters
|
|
426
|
+
----------
|
|
427
|
+
job_id : JobId
|
|
428
|
+
Job ID to cancel.
|
|
429
|
+
"""
|
|
430
|
+
for i, item in enumerate(deque(self._queue)):
|
|
431
|
+
if item.ident == job_id:
|
|
432
|
+
if i == 0:
|
|
433
|
+
# first in queue, currently running
|
|
434
|
+
self._queue.remove(item)
|
|
435
|
+
|
|
436
|
+
with contextlib.suppress(RuntimeError):
|
|
437
|
+
item.process.finished.disconnect(
|
|
438
|
+
self._on_process_finished
|
|
439
|
+
)
|
|
440
|
+
item.process.errorOccurred.disconnect(
|
|
441
|
+
self._on_error_occurred
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
self._end_process(item.process)
|
|
445
|
+
else:
|
|
446
|
+
# job is still pending, just remove it from the queue
|
|
447
|
+
self._queue.remove(item)
|
|
448
|
+
|
|
449
|
+
self.processFinished.emit(
|
|
450
|
+
{
|
|
451
|
+
'exit_code': 1,
|
|
452
|
+
'exit_status': 0,
|
|
453
|
+
'action': InstallerActions.CANCEL,
|
|
454
|
+
'pkgs': item.pkgs,
|
|
455
|
+
}
|
|
456
|
+
)
|
|
457
|
+
# continue processing the queue
|
|
458
|
+
self._process_queue()
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
msg = f"No job with id {job_id}. Current queue:\n - "
|
|
462
|
+
msg += "\n - ".join(
|
|
463
|
+
[
|
|
464
|
+
f"{item.ident} -> {item.executable()} {item.arguments()}"
|
|
465
|
+
for item in self._queue
|
|
466
|
+
]
|
|
467
|
+
)
|
|
468
|
+
raise ValueError(msg)
|
|
469
|
+
|
|
470
|
+
def cancel_all(self):
|
|
471
|
+
"""Terminate all processes in the queue and emit the `processFinished` signal."""
|
|
472
|
+
all_pkgs = []
|
|
473
|
+
for item in deque(self._queue):
|
|
474
|
+
all_pkgs.extend(item.pkgs)
|
|
475
|
+
process = item.process
|
|
476
|
+
|
|
477
|
+
with contextlib.suppress(RuntimeError):
|
|
478
|
+
process.finished.disconnect(self._on_process_finished)
|
|
479
|
+
process.errorOccurred.disconnect(self._on_error_occurred)
|
|
480
|
+
|
|
481
|
+
self._end_process(process)
|
|
482
|
+
|
|
483
|
+
self._queue.clear()
|
|
484
|
+
self._current_process = None
|
|
485
|
+
self.processFinished.emit(
|
|
486
|
+
{
|
|
487
|
+
'exit_code': 1,
|
|
488
|
+
'exit_status': 0,
|
|
489
|
+
'action': InstallerActions.CANCEL_ALL,
|
|
490
|
+
'pkgs': all_pkgs,
|
|
491
|
+
}
|
|
492
|
+
)
|
|
493
|
+
self._process_queue()
|
|
494
|
+
return
|
|
495
|
+
|
|
496
|
+
def waitForFinished(self, msecs: int = 10000) -> bool:
|
|
497
|
+
"""Block and wait for all jobs to finish.
|
|
498
|
+
|
|
499
|
+
Parameters
|
|
500
|
+
----------
|
|
501
|
+
msecs : int, optional
|
|
502
|
+
Time to wait, by default 10000
|
|
503
|
+
"""
|
|
504
|
+
while self.hasJobs():
|
|
505
|
+
if self._current_process is not None:
|
|
506
|
+
self._current_process.waitForFinished(msecs)
|
|
507
|
+
return True
|
|
508
|
+
|
|
509
|
+
def hasJobs(self) -> bool:
|
|
510
|
+
"""True if there are jobs remaining in the queue."""
|
|
511
|
+
return bool(self._queue)
|
|
512
|
+
|
|
513
|
+
def currentJobs(self) -> int:
|
|
514
|
+
"""Return the number of running jobs in the queue."""
|
|
515
|
+
return len(self._queue)
|
|
516
|
+
|
|
517
|
+
def set_output_widget(self, output_widget: QTextEdit):
|
|
518
|
+
"""Set the output widget for text output."""
|
|
519
|
+
if output_widget:
|
|
520
|
+
self._output_widget = output_widget
|
|
521
|
+
|
|
522
|
+
# -------------------------- Private methods ------------------------------
|
|
523
|
+
def _create_process(self) -> QProcess:
|
|
524
|
+
process = QProcess(self)
|
|
525
|
+
process.setProcessChannelMode(QProcess.MergedChannels)
|
|
526
|
+
process.readyReadStandardOutput.connect(self._on_stdout_ready)
|
|
527
|
+
process.readyReadStandardError.connect(self._on_stderr_ready)
|
|
528
|
+
process.finished.connect(self._on_process_finished)
|
|
529
|
+
process.errorOccurred.connect(self._on_error_occurred)
|
|
530
|
+
return process
|
|
531
|
+
|
|
532
|
+
def _log(self, msg: str):
|
|
533
|
+
log.debug(msg)
|
|
534
|
+
if self._output_widget:
|
|
535
|
+
self._output_widget.append(msg)
|
|
536
|
+
|
|
537
|
+
def _get_tool(self, tool: InstallerTools):
|
|
538
|
+
if tool == InstallerTools.PIP:
|
|
539
|
+
return self.PIP_INSTALLER_TOOL_CLASS
|
|
540
|
+
if tool == InstallerTools.CONDA:
|
|
541
|
+
return self.CONDA_INSTALLER_TOOL_CLASS
|
|
542
|
+
raise ValueError(f"InstallerTool {tool} not recognized!")
|
|
543
|
+
|
|
544
|
+
def _build_queue_item(
|
|
545
|
+
self,
|
|
546
|
+
tool: InstallerTools,
|
|
547
|
+
action: InstallerActions,
|
|
548
|
+
pkgs: Sequence[str],
|
|
549
|
+
prefix: str | None = None,
|
|
550
|
+
origins: Sequence[str] = (),
|
|
551
|
+
**kwargs,
|
|
552
|
+
) -> AbstractInstallerTool:
|
|
553
|
+
return self._get_tool(tool)(
|
|
554
|
+
pkgs=pkgs,
|
|
555
|
+
action=action,
|
|
556
|
+
origins=origins,
|
|
557
|
+
prefix=prefix or self._prefix,
|
|
558
|
+
**kwargs,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
def _queue_item(self, item: AbstractInstallerTool) -> JobId:
|
|
562
|
+
self._queue.append(item)
|
|
563
|
+
self._process_queue()
|
|
564
|
+
return item.ident
|
|
565
|
+
|
|
566
|
+
def _process_queue(self):
|
|
567
|
+
if not self._queue:
|
|
568
|
+
self.allFinished.emit(tuple(self._exit_codes))
|
|
569
|
+
self._exit_codes = []
|
|
570
|
+
return
|
|
571
|
+
|
|
572
|
+
tool = self._queue[0]
|
|
573
|
+
process = tool.process
|
|
574
|
+
|
|
575
|
+
if process.state() != QProcess.Running:
|
|
576
|
+
process.setProgram(str(tool.executable()))
|
|
577
|
+
process.setProcessEnvironment(tool.environment())
|
|
578
|
+
process.setArguments([str(arg) for arg in tool.arguments()])
|
|
579
|
+
process.started.connect(self.started)
|
|
580
|
+
|
|
581
|
+
self._log(
|
|
582
|
+
trans._(
|
|
583
|
+
"Starting '{program}' with args {args}",
|
|
584
|
+
program=process.program(),
|
|
585
|
+
args=process.arguments(),
|
|
586
|
+
)
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
process.start()
|
|
590
|
+
self._current_process = process
|
|
591
|
+
|
|
592
|
+
def _end_process(self, process: QProcess):
|
|
593
|
+
if os.name == 'nt':
|
|
594
|
+
# TODO: this might be too agressive and won't allow rollbacks!
|
|
595
|
+
# investigate whether we can also do .terminate()
|
|
596
|
+
process.kill()
|
|
597
|
+
else:
|
|
598
|
+
process.terminate()
|
|
599
|
+
|
|
600
|
+
if self._output_widget:
|
|
601
|
+
self._output_widget.append(
|
|
602
|
+
trans._("\nTask was cancelled by the user.")
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
def _on_process_finished(
|
|
606
|
+
self, exit_code: int, exit_status: QProcess.ExitStatus
|
|
607
|
+
):
|
|
608
|
+
try:
|
|
609
|
+
current = self._queue[0]
|
|
610
|
+
except IndexError:
|
|
611
|
+
current = None
|
|
612
|
+
if (
|
|
613
|
+
current
|
|
614
|
+
and current.action == InstallerActions.UNINSTALL
|
|
615
|
+
and exit_status == QProcess.ExitStatus.NormalExit
|
|
616
|
+
and exit_code == 0
|
|
617
|
+
):
|
|
618
|
+
pm2 = PluginManager.instance()
|
|
619
|
+
npe1_plugins = set(plugin_manager.iter_available())
|
|
620
|
+
for pkg in current.pkgs:
|
|
621
|
+
if pkg in pm2:
|
|
622
|
+
pm2.unregister(pkg)
|
|
623
|
+
elif pkg in npe1_plugins:
|
|
624
|
+
plugin_manager.unregister(pkg)
|
|
625
|
+
else:
|
|
626
|
+
log.warning(
|
|
627
|
+
'Cannot unregister %s, not a known %s plugin.',
|
|
628
|
+
pkg,
|
|
629
|
+
self.BASE_PACKAGE_NAME,
|
|
630
|
+
)
|
|
631
|
+
self._on_process_done(exit_code=exit_code, exit_status=exit_status)
|
|
632
|
+
|
|
633
|
+
def _on_error_occurred(self, error: QProcess.ProcessError):
|
|
634
|
+
self._on_process_done(error=error)
|
|
635
|
+
|
|
636
|
+
def _on_process_done(
|
|
637
|
+
self,
|
|
638
|
+
exit_code: int | None = None,
|
|
639
|
+
exit_status: QProcess.ExitStatus | None = None,
|
|
640
|
+
error: QProcess.ProcessError | None = None,
|
|
641
|
+
):
|
|
642
|
+
item = None
|
|
643
|
+
with contextlib.suppress(IndexError):
|
|
644
|
+
item = self._queue.popleft()
|
|
645
|
+
|
|
646
|
+
if error:
|
|
647
|
+
msg = trans._(
|
|
648
|
+
"Task finished with errors! Error: {error}.", error=error
|
|
649
|
+
)
|
|
650
|
+
else:
|
|
651
|
+
msg = trans._(
|
|
652
|
+
"Task finished with exit code {exit_code} with status {exit_status}.",
|
|
653
|
+
exit_code=exit_code,
|
|
654
|
+
exit_status=exit_status,
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
if item is not None:
|
|
658
|
+
self.processFinished.emit(
|
|
659
|
+
{
|
|
660
|
+
'exit_code': exit_code,
|
|
661
|
+
'exit_status': exit_status,
|
|
662
|
+
'action': item.action,
|
|
663
|
+
'pkgs': item.pkgs,
|
|
664
|
+
}
|
|
665
|
+
)
|
|
666
|
+
self._exit_codes.append(exit_code)
|
|
667
|
+
|
|
668
|
+
self._log(msg)
|
|
669
|
+
self._process_queue()
|
|
670
|
+
|
|
671
|
+
def _on_stdout_ready(self):
|
|
672
|
+
if self._current_process is not None:
|
|
673
|
+
try:
|
|
674
|
+
text = (
|
|
675
|
+
self._current_process.readAllStandardOutput()
|
|
676
|
+
.data()
|
|
677
|
+
.decode()
|
|
678
|
+
)
|
|
679
|
+
except UnicodeDecodeError:
|
|
680
|
+
logging.exception("Could not decode stdout")
|
|
681
|
+
return
|
|
682
|
+
if text:
|
|
683
|
+
self._log(text)
|
|
684
|
+
|
|
685
|
+
def _on_stderr_ready(self):
|
|
686
|
+
if self._current_process is not None:
|
|
687
|
+
text = self._current_process.readAllStandardError().data().decode()
|
|
688
|
+
if text:
|
|
689
|
+
self._log(text)
|