napari-plugin-manager 0.1.0a1__py3-none-any.whl → 0.1.1__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.
@@ -8,6 +8,7 @@ executable path, arguments and environment modifications.
8
8
  Available actions for each tool are `install`, `uninstall`
9
9
  and `cancel`.
10
10
  """
11
+
11
12
  import atexit
12
13
  import contextlib
13
14
  import os
@@ -19,8 +20,8 @@ from functools import lru_cache
19
20
  from logging import getLogger
20
21
  from pathlib import Path
21
22
  from subprocess import call
22
- from tempfile import gettempdir, mkstemp
23
- from typing import Deque, Optional, Sequence, Tuple
23
+ from tempfile import NamedTemporaryFile, gettempdir
24
+ from typing import Deque, Optional, Sequence, Tuple, TypedDict
24
25
 
25
26
  from napari._version import version as _napari_version
26
27
  from napari._version import version_tuple as _napari_version_tuple
@@ -41,9 +42,17 @@ class InstallerActions(StringEnum):
41
42
  INSTALL = auto()
42
43
  UNINSTALL = auto()
43
44
  CANCEL = auto()
45
+ CANCEL_ALL = auto()
44
46
  UPGRADE = auto()
45
47
 
46
48
 
49
+ class ProcessFinishedData(TypedDict):
50
+ exit_code: int
51
+ exit_status: int
52
+ action: InstallerActions
53
+ pkgs: Tuple[str, ...]
54
+
55
+
47
56
  class InstallerTools(StringEnum):
48
57
  "Available tools for InstallerQueue jobs"
49
58
  CONDA = auto()
@@ -56,10 +65,13 @@ class AbstractInstallerTool:
56
65
  pkgs: Tuple[str, ...]
57
66
  origins: Tuple[str, ...] = ()
58
67
  prefix: Optional[str] = None
68
+ process: QProcess = None
59
69
 
60
70
  @property
61
71
  def ident(self):
62
- return hash((self.action, *self.pkgs, *self.origins, self.prefix))
72
+ return hash(
73
+ (self.action, *self.pkgs, *self.origins, self.prefix, self.process)
74
+ )
63
75
 
64
76
  # abstract method
65
77
  @classmethod
@@ -84,7 +96,7 @@ class AbstractInstallerTool:
84
96
  """
85
97
  Version constraints to limit unwanted changes in installation.
86
98
  """
87
- return [f"napari=={_napari_version}", "pydantic<2"]
99
+ return [f"napari=={_napari_version}", "numpy<2"]
88
100
 
89
101
  @classmethod
90
102
  def available(cls) -> bool:
@@ -139,11 +151,12 @@ class PipInstallerTool(AbstractInstallerTool):
139
151
  @classmethod
140
152
  @lru_cache(maxsize=0)
141
153
  def _constraints_file(cls) -> str:
142
- _, path = mkstemp("-napari-constraints.txt", text=True)
143
- with open(path, "w") as f:
154
+ with NamedTemporaryFile(
155
+ "w", suffix="-napari-constraints.txt", delete=False
156
+ ) as f:
144
157
  f.write("\n".join(cls.constraints()))
145
- atexit.register(os.unlink, path)
146
- return path
158
+ atexit.register(os.unlink, f.name)
159
+ return f.name
147
160
 
148
161
 
149
162
  class CondaInstallerTool(AbstractInstallerTool):
@@ -214,7 +227,7 @@ class CondaInstallerTool(AbstractInstallerTool):
214
227
  pin_level = 2 if is_dev else 3
215
228
  version = ".".join([str(x) for x in _napari_version_tuple[:pin_level]])
216
229
 
217
- return [f"napari={version}", "pydantic<2.0a0"]
230
+ return [f"napari={version}", "numpy<2.0a0"]
218
231
 
219
232
  def _add_constraints_to_env(
220
233
  self, env: QProcessEnvironment
@@ -235,24 +248,30 @@ class CondaInstallerTool(AbstractInstallerTool):
235
248
  raise ValueError("Prefix has not been specified!")
236
249
 
237
250
 
238
- class InstallerQueue(QProcess):
251
+ class InstallerQueue(QObject):
239
252
  """Queue for installation and uninstallation tasks in the plugin manager."""
240
253
 
241
- # emitted when all jobs are finished
242
- # not to be confused with finished, which is emitted when each job is finished
243
- allFinished = Signal()
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)
244
262
 
245
- def __init__(self, parent: Optional[QObject] = None) -> None:
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:
246
269
  super().__init__(parent)
247
270
  self._queue: Deque[AbstractInstallerTool] = deque()
271
+ self._current_process: QProcess = None
272
+ self._prefix = prefix
248
273
  self._output_widget = None
249
-
250
- self.setProcessChannelMode(QProcess.MergedChannels)
251
- self.readyReadStandardOutput.connect(self._on_stdout_ready)
252
- self.readyReadStandardError.connect(self._on_stderr_ready)
253
-
254
- self.finished.connect(self._on_process_finished)
255
- self.errorOccurred.connect(self._on_error_occurred)
274
+ self._exit_codes = []
256
275
 
257
276
  # -------------------------- Public API ------------------------------
258
277
  def install(
@@ -289,6 +308,7 @@ class InstallerQueue(QProcess):
289
308
  pkgs=pkgs,
290
309
  prefix=prefix,
291
310
  origins=origins,
311
+ process=self._create_process(),
292
312
  **kwargs,
293
313
  )
294
314
  return self._queue_item(item)
@@ -327,6 +347,7 @@ class InstallerQueue(QProcess):
327
347
  pkgs=pkgs,
328
348
  prefix=prefix,
329
349
  origins=origins,
350
+ process=self._create_process(),
330
351
  **kwargs,
331
352
  )
332
353
  return self._queue_item(item)
@@ -360,31 +381,50 @@ class InstallerQueue(QProcess):
360
381
  action=InstallerActions.UNINSTALL,
361
382
  pkgs=pkgs,
362
383
  prefix=prefix,
384
+ process=self._create_process(),
363
385
  **kwargs,
364
386
  )
365
387
  return self._queue_item(item)
366
388
 
367
- def cancel(self, job_id: Optional[JobId] = None):
368
- """Cancel `job_id` if it is running.
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.
369
392
 
