synodic-client 0.0.1.dev36__tar.gz → 0.0.1.dev37__tar.gz

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.
Files changed (59) hide show
  1. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/PKG-INFO +2 -2
  2. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/pyproject.toml +10 -2
  3. synodic_client-0.0.1.dev37/synodic_client/_version.py +1 -0
  4. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/application/bootstrap.py +1 -1
  5. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/application/icon.py +5 -7
  6. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/application/screen/__init__.py +15 -1
  7. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/application/screen/action_card.py +67 -30
  8. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/application/screen/card.py +1 -1
  9. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/application/screen/install.py +31 -158
  10. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/application/screen/screen.py +1 -1
  11. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/application/screen/spinner.py +1 -1
  12. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/application/screen/tray.py +5 -1
  13. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/application/screen/update_banner.py +54 -50
  14. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/application/theme.py +3 -0
  15. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/cli.py +1 -1
  16. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/config.py +8 -5
  17. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/logging.py +9 -1
  18. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/resolution.py +1 -1
  19. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/tests/unit/qt/test_action_card.py +112 -13
  20. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/tests/unit/qt/test_install_preview.py +7 -5
  21. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/tests/unit/qt/test_log_panel.py +7 -5
  22. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/tests/unit/qt/test_logging.py +71 -0
  23. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/tests/unit/qt/test_settings.py +4 -3
  24. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/tests/unit/qt/test_update_banner.py +57 -23
  25. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/tests/unit/test_resolution.py +4 -16
  26. synodic_client-0.0.1.dev36/synodic_client/_version.py +0 -1
  27. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/LICENSE.md +0 -0
  28. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/README.md +0 -0
  29. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/__init__.py +0 -0
  30. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/__main__.py +0 -0
  31. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/application/__init__.py +0 -0
  32. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/application/instance.py +0 -0
  33. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/application/qt.py +0 -0
  34. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/application/screen/log_panel.py +0 -0
  35. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/application/screen/settings.py +0 -0
  36. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/application/uri.py +0 -0
  37. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/application/workers.py +0 -0
  38. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/client.py +0 -0
  39. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/protocol.py +0 -0
  40. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/py.typed +0 -0
  41. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/startup.py +0 -0
  42. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/synodic_client/updater.py +0 -0
  43. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/tests/__init__.py +0 -0
  44. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/tests/conftest.py +0 -0
  45. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/tests/unit/__init__.py +0 -0
  46. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/tests/unit/qt/__init__.py +0 -0
  47. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/tests/unit/qt/conftest.py +0 -0
  48. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/tests/unit/test_cli.py +0 -0
  49. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/tests/unit/test_client_updater.py +0 -0
  50. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/tests/unit/test_client_version.py +0 -0
  51. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/tests/unit/test_config.py +0 -0
  52. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/tests/unit/test_examples.py +0 -0
  53. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/tests/unit/test_install.py +0 -0
  54. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/tests/unit/test_updater.py +0 -0
  55. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/tests/unit/test_uri.py +0 -0
  56. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/tests/unit/windows/__init__.py +0 -0
  57. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/tests/unit/windows/conftest.py +0 -0
  58. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/tests/unit/windows/test_protocol.py +0 -0
  59. {synodic_client-0.0.1.dev36 → synodic_client-0.0.1.dev37}/tests/unit/windows/test_startup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: synodic_client
3
- Version: 0.0.1.dev36
3
+ Version: 0.0.1.dev37
4
4
  Author-Email: Synodic Software <contact@synodic.software>
5
5
  License: LGPL-3.0-or-later
6
6
  Project-URL: homepage, https://github.com/synodic/synodic-client
@@ -8,7 +8,7 @@ Project-URL: repository, https://github.com/synodic/synodic-client
8
8
  Requires-Python: <3.15,>=3.14
9
9
  Requires-Dist: pyside6>=6.10.2
10
10
  Requires-Dist: packaging>=26.0
11
- Requires-Dist: porringer>=0.2.1.dev52
11
+ Requires-Dist: porringer>=0.2.1.dev53
12
12
  Requires-Dist: qasync>=0.28.0
