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