370
393
  Parameters
371
394
  ----------
372
- job_id : Optional[JobId], optional
373
- Job ID to cancel. If not provided, cancel all jobs.
395
+ job_id : JobId
396
+ Job ID to cancel.
374
397
  """
375
- if job_id is None:
376
- # cancel all jobs
377
- self._queue.clear()
378
- self._end_process()
379
- return
380
-
381
- for i, item in enumerate(self._queue):
398
+ for i, item in enumerate(deque(self._queue)):
382
399
  if item.ident == job_id:
383
- if i == 0: # first in queue, currently running
384
- self._end_process()
385
- else: # still pending, just remove from queue
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
386
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()
387
426
  return
427
+
388
428
  msg = f"No job with id {job_id}. Current queue:\n - "
389
429
  msg += "\n - ".join(
390
430
  [
@@ -394,6 +434,32 @@ class InstallerQueue(QProcess):
394
434
  )
395
435
  raise ValueError(msg)
396
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
+
397
463
  def waitForFinished(self, msecs: int = 10000) -> bool:
398
464
  """Block and wait for all jobs to finish.
399
465
 
@@ -403,18 +469,32 @@ class InstallerQueue(QProcess):
403
469
  Time to wait, by default 10000
404
470
  """
405
471
  while self.hasJobs():
406
- super().waitForFinished(msecs)
472
+ if self._current_process is not None:
473
+ self._current_process.waitForFinished(msecs)
407
474
  return True
408
475
 
409
476
  def hasJobs(self) -> bool:
410
477
  """True if there are jobs remaining in the queue."""
411
478
  return bool(self._queue)
412
479
 
480
+ def currentJobs(self) -> int:
481
+ """Return the number of running jobs in the queue."""
482
+ return len(self._queue)
483
+
413
484
  def set_output_widget(self, output_widget: QTextEdit):
414
485
  if output_widget:
415
486
  self._output_widget = output_widget
416
487
 
417
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
+
418
498
  def _log(self, msg: str):
419
499
  log.debug(msg)
420
500
  if self._output_widget:
@@ -440,7 +520,7 @@ class InstallerQueue(QProcess):
440
520
  pkgs=pkgs,
441
521
  action=action,
442
522
  origins=origins,
443
- prefix=prefix,
523
+ prefix=prefix or self._prefix,
444
524
  **kwargs,
445
525
  )
446
526
 
@@ -451,30 +531,38 @@ class InstallerQueue(QProcess):
451
531
 
452
532
  def _process_queue(self):
453
533
  if not self._queue:
454
- self.allFinished.emit()
534
+ self.allFinished.emit(tuple(self._exit_codes))
535
+ self._exit_codes = []
455
536
  return
537
+
456
538
  tool = self._queue[0]
457
- self.setProgram(str(tool.executable()))
458
- self.setProcessEnvironment(tool.environment())
459
- self.setArguments([str(arg) for arg in tool.arguments()])
460
- # this might throw a warning because the same process
461
- # was already running but it's ok
462
- self._log(
463
- trans._(
464
- "Starting '{program}' with args {args}",
465
- program=self.program(),
466
- args=self.arguments(),
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
+ )
467
553
  )
468
- )
469
- self.start()
470
554
 
471
- def _end_process(self):
555
+ process.start()
556
+ self._current_process = process
557
+
558
+ def _end_process(self, process: QProcess):
472
559
  if os.name == 'nt':
473
560
  # TODO: this might be too agressive and won't allow rollbacks!
474
561
  # investigate whether we can also do .terminate()
475
- self.kill()
562
+ process.kill()
476
563
  else:
477
- self.terminate()
564
+ process.terminate()
565
+
478
566
  if self._output_widget:
479
567
  self._output_widget.append(
480
568
  trans._("\nTask was cancelled by the user.")
@@ -515,8 +603,10 @@ class InstallerQueue(QProcess):
515
603
  exit_status: Optional[QProcess.ExitStatus] = None,
516
604
  error: Optional[QProcess.ProcessError] = None,
517
605
  ):
606
+ item = None
518
607
  with contextlib.suppress(IndexError):
519
- self._queue.popleft()
608
+ item = self._queue.popleft()
609
+
520
610
  if error:
521
611
  msg = trans._(
522
612
  "Task finished with errors! Error: {error}.", error=error
@@ -527,18 +617,34 @@ class InstallerQueue(QProcess):
527
617
  exit_code=exit_code,
528
618
  exit_status=exit_status,
529
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
+
530
632
  self._log(msg)
531
633
  self._process_queue()
532
634
 
533
635
  def _on_stdout_ready(self):
534
- text = self.readAllStandardOutput().data().decode()
535
- if text:
536
- self._log(text)
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)
537
642
 
538
643
  def _on_stderr_ready(self):
539
- text = self.readAllStandardError().data().decode()
540
- if text:
541
- self._log(text)
644
+ if self._current_process is not None:
645
+ text = self._current_process.readAllStandardError().data().decode()
646
+ if text:
647
+ self._log(text)
542
648
 
543
649
 
544
650
  def _get_python_exe():