napari-plugin-manager 0.1.6__py3-none-any.whl → 0.1.7__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.
@@ -11,19 +11,18 @@ and `cancel`.
11
11
  """
12
12
 
13
13
  import contextlib
14
- import logging
15
14
  import os
16
15
  import sys
17
16
  from collections import deque
18
- from collections.abc import Sequence
17
+ from collections.abc import Iterable, Sequence
19
18
  from dataclasses import dataclass
20
19
  from enum import auto
21
20
  from functools import lru_cache
22
21
  from logging import getLogger
23
22
  from pathlib import Path
24
- from subprocess import call
23
+ from subprocess import run
25
24
  from tempfile import gettempdir
26
- from typing import Optional, TypedDict
25
+ from typing import TypedDict
27
26
 
28
27
  from napari.plugins import plugin_manager
29
28
  from napari.plugins.npe2api import _user_agent
@@ -62,7 +61,7 @@ class InstallerTools(StringEnum):
62
61
  "Installer tools selectable by InstallerQueue jobs"
63
62
 
64
63
  CONDA = auto()
65
- PIP = auto()
64
+ PYPI = auto()
66
65
 
67
66
 
68
67
  @dataclass(frozen=True)
@@ -76,19 +75,19 @@ class AbstractInstallerTool:
76
75
  process: QProcess = None
77
76
 
78
77
  @property
79
- def ident(self):
78
+ def ident(self) -> JobId:
80
79
  return hash(
81
80
  (self.action, *self.pkgs, *self.origins, self.prefix, self.process)
82
81
  )
83
82
 
84
83
  # abstract method
85
84
  @classmethod
86
- def executable(cls):
85
+ def executable(cls) -> str:
87
86
  "Path to the executable that will run the task"
88
87
  raise NotImplementedError
89
88
 
90
89
  # abstract method
91
- def arguments(self):
90
+ def arguments(self) -> list[str]:
92
91
  "Arguments supplied to the executable"
93
92
  raise NotImplementedError
94
93
 
@@ -100,7 +99,7 @@ class AbstractInstallerTool:
100
99
  raise NotImplementedError
101
100
 
102
101
  @staticmethod
103
- def constraints() -> Sequence[str]:
102
+ def constraints() -> list[str]:
104
103
  """
105
104
  Version constraints to limit unwanted changes in installation.
106
105
  """
@@ -121,11 +120,14 @@ class PipInstallerTool(AbstractInstallerTool):
121
120
  """
122
121
 
123
122
  @classmethod
124
- def available(cls):
123
+ def available(cls) -> bool:
125
124
  """Check if pip is available."""
126
- return call([cls.executable(), "-m", "pip", "--version"]) == 0
125
+ process = run(
126
+ [cls.executable(), '-m', 'pip', '--version'], capture_output=True
127
+ )
128
+ return process.returncode == 0
127
129
 
128
- def arguments(self) -> tuple[str, ...]:
130
+ def arguments(self) -> list[str]:
129
131
  """Compose arguments for the pip command."""
130
132
  args = ['-m', 'pip']
131
133
 
@@ -150,20 +152,20 @@ class PipInstallerTool(AbstractInstallerTool):
150
152
  else:
151
153
  raise ValueError(f"Action '{self.action}' not supported!")
152
154
 
153
- if log.getEffectiveLevel() < 30: # DEBUG and INFOlevel
155
+ if log.getEffectiveLevel() < 30: # DEBUG and INFO level
154
156
  args.append('-vvv')
155
157
 
156
158
  if self.prefix is not None:
157
159
  args.extend(['--prefix', str(self.prefix)])
158
160
 
159
- return (*args, *self.pkgs)
161
+ return [*args, *self.pkgs]
160
162
 
161
163
  def environment(
162
164
  self, env: QProcessEnvironment = None
163
165
  ) -> QProcessEnvironment:
164
166
  if env is None:
165
167
  env = QProcessEnvironment.systemEnvironment()
