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.
@@ -88,7 +88,6 @@ def iter_napari_plugin_info() -> Iterator[tuple[PackageMetadata, bool, dict]]:
88
88
  with ThreadPoolExecutor() as executor:
89
89
  data = executor.submit(plugin_summaries)
90
90
  _conda = executor.submit(conda_map)
91
-
92
91
  conda = _conda.result()
93
92
  data_set = data.result()
94
93
  except (HTTPError, URLError):
@@ -1,95 +1,47 @@
1
1
  """
2
- A tool-agnostic installation logic for the plugin manager.
2
+ The installation logic for the napari plugin manager.
3
3
 
4
- The main object is `InstallerQueue`, a `QProcess` subclass
4
+ The main object is `NapariInstallerQueue`, a `InstallerQueue` subclass
5
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`.
6
+ by a `deque` of `*InstallerTool` dataclasses (`NapariPipInstallerTool` and
7
+ `NapariCondaInstallerTool`).
10
8
  """
11
9
 
12
10
  import atexit
13
- import contextlib
14
11
  import os
15
12
  import sys
16
- from collections import deque
17
- from dataclasses import dataclass
18
- from enum import auto
19
13
  from functools import lru_cache
20
- from logging import getLogger
21
14
  from pathlib import Path
22
- from subprocess import call
23
- from tempfile import NamedTemporaryFile, gettempdir
24
- from typing import Deque, Optional, Sequence, Tuple, TypedDict
15
+ from tempfile import NamedTemporaryFile
16
+ from typing import Sequence
25
17
 
26
18
  from napari._version import version as _napari_version
27
19
  from napari._version import version_tuple as _napari_version_tuple
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
20
 
36
- JobId = int
37
- log = getLogger(__name__)
21
+ from napari_plugin_manager.base_qt_package_installer import (
22
+ CondaInstallerTool,
23
+ InstallerQueue,
24
+ PipInstallerTool,
25
+ )
38
26
 
39
27
 
40
- class InstallerActions(StringEnum):
41
- "Available actions for the plugin manager"
42
- INSTALL = auto()
43
- UNINSTALL = auto()
44
- CANCEL = auto()
45
- CANCEL_ALL = auto()
46
- UPGRADE = auto()
47
-
48
-
49
- class ProcessFinishedData(TypedDict):
50
- exit_code: int
51
- exit_status: int
52
- action: InstallerActions
53
- pkgs: Tuple[str, ...]
54
-
55
-
56
- class InstallerTools(StringEnum):
57
- "Available tools for InstallerQueue jobs"
58
- CONDA = auto()
59
- PIP = auto()
60
-
61
-
62
- @dataclass(frozen=True)
63
- class AbstractInstallerTool:
64
- action: InstallerActions
65
- pkgs: Tuple[str, ...]
66
- origins: Tuple[str, ...] = ()
67
- prefix: Optional[str] = None
68
- process: QProcess = None
28
+ def _get_python_exe():
29
+ # Note: is_bundled_app() returns False even if using a Briefcase bundle...
30
+ # Workaround: see if sys.executable is set to something something napari on Mac
31
+ if (
32
+ sys.executable.endswith("napari")
33
+ and sys.platform == 'darwin'
34
+ and (python := Path(sys.prefix) / "bin" / "python3").is_file()
35
+ ):
36
+ # sys.prefix should be <napari.app>/Contents/Resources/Support/Python/Resources
37
+ return str(python)
38
+ return sys.executable
69
39
 
70
- @property
71
- def ident(self):
72
- return hash(
73
- (self.action, *self.pkgs, *self.origins, self.prefix, self.process)
74
- )
75
40
 
76
- # abstract method
41
+ class NapariPipInstallerTool(PipInstallerTool):
77
42
  @classmethod
78
43
  def executable(cls):
