napari-plugin-manager 0.1.0a2__py3-none-any.whl → 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- napari_plugin_manager/_tests/conftest.py +45 -0
- napari_plugin_manager/_tests/test_installer_process.py +179 -67
- napari_plugin_manager/_tests/test_npe2api.py +54 -0
- napari_plugin_manager/_tests/test_qt_plugin_dialog.py +269 -60
- napari_plugin_manager/_tests/test_utils.py +27 -0
- napari_plugin_manager/_version.py +14 -2
- napari_plugin_manager/npe2api.py +131 -0
- napari_plugin_manager/qt_package_installer.py +166 -60
- napari_plugin_manager/qt_plugin_dialog.py +871 -407
- napari_plugin_manager/qt_widgets.py +14 -0
- napari_plugin_manager/styles.qss +383 -0
- napari_plugin_manager/utils.py +22 -0
- napari_plugin_manager-0.1.2.dist-info/METADATA +257 -0
- napari_plugin_manager-0.1.2.dist-info/RECORD +19 -0
- {napari_plugin_manager-0.1.0a2.dist-info → napari_plugin_manager-0.1.2.dist-info}/WHEEL +1 -1
- napari_plugin_manager-0.1.0a2.dist-info/METADATA +0 -107
- napari_plugin_manager-0.1.0a2.dist-info/RECORD +0 -13
- {napari_plugin_manager-0.1.0a2.dist-info → napari_plugin_manager-0.1.2.dist-info}/LICENSE +0 -0
- {napari_plugin_manager-0.1.0a2.dist-info → napari_plugin_manager-0.1.2.dist-info}/top_level.txt +0 -0
|
@@ -8,6 +8,7 @@ executable path, arguments and environment modifications.
|
|
|
8
8
|
Available actions for each tool are `install`, `uninstall`
|
|
9
9
|
and `cancel`.
|
|
10
10
|
"""
|
|
11
|
+
|
|
11
12
|
import atexit
|
|
12
13
|
import contextlib
|
|
13
14
|
import os
|
|
@@ -19,8 +20,8 @@ from functools import lru_cache
|
|
|
19
20
|
from logging import getLogger
|
|
20
21
|
from pathlib import Path
|
|
21
22
|
from subprocess import call
|
|
22
|
-
from tempfile import
|
|
23
|
-
from typing import Deque, Optional, Sequence, Tuple
|
|
23
|
+
from tempfile import NamedTemporaryFile, gettempdir
|
|
24
|
+
from typing import Deque, Optional, Sequence, Tuple, TypedDict
|
|
24
25
|
|
|
25
26
|
from napari._version import version as _napari_version
|
|
26
27
|
from napari._version import version_tuple as _napari_version_tuple
|
|
@@ -41,9 +42,17 @@ class InstallerActions(StringEnum):
|
|
|
41
42
|
INSTALL = auto()
|
|
42
43
|
UNINSTALL = auto()
|
|
43
44
|
CANCEL = auto()
|
|
45
|
+
CANCEL_ALL = auto()
|
|
44
46
|
UPGRADE = auto()
|
|
45
47
|
|
|
46
48
|
|
|
49
|
+
class ProcessFinishedData(TypedDict):
|
|
50
|
+
exit_code: int
|
|
51
|
+
exit_status: int
|
|
52
|
+
action: InstallerActions
|
|
53
|
+
pkgs: Tuple[str, ...]
|
|
54
|
+
|
|
55
|
+
|
|
47
56
|
class InstallerTools(StringEnum):
|
|
48
57
|
"Available tools for InstallerQueue jobs"
|
|
49
58
|
CONDA = auto()
|
|
@@ -56,10 +65,13 @@ class AbstractInstallerTool:
|
|
|
56
65
|
pkgs: Tuple[str, ...]
|
|
57
66
|
origins: Tuple[str, ...] = ()
|
|
58
67
|
prefix: Optional[str] = None
|
|
68
|
+
process: QProcess = None
|
|
59
69
|
|
|
60
70
|
@property
|
|
61
71
|
def ident(self):
|
|
62
|
-
return hash(
|
|
72
|
+
return hash(
|
|
73
|
+
(self.action, *self.pkgs, *self.origins, self.prefix, self.process)
|
|
74
|
+
)
|
|
63
75
|
|
|
64
76
|
# abstract method
|
|
65
77
|
@classmethod
|
|
@@ -84,7 +96,7 @@ class AbstractInstallerTool:
|
|
|
84
96
|
"""
|
|
85
97
|
Version constraints to limit unwanted changes in installation.
|
|
86
98
|
"""
|
|
87
|
-
return [f"napari=={_napari_version}", "
|
|
99
|
+
return [f"napari=={_napari_version}", "numpy<2"]
|
|
88
100
|
|
|
89
101
|
@classmethod
|
|
90
102
|
def available(cls) -> bool:
|
|
@@ -139,11 +151,12 @@ class PipInstallerTool(AbstractInstallerTool):
|
|
|
139
151
|
@classmethod
|
|
140
152
|
@lru_cache(maxsize=0)
|
|
141
153
|
def _constraints_file(cls) -> str:
|
|
142
|
-
|
|
143
|
-
|
|
154
|
+
with NamedTemporaryFile(
|
|
155
|
+
"w", suffix="-napari-constraints.txt", delete=False
|
|
156
|
+
) as f:
|
|
144
157
|
f.write("\n".join(cls.constraints()))
|
|
145
|
-
atexit.register(os.unlink,
|
|
146
|
-
return
|
|
158
|
+
atexit.register(os.unlink, f.name)
|
|
159
|
+
return f.name
|
|
147
160
|
|
|
148
161
|
|
|
149
162
|
class CondaInstallerTool(AbstractInstallerTool):
|
|
@@ -214,7 +227,7 @@ class CondaInstallerTool(AbstractInstallerTool):
|
|
|
214
227
|
pin_level = 2 if is_dev else 3
|
|
215
228
|
version = ".".join([str(x) for x in _napari_version_tuple[:pin_level]])
|
|
216
229
|
|
|
217
|
-
return [f"napari={version}", "
|
|
230
|
+
return [f"napari={version}", "numpy<2.0a0"]
|
|
218
231
|
|
|
219
232
|
def _add_constraints_to_env(
|
|
220
233
|
self, env: QProcessEnvironment
|
|
@@ -235,24 +248,30 @@ class CondaInstallerTool(AbstractInstallerTool):
|
|
|
235
248
|
raise ValueError("Prefix has not been specified!")
|
|
236
249
|
|
|
237
250
|
|
|
238
|
-
class InstallerQueue(
|
|
251
|
+
class InstallerQueue(QObject):
|
|
239
252
|
"""Queue for installation and uninstallation tasks in the plugin manager."""
|
|
240
253
|
|
|
241
|
-
# emitted when all jobs are finished
|
|
242
|
-
#
|
|
243
|
-
|
|
254
|
+
# emitted when all jobs are finished. Not to be confused with finished,
|
|
255
|
+
# which is emitted when each individual job is finished.
|
|
256
|
+
# Tuple of exit codes for each individual job
|
|
257
|
+
allFinished = Signal(tuple)
|
|
258
|
+
|
|
259
|
+
# emitted when each job finishes
|
|
260
|
+
# dict: ProcessFinishedData
|
|
261
|
+
processFinished = Signal(dict)
|
|
244
262
|
|
|
245
|
-
|
|
263
|
+
# emitted when each job starts
|
|
264
|
+
started = Signal()
|
|
265
|
+
|
|
266
|
+
def __init__(
|
|
267
|
+
self, parent: Optional[QObject] = None, prefix: Optional[str] = None
|
|
268
|
+
) -> None:
|
|
246
269
|
super().__init__(parent)
|
|
247
270
|
self._queue: Deque[AbstractInstallerTool] = deque()
|
|
271
|
+
self._current_process: QProcess = None
|
|
272
|
+
self._prefix = prefix
|
|
248
273
|
self._output_widget = None
|
|
249
|
-
|
|
250
|
-
self.setProcessChannelMode(QProcess.MergedChannels)
|
|
251
|
-
self.readyReadStandardOutput.connect(self._on_stdout_ready)
|
|
252
|
-
self.readyReadStandardError.connect(self._on_stderr_ready)
|
|
253
|
-
|
|
254
|
-
self.finished.connect(self._on_process_finished)
|
|
255
|
-
self.errorOccurred.connect(self._on_error_occurred)
|
|
274
|
+
self._exit_codes = []
|
|
256
275
|
|
|
257
276
|
# -------------------------- Public API ------------------------------
|
|
258
277
|
def install(
|
|
@@ -289,6 +308,7 @@ class InstallerQueue(QProcess):
|
|
|
289
308
|
pkgs=pkgs,
|
|
290
309
|
prefix=prefix,
|
|
291
310
|
origins=origins,
|
|
311
|
+
process=self._create_process(),
|
|
292
312
|
**kwargs,
|
|
293
313
|
)
|
|
294
314
|
return self._queue_item(item)
|
|
@@ -327,6 +347,7 @@ class InstallerQueue(QProcess):
|
|
|
327
347
|
pkgs=pkgs,
|
|
328
348
|
prefix=prefix,
|
|
329
349
|
origins=origins,
|
|
350
|
+
process=self._create_process(),
|
|
330
351
|
**kwargs,
|
|
331
352
|
)
|
|
332
353
|
return self._queue_item(item)
|
|
@@ -360,31 +381,50 @@ class InstallerQueue(QProcess):
|
|
|
360
381
|
action=InstallerActions.UNINSTALL,
|
|
361
382
|
pkgs=pkgs,
|
|
362
383
|
prefix=prefix,
|
|
384
|
+
process=self._create_process(),
|
|
363
385
|
**kwargs,
|
|
364
386
|
)
|
|
365
387
|
return self._queue_item(item)
|
|
366
388
|
|
|
367
|
-
def cancel(self, job_id:
|
|
368
|
-
"""Cancel `job_id` if it is running.
|
|
389
|
+
def cancel(self, job_id: JobId):
|
|
390
|
+
"""Cancel `job_id` if it is running. If `job_id` does not exist int the queue,
|
|
391
|
+
a ValueError is raised.
|
|
369
392
|
|
|
370
393
|
Parameters
|
|
371
394
|
----------
|
|
372
|
-
job_id :
|
|
373
|
-
Job ID to cancel.
|
|
395
|
+
job_id : JobId
|
|
396
|
+
Job ID to cancel.
|
|
374
397
|
"""
|
|
375
|
-
|
|
376
|
-
# cancel all jobs
|
|
377
|
-
self._queue.clear()
|
|
378
|
-
self._end_process()
|
|
379
|
-
return
|
|
380
|
-
|
|
381
|
-
for i, item in enumerate(self._queue):
|
|
398
|
+
for i, item in enumerate(deque(self._queue)):
|
|
382
399
|
if item.ident == job_id:
|
|
383
|
-
if i == 0:
|
|
384
|
-
|
|
385
|
-
|
|
400
|
+
if i == 0:
|
|
401
|
+
# first in queue, currently running
|
|
402
|
+
self._queue.remove(item)
|
|
403
|
+
|
|
404
|
+
with contextlib.suppress(RuntimeError):
|
|
405
|
+
item.process.finished.disconnect(
|
|
406
|
+
self._on_process_finished
|
|
407
|
+
)
|
|
408
|
+
item.process.errorOccurred.disconnect(
|
|
409
|
+
self._on_error_occurred
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
self._end_process(item.process)
|
|
413
|
+
else:
|
|
414
|
+
# still pending, just remove from queue
|
|
386
415
|
self._queue.remove(item)
|
|
416
|
+
|
|
417
|
+
self.processFinished.emit(
|
|
418
|
+
{
|
|
419
|
+
'exit_code': 1,
|
|
420
|
+
'exit_status': 0,
|
|
421
|
+
'action': InstallerActions.CANCEL,
|
|
422
|
+
'pkgs': item.pkgs,
|
|
423
|
+
}
|
|
424
|
+
)
|
|
425
|
+
self._process_queue()
|
|
387
426
|
return
|
|
427
|
+
|
|
388
428
|
msg = f"No job with id {job_id}. Current queue:\n - "
|
|
389
429
|
msg += "\n - ".join(
|
|
390
430
|
[
|
|
@@ -394,6 +434,32 @@ class InstallerQueue(QProcess):
|
|
|
394
434
|
)
|
|
395
435
|
raise ValueError(msg)
|
|
396
436
|
|
|
437
|
+
def cancel_all(self):
|
|
438
|
+
"""Terminate all process in the queue and emit the `processFinished` signal."""
|
|
439
|
+
all_pkgs = []
|
|
440
|
+
for item in deque(self._queue):
|
|
441
|
+
all_pkgs.extend(item.pkgs)
|
|
442
|
+
process = item.process
|
|
443
|
+
|
|
444
|
+
with contextlib.suppress(RuntimeError):
|
|
445
|
+
process.finished.disconnect(self._on_process_finished)
|
|
446
|
+
process.errorOccurred.disconnect(self._on_error_occurred)
|
|
447
|
+
|
|
448
|
+
self._end_process(process)
|
|
449
|
+
|
|
450
|
+
self._queue.clear()
|
|
451
|
+
self._current_process = None
|
|
452
|
+
self.processFinished.emit(
|
|
453
|
+
{
|
|
454
|
+
'exit_code': 1,
|
|
455
|
+
'exit_status': 0,
|
|
456
|
+
'action': InstallerActions.CANCEL_ALL,
|
|
457
|
+
'pkgs': all_pkgs,
|
|
458
|
+
}
|
|
459
|
+
)
|
|
460
|
+
self._process_queue()
|
|
461
|
+
return
|
|
462
|
+
|
|
397
463
|
def waitForFinished(self, msecs: int = 10000) -> bool:
|
|
398
464
|
"""Block and wait for all jobs to finish.
|
|
399
465
|
|
|
@@ -403,18 +469,32 @@ class InstallerQueue(QProcess):
|
|
|
403
469
|
Time to wait, by default 10000
|
|
404
470
|
"""
|
|
405
471
|
while self.hasJobs():
|
|
406
|
-
|
|
472
|
+
if self._current_process is not None:
|
|
473
|
+
self._current_process.waitForFinished(msecs)
|
|
407
474
|
return True
|
|
408
475
|
|
|
409
476
|
def hasJobs(self) -> bool:
|
|
410
477
|
"""True if there are jobs remaining in the queue."""
|
|
411
478
|
return bool(self._queue)
|
|
412
479
|
|
|
480
|
+
def currentJobs(self) -> int:
|
|
481
|
+
"""Return the number of running jobs in the queue."""
|
|
482
|
+
return len(self._queue)
|
|
483
|
+
|
|
413
484
|
def set_output_widget(self, output_widget: QTextEdit):
|
|
414
485
|
if output_widget:
|
|
415
486
|
self._output_widget = output_widget
|
|
416
487
|
|
|
417
488
|
# -------------------------- Private methods ------------------------------
|
|
489
|
+
def _create_process(self) -> QProcess:
|
|
490
|
+
process = QProcess(self)
|
|
491
|
+
process.setProcessChannelMode(QProcess.MergedChannels)
|
|
492
|
+
process.readyReadStandardOutput.connect(self._on_stdout_ready)
|
|
493
|
+
process.readyReadStandardError.connect(self._on_stderr_ready)
|
|
494
|
+
process.finished.connect(self._on_process_finished)
|
|
495
|
+
process.errorOccurred.connect(self._on_error_occurred)
|
|
496
|
+
return process
|
|
497
|
+
|
|
418
498
|
def _log(self, msg: str):
|
|
419
499
|
log.debug(msg)
|
|
420
500
|
if self._output_widget:
|
|
@@ -440,7 +520,7 @@ class InstallerQueue(QProcess):
|
|
|
440
520
|
pkgs=pkgs,
|
|
441
521
|
action=action,
|
|
442
522
|
origins=origins,
|
|
443
|
-
prefix=prefix,
|
|
523
|
+
prefix=prefix or self._prefix,
|
|
444
524
|
**kwargs,
|
|
445
525
|
)
|
|
446
526
|
|
|
@@ -451,30 +531,38 @@ class InstallerQueue(QProcess):
|
|
|
451
531
|
|
|
452
532
|
def _process_queue(self):
|
|
453
533
|
if not self._queue:
|
|
454
|
-
self.allFinished.emit()
|
|
534
|
+
self.allFinished.emit(tuple(self._exit_codes))
|
|
535
|
+
self._exit_codes = []
|
|
455
536
|
return
|
|
537
|
+
|
|
456
538
|
tool = self._queue[0]
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
539
|
+
process = tool.process
|
|
540
|
+
|
|
541
|
+
if process.state() != QProcess.Running:
|
|
542
|
+
process.setProgram(str(tool.executable()))
|
|
543
|
+
process.setProcessEnvironment(tool.environment())
|
|
544
|
+
process.setArguments([str(arg) for arg in tool.arguments()])
|
|
545
|
+
process.started.connect(self.started)
|
|
546
|
+
|
|
547
|
+
self._log(
|
|
548
|
+
trans._(
|
|
549
|
+
"Starting '{program}' with args {args}",
|
|
550
|
+
program=process.program(),
|
|
551
|
+
args=process.arguments(),
|
|
552
|
+
)
|
|
467
553
|
)
|
|
468
|
-
)
|
|
469
|
-
self.start()
|
|
470
554
|
|
|
471
|
-
|
|
555
|
+
process.start()
|
|
556
|
+
self._current_process = process
|
|
557
|
+
|
|
558
|
+
def _end_process(self, process: QProcess):
|
|
472
559
|
if os.name == 'nt':
|
|
473
560
|
# TODO: this might be too agressive and won't allow rollbacks!
|
|
474
561
|
# investigate whether we can also do .terminate()
|
|
475
|
-
|
|
562
|
+
process.kill()
|
|
476
563
|
else:
|
|
477
|
-
|
|
564
|
+
process.terminate()
|
|
565
|
+
|
|
478
566
|
if self._output_widget:
|
|
479
567
|
self._output_widget.append(
|
|
480
568
|
trans._("\nTask was cancelled by the user.")
|
|
@@ -515,8 +603,10 @@ class InstallerQueue(QProcess):
|
|
|
515
603
|
exit_status: Optional[QProcess.ExitStatus] = None,
|
|
516
604
|
error: Optional[QProcess.ProcessError] = None,
|
|
517
605
|
):
|
|
606
|
+
item = None
|
|
518
607
|
with contextlib.suppress(IndexError):
|
|
519
|
-
self._queue.popleft()
|
|
608
|
+
item = self._queue.popleft()
|
|
609
|
+
|
|
520
610
|
if error:
|
|
521
611
|
msg = trans._(
|
|
522
612
|
"Task finished with errors! Error: {error}.", error=error
|
|
@@ -527,18 +617,34 @@ class InstallerQueue(QProcess):
|
|
|
527
617
|
exit_code=exit_code,
|
|
528
618
|
exit_status=exit_status,
|
|
529
619
|
)
|
|
620
|
+
|
|
621
|
+
if item is not None:
|
|
622
|
+
self.processFinished.emit(
|
|
623
|
+
{
|
|
624
|
+
'exit_code': exit_code,
|
|
625
|
+
'exit_status': exit_status,
|
|
626
|
+
'action': item.action,
|
|
627
|
+
'pkgs': item.pkgs,
|
|
628
|
+
}
|
|
629
|
+
)
|
|
630
|
+
self._exit_codes.append(exit_code)
|
|
631
|
+
|
|
530
632
|
self._log(msg)
|
|
531
633
|
self._process_queue()
|
|
532
634
|
|
|
533
635
|
def _on_stdout_ready(self):
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
636
|
+
if self._current_process is not None:
|
|
637
|
+
text = (
|
|
638
|
+
self._current_process.readAllStandardOutput().data().decode()
|
|
639
|
+
)
|
|
640
|
+
if text:
|
|
641
|
+
self._log(text)
|
|
537
642
|
|
|
538
643
|
def _on_stderr_ready(self):
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
644
|
+
if self._current_process is not None:
|
|
645
|
+
text = self._current_process.readAllStandardError().data().decode()
|
|
646
|
+
if text:
|
|
647
|
+
self._log(text)
|
|
542
648
|
|
|
543
649
|
|
|
544
650
|
def _get_python_exe():
|