166
- env.insert("PIP_USER_AGENT_USER_DATA", _user_agent())
168
+ env.insert('PIP_USER_AGENT_USER_DATA', _user_agent())
167
169
  return env
168
170
 
169
171
  @classmethod
@@ -172,6 +174,79 @@ class PipInstallerTool(AbstractInstallerTool):
172
174
  raise NotImplementedError
173
175
 
174
176
 
177
+ class UvInstallerTool(AbstractInstallerTool):
178
+ """Uv installer tool for the plugin manager.
179
+
180
+ This class is used to install and uninstall packages using uv.
181
+ """
182
+
183
+ @classmethod
184
+ def executable(cls) -> str:
185
+ "Path to the executable that will run the task"
186
+ if sys.platform == 'win32':
187
+ path = os.path.join(sys.prefix, 'Scripts', 'uv.exe')
188
+ else:
189
+ path = os.path.join(sys.prefix, 'bin', 'uv')
190
+ if os.path.isfile(path):
191
+ return path
192
+ return 'uv'
193
+
194
+ @classmethod
195
+ def available(cls) -> bool:
196
+ """Check if uv is available."""
197
+ try:
198
+ process = run([cls.executable(), '--version'], capture_output=True)
199
+ except FileNotFoundError: # pragma: no cover
200
+ return False
201
+ else:
202
+ return process.returncode == 0
203
+
204
+ def arguments(self) -> list[str]:
205
+ """Compose arguments for the uv pip command."""
206
+ args = ['pip']
207
+
208
+ if self.action == InstallerActions.INSTALL:
209
+ args += ['install', '-c', self._constraints_file()]
210
+ for origin in self.origins:
211
+ args += ['--extra-index-url', origin]
212
+
213
+ elif self.action == InstallerActions.UPGRADE:
214
+ args += ['install', '-c', self._constraints_file()]
215
+ for origin in self.origins:
216
+ args += ['--extra-index-url', origin]
217
+ for pkg in self.pkgs:
218
+ args.append(f'--upgrade-package={pkg}')
219
+ elif self.action == InstallerActions.UNINSTALL:
220
+ args += ['uninstall']
221
+
222
+ else:
223
+ raise ValueError(f"Action '{self.action}' not supported!")
224
+
225
+ if log.getEffectiveLevel() < 30: # DEBUG and INFO level
226
+ args.append('-vvv')
227
+
228
+ if self.prefix is not None:
229
+ args.extend(['--prefix', str(self.prefix)])
230
+ args.extend(['--python', self._python_executable()])
231
+
232
+ return [*args, *self.pkgs]
233
+
234
+ def environment(
235
+ self, env: QProcessEnvironment = None
236
+ ) -> QProcessEnvironment:
237
+ if env is None:
238
+ env = QProcessEnvironment.systemEnvironment()
239
+ return env
240
+
241
+ @classmethod
242
+ @lru_cache(maxsize=0)
243
+ def _constraints_file(cls) -> str:
244
+ raise NotImplementedError
245
+
246
+ def _python_executable(self) -> str:
247
+ raise NotImplementedError
248
+
249
+
175
250
  class CondaInstallerTool(AbstractInstallerTool):
176
251
  """Conda installer tool for the plugin manager.
177
252
 
@@ -179,12 +254,12 @@ class CondaInstallerTool(AbstractInstallerTool):
179
254
  """
180
255
 
181
256
  @classmethod
182
- def executable(cls):
257
+ def executable(cls) -> str:
183
258
  """Find a path to the executable.
184
259
 
185
260
  This method assumes that if no environment variable is set that conda is available in the PATH.
186
261
  """