79
- "Path to the executable that will run the task"
80
- raise NotImplementedError
81
-
82
- # abstract method
83
- def arguments(self):
84
- "Arguments supplied to the executable"
85
- raise NotImplementedError
86
-
87
- # abstract method
88
- def environment(
89
- self, env: QProcessEnvironment = None
90
- ) -> QProcessEnvironment:
91
- "Changes needed in the environment variables."
92
- raise NotImplementedError
44
+ return str(_get_python_exe())
93
45
 
94
46
  @staticmethod
95
47
  def constraints() -> Sequence[str]:
@@ -98,56 +50,6 @@ class AbstractInstallerTool:
98
50
  """
99
51
  return [f"napari=={_napari_version}", "numpy<2"]
100
52
 
101
- @classmethod
102
- def available(cls) -> bool:
103
- """
104
- Check if the tool is available by performing a little test
105
- """
106
- raise NotImplementedError
107
-
108
-
109
- class PipInstallerTool(AbstractInstallerTool):
110
- @classmethod
111
- def executable(cls):
112
- return str(_get_python_exe())
113
-
114
- @classmethod
115
- def available(cls):
116
- return call([cls.executable(), "-m", "pip", "--version"]) == 0
117
-
118
- def arguments(self) -> Tuple[str, ...]:
119
- args = ['-m', 'pip']
120
- if self.action == InstallerActions.INSTALL:
121
- args += ['install', '-c', self._constraints_file()]
122
- for origin in self.origins:
123
- args += ['--extra-index-url', origin]
124
- elif self.action == InstallerActions.UPGRADE:
125
- args += [
126
- 'install',
127
- '--upgrade',
128
- '-c',
129
- self._constraints_file(),
130
- ]
131
- for origin in self.origins:
132
- args += ['--extra-index-url', origin]
133
- elif self.action == InstallerActions.UNINSTALL:
134
- args += ['uninstall', '-y']
135
- else:
136
- raise ValueError(f"Action '{self.action}' not supported!")
137
- if 10 <= log.getEffectiveLevel() < 30: # DEBUG level
138
- args.append('-vvv')
139
- if self.prefix is not None:
140
- args.extend(['--prefix', str(self.prefix)])
141
- return (*args, *self.pkgs)
142
-
143
- def environment(
144
- self, env: QProcessEnvironment = None
145
- ) -> QProcessEnvironment:
146
- if env is None:
147
- env = QProcessEnvironment.systemEnvironment()
148
- env.insert("PIP_USER_AGENT_USER_DATA", _user_agent())
149
- return env
150
-
151
53
  @classmethod
152
54
  @lru_cache(maxsize=0)
153
55
  def _constraints_file(cls) -> str:
@@ -159,61 +61,7 @@ class PipInstallerTool(AbstractInstallerTool):
159
61
  return f.name
160
62
 
161
63
 
162
- class CondaInstallerTool(AbstractInstallerTool):
163
- @classmethod
164
- def executable(cls):
165
- bat = ".bat" if os.name == "nt" else ""
166
- for path in (
167
- Path(os.environ.get('MAMBA_EXE', '')),
168
- Path(os.environ.get('CONDA_EXE', '')),
169
- # $CONDA is usually only available on GitHub Actions
170
- Path(os.environ.get('CONDA', '')) / 'condabin' / f'conda{bat}',
171
- ):
172
- if path.is_file():
173
- return str(path)
174
- return f'conda{bat}' # cross our fingers 'conda' is in PATH
175
-
176
- @classmethod
177
- def available(cls):
178
- executable = cls.executable()
179
- try:
180
- return call([executable, "--version"]) == 0
181
- except FileNotFoundError: # pragma: no cover
182
- return False
183
-
184
- def arguments(self) -> Tuple[str, ...]:
185
- prefix = self.prefix or self._default_prefix()
186
- if self.action == InstallerActions.UPGRADE:
187
- args = ['update', '-y', '--prefix', prefix]
188
- else:
189
- args = [self.action.value, '-y', '--prefix', prefix]
190
- args.append('--override-channels')
191
- for channel in (*self.origins, *self._default_channels()):
192
- args.extend(["-c", channel])
193
- return (*args, *self.pkgs)
194
-
195
- def environment(
196
- self, env: QProcessEnvironment = None
197
- ) -> QProcessEnvironment:
198
- if env is None:
199
- env = QProcessEnvironment.systemEnvironment()
200
- self._add_constraints_to_env(env)
201
- if 10 <= log.getEffectiveLevel() < 30: # DEBUG level
202
- env.insert('CONDA_VERBOSITY', '3')
203
- if os.name == "nt":
204
- if not env.contains("TEMP"):
205
- temp = gettempdir()
206
- env.insert("TMP", temp)
207
- env.insert("TEMP", temp)
208
- if not env.contains("USERPROFILE"):
209
- env.insert("HOME", os.path.expanduser("~"))
210
- env.insert("USERPROFILE", os.path.expanduser("~"))
211
- if sys.platform == 'darwin' and env.contains('PYTHONEXECUTABLE'):
212
- # Fix for macOS when napari launched from terminal
213
- # related to https://github.com/napari/napari/pull/5531
214
- env.remove("PYTHONEXECUTABLE")
215
- return env
216
-
64
+ class NapariCondaInstallerTool(CondaInstallerTool):
217
65
  @staticmethod
218
66
  def constraints() -> Sequence[str]:
219
67
  # FIXME
@@ -229,432 +77,8 @@ class CondaInstallerTool(AbstractInstallerTool):
229
77
 
230
78
  return [f"napari={version}", "numpy<2.0a0"]
231
79
 
232
- def _add_constraints_to_env(
233
- self, env: QProcessEnvironment
234
- ) -> QProcessEnvironment:
235
- PINNED = 'CONDA_PINNED_PACKAGES'
236
- constraints = self.constraints()
237
- if env.contains(PINNED):
238
- constraints.append(env.value(PINNED))
239
- env.insert(PINNED, "&".join(constraints))
240
- return env
241
-
242
- def _default_channels(self):
243
- return ('conda-forge',)
244
-
245
- def _default_prefix(self):
246
- if (Path(sys.prefix) / "conda-meta").is_dir():
247
- return sys.prefix
248
- raise ValueError("Prefix has not been specified!")
249
-
250
-
251
- class InstallerQueue(QObject):
252
- """Queue for installation and uninstallation tasks in the plugin manager."""
253
-
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)
262
-
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:
269
- super().__init__(parent)
270
- self._queue: Deque[AbstractInstallerTool] = deque()
271
- self._current_process: QProcess = None
272
- self._prefix = prefix
273
- self._output_widget = None
274
- self._exit_codes = []
275
-
276
- # -------------------------- Public API ------------------------------
277
- def install(
278
- self,
279
- tool: InstallerTools,
280
- pkgs: Sequence[str],
281
- *,
282
- prefix: Optional[str] = None,
283
- origins: Sequence[str] = (),
284
- **kwargs,
285
- ) -> JobId:
286
- """Install packages in `pkgs` into `prefix` using `tool` with additional
287
- `origins` as source for `pkgs`.
288
-
289
- Parameters
290
- ----------
291
- tool : InstallerTools
292
- Which type of installation tool to use.
293
- pkgs : Sequence[str]
294
- List of packages to install.
295
- prefix : Optional[str], optional
296
- Optional prefix to install packages into.
297
- origins : Optional[Sequence[str]], optional
298
- Additional sources for packages to be downloaded from.
299
-
300
- Returns
301
- -------
302
- JobId : int
303
- ID that can be used to cancel the process.
304
- """
305
- item = self._build_queue_item(
306
- tool=tool,
307
- action=InstallerActions.INSTALL,
308
- pkgs=pkgs,
309
- prefix=prefix,
310
- origins=origins,
311
- process=self._create_process(),
312
- **kwargs,
313
- )
314
- return self._queue_item(item)
315
-
316
- def upgrade(
317
- self,
318
- tool: InstallerTools,
319
- pkgs: Sequence[str],
320
- *,
321
- prefix: Optional[str] = None,
322
- origins: Sequence[str] = (),
323
- **kwargs,
324
- ) -> JobId:
325
- """Upgrade packages in `pkgs` into `prefix` using `tool` with additional
326
- `origins` as source for `pkgs`.
327
-
328
- Parameters
329
- ----------
330
- tool : InstallerTools
331
- Which type of installation tool to use.
332
- pkgs : Sequence[str]
333
- List of packages to install.
334
- prefix : Optional[str], optional
335
- Optional prefix to install packages into.
336
- origins : Optional[Sequence[str]], optional
337
- Additional sources for packages to be downloaded from.
338
-
339
- Returns
340
- -------
341
- JobId : int
342
- ID that can be used to cancel the process.
343
- """
344
- item = self._build_queue_item(
345
- tool=tool,
346
- action=InstallerActions.UPGRADE,
347
- pkgs=pkgs,
348
- prefix=prefix,
349
- origins=origins,
350
- process=self._create_process(),
351
- **kwargs,
352
- )
353
- return self._queue_item(item)
354
80
 
355
- def uninstall(
356
- self,
357
- tool: InstallerTools,
358
- pkgs: Sequence[str],
359
- *,
360
- prefix: Optional[str] = None,
361
- **kwargs,
362
- ) -> JobId:
363
- """Uninstall packages in `pkgs` from `prefix` using `tool`.
364
-
365
- Parameters
366
- ----------
367
- tool : InstallerTools
368
- Which type of installation tool to use.
369
- pkgs : Sequence[str]
370
- List of packages to uninstall.
371
- prefix : Optional[str], optional
372
- Optional prefix from which to uninstall packages.
373
-
374
- Returns
375
- -------
376
- JobId : int
377
- ID that can be used to cancel the process.
378
- """
379
- item = self._build_queue_item(
380
- tool=tool,
381
- action=InstallerActions.UNINSTALL,
382
- pkgs=pkgs,
383
- prefix=prefix,
384
- process=self._create_process(),
385
- **kwargs,
386
- )
387
- return self._queue_item(item)
388
-
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.
392
-
393
- Parameters
394
- ----------
395
- job_id : JobId
396
- Job ID to cancel.
397
- """
398
- for i, item in enumerate(deque(self._queue)):
399
- if item.ident == job_id:
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
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()
426
- return
427
-
428
- msg = f"No job with id {job_id}. Current queue:\n - "
429
- msg += "\n - ".join(
430
- [
431
- f"{item.ident} -> {item.executable()} {item.arguments()}"
432
- for item in self._queue
433
- ]
434
- )
435
- raise ValueError(msg)
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
-
463
- def waitForFinished(self, msecs: int = 10000) -> bool:
464
- """Block and wait for all jobs to finish.
465
-
466
- Parameters
467
- ----------
468
- msecs : int, optional
469
- Time to wait, by default 10000
470
- """
471
- while self.hasJobs():
472
- if self._current_process is not None:
473
- self._current_process.waitForFinished(msecs)
474
- return True
475
-
476
- def hasJobs(self) -> bool:
477
- """True if there are jobs remaining in the queue."""
478
- return bool(self._queue)
479
-
480
- def currentJobs(self) -> int:
481
- """Return the number of running jobs in the queue."""
482
- return len(self._queue)
483
-
484
- def set_output_widget(self, output_widget: QTextEdit):
485
- if output_widget:
486
- self._output_widget = output_widget
487
-
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
-
498
- def _log(self, msg: str):
499
- log.debug(msg)
500
- if self._output_widget:
501
- self._output_widget.append(msg)
502
-
503
- def _get_tool(self, tool: InstallerTools):
504
- if tool == InstallerTools.PIP:
505
- return PipInstallerTool
506
- if tool == InstallerTools.CONDA:
507
- return CondaInstallerTool
508
- raise ValueError(f"InstallerTool {tool} not recognized!")
509
-
510
- def _build_queue_item(
511
- self,
512
- tool: InstallerTools,
513
- action: InstallerActions,
514
- pkgs: Sequence[str],
515
- prefix: Optional[str] = None,
516
- origins: Sequence[str] = (),
517
- **kwargs,
518
- ) -> AbstractInstallerTool:
519
- return self._get_tool(tool)(
520
- pkgs=pkgs,
521
- action=action,
522
- origins=origins,
523
- prefix=prefix or self._prefix,
524
- **kwargs,
525
- )
526
-
527
- def _queue_item(self, item: AbstractInstallerTool) -> JobId:
528
- self._queue.append(item)
529
- self._process_queue()
530
- return item.ident
531
-
532
- def _process_queue(self):
533
- if not self._queue:
534
- self.allFinished.emit(tuple(self._exit_codes))
535
- self._exit_codes = []
536
- return
537
-
538
- tool = self._queue[0]
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
- )
553
- )
554
-
555
- process.start()
556
- self._current_process = process
557
-
558
- def _end_process(self, process: QProcess):
559
- if os.name == 'nt':
560
- # TODO: this might be too agressive and won't allow rollbacks!
561
- # investigate whether we can also do .terminate()
562
- process.kill()
563
- else:
564
- process.terminate()
565
-
566
- if self._output_widget:
567
- self._output_widget.append(
568
- trans._("\nTask was cancelled by the user.")
569
- )
570
-
571
- def _on_process_finished(
572
- self, exit_code: int, exit_status: QProcess.ExitStatus
573
- ):
574
- try:
575
- current = self._queue[0]
576
- except IndexError:
577
- current = None
578
- if (
579
- current
580
- and current.action == InstallerActions.UNINSTALL
581
- and exit_status == QProcess.ExitStatus.NormalExit
582
- and exit_code == 0
583
- ):
584
- pm2 = PluginManager.instance()
585
- npe1_plugins = set(plugin_manager.iter_available())
586
- for pkg in current.pkgs:
587
- if pkg in pm2:
588
- pm2.unregister(pkg)
589
- elif pkg in npe1_plugins:
590
- plugin_manager.unregister(pkg)
591
- else:
592
- log.warning(
593
- 'Cannot unregister %s, not a known napari plugin.', pkg
594
- )
595
- self._on_process_done(exit_code=exit_code, exit_status=exit_status)
596
-
597
- def _on_error_occurred(self, error: QProcess.ProcessError):
598
- self._on_process_done(error=error)
599
-
600
- def _on_process_done(
601
- self,
602
- exit_code: Optional[int] = None,
603
- exit_status: Optional[QProcess.ExitStatus] = None,
604
- error: Optional[QProcess.ProcessError] = None,
605
- ):
606
- item = None
607
- with contextlib.suppress(IndexError):
608
- item = self._queue.popleft()
609
-
610
- if error:
611
- msg = trans._(
612
- "Task finished with errors! Error: {error}.", error=error
613
- )
614
- else:
615
- msg = trans._(
616
- "Task finished with exit code {exit_code} with status {exit_status}.",
617
- exit_code=exit_code,
618
- exit_status=exit_status,
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
-
632
- self._log(msg)
633
- self._process_queue()
634
-
635
- def _on_stdout_ready(self):
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)
642
-
643
- def _on_stderr_ready(self):
644
- if self._current_process is not None:
645
- text = self._current_process.readAllStandardError().data().decode()
646
- if text:
647
- self._log(text)
648
-
649
-
650
- def _get_python_exe():
651
- # Note: is_bundled_app() returns False even if using a Briefcase bundle...
652
- # Workaround: see if sys.executable is set to something something napari on Mac
653
- if (
654
- sys.executable.endswith("napari")
655
- and sys.platform == 'darwin'
656
- and (python := Path(sys.prefix) / "bin" / "python3").is_file()
657
- ):
658
- # sys.prefix should be <napari.app>/Contents/Resources/Support/Python/Resources
659
- return str(python)
660
- return sys.executable
81
+ class NapariInstallerQueue(InstallerQueue):
82
+ PIP_INSTALLER_TOOL_CLASS = NapariPipInstallerTool
83
+ CONDA_INSTALLER_TOOL_CLASS = NapariCondaInstallerTool
84
+ BASE_PACKAGE_NAME = 'napari'