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