13
13
  Requires-Dist: velopack>=0.0.1442.dev64255
14
14
  Requires-Dist: typer>=0.24.1
@@ -10,12 +10,12 @@ requires-python = ">=3.14, <3.15"
10
10
  dependencies = [
11
11
  "pyside6>=6.10.2",
12
12
  "packaging>=26.0",
13
- "porringer>=0.2.1.dev52",
13
+ "porringer>=0.2.1.dev53",
14
14
  "qasync>=0.28.0",
15
15
  "velopack>=0.0.1442.dev64255",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev36"
18
+ version = "0.0.1.dev37"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -75,6 +75,14 @@ select = [
75
75
  "PT",
76
76
  ]
77
77
 
78
+ [tool.ruff.lint.per-file-ignores]
79
+ "synodic_client/application/bootstrap.py" = [
80
+ "E402",
81
+ ]
82
+ "synodic_client/cli.py" = [
83
+ "PLC0415",
84
+ ]
85
+
78
86
  [tool.ruff.lint.pydocstyle]
79
87
  convention = "google"
80
88
 
@@ -0,0 +1 @@
1
+ __version__ = '0.0.1.dev37'
@@ -44,7 +44,7 @@ if not _dev_mode:
44
44
  remove_startup()
45
45
 
46
46
  # Heavy imports happen here — PySide6, porringer, etc.
47
- from synodic_client.application.qt import application # noqa: E402
47
+ from synodic_client.application.qt import application
48
48
 
49
49
  _uri = next((a for a in sys.argv[1:] if a.lower().startswith(f'{_PROTOCOL_SCHEME}://')), None)
50
50
  application(uri=_uri, dev_mode=_dev_mode)
@@ -4,13 +4,14 @@ Loads the icon once from package resources and caches the ``QIcon``
4
4
  so every caller shares the same instance.
5
5
  """
6
6
 
7
+ import functools
8
+
7
9
  from PySide6.QtGui import QIcon, QPixmap
8
10
 
9
11
  from synodic_client.client import Client
10
12
 
11
- _cached_icon: QIcon | None = None
12
-
13
13
 
14
+ @functools.cache
14
15
  def app_icon() -> QIcon:
15
16
  """Return the shared application ``QIcon``, loading it on first call.
16
17
 
@@ -20,8 +21,5 @@ def app_icon() -> QIcon:
20
21
  Returns:
21
22
  A ``QIcon`` backed by the application logo.
22
23
  """
23
- global _cached_icon # noqa: PLW0603
24
- if _cached_icon is None:
25
- with Client.resource(Client.icon) as icon_path:
26
- _cached_icon = QIcon(QPixmap(str(icon_path)))
27
- return _cached_icon
24
+ with Client.resource(Client.icon) as icon_path:
25
+ return QIcon(QPixmap(str(icon_path)))
@@ -6,7 +6,7 @@ execution log panel live here to avoid circular imports.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from porringer.schema import SkipReason
9
+ from porringer.schema import SetupAction, SkipReason
10
10
  from porringer.schema.plugin import PluginKind
11
11
 
12
12
  ACTION_KIND_LABELS: dict[PluginKind | None, str] = {
@@ -54,3 +54,17 @@ def skip_reason_label(reason: SkipReason | None) -> str:
54
54
  if reason is None:
55
55
  return 'Skipped'
56
56
  return SKIP_REASON_LABELS.get(reason, reason.name.replace('_', ' ').capitalize())
57
+
58
+
59
+ def format_cli_command(action: SetupAction) -> str:
60
+ """Return a human-readable CLI command string for *action*.
61
+
62
+ Prefers ``cli_command``, falls back to ``command``, then synthesises
63
+ an ``installer install <package>`` string for package actions, and
64
+ finally returns the action description as a last resort.
65
+ """
66
+ if parts := (action.cli_command or action.command):
67
+ return ' '.join(parts)
68
+ if action.kind == PluginKind.PACKAGE and action.package:
69
+ return f'{action.installer or "pip"} install {action.package}'
70
+ return action.description
@@ -33,7 +33,7 @@ from PySide6.QtWidgets import (
33
33
  QWidget,
34
34
  )
35
35
 
36
- from synodic_client.application.screen import ACTION_KIND_LABELS, skip_reason_label
36
+ from synodic_client.application.screen import ACTION_KIND_LABELS, format_cli_command, skip_reason_label
37
37
  from synodic_client.application.theme import (
38
38
  ACTION_CARD_COMMAND_STYLE,
39
39
  ACTION_CARD_DESC_STYLE,
@@ -48,6 +48,7 @@ from synodic_client.application.theme import (
48
48
  ACTION_CARD_STATUS_DONE,
49
49
  ACTION_CARD_STATUS_FAILED,
50
50
  ACTION_CARD_STATUS_NEEDED,
51
+ ACTION_CARD_STATUS_PENDING,
51
52
  ACTION_CARD_STATUS_RUNNING,
52
53
  ACTION_CARD_STATUS_SATISFIED,
53
54
  ACTION_CARD_STATUS_SKIPPED,
@@ -113,12 +114,14 @@ def action_sort_key(action: SetupAction) -> int:
113
114
 
114
115
 
115
116
  def _format_command(action: SetupAction) -> str:
116
- """Return a short CLI command string for display."""
117
- if parts := (action.cli_command or action.command):
118
- return ' '.join(parts)
119
- if action.kind == PluginKind.PACKAGE and action.package:
120
- return f'{action.installer or "pip"} install {action.package}'
121
- return ''
117
+ """Return a short CLI command string for display.
118
+
119
+ Wraps :func:`~synodic_client.application.screen.format_cli_command`
120
+ but returns an empty string instead of the description fallback so
121
+ cards only show an explicit command line.
122
+ """
123
+ text = format_cli_command(action)
124
+ return '' if text == action.description else text
122
125
 
123
126
 
124
127
  # ---------------------------------------------------------------------------
@@ -138,7 +141,7 @@ class _CardSpinner(QWidget):
138
141
  self._angle = 0
139
142
  self.setFixedSize(ACTION_CARD_SPINNER_SIZE, ACTION_CARD_SPINNER_SIZE)
140
143
 
141
- def paintEvent(self, _event: object) -> None: # noqa: N802
144
+ def paintEvent(self, _event: object) -> None:
142
145
  """Draw the muted track and animated highlight arc."""
143
146
  painter = QPainter(self)
144
147
  painter.setRenderHint(QPainter.RenderHint.Antialiasing)
@@ -379,7 +382,7 @@ class ActionCard(QFrame):
379
382
  # Mouse events (toggle log)
380
383
  # ------------------------------------------------------------------
381
384
 
382
- def mousePressEvent(self, event: object) -> None: # noqa: N802
385
+ def mousePressEvent(self, event: object) -> None:
383
386
  """Toggle the inline log body on click."""
384
387
  if self._is_skeleton or not hasattr(self, '_log_output'):
385
388
  return
@@ -471,23 +474,8 @@ class ActionCard(QFrame):
471
474
 
472
475
  self._version_label.setText('')
473
476
 
474
- # Status — check plugin presence first
475
- installer_missing = (
476
- action.installer is not None
477
- and action.installer in plugin_installed
478
- and not plugin_installed[action.installer]
479
- )
480
-
481
- if installer_missing:
482
- self._status_label.setText('Not installed')
483
- self._status_label.setStyleSheet(ACTION_CARD_STATUS_UNAVAILABLE)
484
- self._status_label.show()
485
- else:
486
- # Show spinner instead of status text while checking
487
- self._status_label.hide()
488
- self._checking = True
489
- self._spinner_canvas.show()
490
- self._spinner_timer.start()
477
+ # Status
478
+ self._populate_status(action, plugin_installed)
491
479
 
492
480
  # Pre-release checkbox
493
481
  if action.package is not None:
@@ -525,6 +513,40 @@ class ActionCard(QFrame):
525
513
  else:
526
514
  self._command_row.hide()
527
515
 
516
+ def _populate_status(
517
+ self,
518
+ action: SetupAction,
519
+ plugin_installed: dict[str, bool],
520
+ ) -> None:
521
+ """Set the initial status badge during :meth:`populate`.
522
+
523
+ Bare-command actions (``kind is None``) show a static *Pending*
524
+ badge. Plugin-backed actions either flag a missing installer or
525
+ start the dry-run spinner.
526
+ """
527
+ if action.kind is None:
528
+ self._status_label.setText('Pending')
529
+ self._status_label.setStyleSheet(ACTION_CARD_STATUS_PENDING)
530
+ self._status_label.show()
531
+ return
532
+
533
+ installer_missing = (
534
+ action.installer is not None
535
+ and action.installer in plugin_installed
536
+ and not plugin_installed[action.installer]
537
+ )
538
+
539
+ if installer_missing:
540
+ self._status_label.setText('Not installed')
541
+ self._status_label.setStyleSheet(ACTION_CARD_STATUS_UNAVAILABLE)
542
+ self._status_label.show()
543
+ else:
544
+ # Show spinner instead of status text while checking
545
+ self._status_label.hide()
546
+ self._checking = True
547
+ self._spinner_canvas.show()
548
+ self._spinner_timer.start()
549
+
528
550
  def initial_status(self) -> str:
529
551
  """Return the initial status text set during :meth:`populate`."""
530
552
  if self._is_skeleton or not hasattr(self, '_status_label'):
@@ -549,6 +571,15 @@ class ActionCard(QFrame):
549
571
  def set_check_result(self, result: SetupActionResult) -> None:
550
572
  """Update the card with a dry-run check result.
551
573
 
574
+ Handles four cases:
575
+
576
+ * **Skipped (update available)** — amber "Update available" badge.
577
+ * **Skipped (other)** — muted satisfied badge.
578
+ * **Failed** — red "Failed" badge with diagnostic tooltip.
579
+ This covers backend failures surfaced during the dry-run
580
+ (e.g. missing SCM plugin, unresolvable deferred action).
581
+ * **Needed** — default blue badge.
582
+
552
583
  Args:
553
584
  result: The action check result from the preview worker.
554
585
  """
@@ -565,6 +596,15 @@ class ActionCard(QFrame):
565
596
  label = skip_reason_label(result.skip_reason)
566
597
  self._status_label.setText(label)
567
598
  self._status_label.setStyleSheet(ACTION_CARD_STATUS_SATISFIED)
599
+ elif not result.success:
600
+ label = 'Failed'
601
+ self._status_label.setText(label)
602
+ self._status_label.setStyleSheet(ACTION_CARD_STATUS_FAILED)
603
+ logger.warning(
604
+ 'Dry-run check failed for %s: %s',
605
+ self._action.description if self._action else '(unknown)',
606
+ result.message or 'unknown error',
607
+ )
568
608
  else:
569
609
  label = 'Needed'
570
610
  self._status_label.setText(label)
@@ -768,10 +808,7 @@ class ActionCardList(QScrollArea):
768
808
  prerelease_overrides: Package names with user pre-release overrides.
769
809
  """
770
810
  self.clear()
771
- sorted_actions = sorted(
772
- (a for a in actions if a.kind is not None),
773
- key=action_sort_key,
774
- )
811
+ sorted_actions = sorted(actions, key=action_sort_key)
775
812
  for act in sorted_actions:
776
813
  card = ActionCard(self._container)
777
814
  card.populate(
@@ -64,7 +64,7 @@ class ClickableHeader(QWidget):
64
64
 
65
65
  # --- Event handling ---------------------------------------------------
66
66
 
67
- def mousePressEvent(self, _event: object) -> None: # noqa: N802
67
+ def mousePressEvent(self, _event: object) -> None:
68
68
  """Emit :attr:`clicked` on any mouse press."""
69
69
  self.clicked.emit()
70
70
 
@@ -14,6 +14,7 @@ import asyncio
14
14
  import logging
15
15
  import shutil
16
16
  import tempfile
17
+ from dataclasses import dataclass, field
17
18
  from pathlib import Path
18
19
  from typing import Any
19
20
  from urllib.parse import urlparse
@@ -32,11 +33,8 @@ from porringer.schema import (
32
33
  SubActionProgress,
33
34
  SyncStrategy,
34
35
  )
35
- from porringer.schema.plugin import PluginKind
36
- from PySide6.QtCore import Qt, QThread, QTimer, Signal
37
- from PySide6.QtGui import QFont
36
+ from PySide6.QtCore import QThread, QTimer, Signal
38
37
  from PySide6.QtWidgets import (
39
- QApplication,
40
38
  QFileDialog,
41
39
  QFrame,
42
40
  QHBoxLayout,
@@ -45,7 +43,6 @@ from PySide6.QtWidgets import (
45
43
  QMainWindow,
46
44
  QMessageBox,
47
45
  QPushButton,
48
- QToolButton,
49
46
  QVBoxLayout,
50
47
  QWidget,
51
48
  )
@@ -56,19 +53,12 @@ from synodic_client.application.screen.card import CardFrame
56
53
  from synodic_client.application.theme import (
57
54
  ACTION_CARD_SKELETON_BAR_STYLE,
58
55
  CARD_SPACING,
59
- COMMAND_HEADER_STYLE,
60
56
  COMPACT_MARGINS,
61
57
  CONTENT_MARGINS,
62
- COPY_BTN_SIZE,
63
- COPY_BTN_STYLE,
64
- COPY_FEEDBACK_MS,
65
- COPY_ICON,
66
58
  HEADER_STYLE,
67
59
  INSTALL_PREVIEW_MIN_SIZE,
68
60
  METADATA_SKELETON_HEIGHT,
69
61
  METADATA_SKELETON_STYLE,
70
- MONOSPACE_FAMILY,
71
- MONOSPACE_SIZE,
72
62
  MUTED_STYLE,
73
63
  NO_MARGINS,
74
64
  )
@@ -94,13 +84,13 @@ def normalize_manifest_key(path_or_url: str) -> str:
94
84
  return path_or_url
95
85
 
96
86
 
97
- def format_cli_command(action: SetupAction) -> str:
98
- """Return a copyable CLI command string for *action*."""
99
- if parts := (action.cli_command or action.command):
100
- return ' '.join(parts)
101
- if action.kind == PluginKind.PACKAGE and action.package:
102
- return f'{action.installer or "pip"} install {action.package}'
103
- return action.description
87
+ @dataclass(frozen=True, slots=True)
88
+ class InstallConfig:
89
+ """Optional execution parameters for :class:`InstallWorker`."""
90
+
91
+ project_directory: Path | None = None
92
+ strategy: SyncStrategy = SyncStrategy.MINIMAL
93
+ prerelease_packages: set[str] | None = field(default=None)
104
94
 
105
95
 
106
96
  class InstallWorker(QThread):
@@ -116,15 +106,12 @@ class InstallWorker(QThread):
116
106
  sub_progress = Signal(object, object) # (SetupAction, SubActionProgress)
117
107
  error = Signal(str)
118
108
 
119
- def __init__( # noqa: PLR0913
109
+ def __init__(
120
110
  self,
121
111
  porringer: API,
122
112
  manifest_path: Path,
123
113
  cancellation_token: CancellationToken,
124
- *,
125
- project_directory: Path | None = None,
126
- strategy: SyncStrategy = SyncStrategy.MINIMAL,
127
- prerelease_packages: set[str] | None = None,
114
+ config: InstallConfig | None = None,
128
115
  ) -> None:
129
116
  """Initialize the worker.
130
117
 
@@ -132,19 +119,14 @@ class InstallWorker(QThread):
132
119
  porringer: The porringer API instance.
133
120
  manifest_path: Path to the manifest file to execute.
134
121
  cancellation_token: Token for cooperative cancellation.
135
- project_directory: Working directory for project sync actions.
136
- strategy: Sync strategy — ``LATEST`` when upgrades are pending.
137
- prerelease_packages: Package names whose ``include_prereleases``
138
- flag should be forced to ``True``, overriding the manifest
139
- default.
122
+ config: Optional execution parameters (directory, strategy,
123
+ prerelease overrides).
140
124
  """
141
125
  super().__init__()
142
126
  self._porringer = porringer
143
127
  self._manifest_path = manifest_path
144
128
  self._cancellation_token = cancellation_token
145
- self._project_directory = project_directory
146
- self._strategy = strategy
147
- self._prerelease_packages = prerelease_packages
129
+ self._config = config or InstallConfig()
148
130
 
149
131
  def run(self) -> None:
150
132
  """Execute the setup actions on this thread's event loop."""
@@ -161,9 +143,9 @@ class InstallWorker(QThread):
161
143
  """Stream execution events and collect results."""
162
144
  params = SetupParameters(
163
145
  paths=[self._manifest_path],
164
- project_directory=self._project_directory,
165
- strategy=self._strategy,
166
- prerelease_packages=self._prerelease_packages,
146
+ project_directory=self._config.project_directory,
147
+ strategy=self._config.strategy,
148
+ prerelease_packages=self._config.prerelease_packages,
167
149
  )
168
150
  actions: list[SetupAction] = []
169
151
  collected: list[SetupActionResult] = []
@@ -195,109 +177,6 @@ class InstallWorker(QThread):
195
177
  )
196
178
 
197
179
 
198
- class PostInstallSection(QWidget):
199
- """Always-visible section showing bare-command (post-install) actions.
200
-
201
- Bare-command actions (``kind is None``) cannot be dry-run checked, so
202
- they are excluded from the main actions table. This widget gives
203
- them a dedicated, always-visible home with copyable CLI text.
204
- """
205
-
206
- def __init__(self, parent: QWidget | None = None) -> None:
207
- """Initialise the section (hidden until :meth:`populate` is called)."""
208
- super().__init__(parent)
209
- self.hide()
210
-
211
- self._layout = QVBoxLayout(self)
212
- self._layout.setContentsMargins(*NO_MARGINS)
213
- self._layout.setSpacing(4)
214
-
215
- header = QLabel('Post-Install Commands')
216
- header.setStyleSheet(COMMAND_HEADER_STYLE)
217
- self._layout.addWidget(header)
218
-
219
- self._content = QWidget()
220
- self._content_layout = QVBoxLayout(self._content)
221
- self._content_layout.setContentsMargins(*NO_MARGINS)
222
- self._content_layout.setSpacing(4)
223
- self._layout.addWidget(self._content)
224
-
225
- def populate(self, actions: list[SetupAction]) -> None:
226
- """Show command actions from *actions*.
227
-
228
- Only actions whose ``kind`` is ``None`` are shown. If there are
229
- none the widget stays hidden.
230
-
231
- Args:
232
- actions: The full list of setup actions.
233
- """
234
- # Clear previous content
235
- while self._content_layout.count():
236
- item = self._content_layout.takeAt(0)
237
- if item is not None:
238
- widget = item.widget()
239
- if widget is not None:
240
- widget.deleteLater()
241
-
242
- commands = [(i, a) for i, a in enumerate(actions, 1) if a.kind is None]
243
- if not commands:
244
- self.hide()
245
- return
246
-
247
- mono = QFont(MONOSPACE_FAMILY, MONOSPACE_SIZE)
248
- for idx, action in commands:
249
- desc = action.package_description or action.description
250
- label = QLabel(f'{idx}. {desc}')
251
- label.setStyleSheet(COMMAND_HEADER_STYLE)
252
- self._content_layout.addWidget(label)
253
-
254
- field = QLineEdit(format_cli_command(action))
255
- field.setReadOnly(True)
256
- field.setFont(mono)
257
-
258
- row_layout = QHBoxLayout()
259
- row_layout.setContentsMargins(*NO_MARGINS)
260
- row_layout.setSpacing(4)
261
- row_layout.addWidget(field)
262
- row_layout.addWidget(_make_copy_button(field))
263
-
264
- row_widget = QWidget()
265
- row_widget.setLayout(row_layout)
266
- self._content_layout.addWidget(row_widget)
267
-
268
- self.show()
269
-
270
-
271
- def _make_copy_button(field: QLineEdit) -> QToolButton:
272
- """Create a copy-to-clipboard button bound to *field*."""
273
- btn = QToolButton()
274
- btn.setText(COPY_ICON)
275
- btn.setToolTip('Copy to clipboard')
276
- btn.setFixedSize(*COPY_BTN_SIZE)
277
- btn.setStyleSheet(COPY_BTN_STYLE)
278
- btn.setCursor(Qt.CursorShape.PointingHandCursor)
279
- btn.clicked.connect(lambda: _copy_command(field, btn))
280
- return btn
281
-
282
-
283
- def _copy_command(field: QLineEdit, button: QToolButton) -> None:
284
- """Copy the field text to the clipboard and briefly show a check mark."""
285
- clipboard = QApplication.clipboard()
286
- if clipboard:
287
- clipboard.setText(field.text())
288
- button.setText('\u2713')
289
- button.setToolTip('Copied!')
290
-
291
- def _restore() -> None:
292
- try:
293
- button.setText(COPY_ICON)
294
- button.setToolTip('Copy to clipboard')
295
- except RuntimeError:
296
- pass
297
-
298
- QTimer.singleShot(COPY_FEEDBACK_MS, _restore)
299
-
300
-
301
180
  # ---------------------------------------------------------------------------
302
181
  # SetupPreviewWidget — reusable preview + install widget
303
182
  # ---------------------------------------------------------------------------
@@ -400,10 +279,6 @@ class SetupPreviewWidget(QWidget):
400
279
  self._card_list.prerelease_toggled.connect(self._on_prerelease_row_toggled)
401
280
  outer.addWidget(self._card_list, stretch=1)
402
281
 
403
- # Post-install section lives below the card list but still scrolls.
404
- # It starts hidden and is inserted into the layout after populate().
405
- self._post_install_section = PostInstallSection()
406
-
407
282
  # --- Button bar (fixed at bottom) ---
408
283
  button_bar = self._init_button_bar()
409
284
  outer.addLayout(button_bar)
@@ -524,7 +399,6 @@ class SetupPreviewWidget(QWidget):
524
399
  self._prerelease_debounce.stop()
525
400
 
526
401
  self._card_list.clear()
527
- self._post_install_section.hide()
528
402
  self._name_label.hide()
529
403
  self._description_label.hide()
530
404
  self._meta_label.hide()
@@ -643,8 +517,6 @@ class SetupPreviewWidget(QWidget):
643
517
 
644
518
  # Mark installer-missing actions as 'Not installed' in the status list
645
519
  for i, action in enumerate(preview.actions):
646
- if action.kind is None:
647
- continue
648
520
  installer_missing = (
649
521
  action.installer is not None
650
522
  and action.installer in self._plugin_installed
@@ -653,13 +525,6 @@ class SetupPreviewWidget(QWidget):
653
525
  if installer_missing:
654
526
  self._action_statuses[i] = 'Not installed'
655
527
 
656
- # Populate post-install commands and place them after all cards.
657
- self._post_install_section.populate(preview.actions)
658
- self._card_list._layout.insertWidget(
659
- self._card_list._layout.count() - 1,
660
- self._post_install_section,
661
- )
662
-
663
528
  self._install_btn.setEnabled(True)
664
529
 
665
530
  def on_preview_resolved(self, preview: SetupResults) -> None:
@@ -688,6 +553,8 @@ class SetupPreviewWidget(QWidget):
688
553
  self._upgradable_rows.add(row)
689
554
  elif result.skipped:
690
555
  label = skip_reason_label(result.skip_reason)
556
+ elif not result.success:
557
+ label = 'Failed'
691
558
  else:
692
559
  label = 'Needed'
693
560
 
@@ -724,7 +591,8 @@ class SetupPreviewWidget(QWidget):
724
591
  needed = sum(1 for s in self._action_statuses if s == 'Needed')
725
592
  upgradable = len(self._upgradable_rows)
726
593
  unavailable = sum(1 for s in self._action_statuses if s == 'Not installed')
727
- satisfied = total - needed - upgradable - unavailable
594
+ failed = sum(1 for s in self._action_statuses if s == 'Failed')
595
+ satisfied = total - needed - upgradable - unavailable - failed
728
596
 
729
597
  parts: list[str] = []
730
598
  if needed:
@@ -735,21 +603,24 @@ class SetupPreviewWidget(QWidget):
735
603
  parts.append(f'{satisfied} already satisfied')
736
604
  if unavailable:
737
605
  parts.append(f'{unavailable} unavailable (plugin not installed)')
606
+ if failed:
607
+ parts.append(f'{failed} failed')
738
608
 
739
609
  actionable = needed + upgradable
740
- if actionable == 0 and unavailable == 0:
610
+ if actionable == 0 and unavailable == 0 and failed == 0:
741
611
  self._status_label.setText(f'{total} action(s) \u2014 all already satisfied.')
742
612
  self._install_btn.setEnabled(False)
743
613
  else:
744
614
  self._status_label.setText(f'{total} action(s): {", ".join(parts)}.')
745
615
 
746
616
  logger.info(
747
- 'Preview complete: %d total, %d needed, %d upgradable, %d satisfied, %d unavailable',
617
+ 'Preview complete: %d total, %d needed, %d upgradable, %d satisfied, %d unavailable, %d failed',
748
618
  total,
749
619
  needed,
750
620
  upgradable,
751
621
  satisfied,
752
622
  unavailable,
623
+ failed,
753
624
  )
754
625
 
755
626
  def on_preview_error(self, message: str) -> None:
@@ -818,9 +689,11 @@ class SetupPreviewWidget(QWidget):
818
689
  self._porringer,
819
690
  self._manifest_path,
820
691
  self._cancellation_token,
821
- project_directory=self._project_directory,
822
- strategy=strategy,
823
- prerelease_packages=self._prerelease_overrides or None,
692
+ InstallConfig(
693
+ project_directory=self._project_directory,
694
+ strategy=strategy,
695
+ prerelease_packages=self._prerelease_overrides or None,
696
+ ),
824
697
  )
825
698
  worker.action_started.connect(self._on_action_started)
826
699
  worker.sub_progress.connect(self._on_sub_progress)
@@ -566,7 +566,7 @@ class ProjectsView(QWidget):
566
566
  self._loading_spinner.raise_()
567
567
 
568
568
  # ------------------------------------------------------------------
569
- def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802
569
+ def resizeEvent(self, event: QResizeEvent) -> None:
570
570
  """Keep the overlay spinner filling the entire view."""
571
571
  super().resizeEvent(event)
572
572
  self._loading_spinner.setGeometry(self.rect())
@@ -25,7 +25,7 @@ class _Canvas(QWidget):
25
25
  self._angle = 0
26
26
  self.setFixedSize(_SIZE, _SIZE)
27
27
 
28
- def paintEvent(self, _event: object) -> None: # noqa: N802
28
+ def paintEvent(self, _event: object) -> None:
29
29
  """Draw a muted track circle and the animated highlight arc."""
30
30
  painter = QPainter(self)
31
31
  painter.setRenderHint(QPainter.RenderHint.Antialiasing)
@@ -316,7 +316,11 @@ class TrayScreen:
316
316
  """Resolve enabled plugins off-thread, then start the update worker."""
317
317
  loop = asyncio.get_running_loop()
318
318
  config = self._resolve_config()
319
- all_plugins = await loop.run_in_executor(None, lambda: porringer.plugin.list()) # noqa: PLW0108
319
+
320
+ def fetch_plugins() -> list:
321
+ return porringer.plugin.list()
322
+
323
+ all_plugins = await loop.run_in_executor(None, fetch_plugins)
320
324
  all_names = [p.name for p in all_plugins if p.installed]
321
325
  enabled = resolve_enabled_plugins(config, all_names)
322
326