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.
@@ -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)