napari-plugin-manager 0.1.0a0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- napari_plugin_manager/__init__.py +0 -0
- napari_plugin_manager/_tests/__init__.py +0 -0
- napari_plugin_manager/_tests/conftest.py +18 -0
- napari_plugin_manager/_tests/test_installer_process.py +226 -0
- napari_plugin_manager/_tests/test_qt_plugin_dialog.py +374 -0
- napari_plugin_manager/_version.py +4 -0
- napari_plugin_manager/qt_package_installer.py +570 -0
- napari_plugin_manager/qt_plugin_dialog.py +1086 -0
- napari_plugin_manager-0.1.0a0.dist-info/LICENSE +29 -0
- napari_plugin_manager-0.1.0a0.dist-info/METADATA +108 -0
- napari_plugin_manager-0.1.0a0.dist-info/RECORD +13 -0
- napari_plugin_manager-0.1.0a0.dist-info/WHEEL +5 -0
- napari_plugin_manager-0.1.0a0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A tool-agnostic installation logic for the plugin manager.
|
|
3
|
+
|
|
4
|
+
The main object is `InstallerQueue`, a `QProcess` subclass
|
|
5
|
+
with the notion of a job queue. The queued jobs are represented
|
|
6
|
+
by a `deque` of `*InstallerTool` dataclasses that contain the
|
|
7
|
+
executable path, arguments and environment modifications.
|
|
8
|
+
Available actions for each tool are `install`, `uninstall`
|
|
9
|
+
and `cancel`.
|
|
10
|
+
"""
|
|
11
|
+
import atexit
|
|
12
|
+
import contextlib
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from collections import deque
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from enum import auto
|
|
18
|
+
from functools import lru_cache
|
|
19
|
+
from logging import getLogger
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from subprocess import call
|
|
22
|
+
from tempfile import gettempdir, mkstemp
|
|
23
|
+
from typing import Deque, Optional, Sequence, Tuple
|
|
24
|
+
|
|
25
|
+
from napari._version import version as _napari_version
|
|
26
|
+
from napari._version import version_tuple as _napari_version_tuple
|
|
27
|
+
from napari.plugins import plugin_manager
|
|
28
|
+
from napari.plugins.npe2api import _user_agent
|
|
29
|
+
from napari.utils._appdirs import user_plugin_dir, user_site_packages
|
|
30
|
+
from napari.utils.misc import StringEnum, running_as_bundled_app
|
|
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
|
+
JobId = int
|
|
37
|
+
log = getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class InstallerActions(StringEnum):
|
|
41
|
+
"Available actions for the plugin manager"
|
|
42
|
+
INSTALL = auto()
|
|
43
|
+
UNINSTALL = auto()
|
|
44
|
+
CANCEL = auto()
|
|
45
|
+
UPGRADE = auto()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class InstallerTools(StringEnum):
|
|
49
|
+
"Available tools for InstallerQueue jobs"
|
|
50
|
+
CONDA = auto()
|
|
51
|
+
PIP = auto()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class AbstractInstallerTool:
|
|
56
|
+
action: InstallerActions
|
|
57
|
+
pkgs: Tuple[str, ...]
|
|
58
|
+
origins: Tuple[str, ...] = ()
|
|
59
|
+
prefix: Optional[str] = None
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def ident(self):
|
|
63
|
+
return hash((self.action, *self.pkgs, *self.origins, self.prefix))
|
|
64
|
+
|
|
65
|
+
# abstract method
|
|
66
|
+
@classmethod
|
|
67
|
+
def executable(cls):
|
|
68
|
+
"Path to the executable that will run the task"
|
|
69
|
+
raise NotImplementedError
|
|
70
|
+
|
|
71
|
+
# abstract method
|
|
72
|
+
def arguments(self):
|
|
73
|
+
"Arguments supplied to the executable"
|
|
74
|
+
raise NotImplementedError
|
|
75
|
+
|
|
76
|
+
# abstract method
|
|
77
|
+
def environment(
|
|
78
|
+
self, env: QProcessEnvironment = None
|
|
79
|
+
) -> QProcessEnvironment:
|
|
80
|
+
"Changes needed in the environment variables."
|
|
81
|
+
raise NotImplementedError
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def constraints() -> Sequence[str]:
|
|
85
|
+
"""
|
|
86
|
+
Version constraints to limit unwanted changes in installation.
|
|
87
|
+
"""
|
|
88
|
+
return [f"napari=={_napari_version}"]
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def available(cls) -> bool:
|
|
92
|
+
"""
|
|
93
|
+
Check if the tool is available by performing a little test
|
|
94
|
+
"""
|
|
95
|
+
raise NotImplementedError
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class PipInstallerTool(AbstractInstallerTool):
|
|
99
|
+
@classmethod
|
|
100
|
+
def executable(cls):
|
|
101
|
+
return str(_get_python_exe())
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def available(cls):
|
|
105
|
+
return call([cls.executable(), "-m", "pip", "--version"]) == 0
|
|
106
|
+
|
|
107
|
+
def arguments(self) -> Tuple[str, ...]:
|
|
108
|
+
args = ['-m', 'pip']
|
|
109
|
+
if self.action == InstallerActions.INSTALL:
|
|
110
|
+
args += ['install', '-c', self._constraints_file()]
|
|
111
|
+
for origin in self.origins:
|
|
112
|
+
args += ['--extra-index-url', origin]
|
|
113
|
+
elif self.action == InstallerActions.UPGRADE:
|
|
114
|
+
args += [
|
|
115
|
+
'install',
|
|
116
|
+
'--upgrade',
|
|
117
|
+
'-c',
|
|
118
|
+
self._constraints_file(),
|
|
119
|
+
]
|
|
120
|
+
for origin in self.origins:
|
|
121
|
+
args += ['--extra-index-url', origin]
|
|
122
|
+
elif self.action == InstallerActions.UNINSTALL:
|
|
123
|
+
args += ['uninstall', '-y']
|
|
124
|
+
else:
|
|
125
|
+
raise ValueError(f"Action '{self.action}' not supported!")
|
|
126
|
+
if 10 <= log.getEffectiveLevel() < 30: # DEBUG level
|
|
127
|
+
args.append('-vvv')
|
|
128
|
+
if self.prefix is not None:
|
|
129
|
+
args.extend(['--prefix', str(self.prefix)])
|
|
130
|
+
elif running_as_bundled_app(
|
|
131
|
+
check_conda=False
|
|
132
|
+
) and sys.platform.startswith('linux'):
|
|
133
|
+
args += [
|
|
134
|
+
'--no-warn-script-location',
|
|
135
|
+
'--prefix',
|
|
136
|
+
user_plugin_dir(),
|
|
137
|
+
]
|
|
138
|
+
return (*args, *self.pkgs)
|
|
139
|
+
|
|
140
|
+
def environment(
|
|
141
|
+
self, env: QProcessEnvironment = None
|
|
142
|
+
) -> QProcessEnvironment:
|
|
143
|
+
if env is None:
|
|
144
|
+
env = QProcessEnvironment.systemEnvironment()
|
|
145
|
+
combined_paths = os.pathsep.join(
|
|
146
|
+
[
|
|
147
|
+
user_site_packages(),
|
|
148
|
+
env.systemEnvironment().value("PYTHONPATH"),
|
|
149
|
+
]
|
|
150
|
+
)
|
|
151
|
+
env.insert("PYTHONPATH", combined_paths)
|
|
152
|
+
env.insert("PIP_USER_AGENT_USER_DATA", _user_agent())
|
|
153
|
+
return env
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
@lru_cache(maxsize=0)
|
|
157
|
+
def _constraints_file(cls) -> str:
|
|
158
|
+
_, path = mkstemp("-napari-constraints.txt", text=True)
|
|
159
|
+
with open(path, "w") as f:
|
|
160
|
+
f.write("\n".join(cls.constraints()))
|
|
161
|
+
atexit.register(os.unlink, path)
|
|
162
|
+
return path
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class CondaInstallerTool(AbstractInstallerTool):
|
|
166
|
+
@classmethod
|
|
167
|
+
def executable(cls):
|
|
168
|
+
bat = ".bat" if os.name == "nt" else ""
|
|
169
|
+
for path in (
|
|
170
|
+
Path(os.environ.get('MAMBA_EXE', '')),
|
|
171
|
+
Path(os.environ.get('CONDA_EXE', '')),
|
|
172
|
+
# $CONDA is usually only available on GitHub Actions
|
|
173
|
+
Path(os.environ.get('CONDA', '')) / 'condabin' / f'conda{bat}',
|
|
174
|
+
):
|
|
175
|
+
if path.is_file():
|
|
176
|
+
return str(path)
|
|
177
|
+
return f'conda{bat}' # cross our fingers 'conda' is in PATH
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def available(cls):
|
|
181
|
+
executable = cls.executable()
|
|
182
|
+
try:
|
|
183
|
+
return call([executable, "--version"]) == 0
|
|
184
|
+
except FileNotFoundError: # pragma: no cover
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
def arguments(self) -> Tuple[str, ...]:
|
|
188
|
+
prefix = self.prefix or self._default_prefix()
|
|
189
|
+
if self.action == InstallerActions.UPGRADE:
|
|
190
|
+
args = ['update', '-y', '--prefix', prefix]
|
|
191
|
+
else:
|
|
192
|
+
args = [self.action.value, '-y', '--prefix', prefix]
|
|
193
|
+
args.append('--override-channels')
|
|
194
|
+
for channel in (*self.origins, *self._default_channels()):
|
|
195
|
+
args.extend(["-c", channel])
|
|
196
|
+
return (*args, *self.pkgs)
|
|
197
|
+
|
|
198
|
+
def environment(
|
|
199
|
+
self, env: QProcessEnvironment = None
|
|
200
|
+
) -> QProcessEnvironment:
|
|
201
|
+
if env is None:
|
|
202
|
+
env = QProcessEnvironment.systemEnvironment()
|
|
203
|
+
self._add_constraints_to_env(env)
|
|
204
|
+
if 10 <= log.getEffectiveLevel() < 30: # DEBUG level
|
|
205
|
+
env.insert('CONDA_VERBOSITY', '3')
|
|
206
|
+
if os.name == "nt":
|
|
207
|
+
if not env.contains("TEMP"):
|
|
208
|
+
temp = gettempdir()
|
|
209
|
+
env.insert("TMP", temp)
|
|
210
|
+
env.insert("TEMP", temp)
|
|
211
|
+
if not env.contains("USERPROFILE"):
|
|
212
|
+
env.insert("HOME", os.path.expanduser("~"))
|
|
213
|
+
env.insert("USERPROFILE", os.path.expanduser("~"))
|
|
214
|
+
if sys.platform == 'darwin' and env.contains('PYTHONEXECUTABLE'):
|
|
215
|
+
# Fix for macOS when napari launched from terminal
|
|
216
|
+
# related to https://github.com/napari/napari/pull/5531
|
|
217
|
+
env.remove("PYTHONEXECUTABLE")
|
|
218
|
+
return env
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def constraints() -> Sequence[str]:
|
|
222
|
+
# FIXME
|
|
223
|
+
# dev or rc versions might not be available in public channels
|
|
224
|
+
# but only installed locally - if we try to pin those, mamba
|
|
225
|
+
# will fail to pin it because there's no record of that version
|
|
226
|
+
# in the remote index, only locally; to work around this bug
|
|
227
|
+
# we will have to pin to e.g. 0.4.* instead of 0.4.17.* for now
|
|
228
|
+
version_lower = _napari_version.lower()
|
|
229
|
+
is_dev = "rc" in version_lower or "dev" in version_lower
|
|
230
|
+
pin_level = 2 if is_dev else 3
|
|
231
|
+
version = ".".join([str(x) for x in _napari_version_tuple[:pin_level]])
|
|
232
|
+
|
|
233
|
+
return [f"napari={version}"]
|
|
234
|
+
|
|
235
|
+
def _add_constraints_to_env(
|
|
236
|
+
self, env: QProcessEnvironment
|
|
237
|
+
) -> QProcessEnvironment:
|
|
238
|
+
PINNED = 'CONDA_PINNED_PACKAGES'
|
|
239
|
+
constraints = self.constraints()
|
|
240
|
+
if env.contains(PINNED):
|
|
241
|
+
constraints.append(env.value(PINNED))
|
|
242
|
+
env.insert(PINNED, "&".join(constraints))
|
|
243
|
+
return env
|
|
244
|
+
|
|
245
|
+
def _default_channels(self):
|
|
246
|
+
return ('conda-forge',)
|
|
247
|
+
|
|
248
|
+
def _default_prefix(self):
|
|
249
|
+
if (Path(sys.prefix) / "conda-meta").is_dir():
|
|
250
|
+
return sys.prefix
|
|
251
|
+
raise ValueError("Prefix has not been specified!")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class InstallerQueue(QProcess):
|
|
255
|
+
"""Queue for installation and uninstallation tasks in the plugin manager."""
|
|
256
|
+
|
|
257
|
+
# emitted when all jobs are finished
|
|
258
|
+
# not to be confused with finished, which is emitted when each job is finished
|
|
259
|
+
allFinished = Signal()
|
|
260
|
+
|
|
261
|
+
def __init__(self, parent: Optional[QObject] = None) -> None:
|
|
262
|
+
super().__init__(parent)
|
|
263
|
+
self._queue: Deque[AbstractInstallerTool] = deque()
|
|
264
|
+
self._output_widget = None
|
|
265
|
+
|
|
266
|
+
self.setProcessChannelMode(QProcess.MergedChannels)
|
|
267
|
+
self.readyReadStandardOutput.connect(self._on_stdout_ready)
|
|
268
|
+
self.readyReadStandardError.connect(self._on_stderr_ready)
|
|
269
|
+
|
|
270
|
+
self.finished.connect(self._on_process_finished)
|
|
271
|
+
self.errorOccurred.connect(self._on_error_occurred)
|
|
272
|
+
|
|
273
|
+
# -------------------------- Public API ------------------------------
|
|
274
|
+
def install(
|
|
275
|
+
self,
|
|
276
|
+
tool: InstallerTools,
|
|
277
|
+
pkgs: Sequence[str],
|
|
278
|
+
*,
|
|
279
|
+
prefix: Optional[str] = None,
|
|
280
|
+
origins: Sequence[str] = (),
|
|
281
|
+
**kwargs,
|
|
282
|
+
) -> JobId:
|
|
283
|
+
"""Install packages in `pkgs` into `prefix` using `tool` with additional
|
|
284
|
+
`origins` as source for `pkgs`.
|
|
285
|
+
|
|
286
|
+
Parameters
|
|
287
|
+
----------
|
|
288
|
+
tool : InstallerTools
|
|
289
|
+
Which type of installation tool to use.
|
|
290
|
+
pkgs : Sequence[str]
|
|
291
|
+
List of packages to install.
|
|
292
|
+
prefix : Optional[str], optional
|
|
293
|
+
Optional prefix to install packages into.
|
|
294
|
+
origins : Optional[Sequence[str]], optional
|
|
295
|
+
Additional sources for packages to be downloaded from.
|
|
296
|
+
|
|
297
|
+
Returns
|
|
298
|
+
-------
|
|
299
|
+
JobId : int
|
|
300
|
+
ID that can be used to cancel the process.
|
|
301
|
+
"""
|
|
302
|
+
item = self._build_queue_item(
|
|
303
|
+
tool=tool,
|
|
304
|
+
action=InstallerActions.INSTALL,
|
|
305
|
+
pkgs=pkgs,
|
|
306
|
+
prefix=prefix,
|
|
307
|
+
origins=origins,
|
|
308
|
+
**kwargs,
|
|
309
|
+
)
|
|
310
|
+
return self._queue_item(item)
|
|
311
|
+
|
|
312
|
+
def upgrade(
|
|
313
|
+
self,
|
|
314
|
+
tool: InstallerTools,
|
|
315
|
+
pkgs: Sequence[str],
|
|
316
|
+
*,
|
|
317
|
+
prefix: Optional[str] = None,
|
|
318
|
+
origins: Sequence[str] = (),
|
|
319
|
+
**kwargs,
|
|
320
|
+
) -> JobId:
|
|
321
|
+
"""Upgrade packages in `pkgs` into `prefix` using `tool` with additional
|
|
322
|
+
`origins` as source for `pkgs`.
|
|
323
|
+
|
|
324
|
+
Parameters
|
|
325
|
+
----------
|
|
326
|
+
tool : InstallerTools
|
|
327
|
+
Which type of installation tool to use.
|
|
328
|
+
pkgs : Sequence[str]
|
|
329
|
+
List of packages to install.
|
|
330
|
+
prefix : Optional[str], optional
|
|
331
|
+
Optional prefix to install packages into.
|
|
332
|
+
origins : Optional[Sequence[str]], optional
|
|
333
|
+
Additional sources for packages to be downloaded from.
|
|
334
|
+
|
|
335
|
+
Returns
|
|
336
|
+
-------
|
|
337
|
+
JobId : int
|
|
338
|
+
ID that can be used to cancel the process.
|
|
339
|
+
"""
|
|
340
|
+
item = self._build_queue_item(
|
|
341
|
+
tool=tool,
|
|
342
|
+
action=InstallerActions.UPGRADE,
|
|
343
|
+
pkgs=pkgs,
|
|
344
|
+
prefix=prefix,
|
|
345
|
+
origins=origins,
|
|
346
|
+
**kwargs,
|
|
347
|
+
)
|
|
348
|
+
return self._queue_item(item)
|
|
349
|
+
|
|
350
|
+
def uninstall(
|
|
351
|
+
self,
|
|
352
|
+
tool: InstallerTools,
|
|
353
|
+
pkgs: Sequence[str],
|
|
354
|
+
*,
|
|
355
|
+
prefix: Optional[str] = None,
|
|
356
|
+
**kwargs,
|
|
357
|
+
) -> JobId:
|
|
358
|
+
"""Uninstall packages in `pkgs` from `prefix` using `tool`.
|
|
359
|
+
|
|
360
|
+
Parameters
|
|
361
|
+
----------
|
|
362
|
+
tool : InstallerTools
|
|
363
|
+
Which type of installation tool to use.
|
|
364
|
+
pkgs : Sequence[str]
|
|
365
|
+
List of packages to uninstall.
|
|
366
|
+
prefix : Optional[str], optional
|
|
367
|
+
Optional prefix from which to uninstall packages.
|
|
368
|
+
|
|
369
|
+
Returns
|
|
370
|
+
-------
|
|
371
|
+
JobId : int
|
|
372
|
+
ID that can be used to cancel the process.
|
|
373
|
+
"""
|
|
374
|
+
item = self._build_queue_item(
|
|
375
|
+
tool=tool,
|
|
376
|
+
action=InstallerActions.UNINSTALL,
|
|
377
|
+
pkgs=pkgs,
|
|
378
|
+
prefix=prefix,
|
|
379
|
+
**kwargs,
|
|
380
|
+
)
|
|
381
|
+
return self._queue_item(item)
|
|
382
|
+
|
|
383
|
+
def cancel(self, job_id: Optional[JobId] = None):
|
|
384
|
+
"""Cancel `job_id` if it is running.
|
|
385
|
+
|
|
386
|
+
Parameters
|
|
387
|
+
----------
|
|
388
|
+
job_id : Optional[JobId], optional
|
|
389
|
+
Job ID to cancel. If not provided, cancel all jobs.
|
|
390
|
+
"""
|
|
391
|
+
if job_id is None:
|
|
392
|
+
# cancel all jobs
|
|
393
|
+
self._queue.clear()
|
|
394
|
+
self._end_process()
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
for i, item in enumerate(self._queue):
|
|
398
|
+
if item.ident == job_id:
|
|
399
|
+
if i == 0: # first in queue, currently running
|
|
400
|
+
self._end_process()
|
|
401
|
+
else: # still pending, just remove from queue
|
|
402
|
+
self._queue.remove(item)
|
|
403
|
+
return
|
|
404
|
+
msg = f"No job with id {job_id}. Current queue:\n - "
|
|
405
|
+
msg += "\n - ".join(
|
|
406
|
+
[
|
|
407
|
+
f"{item.ident} -> {item.executable()} {item.arguments()}"
|
|
408
|
+
for item in self._queue
|
|
409
|
+
]
|
|
410
|
+
)
|
|
411
|
+
raise ValueError(msg)
|
|
412
|
+
|
|
413
|
+
def waitForFinished(self, msecs: int = 10000) -> bool:
|
|
414
|
+
"""Block and wait for all jobs to finish.
|
|
415
|
+
|
|
416
|
+
Parameters
|
|
417
|
+
----------
|
|
418
|
+
msecs : int, optional
|
|
419
|
+
Time to wait, by default 10000
|
|
420
|
+
"""
|
|
421
|
+
while self.hasJobs():
|
|
422
|
+
super().waitForFinished(msecs)
|
|
423
|
+
return True
|
|
424
|
+
|
|
425
|
+
def hasJobs(self) -> bool:
|
|
426
|
+
"""True if there are jobs remaining in the queue."""
|
|
427
|
+
return bool(self._queue)
|
|
428
|
+
|
|
429
|
+
def set_output_widget(self, output_widget: QTextEdit):
|
|
430
|
+
if output_widget:
|
|
431
|
+
self._output_widget = output_widget
|
|
432
|
+
|
|
433
|
+
# -------------------------- Private methods ------------------------------
|
|
434
|
+
def _log(self, msg: str):
|
|
435
|
+
log.debug(msg)
|
|
436
|
+
if self._output_widget:
|
|
437
|
+
self._output_widget.append(msg)
|
|
438
|
+
|
|
439
|
+
def _get_tool(self, tool: InstallerTools):
|
|
440
|
+
if tool == InstallerTools.PIP:
|
|
441
|
+
return PipInstallerTool
|
|
442
|
+
if tool == InstallerTools.CONDA:
|
|
443
|
+
return CondaInstallerTool
|
|
444
|
+
raise ValueError(f"InstallerTool {tool} not recognized!")
|
|
445
|
+
|
|
446
|
+
def _build_queue_item(
|
|
447
|
+
self,
|
|
448
|
+
tool: InstallerTools,
|
|
449
|
+
action: InstallerActions,
|
|
450
|
+
pkgs: Sequence[str],
|
|
451
|
+
prefix: Optional[str] = None,
|
|
452
|
+
origins: Sequence[str] = (),
|
|
453
|
+
**kwargs,
|
|
454
|
+
) -> AbstractInstallerTool:
|
|
455
|
+
return self._get_tool(tool)(
|
|
456
|
+
pkgs=pkgs,
|
|
457
|
+
action=action,
|
|
458
|
+
origins=origins,
|
|
459
|
+
prefix=prefix,
|
|
460
|
+
**kwargs,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
def _queue_item(self, item: AbstractInstallerTool) -> JobId:
|
|
464
|
+
self._queue.append(item)
|
|
465
|
+
self._process_queue()
|
|
466
|
+
return item.ident
|
|
467
|
+
|
|
468
|
+
def _process_queue(self):
|
|
469
|
+
if not self._queue:
|
|
470
|
+
self.allFinished.emit()
|
|
471
|
+
return
|
|
472
|
+
tool = self._queue[0]
|
|
473
|
+
self.setProgram(str(tool.executable()))
|
|
474
|
+
self.setProcessEnvironment(tool.environment())
|
|
475
|
+
self.setArguments([str(arg) for arg in tool.arguments()])
|
|
476
|
+
# this might throw a warning because the same process
|
|
477
|
+
# was already running but it's ok
|
|
478
|
+
self._log(
|
|
479
|
+
trans._(
|
|
480
|
+
"Starting '{program}' with args {args}",
|
|
481
|
+
program=self.program(),
|
|
482
|
+
args=self.arguments(),
|
|
483
|
+
)
|
|
484
|
+
)
|
|
485
|
+
self.start()
|
|
486
|
+
|
|
487
|
+
def _end_process(self):
|
|
488
|
+
if os.name == 'nt':
|
|
489
|
+
# TODO: this might be too agressive and won't allow rollbacks!
|
|
490
|
+
# investigate whether we can also do .terminate()
|
|
491
|
+
self.kill()
|
|
492
|
+
else:
|
|
493
|
+
self.terminate()
|
|
494
|
+
if self._output_widget:
|
|
495
|
+
self._output_widget.append(
|
|
496
|
+
trans._("\nTask was cancelled by the user.")
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
def _on_process_finished(
|
|
500
|
+
self, exit_code: int, exit_status: QProcess.ExitStatus
|
|
501
|
+
):
|
|
502
|
+
try:
|
|
503
|
+
current = self._queue[0]
|
|
504
|
+
except IndexError:
|
|
505
|
+
current = None
|
|
506
|
+
if (
|
|
507
|
+
current
|
|
508
|
+
and current.action == InstallerActions.UNINSTALL
|
|
509
|
+
and exit_status == QProcess.ExitStatus.NormalExit
|
|
510
|
+
and exit_code == 0
|
|
511
|
+
):
|
|
512
|
+
pm2 = PluginManager.instance()
|
|
513
|
+
npe1_plugins = set(plugin_manager.iter_available())
|
|
514
|
+
for pkg in current.pkgs:
|
|
515
|
+
if pkg in pm2:
|
|
516
|
+
pm2.unregister(pkg)
|
|
517
|
+
elif pkg in npe1_plugins:
|
|
518
|
+
plugin_manager.unregister(pkg)
|
|
519
|
+
else:
|
|
520
|
+
log.warning(
|
|
521
|
+
'Cannot unregister %s, not a known napari plugin.', pkg
|
|
522
|
+
)
|
|
523
|
+
self._on_process_done(exit_code=exit_code, exit_status=exit_status)
|
|
524
|
+
|
|
525
|
+
def _on_error_occurred(self, error: QProcess.ProcessError):
|
|
526
|
+
self._on_process_done(error=error)
|
|
527
|
+
|
|
528
|
+
def _on_process_done(
|
|
529
|
+
self,
|
|
530
|
+
exit_code: Optional[int] = None,
|
|
531
|
+
exit_status: Optional[QProcess.ExitStatus] = None,
|
|
532
|
+
error: Optional[QProcess.ProcessError] = None,
|
|
533
|
+
):
|
|
534
|
+
with contextlib.suppress(IndexError):
|
|
535
|
+
self._queue.popleft()
|
|
536
|
+
if error:
|
|
537
|
+
msg = trans._(
|
|
538
|
+
"Task finished with errors! Error: {error}.", error=error
|
|
539
|
+
)
|
|
540
|
+
else:
|
|
541
|
+
msg = trans._(
|
|
542
|
+
"Task finished with exit code {exit_code} with status {exit_status}.",
|
|
543
|
+
exit_code=exit_code,
|
|
544
|
+
exit_status=exit_status,
|
|
545
|
+
)
|
|
546
|
+
self._log(msg)
|
|
547
|
+
self._process_queue()
|
|
548
|
+
|
|
549
|
+
def _on_stdout_ready(self):
|
|
550
|
+
text = self.readAllStandardOutput().data().decode()
|
|
551
|
+
if text:
|
|
552
|
+
self._log(text)
|
|
553
|
+
|
|
554
|
+
def _on_stderr_ready(self):
|
|
555
|
+
text = self.readAllStandardError().data().decode()
|
|
556
|
+
if text:
|
|
557
|
+
self._log(text)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _get_python_exe():
|
|
561
|
+
# Note: is_bundled_app() returns False even if using a Briefcase bundle...
|
|
562
|
+
# Workaround: see if sys.executable is set to something something napari on Mac
|
|
563
|
+
if (
|
|
564
|
+
sys.executable.endswith("napari")
|
|
565
|
+
and sys.platform == 'darwin'
|
|
566
|
+
and (python := Path(sys.prefix) / "bin" / "python3").is_file()
|
|
567
|
+
):
|
|
568
|
+
# sys.prefix should be <napari.app>/Contents/Resources/Support/Python/Resources
|
|
569
|
+
return str(python)
|
|
570
|
+
return sys.executable
|