From 234de654e61d66a897715df3d1d633af430745e9 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Wed, 25 Feb 2026 19:55:39 -0800 Subject: [PATCH 1/4] Lint Fixes --- pyproject.toml | 4 + synodic_client/application/bootstrap.py | 2 +- synodic_client/application/icon.py | 12 +- .../application/screen/action_card.py | 4 +- synodic_client/application/screen/card.py | 2 +- synodic_client/application/screen/install.py | 42 +++---- synodic_client/application/screen/screen.py | 2 +- synodic_client/application/screen/spinner.py | 2 +- synodic_client/application/screen/tray.py | 6 +- .../application/screen/update_banner.py | 104 +++++++++--------- synodic_client/cli.py | 2 +- synodic_client/config.py | 13 ++- synodic_client/resolution.py | 2 +- tests/unit/qt/test_action_card.py | 11 +- tests/unit/qt/test_install_preview.py | 10 +- tests/unit/qt/test_log_panel.py | 12 +- tests/unit/qt/test_settings.py | 7 +- tests/unit/qt/test_update_banner.py | 80 ++++++++++---- tests/unit/test_resolution.py | 20 +--- 19 files changed, 191 insertions(+), 146 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 90c7ccf..5e3191f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,10 @@ select = [ "PT", # flake8-pytest-style ] +[tool.ruff.lint.per-file-ignores] +"synodic_client/application/bootstrap.py" = ["E402"] +"synodic_client/cli.py" = ["PLC0415"] + [tool.ruff.lint.pydocstyle] convention = "google" diff --git a/synodic_client/application/bootstrap.py b/synodic_client/application/bootstrap.py index d155bcc..d7b02ba 100644 --- a/synodic_client/application/bootstrap.py +++ b/synodic_client/application/bootstrap.py @@ -44,7 +44,7 @@ remove_startup() # Heavy imports happen here — PySide6, porringer, etc. -from synodic_client.application.qt import application # noqa: E402 +from synodic_client.application.qt import application _uri = next((a for a in sys.argv[1:] if a.lower().startswith(f'{_PROTOCOL_SCHEME}://')), None) application(uri=_uri, dev_mode=_dev_mode) diff --git a/synodic_client/application/icon.py b/synodic_client/application/icon.py index 17a5ec4..a4bad6f 100644 --- a/synodic_client/application/icon.py +++ b/synodic_client/application/icon.py @@ -4,13 +4,14 @@ so every caller shares the same instance. """ +import functools + from PySide6.QtGui import QIcon, QPixmap from synodic_client.client import Client -_cached_icon: QIcon | None = None - +@functools.cache def app_icon() -> QIcon: """Return the shared application ``QIcon``, loading it on first call. @@ -20,8 +21,5 @@ def app_icon() -> QIcon: Returns: A ``QIcon`` backed by the application logo. """ - global _cached_icon # noqa: PLW0603 - if _cached_icon is None: - with Client.resource(Client.icon) as icon_path: - _cached_icon = QIcon(QPixmap(str(icon_path))) - return _cached_icon + with Client.resource(Client.icon) as icon_path: + return QIcon(QPixmap(str(icon_path))) diff --git a/synodic_client/application/screen/action_card.py b/synodic_client/application/screen/action_card.py index 9919424..98e767c 100644 --- a/synodic_client/application/screen/action_card.py +++ b/synodic_client/application/screen/action_card.py @@ -138,7 +138,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self._angle = 0 self.setFixedSize(ACTION_CARD_SPINNER_SIZE, ACTION_CARD_SPINNER_SIZE) - def paintEvent(self, _event: object) -> None: # noqa: N802 + def paintEvent(self, _event: object) -> None: """Draw the muted track and animated highlight arc.""" painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) @@ -379,7 +379,7 @@ def _build_log_output(self) -> QTextEdit: # Mouse events (toggle log) # ------------------------------------------------------------------ - def mousePressEvent(self, event: object) -> None: # noqa: N802 + def mousePressEvent(self, event: object) -> None: """Toggle the inline log body on click.""" if self._is_skeleton or not hasattr(self, '_log_output'): return diff --git a/synodic_client/application/screen/card.py b/synodic_client/application/screen/card.py index 32de31a..53c78cf 100644 --- a/synodic_client/application/screen/card.py +++ b/synodic_client/application/screen/card.py @@ -64,7 +64,7 @@ def header_layout(self) -> QHBoxLayout: # --- Event handling --------------------------------------------------- - def mousePressEvent(self, _event: object) -> None: # noqa: N802 + def mousePressEvent(self, _event: object) -> None: """Emit :attr:`clicked` on any mouse press.""" self.clicked.emit() diff --git a/synodic_client/application/screen/install.py b/synodic_client/application/screen/install.py index 138c25e..5e358f5 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -14,6 +14,7 @@ import logging import shutil import tempfile +from dataclasses import dataclass, field from pathlib import Path from typing import Any from urllib.parse import urlparse @@ -103,6 +104,15 @@ def format_cli_command(action: SetupAction) -> str: return action.description +@dataclass(frozen=True, slots=True) +class InstallConfig: + """Optional execution parameters for :class:`InstallWorker`.""" + + project_directory: Path | None = None + strategy: SyncStrategy = SyncStrategy.MINIMAL + prerelease_packages: set[str] | None = field(default=None) + + class InstallWorker(QThread): """Background worker that executes setup actions via porringer. @@ -116,15 +126,12 @@ class InstallWorker(QThread): sub_progress = Signal(object, object) # (SetupAction, SubActionProgress) error = Signal(str) - def __init__( # noqa: PLR0913 + def __init__( self, porringer: API, manifest_path: Path, cancellation_token: CancellationToken, - *, - project_directory: Path | None = None, - strategy: SyncStrategy = SyncStrategy.MINIMAL, - prerelease_packages: set[str] | None = None, + config: InstallConfig | None = None, ) -> None: """Initialize the worker. @@ -132,19 +139,14 @@ def __init__( # noqa: PLR0913 porringer: The porringer API instance. manifest_path: Path to the manifest file to execute. cancellation_token: Token for cooperative cancellation. - project_directory: Working directory for project sync actions. - strategy: Sync strategy — ``LATEST`` when upgrades are pending. - prerelease_packages: Package names whose ``include_prereleases`` - flag should be forced to ``True``, overriding the manifest - default. + config: Optional execution parameters (directory, strategy, + prerelease overrides). """ super().__init__() self._porringer = porringer self._manifest_path = manifest_path self._cancellation_token = cancellation_token - self._project_directory = project_directory - self._strategy = strategy - self._prerelease_packages = prerelease_packages + self._config = config or InstallConfig() def run(self) -> None: """Execute the setup actions on this thread's event loop.""" @@ -161,9 +163,9 @@ async def _execute(self) -> SetupResults: """Stream execution events and collect results.""" params = SetupParameters( paths=[self._manifest_path], - project_directory=self._project_directory, - strategy=self._strategy, - prerelease_packages=self._prerelease_packages, + project_directory=self._config.project_directory, + strategy=self._config.strategy, + prerelease_packages=self._config.prerelease_packages, ) actions: list[SetupAction] = [] collected: list[SetupActionResult] = [] @@ -818,9 +820,11 @@ def _on_install(self) -> None: self._porringer, self._manifest_path, self._cancellation_token, - project_directory=self._project_directory, - strategy=strategy, - prerelease_packages=self._prerelease_overrides or None, + InstallConfig( + project_directory=self._project_directory, + strategy=strategy, + prerelease_packages=self._prerelease_overrides or None, + ), ) worker.action_started.connect(self._on_action_started) worker.sub_progress.connect(self._on_sub_progress) diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index f816c47..3a5a4eb 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -566,7 +566,7 @@ def _init_ui(self) -> None: self._loading_spinner.raise_() # ------------------------------------------------------------------ - def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 + def resizeEvent(self, event: QResizeEvent) -> None: """Keep the overlay spinner filling the entire view.""" super().resizeEvent(event) self._loading_spinner.setGeometry(self.rect()) diff --git a/synodic_client/application/screen/spinner.py b/synodic_client/application/screen/spinner.py index 000492d..fe121dd 100644 --- a/synodic_client/application/screen/spinner.py +++ b/synodic_client/application/screen/spinner.py @@ -25,7 +25,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self._angle = 0 self.setFixedSize(_SIZE, _SIZE) - def paintEvent(self, _event: object) -> None: # noqa: N802 + def paintEvent(self, _event: object) -> None: """Draw a muted track circle and the animated highlight arc.""" painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) diff --git a/synodic_client/application/screen/tray.py b/synodic_client/application/screen/tray.py index 7e0f3aa..84a41e0 100644 --- a/synodic_client/application/screen/tray.py +++ b/synodic_client/application/screen/tray.py @@ -316,7 +316,11 @@ async def _do_tool_update(self, porringer: API) -> None: """Resolve enabled plugins off-thread, then start the update worker.""" loop = asyncio.get_running_loop() config = self._resolve_config() - all_plugins = await loop.run_in_executor(None, lambda: porringer.plugin.list()) # noqa: PLW0108 + + def fetch_plugins() -> list: + return porringer.plugin.list() + + all_plugins = await loop.run_in_executor(None, fetch_plugins) all_names = [p.name for p in all_plugins if p.installed] enabled = resolve_enabled_plugins(config, all_names) diff --git a/synodic_client/application/screen/update_banner.py b/synodic_client/application/screen/update_banner.py index a32b9a9..851d7bf 100644 --- a/synodic_client/application/screen/update_banner.py +++ b/synodic_client/application/screen/update_banner.py @@ -15,6 +15,7 @@ from __future__ import annotations import logging +from dataclasses import dataclass from enum import Enum, auto from PySide6.QtCore import ( @@ -60,6 +61,20 @@ class UpdateBannerState(Enum): ERROR = auto() +@dataclass(frozen=True, slots=True) +class _BannerConfig: + """Bundled visual configuration for a banner state transition.""" + + state: UpdateBannerState + style: str + icon: str + text: str + text_style: str + version: str = '' + action_label: str = '' + show_progress: bool = False + + # Height of the banner content (progress variant is slightly taller). _BANNER_HEIGHT = 38 _BANNER_HEIGHT_WITH_PROGRESS = 44 @@ -79,6 +94,7 @@ class UpdateBanner(QFrame): dismissed = Signal() def __init__(self, parent: QWidget | None = None) -> None: + """Initialise the banner (starts hidden and fully collapsed).""" super().__init__(parent) self.setObjectName('updateBanner') self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) @@ -151,13 +167,15 @@ def show_downloading(self, version: str) -> None: version: The version string being downloaded (e.g. ``"0.0.1.dev35"``). """ self._configure( - state=UpdateBannerState.DOWNLOADING, - version=version, - style=UPDATE_BANNER_STYLE, - icon='\u2b07', - text=f'Downloading update {version}\u2026', - text_style=UPDATE_BANNER_MESSAGE_STYLE, - show_progress=True, + _BannerConfig( + state=UpdateBannerState.DOWNLOADING, + version=version, + style=UPDATE_BANNER_STYLE, + icon='\u2b07', + text=f'Downloading update {version}\u2026', + text_style=UPDATE_BANNER_MESSAGE_STYLE, + show_progress=True, + ) ) def show_downloading_progress(self, percentage: int) -> None: @@ -180,13 +198,15 @@ def show_ready(self, version: str) -> None: version: The version that is ready to install. """ self._configure( - state=UpdateBannerState.READY, - version=version, - style=UPDATE_BANNER_READY_STYLE, - icon='\u2705', - text=f'Update {version} is ready \u2014 restart to finish installing', - text_style=UPDATE_BANNER_VERSION_STYLE, - action_label='Restart Now', + _BannerConfig( + state=UpdateBannerState.READY, + version=version, + style=UPDATE_BANNER_READY_STYLE, + icon='\u2705', + text=f'Update {version} is ready \u2014 restart to finish installing', + text_style=UPDATE_BANNER_VERSION_STYLE, + action_label='Restart Now', + ) ) def show_error(self, message: str) -> None: @@ -196,12 +216,14 @@ def show_error(self, message: str) -> None: message: Human-readable error description. """ self._configure( - state=UpdateBannerState.ERROR, - style=UPDATE_BANNER_ERROR_STYLE, - icon='\u26a0', - text=message, - text_style=UPDATE_BANNER_MESSAGE_STYLE, - action_label='Retry', + _BannerConfig( + state=UpdateBannerState.ERROR, + style=UPDATE_BANNER_ERROR_STYLE, + icon='\u26a0', + text=message, + text_style=UPDATE_BANNER_MESSAGE_STYLE, + action_label='Retry', + ) ) QTimer.singleShot(UPDATE_BANNER_ERROR_DISMISS_MS, self._auto_dismiss_error) @@ -214,51 +236,33 @@ def hide_banner(self) -> None: # --- Internal --- - def _configure( - self, - *, - state: UpdateBannerState, - style: str, - icon: str, - text: str, - text_style: str, - version: str = '', - action_label: str = '', - show_progress: bool = False, - ) -> None: + def _configure(self, config: _BannerConfig) -> None: """Apply common visual configuration and slide the banner in. Args: - state: The new banner state. - style: QSS for the banner frame. - icon: Single character displayed as the leading icon. - text: Message (may contain HTML). - text_style: QSS for the message label. - version: Version string to store (optional). - action_label: Text for the action button; hidden when empty. - show_progress: Whether to show the progress bar. + config: Bundled visual properties for the new state. """ - self._state = state - self._target_version = version + self._state = config.state + self._target_version = config.version - self.setStyleSheet(style) - self._icon_label.setText(icon) - self._message.setText(text) - self._message.setStyleSheet(text_style) + self.setStyleSheet(config.style) + self._icon_label.setText(config.icon) + self._message.setText(config.text) + self._message.setStyleSheet(config.text_style) - if action_label: - self._action_btn.setText(action_label) + if config.action_label: + self._action_btn.setText(config.action_label) self._action_btn.show() else: self._action_btn.hide() - if show_progress: + if config.show_progress: self._progress.setRange(0, 0) # indeterminate self._progress.show() else: self._progress.hide() - target_height = _BANNER_HEIGHT_WITH_PROGRESS if show_progress else _BANNER_HEIGHT + target_height = _BANNER_HEIGHT_WITH_PROGRESS if config.show_progress else _BANNER_HEIGHT self._slide_in(target_height) def _slide_in(self, target_height: int) -> None: diff --git a/synodic_client/cli.py b/synodic_client/cli.py index ca7494f..9b9a5a8 100644 --- a/synodic_client/cli.py +++ b/synodic_client/cli.py @@ -37,6 +37,6 @@ def main( ] = False, ) -> None: """Launch the Synodic Client GUI application.""" - from synodic_client.application.qt import application # noqa: PLC0415 + from synodic_client.application.qt import application application(uri=uri, dev_mode=dev) diff --git a/synodic_client/config.py b/synodic_client/config.py index 09f1387..40bc96b 100644 --- a/synodic_client/config.py +++ b/synodic_client/config.py @@ -28,7 +28,11 @@ _APP_NAME_DEV = 'Synodic-Dev' _CONFIG_FILENAME = 'config.json' -_dev_mode: bool = False + +class _DevMode: + """Module-level mutable state (avoids ``global`` statements).""" + + enabled: bool = False def set_dev_mode(enabled: bool) -> None: @@ -43,13 +47,12 @@ def set_dev_mode(enabled: bool) -> None: Args: enabled: ``True`` to activate dev-mode namespacing. """ - global _dev_mode # noqa: PLW0603 - _dev_mode = enabled + _DevMode.enabled = enabled def is_dev_mode() -> bool: """Return whether dev-mode namespacing is active.""" - return _dev_mode + return _DevMode.enabled # --------------------------------------------------------------------------- @@ -167,7 +170,7 @@ def config_dir() -> Path: Returns: Path to the configuration directory. """ - app_name = _APP_NAME_DEV if _dev_mode else _APP_NAME + app_name = _APP_NAME_DEV if _DevMode.enabled else _APP_NAME if sys.platform == 'win32': base = os.environ.get('LOCALAPPDATA', '') diff --git a/synodic_client/resolution.py b/synodic_client/resolution.py index f04f1cb..6a3614a 100644 --- a/synodic_client/resolution.py +++ b/synodic_client/resolution.py @@ -231,7 +231,7 @@ def resolve_version(client: object) -> Version: try: if updater.is_installed: return updater.current_version - except Exception: # noqa: BLE001 + except Exception: logger.debug('Failed to query Velopack version, falling back', exc_info=True) return getattr(client, 'version', Version('0.0.0')) diff --git a/tests/unit/qt/test_action_card.py b/tests/unit/qt/test_action_card.py index 976dc88..0c09edf 100644 --- a/tests/unit/qt/test_action_card.py +++ b/tests/unit/qt/test_action_card.py @@ -3,6 +3,7 @@ from __future__ import annotations import sys +from typing import Any from unittest.mock import MagicMock from porringer.schema import ( @@ -50,7 +51,7 @@ def _make_action( description: str = 'Install requests', installer: str = 'pip', package: str = 'requests', - **overrides: object, + **overrides: Any, ) -> SetupAction: """Create a mock SetupAction with sensible defaults. @@ -80,7 +81,7 @@ def _make_result( skipped: bool = False, skip_reason: SkipReason | None = None, message: str | None = None, - **overrides: object, + **overrides: Any, ) -> SetupActionResult: """Create a SetupActionResult. @@ -88,13 +89,13 @@ def _make_result( ``available_version``) are forwarded to the constructor. """ return SetupActionResult( - action=overrides.get('action') or _make_action(), # type: ignore[arg-type] + action=overrides.get('action') or _make_action(), success=success, skipped=skipped, skip_reason=skip_reason, message=message, - installed_version=overrides.get('installed_version'), # type: ignore[arg-type] - available_version=overrides.get('available_version'), # type: ignore[arg-type] + installed_version=overrides.get('installed_version'), + available_version=overrides.get('available_version'), ) diff --git a/tests/unit/qt/test_install_preview.py b/tests/unit/qt/test_install_preview.py index 4df3743..f681c12 100644 --- a/tests/unit/qt/test_install_preview.py +++ b/tests/unit/qt/test_install_preview.py @@ -24,6 +24,7 @@ skip_reason_label, ) from synodic_client.application.screen.install import ( + InstallConfig, InstallWorker, PreviewWorker, format_cli_command, @@ -32,6 +33,7 @@ ) _DOWNLOAD_PATCH = 'synodic_client.application.screen.install.API.download' +_EXPECTED_CHECKED_COUNT = 2 class TestInstallPreviewWindow: @@ -268,7 +270,7 @@ def test_worker_emits_finished_on_success() -> None: result=result, ) - async def mock_stream(*args, **kwargs): # noqa: ANN002, ANN003 + async def mock_stream(*args, **kwargs): yield manifest_event yield completed_event @@ -290,7 +292,7 @@ def test_worker_emits_error_on_failure() -> None: porringer = MagicMock() manifest_path = Path('/tmp/test/porringer.json') - async def mock_stream(*args, **kwargs): # noqa: ANN002, ANN003 + async def mock_stream(*args, **kwargs): if False: yield # pragma: no cover — establishes async generator protocol msg = 'boom' @@ -330,7 +332,7 @@ async def mock_stream(params: Any) -> Any: porringer, manifest_path, token, - prerelease_packages={'cppython'}, + InstallConfig(prerelease_packages={'cppython'}), ) worker.run() @@ -602,7 +604,7 @@ async def mock_stream(*args: Any, **kwargs: Any) -> Any: worker.action_checked.connect(lambda row, r: checked.append((row, r))) worker.run() - assert len(checked) == 2 # noqa: PLR2004 + assert len(checked) == _EXPECTED_CHECKED_COUNT # action_b is at index 1 in preview, action_a at index 0 assert checked[0] == (1, result_b) assert checked[1] == (0, result_a) diff --git a/tests/unit/qt/test_log_panel.py b/tests/unit/qt/test_log_panel.py index 001aea0..852a556 100644 --- a/tests/unit/qt/test_log_panel.py +++ b/tests/unit/qt/test_log_panel.py @@ -40,6 +40,8 @@ LOG_STATUS_SUCCESS, ) +_EXPECTED_SECTION_COUNT = 2 + _app = QApplication.instance() or QApplication(sys.argv) # --------------------------------------------------------------------------- @@ -311,7 +313,7 @@ def test_add_section_increments_index() -> None: panel.add_section(a1) panel.add_section(a2) - assert panel._section_count == 2 # noqa: PLR2004 + assert panel._section_count == _EXPECTED_SECTION_COUNT @staticmethod def test_get_section_returns_correct_section() -> None: @@ -473,7 +475,7 @@ def test_emits_action_started() -> None: result=result, ) - async def mock_stream(*args, **kwargs): # noqa: ANN002, ANN003 + async def mock_stream(*args, **kwargs): yield manifest_event yield started_event yield completed_event @@ -518,7 +520,7 @@ def test_emits_sub_progress() -> None: result=result, ) - async def mock_stream(*args, **kwargs): # noqa: ANN002, ANN003 + async def mock_stream(*args, **kwargs): yield manifest_event yield sub_event yield completed_event @@ -557,7 +559,7 @@ def test_sub_progress_requires_action_and_sub_action() -> None: result=result, ) - async def mock_stream(*args, **kwargs): # noqa: ANN002, ANN003 + async def mock_stream(*args, **kwargs): yield manifest_event yield bad_event_1 yield bad_event_2 @@ -592,7 +594,7 @@ def test_action_started_requires_action() -> None: result=result, ) - async def mock_stream(*args, **kwargs): # noqa: ANN002, ANN003 + async def mock_stream(*args, **kwargs): yield manifest_event yield bad_event yield completed_event diff --git a/tests/unit/qt/test_settings.py b/tests/unit/qt/test_settings.py index 7e5a80a..ab34abe 100644 --- a/tests/unit/qt/test_settings.py +++ b/tests/unit/qt/test_settings.py @@ -3,6 +3,7 @@ from __future__ import annotations import sys +from typing import Any from unittest.mock import MagicMock, patch from PySide6.QtWidgets import QApplication @@ -20,9 +21,9 @@ # --------------------------------------------------------------------------- -def _make_config(**overrides: object) -> ResolvedConfig: +def _make_config(**overrides: Any) -> ResolvedConfig: """Create a ``ResolvedConfig`` with sensible defaults and optional overrides.""" - defaults: dict[str, object] = { + defaults: dict[str, Any] = { 'update_source': None, 'update_channel': 'stable', 'auto_update_interval_minutes': DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES, @@ -33,7 +34,7 @@ def _make_config(**overrides: object) -> ResolvedConfig: 'auto_start': True, } defaults.update(overrides) - return ResolvedConfig(**defaults) # type: ignore[arg-type] + return ResolvedConfig(**defaults) def _make_window(config: ResolvedConfig | None = None) -> SettingsWindow: diff --git a/tests/unit/qt/test_update_banner.py b/tests/unit/qt/test_update_banner.py index 2f1d16c..bdcef27 100644 --- a/tests/unit/qt/test_update_banner.py +++ b/tests/unit/qt/test_update_banner.py @@ -10,6 +10,9 @@ _app = QApplication.instance() or QApplication(sys.argv) +_PROGRESS_MAX = 100 +_TEST_PROGRESS = 42 + # --------------------------------------------------------------------------- # Construction @@ -19,17 +22,23 @@ class TestUpdateBannerConstruction: """Basic construction and default state.""" - def test_starts_hidden(self) -> None: + @staticmethod + def test_starts_hidden() -> None: + """Banner starts in HIDDEN state with zero height.""" banner = UpdateBanner() assert banner.state == UpdateBannerState.HIDDEN assert banner.maximumHeight() == 0 assert not banner.isVisible() - def test_progress_bar_hidden_initially(self) -> None: + @staticmethod + def test_progress_bar_hidden_initially() -> None: + """Progress bar is not visible on a fresh banner.""" banner = UpdateBanner() assert not banner._progress.isVisible() - def test_action_btn_hidden_initially(self) -> None: + @staticmethod + def test_action_btn_hidden_initially() -> None: + """Action button is not visible on a fresh banner.""" banner = UpdateBanner() assert not banner._action_btn.isVisible() @@ -42,7 +51,9 @@ def test_action_btn_hidden_initially(self) -> None: class TestUpdateBannerStateTransitions: """Verify visual state transitions.""" - def test_show_downloading(self) -> None: + @staticmethod + def test_show_downloading() -> None: + """Downloading state shows progress and hides action button.""" banner = UpdateBanner() banner.show_downloading('1.2.3') assert banner.state == UpdateBannerState.DOWNLOADING @@ -51,22 +62,26 @@ def test_show_downloading(self) -> None: assert not banner._action_btn.isVisible() assert '1.2.3' in banner._message.text() - def test_show_downloading_progress(self) -> None: + @staticmethod + def test_show_downloading_progress() -> None: + """First progress value switches bar from indeterminate to determinate.""" banner = UpdateBanner() banner.show_downloading('1.0.0') - # First progress value switches from indeterminate to determinate - banner.show_downloading_progress(42) - assert banner._progress.maximum() == 100 - assert banner._progress.value() == 42 + banner.show_downloading_progress(_TEST_PROGRESS) + assert banner._progress.maximum() == _PROGRESS_MAX + assert banner._progress.value() == _TEST_PROGRESS - def test_show_downloading_progress_ignored_when_not_downloading(self) -> None: + @staticmethod + def test_show_downloading_progress_ignored_when_not_downloading() -> None: + """Progress updates are ignored when not in DOWNLOADING state.""" banner = UpdateBanner() banner.show_ready('1.0.0') - # Should be a no-op, not crash banner.show_downloading_progress(50) assert banner.state == UpdateBannerState.READY - def test_show_ready(self) -> None: + @staticmethod + def test_show_ready() -> None: + """Ready state shows Restart Now button and hides progress.""" banner = UpdateBanner() banner.show_ready('2.0.0') assert banner.state == UpdateBannerState.READY @@ -75,7 +90,9 @@ def test_show_ready(self) -> None: assert not banner._progress.isVisible() assert '2.0.0' in banner._message.text() - def test_show_error(self) -> None: + @staticmethod + def test_show_error() -> None: + """Error state shows Retry button and the error message.""" banner = UpdateBanner() banner.show_error('Something broke') assert banner.state == UpdateBannerState.ERROR @@ -83,19 +100,25 @@ def test_show_error(self) -> None: assert banner._action_btn.text() == 'Retry' assert 'Something broke' in banner._message.text() - def test_hide_banner(self) -> None: + @staticmethod + def test_hide_banner() -> None: + """Hiding a visible banner resets state to HIDDEN.""" banner = UpdateBanner() banner.show_ready('1.0.0') assert banner.state == UpdateBannerState.READY banner.hide_banner() assert banner.state == UpdateBannerState.HIDDEN - def test_hide_banner_noop_when_already_hidden(self) -> None: + @staticmethod + def test_hide_banner_noop_when_already_hidden() -> None: + """Hiding an already hidden banner is a safe no-op.""" banner = UpdateBanner() banner.hide_banner() # should not raise assert banner.state == UpdateBannerState.HIDDEN - def test_downloading_to_ready_transition(self) -> None: + @staticmethod + def test_downloading_to_ready_transition() -> None: + """Transitioning from downloading to ready hides the progress bar.""" banner = UpdateBanner() banner.show_downloading('3.0.0') assert banner.state == UpdateBannerState.DOWNLOADING @@ -103,7 +126,9 @@ def test_downloading_to_ready_transition(self) -> None: assert banner.state == UpdateBannerState.READY assert not banner._progress.isVisible() - def test_error_to_downloading_transition(self) -> None: + @staticmethod + def test_error_to_downloading_transition() -> None: + """Transitioning from error to downloading is allowed.""" banner = UpdateBanner() banner.show_error('fail') assert banner.state == UpdateBannerState.ERROR @@ -119,7 +144,9 @@ def test_error_to_downloading_transition(self) -> None: class TestUpdateBannerSignals: """Verify signal emissions from user actions.""" - def test_restart_signal_on_ready_action(self) -> None: + @staticmethod + def test_restart_signal_on_ready_action() -> None: + """Clicking the action button in READY state emits restart_requested.""" banner = UpdateBanner() banner.show_ready('1.0.0') @@ -128,7 +155,9 @@ def test_restart_signal_on_ready_action(self) -> None: banner._action_btn.click() assert received == [True] - def test_retry_signal_on_error_action(self) -> None: + @staticmethod + def test_retry_signal_on_error_action() -> None: + """Clicking the action button in ERROR state emits retry_requested.""" banner = UpdateBanner() banner.show_error('oops') @@ -137,7 +166,9 @@ def test_retry_signal_on_error_action(self) -> None: banner._action_btn.click() assert received == [True] - def test_dismissed_signal_on_dismiss(self) -> None: + @staticmethod + def test_dismissed_signal_on_dismiss() -> None: + """Clicking dismiss emits dismissed and resets to HIDDEN.""" banner = UpdateBanner() banner.show_ready('1.0.0') @@ -147,7 +178,8 @@ def test_dismissed_signal_on_dismiss(self) -> None: assert received == [True] assert banner.state == UpdateBannerState.HIDDEN - def test_action_btn_click_when_hidden_is_noop(self) -> None: + @staticmethod + def test_action_btn_click_when_hidden_is_noop() -> None: """Clicking the action button when hidden should emit no signal.""" banner = UpdateBanner() @@ -166,7 +198,8 @@ def test_action_btn_click_when_hidden_is_noop(self) -> None: class TestUpdateBannerAutoDismiss: """Verify the error banner auto-dismiss timer.""" - def test_error_auto_dismiss_resets_to_hidden(self) -> None: + @staticmethod + def test_error_auto_dismiss_resets_to_hidden() -> None: """The error banner should auto-dismiss after the configured delay.""" banner = UpdateBanner() banner.show_error('transient error') @@ -176,7 +209,8 @@ def test_error_auto_dismiss_resets_to_hidden(self) -> None: banner._auto_dismiss_error() assert banner.state == UpdateBannerState.HIDDEN - def test_auto_dismiss_noop_if_state_changed(self) -> None: + @staticmethod + def test_auto_dismiss_noop_if_state_changed() -> None: """If the state changed before the timer fires, it's a no-op.""" banner = UpdateBanner() banner.show_error('oops') diff --git a/tests/unit/test_resolution.py b/tests/unit/test_resolution.py index c1b0384..e8f79a5 100644 --- a/tests/unit/test_resolution.py +++ b/tests/unit/test_resolution.py @@ -1,10 +1,9 @@ """Tests for the configuration resolution module.""" -import dataclasses from pathlib import Path +from typing import Any from unittest.mock import MagicMock, PropertyMock, patch -import pytest from packaging.version import Version from synodic_client.config import BuildConfig, UserConfig @@ -29,9 +28,9 @@ # --------------------------------------------------------------------------- -def _make_resolved(**overrides: object) -> ResolvedConfig: +def _make_resolved(**overrides: Any) -> ResolvedConfig: """Create a ``ResolvedConfig`` with sensible defaults and optional overrides.""" - defaults: dict[str, object] = { + defaults: dict[str, Any] = { 'update_source': None, 'update_channel': 'stable', 'auto_update_interval_minutes': DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES, @@ -42,7 +41,7 @@ def _make_resolved(**overrides: object) -> ResolvedConfig: 'auto_start': True, } defaults.update(overrides) - return ResolvedConfig(**defaults) # type: ignore[arg-type] + return ResolvedConfig(**defaults) # --------------------------------------------------------------------------- @@ -180,17 +179,6 @@ def test_none_auto_start_resolves_to_true() -> None: assert config.auto_start is True - @staticmethod - def test_config_is_frozen() -> None: - """Verify ResolvedConfig is immutable.""" - with patch('synodic_client.resolution.load_user_config', return_value=UserConfig()): - config = resolve_config() - - assert dataclasses.is_dataclass(config) - - with pytest.raises(dataclasses.FrozenInstanceError): - config.update_channel = 'dev' # type: ignore[misc] - # --------------------------------------------------------------------------- # update_user_config From 98f5e0a0e59e24b2482ea45fc2d8f2242b578111 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Wed, 25 Feb 2026 20:26:01 -0800 Subject: [PATCH 2/4] Update Failed Plugin Behavior --- .../application/screen/action_card.py | 18 +++++ synodic_client/application/screen/install.py | 12 +++- synodic_client/logging.py | 10 ++- tests/unit/qt/test_action_card.py | 65 +++++++++++++++++ tests/unit/qt/test_logging.py | 71 +++++++++++++++++++ tool/pyinstaller/synodic.spec | 22 +++--- 6 files changed, 186 insertions(+), 12 deletions(-) diff --git a/synodic_client/application/screen/action_card.py b/synodic_client/application/screen/action_card.py index 98e767c..5b2bb2e 100644 --- a/synodic_client/application/screen/action_card.py +++ b/synodic_client/application/screen/action_card.py @@ -549,6 +549,15 @@ def _stop_spinner(self) -> None: def set_check_result(self, result: SetupActionResult) -> None: """Update the card with a dry-run check result. + Handles four cases: + + * **Skipped (update available)** — amber "Update available" badge. + * **Skipped (other)** — muted satisfied badge. + * **Failed** — red "Failed" badge with diagnostic tooltip. + This covers backend failures surfaced during the dry-run + (e.g. missing SCM plugin, unresolvable deferred action). + * **Needed** — default blue badge. + Args: result: The action check result from the preview worker. """ @@ -565,6 +574,15 @@ def set_check_result(self, result: SetupActionResult) -> None: label = skip_reason_label(result.skip_reason) self._status_label.setText(label) self._status_label.setStyleSheet(ACTION_CARD_STATUS_SATISFIED) + elif not result.success: + label = 'Failed' + self._status_label.setText(label) + self._status_label.setStyleSheet(ACTION_CARD_STATUS_FAILED) + logger.warning( + 'Dry-run check failed for %s: %s', + self._action.description if self._action else '(unknown)', + result.message or 'unknown error', + ) else: label = 'Needed' self._status_label.setText(label) diff --git a/synodic_client/application/screen/install.py b/synodic_client/application/screen/install.py index 5e358f5..fcccc5e 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -690,6 +690,8 @@ def on_action_checked(self, row: int, result: SetupActionResult) -> None: self._upgradable_rows.add(row) elif result.skipped: label = skip_reason_label(result.skip_reason) + elif not result.success: + label = 'Failed' else: label = 'Needed' @@ -726,7 +728,8 @@ def on_preview_finished(self) -> None: needed = sum(1 for s in self._action_statuses if s == 'Needed') upgradable = len(self._upgradable_rows) unavailable = sum(1 for s in self._action_statuses if s == 'Not installed') - satisfied = total - needed - upgradable - unavailable + failed = sum(1 for s in self._action_statuses if s == 'Failed') + satisfied = total - needed - upgradable - unavailable - failed parts: list[str] = [] if needed: @@ -737,21 +740,24 @@ def on_preview_finished(self) -> None: parts.append(f'{satisfied} already satisfied') if unavailable: parts.append(f'{unavailable} unavailable (plugin not installed)') + if failed: + parts.append(f'{failed} failed') actionable = needed + upgradable - if actionable == 0 and unavailable == 0: + if actionable == 0 and unavailable == 0 and failed == 0: self._status_label.setText(f'{total} action(s) \u2014 all already satisfied.') self._install_btn.setEnabled(False) else: self._status_label.setText(f'{total} action(s): {", ".join(parts)}.') logger.info( - 'Preview complete: %d total, %d needed, %d upgradable, %d satisfied, %d unavailable', + 'Preview complete: %d total, %d needed, %d upgradable, %d satisfied, %d unavailable, %d failed', total, needed, upgradable, satisfied, unavailable, + failed, ) def on_preview_error(self, message: str) -> None: diff --git a/synodic_client/logging.py b/synodic_client/logging.py index 70d3e26..cecaf4d 100644 --- a/synodic_client/logging.py +++ b/synodic_client/logging.py @@ -4,6 +4,7 @@ """ import logging +import sys import tempfile from logging.handlers import RotatingFileHandler from pathlib import Path @@ -71,4 +72,11 @@ def configure_logging() -> None: porringer_logger = logging.getLogger('porringer') porringer_logger.addHandler(handler) - porringer_logger.setLevel(logging.INFO) + + # In frozen (PyInstaller) builds, capture porringer DEBUG output so + # that plugin discovery details appear in the log file for post-mortem + # diagnostics. In dev mode, keep INFO to reduce noise. + if getattr(sys, 'frozen', False): + porringer_logger.setLevel(logging.DEBUG) + else: + porringer_logger.setLevel(logging.INFO) diff --git a/tests/unit/qt/test_action_card.py b/tests/unit/qt/test_action_card.py index 0c09edf..ed32848 100644 --- a/tests/unit/qt/test_action_card.py +++ b/tests/unit/qt/test_action_card.py @@ -329,6 +329,71 @@ def test_finalize_checking_leaves_not_installed() -> None: assert card.status_text() == 'Not installed' +# --------------------------------------------------------------------------- +# ActionCard — dry-run check failure (success=False) +# --------------------------------------------------------------------------- + + +class TestActionCardCheckFailure: + """Tests for set_check_result when the dry-run returns a failure.""" + + @staticmethod + def test_failed_check_shows_failed_status() -> None: + """A check result with success=False shows 'Failed'.""" + card = ActionCard() + card.populate(_make_action(kind=PluginKind.SCM, package='periapsis', installer='git')) + result = _make_result( + success=False, + skipped=False, + message="No SCM plugin was found for ecosystem 'git'.", + ) + card.set_check_result(result) + assert card.status_text() == 'Failed' + assert ACTION_CARD_STATUS_FAILED in card._status_label.styleSheet() + + @staticmethod + def test_failed_check_shows_error_tooltip() -> None: + """A failed check result surfaces the error message as a tooltip.""" + card = ActionCard() + action = _make_action(kind=PluginKind.SCM, package='repo') + action.installer = None # Simulate an unresolved deferred action + card.populate(action) + msg = "SCM environment 'None' is not available" + result = _make_result(success=False, skipped=False, message=msg) + card.set_check_result(result) + assert card._status_label.toolTip() == msg + + @staticmethod + def test_failed_check_stops_spinner() -> None: + """A failed check result stops the inline spinner.""" + card = ActionCard() + card.populate(_make_action()) + assert card._checking + result = _make_result(success=False, skipped=False, message='error') + card.set_check_result(result) + assert not card._checking + assert not card._spinner_timer.isActive() + + @staticmethod + def test_failed_check_not_update_available() -> None: + """A failed check is not considered 'Update available'.""" + card = ActionCard() + card.populate(_make_action()) + result = _make_result(success=False, skipped=False, message='backend missing') + card.set_check_result(result) + assert not card.is_update_available() + + @staticmethod + def test_success_true_still_needed() -> None: + """A non-skipped, successful result still shows 'Needed'.""" + card = ActionCard() + card.populate(_make_action()) + result = _make_result(success=True, skipped=False) + card.set_check_result(result) + assert card.status_text() == 'Needed' + assert ACTION_CARD_STATUS_NEEDED in card._status_label.styleSheet() + + # --------------------------------------------------------------------------- # ActionCard — execution (inline log) # --------------------------------------------------------------------------- diff --git a/tests/unit/qt/test_logging.py b/tests/unit/qt/test_logging.py index 375ac34..c753c33 100644 --- a/tests/unit/qt/test_logging.py +++ b/tests/unit/qt/test_logging.py @@ -1,6 +1,7 @@ """Tests for the centralised logging module.""" import logging +import sys import tempfile from pathlib import Path from unittest.mock import patch @@ -134,3 +135,73 @@ def test_opens_existing_file(tmp_path: Path) -> None: ): SettingsWindow._open_log() mock_ds.openUrl.assert_called_once() + + +class TestPorringerLogLevel: + """Tests for porringer logger level in frozen vs normal builds.""" + + @staticmethod + def test_frozen_build_sets_porringer_debug(tmp_path: Path) -> None: + """In frozen builds, porringer logger should be set to DEBUG.""" + porringer_logger = logging.getLogger('porringer') + app_logger = logging.getLogger('synodic_client') + + # Remove any existing EagerRotatingFileHandler so configure_logging re-runs + for h in list(app_logger.handlers): + if isinstance(h, EagerRotatingFileHandler): + app_logger.removeHandler(h) + h.close() + + with ( + patch('synodic_client.logging.log_path', return_value=tmp_path / 'synodic.log'), + patch.object(sys, 'frozen', True, create=True), + ): + configure_logging() + + assert porringer_logger.level == logging.DEBUG + + # Clean up + for h in list(app_logger.handlers): + if isinstance(h, EagerRotatingFileHandler): + app_logger.removeHandler(h) + h.close() + for h in list(porringer_logger.handlers): + if isinstance(h, EagerRotatingFileHandler): + porringer_logger.removeHandler(h) + h.close() + + @staticmethod + def test_normal_build_sets_porringer_info(tmp_path: Path) -> None: + """In normal (non-frozen) builds, porringer logger should be INFO.""" + porringer_logger = logging.getLogger('porringer') + app_logger = logging.getLogger('synodic_client') + + # Remove any existing EagerRotatingFileHandler so configure_logging re-runs + for h in list(app_logger.handlers): + if isinstance(h, EagerRotatingFileHandler): + app_logger.removeHandler(h) + h.close() + + # Ensure frozen is not set + had_frozen = hasattr(sys, 'frozen') + if had_frozen: + old_frozen = sys.frozen # type: ignore[attr-defined] + delattr(sys, 'frozen') + + try: + with patch('synodic_client.logging.log_path', return_value=tmp_path / 'synodic.log'): + configure_logging() + assert porringer_logger.level == logging.INFO + finally: + if had_frozen: + sys.frozen = old_frozen # type: ignore[attr-defined] + + # Clean up + for h in list(app_logger.handlers): + if isinstance(h, EagerRotatingFileHandler): + app_logger.removeHandler(h) + h.close() + for h in list(porringer_logger.handlers): + if isinstance(h, EagerRotatingFileHandler): + porringer_logger.removeHandler(h) + h.close() diff --git a/tool/pyinstaller/synodic.spec b/tool/pyinstaller/synodic.spec index 2ab6d43..a6b30b1 100644 --- a/tool/pyinstaller/synodic.spec +++ b/tool/pyinstaller/synodic.spec @@ -13,27 +13,33 @@ hiddenimports = [] # Add porringer metadata so entry points work datas += copy_metadata('porringer') -# Porringer bundled plugins (discovered via entry points at runtime) +# Porringer bundled plugins (discovered via entry points at runtime). +# Keep in sync with porringer's pyproject.toml [project.entry-points.*] groups. hiddenimports += [ + # porringer.environment 'porringer.plugin.apt.plugin', 'porringer.plugin.brew.plugin', 'porringer.plugin.bun.plugin', - 'porringer.plugin.bun_project.plugin', 'porringer.plugin.deno.plugin', - 'porringer.plugin.deno_project.plugin', 'porringer.plugin.npm.plugin', - 'porringer.plugin.npm_project.plugin', - 'porringer.plugin.pdm.plugin', 'porringer.plugin.pim.plugin', 'porringer.plugin.pip.plugin', 'porringer.plugin.pipx.plugin', - 'porringer.plugin.pnpm_project.plugin', - 'porringer.plugin.poetry.plugin', + 'porringer.plugin.pnpm.plugin', 'porringer.plugin.pyenv.plugin', 'porringer.plugin.uv.plugin', - 'porringer.plugin.uv_project.plugin', 'porringer.plugin.winget.plugin', + # porringer.project_environment + 'porringer.plugin.bun_project.plugin', + 'porringer.plugin.deno_project.plugin', + 'porringer.plugin.npm_project.plugin', + 'porringer.plugin.pdm.plugin', + 'porringer.plugin.pnpm_project.plugin', + 'porringer.plugin.poetry.plugin', + 'porringer.plugin.uv_project.plugin', 'porringer.plugin.yarn_project.plugin', + # porringer.scm + 'porringer.plugin.git.plugin', ] a = Analysis( From cb2117058b4b781f8b7fabd87fceefefdf7938b9 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Wed, 25 Feb 2026 21:05:54 -0800 Subject: [PATCH 3/4] PostInstall Back to Card --- synodic_client/application/screen/__init__.py | 16 +- .../application/screen/action_card.py | 75 ++++++---- synodic_client/application/screen/install.py | 139 +----------------- synodic_client/application/theme.py | 3 + tests/unit/qt/test_action_card.py | 49 +++++- tests/unit/qt/test_install_preview.py | 2 +- 6 files changed, 108 insertions(+), 176 deletions(-) diff --git a/synodic_client/application/screen/__init__.py b/synodic_client/application/screen/__init__.py index 29b881d..e4b639f 100644 --- a/synodic_client/application/screen/__init__.py +++ b/synodic_client/application/screen/__init__.py @@ -6,7 +6,7 @@ from __future__ import annotations -from porringer.schema import SkipReason +from porringer.schema import SetupAction, SkipReason from porringer.schema.plugin import PluginKind ACTION_KIND_LABELS: dict[PluginKind | None, str] = { @@ -54,3 +54,17 @@ def skip_reason_label(reason: SkipReason | None) -> str: if reason is None: return 'Skipped' return SKIP_REASON_LABELS.get(reason, reason.name.replace('_', ' ').capitalize()) + + +def format_cli_command(action: SetupAction) -> str: + """Return a human-readable CLI command string for *action*. + + Prefers ``cli_command``, falls back to ``command``, then synthesises + an ``installer install `` string for package actions, and + finally returns the action description as a last resort. + """ + if parts := (action.cli_command or action.command): + return ' '.join(parts) + if action.kind == PluginKind.PACKAGE and action.package: + return f'{action.installer or "pip"} install {action.package}' + return action.description diff --git a/synodic_client/application/screen/action_card.py b/synodic_client/application/screen/action_card.py index 5b2bb2e..f731d24 100644 --- a/synodic_client/application/screen/action_card.py +++ b/synodic_client/application/screen/action_card.py @@ -33,7 +33,7 @@ QWidget, ) -from synodic_client.application.screen import ACTION_KIND_LABELS, skip_reason_label +from synodic_client.application.screen import ACTION_KIND_LABELS, format_cli_command, skip_reason_label from synodic_client.application.theme import ( ACTION_CARD_COMMAND_STYLE, ACTION_CARD_DESC_STYLE, @@ -48,6 +48,7 @@ ACTION_CARD_STATUS_DONE, ACTION_CARD_STATUS_FAILED, ACTION_CARD_STATUS_NEEDED, + ACTION_CARD_STATUS_PENDING, ACTION_CARD_STATUS_RUNNING, ACTION_CARD_STATUS_SATISFIED, ACTION_CARD_STATUS_SKIPPED, @@ -113,12 +114,14 @@ def action_sort_key(action: SetupAction) -> int: def _format_command(action: SetupAction) -> str: - """Return a short CLI command string for display.""" - if parts := (action.cli_command or action.command): - return ' '.join(parts) - if action.kind == PluginKind.PACKAGE and action.package: - return f'{action.installer or "pip"} install {action.package}' - return '' + """Return a short CLI command string for display. + + Wraps :func:`~synodic_client.application.screen.format_cli_command` + but returns an empty string instead of the description fallback so + cards only show an explicit command line. + """ + text = format_cli_command(action) + return '' if text == action.description else text # --------------------------------------------------------------------------- @@ -471,23 +474,8 @@ def populate( self._version_label.setText('') - # Status — check plugin presence first - installer_missing = ( - action.installer is not None - and action.installer in plugin_installed - and not plugin_installed[action.installer] - ) - - if installer_missing: - self._status_label.setText('Not installed') - self._status_label.setStyleSheet(ACTION_CARD_STATUS_UNAVAILABLE) - self._status_label.show() - else: - # Show spinner instead of status text while checking - self._status_label.hide() - self._checking = True - self._spinner_canvas.show() - self._spinner_timer.start() + # Status + self._populate_status(action, plugin_installed) # Pre-release checkbox if action.package is not None: @@ -525,6 +513,40 @@ def update_command(self, action: SetupAction) -> None: else: self._command_row.hide() + def _populate_status( + self, + action: SetupAction, + plugin_installed: dict[str, bool], + ) -> None: + """Set the initial status badge during :meth:`populate`. + + Bare-command actions (``kind is None``) show a static *Pending* + badge. Plugin-backed actions either flag a missing installer or + start the dry-run spinner. + """ + if action.kind is None: + self._status_label.setText('Pending') + self._status_label.setStyleSheet(ACTION_CARD_STATUS_PENDING) + self._status_label.show() + return + + installer_missing = ( + action.installer is not None + and action.installer in plugin_installed + and not plugin_installed[action.installer] + ) + + if installer_missing: + self._status_label.setText('Not installed') + self._status_label.setStyleSheet(ACTION_CARD_STATUS_UNAVAILABLE) + self._status_label.show() + else: + # Show spinner instead of status text while checking + self._status_label.hide() + self._checking = True + self._spinner_canvas.show() + self._spinner_timer.start() + def initial_status(self) -> str: """Return the initial status text set during :meth:`populate`.""" if self._is_skeleton or not hasattr(self, '_status_label'): @@ -786,10 +808,7 @@ def populate( prerelease_overrides: Package names with user pre-release overrides. """ self.clear() - sorted_actions = sorted( - (a for a in actions if a.kind is not None), - key=action_sort_key, - ) + sorted_actions = sorted(actions, key=action_sort_key) for act in sorted_actions: card = ActionCard(self._container) card.populate( diff --git a/synodic_client/application/screen/install.py b/synodic_client/application/screen/install.py index fcccc5e..b6eff7a 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -33,11 +33,8 @@ SubActionProgress, SyncStrategy, ) -from porringer.schema.plugin import PluginKind -from PySide6.QtCore import Qt, QThread, QTimer, Signal -from PySide6.QtGui import QFont +from PySide6.QtCore import QThread, QTimer, Signal from PySide6.QtWidgets import ( - QApplication, QFileDialog, QFrame, QHBoxLayout, @@ -46,7 +43,6 @@ QMainWindow, QMessageBox, QPushButton, - QToolButton, QVBoxLayout, QWidget, ) @@ -57,19 +53,12 @@ from synodic_client.application.theme import ( ACTION_CARD_SKELETON_BAR_STYLE, CARD_SPACING, - COMMAND_HEADER_STYLE, COMPACT_MARGINS, CONTENT_MARGINS, - COPY_BTN_SIZE, - COPY_BTN_STYLE, - COPY_FEEDBACK_MS, - COPY_ICON, HEADER_STYLE, INSTALL_PREVIEW_MIN_SIZE, METADATA_SKELETON_HEIGHT, METADATA_SKELETON_STYLE, - MONOSPACE_FAMILY, - MONOSPACE_SIZE, MUTED_STYLE, NO_MARGINS, ) @@ -95,15 +84,6 @@ def normalize_manifest_key(path_or_url: str) -> str: return path_or_url -def format_cli_command(action: SetupAction) -> str: - """Return a copyable CLI command string for *action*.""" - if parts := (action.cli_command or action.command): - return ' '.join(parts) - if action.kind == PluginKind.PACKAGE and action.package: - return f'{action.installer or "pip"} install {action.package}' - return action.description - - @dataclass(frozen=True, slots=True) class InstallConfig: """Optional execution parameters for :class:`InstallWorker`.""" @@ -197,109 +177,6 @@ async def _execute(self) -> SetupResults: ) -class PostInstallSection(QWidget): - """Always-visible section showing bare-command (post-install) actions. - - Bare-command actions (``kind is None``) cannot be dry-run checked, so - they are excluded from the main actions table. This widget gives - them a dedicated, always-visible home with copyable CLI text. - """ - - def __init__(self, parent: QWidget | None = None) -> None: - """Initialise the section (hidden until :meth:`populate` is called).""" - super().__init__(parent) - self.hide() - - self._layout = QVBoxLayout(self) - self._layout.setContentsMargins(*NO_MARGINS) - self._layout.setSpacing(4) - - header = QLabel('Post-Install Commands') - header.setStyleSheet(COMMAND_HEADER_STYLE) - self._layout.addWidget(header) - - self._content = QWidget() - self._content_layout = QVBoxLayout(self._content) - self._content_layout.setContentsMargins(*NO_MARGINS) - self._content_layout.setSpacing(4) - self._layout.addWidget(self._content) - - def populate(self, actions: list[SetupAction]) -> None: - """Show command actions from *actions*. - - Only actions whose ``kind`` is ``None`` are shown. If there are - none the widget stays hidden. - - Args: - actions: The full list of setup actions. - """ - # Clear previous content - while self._content_layout.count(): - item = self._content_layout.takeAt(0) - if item is not None: - widget = item.widget() - if widget is not None: - widget.deleteLater() - - commands = [(i, a) for i, a in enumerate(actions, 1) if a.kind is None] - if not commands: - self.hide() - return - - mono = QFont(MONOSPACE_FAMILY, MONOSPACE_SIZE) - for idx, action in commands: - desc = action.package_description or action.description - label = QLabel(f'{idx}. {desc}') - label.setStyleSheet(COMMAND_HEADER_STYLE) - self._content_layout.addWidget(label) - - field = QLineEdit(format_cli_command(action)) - field.setReadOnly(True) - field.setFont(mono) - - row_layout = QHBoxLayout() - row_layout.setContentsMargins(*NO_MARGINS) - row_layout.setSpacing(4) - row_layout.addWidget(field) - row_layout.addWidget(_make_copy_button(field)) - - row_widget = QWidget() - row_widget.setLayout(row_layout) - self._content_layout.addWidget(row_widget) - - self.show() - - -def _make_copy_button(field: QLineEdit) -> QToolButton: - """Create a copy-to-clipboard button bound to *field*.""" - btn = QToolButton() - btn.setText(COPY_ICON) - btn.setToolTip('Copy to clipboard') - btn.setFixedSize(*COPY_BTN_SIZE) - btn.setStyleSheet(COPY_BTN_STYLE) - btn.setCursor(Qt.CursorShape.PointingHandCursor) - btn.clicked.connect(lambda: _copy_command(field, btn)) - return btn - - -def _copy_command(field: QLineEdit, button: QToolButton) -> None: - """Copy the field text to the clipboard and briefly show a check mark.""" - clipboard = QApplication.clipboard() - if clipboard: - clipboard.setText(field.text()) - button.setText('\u2713') - button.setToolTip('Copied!') - - def _restore() -> None: - try: - button.setText(COPY_ICON) - button.setToolTip('Copy to clipboard') - except RuntimeError: - pass - - QTimer.singleShot(COPY_FEEDBACK_MS, _restore) - - # --------------------------------------------------------------------------- # SetupPreviewWidget — reusable preview + install widget # --------------------------------------------------------------------------- @@ -402,10 +279,6 @@ def _init_ui(self) -> None: self._card_list.prerelease_toggled.connect(self._on_prerelease_row_toggled) outer.addWidget(self._card_list, stretch=1) - # Post-install section lives below the card list but still scrolls. - # It starts hidden and is inserted into the layout after populate(). - self._post_install_section = PostInstallSection() - # --- Button bar (fixed at bottom) --- button_bar = self._init_button_bar() outer.addLayout(button_bar) @@ -526,7 +399,6 @@ def reset(self) -> None: self._prerelease_debounce.stop() self._card_list.clear() - self._post_install_section.hide() self._name_label.hide() self._description_label.hide() self._meta_label.hide() @@ -645,8 +517,6 @@ def on_preview_ready(self, preview: SetupResults, manifest_path: str, temp_dir_p # Mark installer-missing actions as 'Not installed' in the status list for i, action in enumerate(preview.actions): - if action.kind is None: - continue installer_missing = ( action.installer is not None and action.installer in self._plugin_installed @@ -655,13 +525,6 @@ def on_preview_ready(self, preview: SetupResults, manifest_path: str, temp_dir_p if installer_missing: self._action_statuses[i] = 'Not installed' - # Populate post-install commands and place them after all cards. - self._post_install_section.populate(preview.actions) - self._card_list._layout.insertWidget( - self._card_list._layout.count() - 1, - self._post_install_section, - ) - self._install_btn.setEnabled(True) def on_preview_resolved(self, preview: SetupResults) -> None: diff --git a/synodic_client/application/theme.py b/synodic_client/application/theme.py index e50e50f..893164a 100644 --- a/synodic_client/application/theme.py +++ b/synodic_client/application/theme.py @@ -236,6 +236,9 @@ ACTION_CARD_STATUS_SKIPPED = 'color: grey; font-size: 11px;' """Status label: Skipped.""" +ACTION_CARD_STATUS_PENDING = 'color: grey; font-size: 11px; font-style: italic;' +"""Status label: Pending (bare commands with no dry-run check).""" + ACTION_CARD_LOG_STYLE = ( 'QTextEdit {' ' background: #1e1e1e;' diff --git a/tests/unit/qt/test_action_card.py b/tests/unit/qt/test_action_card.py index ed32848..4c8fe88 100644 --- a/tests/unit/qt/test_action_card.py +++ b/tests/unit/qt/test_action_card.py @@ -27,6 +27,7 @@ ACTION_CARD_STATUS_DONE, ACTION_CARD_STATUS_FAILED, ACTION_CARD_STATUS_NEEDED, + ACTION_CARD_STATUS_PENDING, ACTION_CARD_STATUS_RUNNING, ACTION_CARD_STATUS_SATISFIED, ACTION_CARD_STATUS_SKIPPED, @@ -559,14 +560,15 @@ def test_populate_replaces_skeletons() -> None: assert not c._is_skeleton @staticmethod - def test_populate_skips_command_actions() -> None: - """Populate skips actions with kind=None.""" + def test_populate_includes_command_actions() -> None: + """Populate includes actions with kind=None.""" card_list = ActionCardList() a1 = _make_action(package='pkg1') a2 = _make_action(package='pkg2') a2.kind = None # bare command - card_list.populate([a1, a2]) - assert card_list.card_count() == 1 + actions = [a1, a2] + card_list.populate(actions) + assert card_list.card_count() == len(actions) @staticmethod def test_get_card_by_stable_key() -> None: @@ -933,13 +935,44 @@ def test_cards_grouped_by_kind_preserving_order() -> None: assert card._package_label.text() == name @staticmethod - def test_bare_commands_excluded() -> None: - """Actions with kind=None are excluded from the card list.""" + def test_bare_commands_included() -> None: + """Actions with kind=None are included in the card list.""" card_list = ActionCardList() pkg = _make_action(kind=PluginKind.PACKAGE, package='requests') cmd = _make_action(kind=None, package='run-something') - card_list.populate([pkg, cmd]) - assert card_list.card_count() == 1 + actions = [pkg, cmd] + card_list.populate(actions) + assert card_list.card_count() == len(actions) + + @staticmethod + def test_bare_commands_sort_last() -> None: + """Bare-command actions sort after all PluginKind phases.""" + card_list = ActionCardList() + cmd = _make_action(kind=None, package='post-cmd') + a_pkg = _make_action(kind=PluginKind.PACKAGE, package='requests') + a_scm = _make_action(kind=PluginKind.SCM, package='my-repo') + actions = [cmd, a_pkg, a_scm] + card_list.populate(actions) + + assert card_list.card_count() == len(actions) + # Package(1) → SCM(4) → None(last) + card_0 = card_list.card_at(0) + card_1 = card_list.card_at(1) + card_2 = card_list.card_at(2) + assert card_0 is not None + assert card_0._package_label.text() == 'requests' + assert card_1 is not None + assert card_1._package_label.text() == 'my-repo' + assert card_2 is not None + assert card_2._package_label.text() == 'post-cmd' + + @staticmethod + def test_bare_command_shows_pending_status() -> None: + """Bare-command card shows 'Pending' status with the pending style.""" + card = ActionCard() + card.populate(_make_action(kind=None, package='echo-hello')) + assert card.status_text() == 'Pending' + assert ACTION_CARD_STATUS_PENDING in card._status_label.styleSheet() @staticmethod def test_scroll_to_card_bottom_exists() -> None: diff --git a/tests/unit/qt/test_install_preview.py b/tests/unit/qt/test_install_preview.py index f681c12..7e2a79a 100644 --- a/tests/unit/qt/test_install_preview.py +++ b/tests/unit/qt/test_install_preview.py @@ -21,13 +21,13 @@ from synodic_client.application.screen import ( ACTION_KIND_LABELS, SKIP_REASON_LABELS, + format_cli_command, skip_reason_label, ) from synodic_client.application.screen.install import ( InstallConfig, InstallWorker, PreviewWorker, - format_cli_command, normalize_manifest_key, resolve_local_path, ) From f188eee7f818657ef88911cbba344f0a6296bd76 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Wed, 25 Feb 2026 23:02:28 -0800 Subject: [PATCH 4/4] Update Chore --- pdm.lock | 8 ++++---- pyproject.toml | 17 +++++++++++++---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/pdm.lock b/pdm.lock index 7176831..fcec85d 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "build", "dev", "lint", "test"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:a11fee70f025f3a84c850cd7cc7fd3629253f7883281b29ce63f141a6147c4f3" +content_hash = "sha256:51fe1fa9d99c43a533dedd1220c24ecfdc3086d950d3d7fb119ef73c17e440ee" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -336,7 +336,7 @@ files = [ [[package]] name = "porringer" -version = "0.2.1.dev52" +version = "0.2.1.dev53" requires_python = ">=3.14" summary = "" groups = ["default"] @@ -349,8 +349,8 @@ dependencies = [ "userpath>=1.9.2", ] files = [ - {file = "porringer-0.2.1.dev52-py3-none-any.whl", hash = "sha256:266c37fb3e338fe4186420db9dc0f30bc0642dc8bab1bb8c1804d366b9d74274"}, - {file = "porringer-0.2.1.dev52.tar.gz", hash = "sha256:84dddf11ecd20696f64ca5b16b27583bde79df6042a37a9c679d9029ba0b6bde"}, + {file = "porringer-0.2.1.dev53-py3-none-any.whl", hash = "sha256:22031fb82061b7255ccc156d2f94541591406cee2897813cfd4d760f24797780"}, + {file = "porringer-0.2.1.dev53.tar.gz", hash = "sha256:05a1673ec2ef0407750cc08f04e388e31141aaf52e46915a8bab54e4c5f50a71"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 5e3191f..f26c2d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ requires-python = ">=3.14, <3.15" dependencies = [ "pyside6>=6.10.2", "packaging>=26.0", - "porringer>=0.2.1.dev52", + "porringer>=0.2.1.dev53", "qasync>=0.28.0", "velopack>=0.0.1442.dev64255", "typer>=0.24.1", @@ -25,9 +25,18 @@ homepage = "https://github.com/synodic/synodic-client" repository = "https://github.com/synodic/synodic-client" [dependency-groups] -build = ["pyinstaller>=6.19.0"] -lint = ["ruff>=0.15.2", "pyrefly>=0.54.0"] -test = ["pytest>=9.0.2", "pytest-cov>=7.0.0", "pytest-mock>=3.15.1"] +build = [ + "pyinstaller>=6.19.0", +] +lint = [ + "ruff>=0.15.2", + "pyrefly>=0.54.0", +] +test = [ + "pytest>=9.0.2", + "pytest-cov>=7.0.0", + "pytest-mock>=3.15.1", +] [project.scripts] synodic-c = "synodic_client.cli:app"