187
- bat = ".bat" if os.name == "nt" else ""
262
+ bat = '.bat' if os.name == 'nt' else ''
188
263
  for path in (
189
264
  Path(os.environ.get('MAMBA_EXE', '')),
190
265
  Path(os.environ.get('CONDA_EXE', '')),
@@ -198,15 +273,16 @@ class CondaInstallerTool(AbstractInstallerTool):
198
273
  return f'conda{bat}'
199
274
 
200
275
  @classmethod
201
- def available(cls):
276
+ def available(cls) -> bool:
202
277
  """Check if the executable is available by checking if it can output its version."""
203
- executable = cls.executable()
204
278
  try:
205
- return call([executable, "--version"]) == 0
279
+ process = run([cls.executable(), '--version'], capture_output=True)
206
280
  except FileNotFoundError: # pragma: no cover
207
281
  return False
282
+ else:
283
+ return process.returncode == 0
208
284
 
209
- def arguments(self) -> tuple[str, ...]:
285
+ def arguments(self) -> list[str]:
210
286
  """Compose arguments for the conda command."""
211
287
  prefix = self.prefix or self._default_prefix()
212
288
 
@@ -217,9 +293,9 @@ class CondaInstallerTool(AbstractInstallerTool):
217
293
 
218
294
  args.append('--override-channels')
219
295
  for channel in (*self.origins, *self._default_channels()):
220
- args.extend(["-c", channel])
296
+ args.extend(['-c', channel])
221
297
 
222
- return (*args, *self.pkgs)
298
+ return [*args, *self.pkgs]
223
299
 
224
300
  def environment(
225
301
  self, env: QProcessEnvironment = None
@@ -229,18 +305,18 @@ class CondaInstallerTool(AbstractInstallerTool):
229
305
  self._add_constraints_to_env(env)
230
306
  if 10 <= log.getEffectiveLevel() < 30: # DEBUG level
231
307
  env.insert('CONDA_VERBOSITY', '3')
232
- if os.name == "nt":
233
- if not env.contains("TEMP"):
308
+ if os.name == 'nt':
309
+ if not env.contains('TEMP'):
234
310
  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("~"))
311
+ env.insert('TMP', temp)
312
+ env.insert('TEMP', temp)
313
+ if not env.contains('USERPROFILE'):
314
+ env.insert('HOME', os.path.expanduser('~'))
315
+ env.insert('USERPROFILE', os.path.expanduser('~'))
240
316
  if sys.platform == 'darwin' and env.contains('PYTHONEXECUTABLE'):
241
317
  # Fix for macOS when napari launched from terminal
242
318
  # related to https://github.com/napari/napari/pull/5531
243
- env.remove("PYTHONEXECUTABLE")
319
+ env.remove('PYTHONEXECUTABLE')
244
320
  return env
245
321
 
246
322
  def _add_constraints_to_env(
@@ -251,18 +327,18 @@ class CondaInstallerTool(AbstractInstallerTool):
251
327
  constraints = self.constraints()
252
328
  if env.contains(PINNED):
253
329
  constraints.append(env.value(PINNED))
254
- env.insert(PINNED, "&".join(constraints))
330
+ env.insert(PINNED, '&'.join(constraints))
255
331
  return env
256
332
 
257
- def _default_channels(self):
333
+ def _default_channels(self) -> list[str]:
258
334
  """Default channels for conda installations."""
259
- return ('conda-forge',)
335
+ return ['conda-forge']
260
336
 
261
- def _default_prefix(self):
337
+ def _default_prefix(self) -> str:
262
338
  """Default prefix for conda installations."""
263
- if (Path(sys.prefix) / "conda-meta").is_dir():
339
+ if (Path(sys.prefix) / 'conda-meta').is_dir():
264
340
  return sys.prefix
265
- raise ValueError("Prefix has not been specified!")
341
+ raise ValueError('Prefix has not been specified!')
266
342
 
267
343
 
268
344
  class InstallerQueue(QObject):
@@ -281,7 +357,7 @@ class InstallerQueue(QObject):
281
357
  started = Signal()
282
358
 
283
359
  # classes to manage pip and conda installations
284
- PIP_INSTALLER_TOOL_CLASS = PipInstallerTool
360
+ PYPI_INSTALLER_TOOL_CLASS = PipInstallerTool
285
361
  CONDA_INSTALLER_TOOL_CLASS = CondaInstallerTool
286
362
  # This should be set to the name of package that handles plugins
287
363
  # e.g `napari` for napari
@@ -295,7 +371,7 @@ class InstallerQueue(QObject):
295
371
  self._current_process: QProcess = None
296
372
  self._prefix = prefix
297
373
  self._output_widget = None
298
- self._exit_codes = []
374
+ self._exit_codes: list[int] = []
299
375
 
300
376
  # -------------------------- Public API ------------------------------
301
377
  def install(
@@ -416,7 +492,7 @@ class InstallerQueue(QObject):
416
492
  )
417
493
  return self._queue_item(item)
418
494
 
419
- def cancel(self, job_id: JobId):
495
+ def cancel(self, job_id: JobId) -> None:
420
496
  """Cancel a job.
421
497
 
422
498
  Cancel the process, if it is running, referenced by `job_id`.
@@ -458,18 +534,18 @@ class InstallerQueue(QObject):
458
534
  self._process_queue()
459
535
  return
460
536
 
461
- msg = f"No job with id {job_id}. Current queue:\n - "
462
- msg += "\n - ".join(
537
+ msg = f'No job with id {job_id}. Current queue:\n - '
538
+ msg += '\n - '.join(
463
539
  [
464
- f"{item.ident} -> {item.executable()} {item.arguments()}"
540
+ f'{item.ident} -> {item.executable()} {item.arguments()}'
465
541
  for item in self._queue
466
542
  ]
467
543
  )
468
544
  raise ValueError(msg)
469
545
 
470
- def cancel_all(self):
546
+ def cancel_all(self) -> None:
471
547
  """Terminate all processes in the queue and emit the `processFinished` signal."""
472
- all_pkgs = []
548
+ all_pkgs: list[str] = []
473
549
  for item in deque(self._queue):
474
550
  all_pkgs.extend(item.pkgs)
475
551
  process = item.process
@@ -514,7 +590,7 @@ class InstallerQueue(QObject):
514
590
  """Return the number of running jobs in the queue."""
515
591
  return len(self._queue)
516
592
 
517
- def set_output_widget(self, output_widget: QTextEdit):
593
+ def set_output_widget(self, output_widget: QTextEdit) -> None:
518
594
  """Set the output widget for text output."""
519
595
  if output_widget:
520
596
  self._output_widget = output_widget
@@ -529,31 +605,31 @@ class InstallerQueue(QObject):
529
605
  process.errorOccurred.connect(self._on_error_occurred)
530
606
  return process
531
607
 
532
- def _log(self, msg: str):
608
+ def _log(self, msg: str) -> None:
533
609
  log.debug(msg)
534
610
  if self._output_widget:
535
611
  self._output_widget.append(msg)
536
612
 
537
- def _get_tool(self, tool: InstallerTools):
538
- if tool == InstallerTools.PIP:
539
- return self.PIP_INSTALLER_TOOL_CLASS
613
+ def _get_tool(self, tool: InstallerTools) -> type[AbstractInstallerTool]:
614
+ if tool == InstallerTools.PYPI:
615
+ return self.PYPI_INSTALLER_TOOL_CLASS
540
616
  if tool == InstallerTools.CONDA:
541
617
  return self.CONDA_INSTALLER_TOOL_CLASS
542
- raise ValueError(f"InstallerTool {tool} not recognized!")
618
+ raise ValueError(f'InstallerTool {tool} not recognized!')
543
619
 
544
620
  def _build_queue_item(
545
621
  self,
546
622
  tool: InstallerTools,
547
623
  action: InstallerActions,
548
- pkgs: Sequence[str],
624
+ pkgs: Iterable[str],
549
625
  prefix: str | None = None,
550
- origins: Sequence[str] = (),
626
+ origins: Iterable[str] = (),
551
627
  **kwargs,
552
628
  ) -> AbstractInstallerTool:
553
629
  return self._get_tool(tool)(
554
- pkgs=pkgs,
630
+ pkgs=tuple(pkgs),
555
631
  action=action,
556
- origins=origins,
632
+ origins=tuple(origins),
557
633
  prefix=prefix or self._prefix,
558
634
  **kwargs,
559
635
  )
@@ -563,7 +639,7 @@ class InstallerQueue(QObject):
563
639
  self._process_queue()
564
640
  return item.ident
565
641
 
566
- def _process_queue(self):
642
+ def _process_queue(self) -> None:
567
643
  if not self._queue:
568
644
  self.allFinished.emit(tuple(self._exit_codes))
569
645
  self._exit_codes = []
@@ -589,7 +665,7 @@ class InstallerQueue(QObject):
589
665
  process.start()
590
666
  self._current_process = process
591
667
 
592
- def _end_process(self, process: QProcess):
668
+ def _end_process(self, process: QProcess) -> None:
593
669
  if os.name == 'nt':
594
670
  # TODO: this might be too agressive and won't allow rollbacks!
595
671
  # investigate whether we can also do .terminate()
@@ -599,12 +675,12 @@ class InstallerQueue(QObject):
599
675
 
600
676
  if self._output_widget:
601
677
  self._output_widget.append(
602
- trans._("\nTask was cancelled by the user.")
678
+ trans._('\nTask was cancelled by the user.')
603
679
  )
604
680
 
605
681
  def _on_process_finished(
606
682
  self, exit_code: int, exit_status: QProcess.ExitStatus
607
- ):
683
+ ) -> None:
608
684
  try:
609
685
  current = self._queue[0]
610
686
  except IndexError:
@@ -630,26 +706,26 @@ class InstallerQueue(QObject):
630
706
  )
631
707
  self._on_process_done(exit_code=exit_code, exit_status=exit_status)
632
708
 
633
- def _on_error_occurred(self, error: QProcess.ProcessError):
709
+ def _on_error_occurred(self, error: QProcess.ProcessError) -> None:
634
710
  self._on_process_done(error=error)
635
711
 
636
712
  def _on_process_done(
637
713
  self,
638
714
  exit_code: int | None = None,
639
- exit_status: Optional[QProcess.ExitStatus] = None, # noqa
640
- error: Optional[QProcess.ProcessError] = None, # noqa
641
- ):
715
+ exit_status: QProcess.ExitStatus | None = None,
716
+ error: QProcess.ProcessError | None = None,
717
+ ) -> None:
642
718
  item = None
643
719
  with contextlib.suppress(IndexError):
644
720
  item = self._queue.popleft()
645
721
 
646
722
  if error:
647
723
  msg = trans._(
648
- "Task finished with errors! Error: {error}.", error=error
724
+ 'Task finished with errors! Error: {error}.', error=error
649
725
  )
650
726
  else:
651
727
  msg = trans._(
652
- "Task finished with exit code {exit_code} with status {exit_status}.",
728
+ 'Task finished with exit code {exit_code} with status {exit_status}.',
653
729
  exit_code=exit_code,
654
730
  exit_status=exit_status,
655
731
  )
@@ -668,7 +744,7 @@ class InstallerQueue(QObject):
668
744
  self._log(msg)
669
745
  self._process_queue()
670
746
 
671
- def _on_stdout_ready(self):
747
+ def _on_stdout_ready(self) -> None:
672
748
  if self._current_process is not None:
673
749
  try:
674
750
  text = (
@@ -677,12 +753,12 @@ class InstallerQueue(QObject):
677
753
  .decode()
678
754
  )
679
755
  except UnicodeDecodeError:
680
- logging.exception("Could not decode stdout")
756
+ log.exception('Could not decode stdout')
681
757
  return
682
758
  if text:
683
759
  self._log(text)
684
760
 
685
- def _on_stderr_ready(self):
761
+ def _on_stderr_ready(self) -> None:
686
762
  if self._current_process is not None:
687
763
  text = self._current_process.readAllStandardError().data().decode()
688
764
  if text: