From f12cada8f3460e3e197c6a53488af13b1e486b21 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Fri, 13 Mar 2026 16:40:55 -0700 Subject: [PATCH 1/9] Another Update Fix --- .../application/update_controller.py | 2 +- synodic_client/updater.py | 7 +++- tests/unit/test_updater.py | 32 +++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/synodic_client/application/update_controller.py b/synodic_client/application/update_controller.py index 0204841..e1cf84a 100644 --- a/synodic_client/application/update_controller.py +++ b/synodic_client/application/update_controller.py @@ -440,8 +440,8 @@ def _apply_update(self, *, silent: bool = False) -> None: # the next launch. sync_startup(sys.executable, auto_start=self._store.config.auto_start) - self._pending_version = None self._client.apply_update_on_exit(restart=True, silent=silent) + self._pending_version = None logger.info('Update scheduled — restarting application') self._app.quit() except Exception as e: diff --git a/synodic_client/updater.py b/synodic_client/updater.py index 61761d3..34c8d2a 100644 --- a/synodic_client/updater.py +++ b/synodic_client/updater.py @@ -184,7 +184,12 @@ def check_for_update(self) -> UpdateInfo: latest_version=latest, _velopack_info=velopack_info, ) - self._state = UpdateState.UPDATE_AVAILABLE + # Only advance to UPDATE_AVAILABLE if we haven't already + # moved past it. A periodic re-check that discovers the + # same release must not regress DOWNLOADED → UPDATE_AVAILABLE, + # which would cause apply_update_on_exit() to reject the update. + if self._state not in (UpdateState.DOWNLOADED, UpdateState.APPLYING, UpdateState.APPLIED): + self._state = UpdateState.UPDATE_AVAILABLE logger.info('Update available: %s -> %s', self._current_version, latest) else: self._update_info = UpdateInfo( diff --git a/tests/unit/test_updater.py b/tests/unit/test_updater.py index 887a0cb..ac932c2 100644 --- a/tests/unit/test_updater.py +++ b/tests/unit/test_updater.py @@ -227,6 +227,38 @@ def test_check_non_404_http_error_is_failed(updater: Updater) -> None: assert info.available is False assert updater.state == UpdateState.FAILED + @staticmethod + def test_check_preserves_downloaded_state(updater: Updater) -> None: + """Re-checking after download must not regress DOWNLOADED → UPDATE_AVAILABLE. + + Regression test: when the periodic auto-check timer fires between + download completion and the user clicking "Restart Now", the state + was incorrectly reset to UPDATE_AVAILABLE, causing apply_update_on_exit + to reject the update with "No downloaded update to apply". + """ + mock_target = MagicMock(spec=velopack.VelopackAsset) + mock_target.Version = '2.0.0' + mock_velopack_info = MagicMock(spec=velopack.UpdateInfo) + mock_velopack_info.TargetFullRelease = mock_target + + mock_manager = MagicMock(spec=velopack.UpdateManager) + mock_manager.check_for_updates.return_value = mock_velopack_info + + # Simulate: download already completed + updater._state = UpdateState.DOWNLOADED + updater._update_info = UpdateInfo( + available=True, + current_version=Version('1.0.0'), + latest_version=Version('2.0.0'), + _velopack_info=mock_velopack_info, + ) + + with patch.object(updater, '_get_velopack_manager', return_value=mock_manager): + info = updater.check_for_update() + + assert info.available is True + assert updater.state == UpdateState.DOWNLOADED + class TestUpdaterDownloadUpdate: """Tests for download_update method.""" From 95767a9e01114758cbcabdfb73805033645e548f Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Fri, 13 Mar 2026 23:04:44 -0700 Subject: [PATCH 2/9] ye --- synodic_client/application/screen/__init__.py | 28 ++- .../application/screen/action_card.py | 61 +++--- synodic_client/application/screen/install.py | 29 +-- .../application/screen/install_workers.py | 161 +++------------- synodic_client/application/screen/schema.py | 11 +- synodic_client/operations/__init__.py | 25 ++- synodic_client/operations/install.py | 182 +++++++++++++++--- synodic_client/operations/schema.py | 83 ++++++++ tests/unit/qt/test_action_card.py | 77 +++++--- tests/unit/qt/test_install_preview.py | 24 +-- 10 files changed, 392 insertions(+), 289 deletions(-) diff --git a/synodic_client/application/screen/__init__.py b/synodic_client/application/screen/__init__.py index 412a4d3..f1da99e 100644 --- a/synodic_client/application/screen/__init__.py +++ b/synodic_client/application/screen/__init__.py @@ -9,9 +9,19 @@ import re from datetime import UTC, datetime -from porringer.schema import SetupAction, SetupActionResult, SkipReason +from porringer.schema import SetupAction, SetupActionResult from porringer.schema.plugin import PluginKind +from synodic_client.operations.schema import ( + SKIP_REASON_LABELS as SKIP_REASON_LABELS, +) +from synodic_client.operations.schema import ( + resolve_action_status as resolve_action_status, +) +from synodic_client.operations.schema import ( + skip_reason_label as skip_reason_label, +) + _SECONDS_PER_MINUTE = 60 _MINUTES_PER_HOUR = 60 _HOURS_PER_DAY = 24 @@ -48,22 +58,6 @@ def plugin_kind_group_label(kind: PluginKind) -> str: return PLUGIN_KIND_GROUP_LABELS.get(kind, kind.name.replace('_', ' ').title()) -SKIP_REASON_LABELS: dict[SkipReason, str] = { - SkipReason.ALREADY_INSTALLED: 'Already installed', - SkipReason.NOT_INSTALLED: 'Not installed', - SkipReason.ALREADY_LATEST: 'Already latest', - SkipReason.NO_PROJECT_DIRECTORY: 'No project directory', - SkipReason.UPDATE_AVAILABLE: 'Update available', -} - - -def skip_reason_label(reason: SkipReason | None) -> str: - """Return a human-readable label for a skip reason.""" - if reason is None: - return 'Skipped' - return SKIP_REASON_LABELS.get(reason, reason.name.replace('_', ' ').capitalize()) - - def format_cli_command( action: SetupAction, *, diff --git a/synodic_client/application/screen/action_card.py b/synodic_client/application/screen/action_card.py index 66a2cfd..12996df 100644 --- a/synodic_client/application/screen/action_card.py +++ b/synodic_client/application/screen/action_card.py @@ -14,7 +14,7 @@ import logging from porringer.backend.command.core.action_builder import PHASE_ORDER -from porringer.schema import SetupAction, SetupActionResult, SkipReason +from porringer.schema import SetupAction, SetupActionResult from porringer.schema.plugin import PluginKind from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtGui import QColor @@ -443,56 +443,47 @@ def _stop_spinner(self) -> None: # Public API — dry-run check result # ------------------------------------------------------------------ - def set_check_result(self, result: SetupActionResult) -> None: + def set_check_result(self, result: SetupActionResult, status: str) -> None: """Update the card with a dry-run check result. - Handles these cases: - - * **Skipped (update available)** — amber "Update available" badge. - * **Skipped (other)** — muted satisfied badge. - * **Failed** — red "Failed" badge with diagnostic tooltip. - * **Bare command** (kind=None, success=True) — keeps "Pending". - * **Project sync** (kind=PROJECT, success=True) — "Ready". - * **Needed** — default blue badge. + The *status* string is pre-resolved by the operations layer via + :func:`resolve_action_status`. This method maps it to the + appropriate style and updates the version / tooltip columns. Args: result: The action check result from the preview worker. + status: Pre-resolved human-readable status label. """ if self._is_skeleton: return self._stop_spinner() - if result.skipped and result.skip_reason == SkipReason.UPDATE_AVAILABLE: - label = skip_reason_label(result.skip_reason) - self._status_label.setText(label) - self._status_label.setStyleSheet(ACTION_CARD_STATUS_UPDATE) - elif result.skipped: - label = '\u2713 ' + 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) + # Status-to-style mapping + _STATUS_STYLES: dict[str, str] = { + 'Update available': ACTION_CARD_STATUS_UPDATE, + 'Failed': ACTION_CARD_STATUS_FAILED, + 'Pending': ACTION_CARD_STATUS_PENDING, + 'Ready': ACTION_CARD_STATUS_SATISFIED, + 'Needed': ACTION_CARD_STATUS_NEEDED, + } + + style = _STATUS_STYLES.get(status, ACTION_CARD_STATUS_SATISFIED) + display = status + + # Satisfied (skipped) statuses get a checkmark prefix + if result.skipped and status not in _STATUS_STYLES: + display = f'\u2713 {status}' + + if not result.success and status == 'Failed': logger.warning( 'Dry-run check failed for %s: %s', self._action.description if self._action else '(unknown)', result.message or 'unknown error', ) - elif self._action is not None and self._action.kind is None: - # Bare command — porringer returns success=True; keep Pending. - label = 'Pending' - self._status_label.setText(label) - self._status_label.setStyleSheet(ACTION_CARD_STATUS_PENDING) - elif self._action is not None and self._action.kind == PluginKind.PROJECT: - label = 'Ready' - self._status_label.setText(label) - self._status_label.setStyleSheet(ACTION_CARD_STATUS_SATISFIED) - else: - label = 'Needed' - self._status_label.setText(label) - self._status_label.setStyleSheet(ACTION_CARD_STATUS_NEEDED) + + self._status_label.setText(display) + self._status_label.setStyleSheet(style) # Surface diagnostic detail (e.g. SCM URL mismatch) as a tooltip if result.message: diff --git a/synodic_client/application/screen/install.py b/synodic_client/application/screen/install.py index 4255a3d..2433936 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -27,7 +27,6 @@ SubActionProgress, SyncStrategy, ) -from porringer.schema.plugin import PluginKind from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtGui import QShowEvent from PySide6.QtWidgets import ( @@ -45,7 +44,6 @@ ) from synodic_client.application.package_state import PackageStateStore -from synodic_client.application.screen import skip_reason_label from synodic_client.application.screen.action_card import ActionCardList from synodic_client.application.screen.card import CardFrame from synodic_client.application.screen.install_workers import run_install, run_preview @@ -615,28 +613,13 @@ def _on_preview_resolved(self, preview: SetupResults, manifest_path: str, temp_d if preview.metadata: self.metadata_ready.emit(preview) - def _on_action_checked(self, row: int, result: SetupActionResult) -> None: + def _on_action_checked(self, row: int, result: SetupActionResult, status: str) -> None: """Update the model and action card with a dry-run result.""" m = self._model - if result.skipped and result.skip_reason == SkipReason.UPDATE_AVAILABLE: - label = skip_reason_label(result.skip_reason) - if 0 <= row < len(m.action_states): - m.upgradable_keys.add(m.action_states[row].action) - elif result.skipped: - label = skip_reason_label(result.skip_reason) - elif not result.success: - label = 'Failed' - else: - # Bare commands (kind=None) and PROJECT actions always return - # success=True from porringer's dry_run_action. Don't - # overwrite their initial status to "Needed". - action = m.action_states[row].action if 0 <= row < len(m.action_states) else None - if action is not None and action.kind is None: - label = 'Pending' - elif action is not None and action.kind == PluginKind.PROJECT: - label = 'Ready' - else: - label = 'Needed' + label = status + + if result.skipped and result.skip_reason == SkipReason.UPDATE_AVAILABLE and 0 <= row < len(m.action_states): + m.upgradable_keys.add(m.action_states[row].action) if 0 <= row < len(m.action_states): m.action_states[row].status = label @@ -657,7 +640,7 @@ def _on_action_checked(self, row: int, result: SetupActionResult) -> None: action = m.preview.actions[row] card = self._card_list.get_card(action) if card is not None: - card.set_check_result(result) + card.set_check_result(result, status) # Record in shared store so ToolsView can reflect the update if self._package_store is not None and action.installer and action.package: diff --git a/synodic_client/application/screen/install_workers.py b/synodic_client/application/screen/install_workers.py index f997891..f2f5b71 100644 --- a/synodic_client/application/screen/install_workers.py +++ b/synodic_client/application/screen/install_workers.py @@ -8,7 +8,6 @@ import asyncio import logging -import tempfile from pathlib import Path from porringer.api import API @@ -16,11 +15,7 @@ from porringer.schema import ( ActionCompletedEvent, ActionStartedEvent, - DownloadParameters, ManifestLoadedEvent, - ManifestParsedEvent, - PluginsDiscoveredEvent, - ProgressEvent, SetupAction, SetupActionResult, SetupParameters, @@ -33,9 +28,15 @@ InstallConfig, PreviewCallbacks, PreviewConfig, - _DispatchState, ) -from synodic_client.application.uri import resolve_local_path, safe_rmtree +from synodic_client.application.uri import safe_rmtree +from synodic_client.operations.install import preview_manifest_stream +from synodic_client.operations.schema import ( + PreviewActionChecked, + PreviewManifestParsed, + PreviewPluginsQueried, + PreviewReady, +) logger = logging.getLogger(__name__) @@ -58,18 +59,6 @@ async def run_install( Runs on the caller's event loop (typically the qasync main-thread loop). Callbacks are invoked between ``await`` points so the GUI stays responsive without cross-thread signalling. - - Args: - porringer: The porringer API instance. - manifest_path: Path to the manifest file to execute. - config: Optional execution parameters (directory, strategy, - prerelease overrides). - callbacks: Optional progress callbacks. - plugins: Pre-discovered plugins to pass through to porringer, - avoiding redundant discovery. - - Returns: - Aggregated :class:`SetupResults`. """ cfg = config or InstallConfig() cb = callbacks or InstallCallbacks() @@ -107,81 +96,6 @@ async def run_install( ) -# --------------------------------------------------------------------------- -# _resolve_manifest_path — local or download -# --------------------------------------------------------------------------- - - -async def _resolve_manifest_path(url: str) -> tuple[Path, str | None]: - """Resolve *url* to a local manifest path, downloading if remote. - - Returns: - ``(manifest_path, temp_dir)`` — *temp_dir* is ``None`` for local - manifests and a temporary directory string for downloads. - - Raises: - FileNotFoundError: If a local path does not exist. - RuntimeError: If the download fails. - """ - local_path = resolve_local_path(url) - - if local_path is not None: - if not local_path.exists(): - msg = f'Manifest not found:\n{local_path}' - raise FileNotFoundError(msg) - return local_path, None - - temp_dir = tempfile.mkdtemp(prefix='synodic_install_') - dest = Path(temp_dir) / 'porringer.json' - - params = DownloadParameters(url=url, destination=dest, timeout=3) - result = await API.download(params) - - if not result.success: - safe_rmtree(temp_dir) - msg = f'Failed to download manifest:\n{result.message}' - raise RuntimeError(msg) - - return dest, temp_dir - - -# --------------------------------------------------------------------------- -# _dispatch_preview_event — route stream events to callbacks -# --------------------------------------------------------------------------- - - -def _dispatch_preview_event( - event: ProgressEvent, - manifest_path: str, - temp_dir_str: str, - state: _DispatchState, - cb: PreviewCallbacks, -) -> None: - """Route a single preview stream event to the appropriate callback. - - Mutates *state* in-place (``got_parsed`` flag). - """ - if isinstance(event, ManifestParsedEvent): - if cb.on_manifest_parsed is not None: - cb.on_manifest_parsed(event.manifest, manifest_path, temp_dir_str) - state.got_parsed = True - return - - if isinstance(event, PluginsDiscoveredEvent) and cb.on_plugins_queried is not None: - availability = {entry.name: entry.available for entry in event.discovered_plugins} - capabilities = {entry.name: entry.capabilities for entry in event.discovered_plugins} - cb.on_plugins_queried(availability, capabilities) - return - - if isinstance(event, ManifestLoadedEvent): - if cb.on_preview_ready is not None: - cb.on_preview_ready(event.manifest, manifest_path, temp_dir_str) - return - - if isinstance(event, ActionCompletedEvent) and event.action_index is not None and cb.on_action_checked is not None: - cb.on_action_checked(event.action_index, event.result) - - # --------------------------------------------------------------------------- # run_preview — dry-run preview of a manifest # --------------------------------------------------------------------------- @@ -197,49 +111,36 @@ async def run_preview( ) -> None: """Download a manifest and perform a dry-run preview. - Runs on the caller's event loop (typically the qasync main-thread - loop). Callbacks fire between ``await`` points so the GUI remains - responsive without cross-thread signalling. - - Combines two stages: - - 1. Download the manifest (if remote) — runs in a thread-pool executor. - 2. Run ``execute_stream`` with ``dry_run=True`` to stream events. - - Args: - porringer: The porringer API instance. - url: Manifest URL or local path. - config: Optional preview configuration. - callbacks: Optional preview callbacks. - plugins: Pre-discovered plugins to pass through to porringer, - avoiding redundant discovery. + Delegates to :func:`preview_manifest_stream` in the operations + layer, then routes each :data:`PreviewEvent` to the appropriate + callback. """ logger.info('run_preview starting for: %s', url) - temp_dir: str | None = None cb = callbacks or PreviewCallbacks() cfg = config or PreviewConfig() + temp_dir: str | None = None try: - manifest_path, temp_dir = await _resolve_manifest_path(url) - - # Dry-run: parses manifest, resolves actions, and checks status - setup_params = SetupParameters( - paths=[manifest_path], - dry_run=True, + async for event in preview_manifest_stream( + porringer, + url, project_directory=cfg.project_directory, prerelease_packages=cfg.prerelease_packages, - ) - state = _DispatchState() - temp_dir_str = temp_dir or '' - manifest_path_str = str(manifest_path) - - async for event in porringer.sync.execute_stream(setup_params, plugins=plugins): - _dispatch_preview_event( - event, - manifest_path_str, - temp_dir_str, - state, - cb, - ) + discovered=plugins, + ): + if isinstance(event, PreviewManifestParsed): + temp_dir = event.temp_dir or None + if cb.on_manifest_parsed is not None: + cb.on_manifest_parsed(event.manifest, event.manifest_path, event.temp_dir) + + elif isinstance(event, PreviewPluginsQueried) and cb.on_plugins_queried is not None: + cb.on_plugins_queried(event.availability, event.capabilities) + + elif isinstance(event, PreviewReady): + if cb.on_preview_ready is not None: + cb.on_preview_ready(event.manifest, event.manifest_path, event.temp_dir) + + elif isinstance(event, PreviewActionChecked) and cb.on_action_checked is not None: + cb.on_action_checked(event.index, event.result, event.status) except asyncio.CancelledError: if temp_dir: diff --git a/synodic_client/application/screen/schema.py b/synodic_client/application/screen/schema.py index 4459024..e71e8ca 100644 --- a/synodic_client/application/screen/schema.py +++ b/synodic_client/application/screen/schema.py @@ -323,8 +323,8 @@ class PreviewCallbacks: on_preview_ready: Callable[[SetupResults, str, str], None] | None = None """``(SetupResults, manifest_path, temp_dir)`` — CLI commands resolved.""" - on_action_checked: Callable[[int, SetupActionResult], None] | None = None - """``(row_index, SetupActionResult)`` — per-action dry-run result.""" + on_action_checked: Callable[[int, SetupActionResult, str], None] | None = None + """``(row_index, SetupActionResult, status)`` — per-action dry-run result with resolved status.""" @dataclass(frozen=True, slots=True) @@ -333,10 +333,3 @@ class PreviewConfig: project_directory: Path | None = None prerelease_packages: set[str] | None = None - - -@dataclass(slots=True) -class _DispatchState: - """Mutable accumulator for :func:`_dispatch_preview_event`.""" - - got_parsed: bool = False diff --git a/synodic_client/operations/__init__.py b/synodic_client/operations/__init__.py index a82ee67..13c3c74 100644 --- a/synodic_client/operations/__init__.py +++ b/synodic_client/operations/__init__.py @@ -17,13 +17,24 @@ from synodic_client.operations.bootstrap import init_services from synodic_client.operations.config import get_config, list_config_keys, set_config -from synodic_client.operations.install import execute_install, preview_manifest +from synodic_client.operations.install import ( + execute_install, + preview_manifest, + preview_manifest_stream, + resolve_manifest_path, +) from synodic_client.operations.project import add_project, list_projects, remove_project from synodic_client.operations.schema import ( + SKIP_REASON_LABELS, ActionInfo, ConfigKeyInfo, DownloadResult, PackageInfo, + PreviewActionChecked, + PreviewEvent, + PreviewManifestParsed, + PreviewPluginsQueried, + PreviewReady, PreviewResult, ProjectInfo, ProjectStatus, @@ -33,6 +44,8 @@ ToolSection, UpdateCheckResult, UpdateResult, + resolve_action_status, + skip_reason_label, ) from synodic_client.operations.tool import ( check_tool_updates, @@ -53,6 +66,8 @@ # install 'execute_install', 'preview_manifest', + 'preview_manifest_stream', + 'resolve_manifest_path', # project 'add_project', 'list_projects', @@ -62,15 +77,23 @@ 'ConfigKeyInfo', 'DownloadResult', 'PackageInfo', + 'PreviewActionChecked', + 'PreviewEvent', + 'PreviewManifestParsed', + 'PreviewPluginsQueried', + 'PreviewReady', 'PreviewResult', 'ProjectInfo', 'ProjectStatus', 'ProviderInfo', + 'SKIP_REASON_LABELS', 'StatusSummary', 'TagInfo', 'ToolSection', 'UpdateCheckResult', 'UpdateResult', + 'resolve_action_status', + 'skip_reason_label', # tool 'check_tool_updates', 'remove_package', diff --git a/synodic_client/operations/install.py b/synodic_client/operations/install.py index 9924da0..81ef83f 100644 --- a/synodic_client/operations/install.py +++ b/synodic_client/operations/install.py @@ -1,12 +1,13 @@ """Install / preview operations. Pure async functions for previewing and executing manifest installs. -No Qt, no signals — progress is reported via optional async callbacks. +No Qt, no signals — progress is reported via streaming async iterators. """ from __future__ import annotations import logging +import tempfile from collections.abc import AsyncIterator from pathlib import Path from typing import TYPE_CHECKING @@ -14,15 +15,27 @@ from porringer.schema import ( ActionCompletedEvent, ActionStartedEvent, + DownloadParameters, ManifestLoadedEvent, ManifestParsedEvent, + PluginsDiscoveredEvent, ProgressEvent, SetupParameters, SubActionProgressEvent, SyncStrategy, ) -from synodic_client.operations.schema import ActionInfo, PreviewResult +from synodic_client.application.uri import resolve_local_path, safe_rmtree +from synodic_client.operations.schema import ( + ActionInfo, + PreviewActionChecked, + PreviewEvent, + PreviewManifestParsed, + PreviewPluginsQueried, + PreviewReady, + PreviewResult, + resolve_action_status, +) if TYPE_CHECKING: from porringer.api import API @@ -32,22 +45,67 @@ # --------------------------------------------------------------------------- -# Preview +# Manifest resolution # --------------------------------------------------------------------------- -async def preview_manifest( +async def resolve_manifest_path(url: str) -> tuple[Path, str | None]: + """Resolve *url* to a local manifest path, downloading if remote. + + Returns: + ``(manifest_path, temp_dir)`` — *temp_dir* is ``None`` for local + manifests and a temporary directory string for downloads. + + Raises: + FileNotFoundError: If a local path does not exist. + RuntimeError: If the download fails. + """ + from porringer.api import API as _API + + local_path = resolve_local_path(url) + + if local_path is not None: + if not local_path.exists(): + msg = f'Manifest not found:\n{local_path}' + raise FileNotFoundError(msg) + return local_path, None + + temp_dir = tempfile.mkdtemp(prefix='synodic_install_') + dest = Path(temp_dir) / 'porringer.json' + + params = DownloadParameters(url=url, destination=dest, timeout=3) + result = await _API.download(params) + + if not result.success: + safe_rmtree(temp_dir) + msg = f'Failed to download manifest:\n{result.message}' + raise RuntimeError(msg) + + return dest, temp_dir + + +# --------------------------------------------------------------------------- +# Streaming preview +# --------------------------------------------------------------------------- + + +async def preview_manifest_stream( porringer: API, url: str, *, project_directory: Path | None = None, prerelease_packages: set[str] | None = None, discovered: DiscoveredPlugins | None = None, -) -> PreviewResult: - """Perform a dry-run preview of a manifest. + resolve: bool = True, +) -> AsyncIterator[PreviewEvent]: + """Stream dry-run preview events for a manifest. - Downloads the manifest if *url* is remote, then runs - ``execute_stream`` with ``dry_run=True`` to resolve actions. + Resolves the manifest (downloading if remote), then runs + ``execute_stream`` with ``dry_run=True``. Each porringer event + is translated into a :data:`PreviewEvent` and yielded. + + Per-action results include a pre-resolved ``status`` string + via :func:`resolve_action_status`. Args: porringer: The porringer API instance. @@ -55,31 +113,106 @@ async def preview_manifest( project_directory: Optional project directory for the preview. prerelease_packages: Optional prerelease overrides. discovered: Pre-discovered plugins. + resolve: Whether to resolve the URL to a local path first + (downloading if remote). When ``False`` the raw *url* + is passed directly to porringer. - Returns: - A :class:`PreviewResult` with the resolved actions. + Yields: + :data:`PreviewEvent` instances as they arrive. """ - params = SetupParameters( - paths=[url], - dry_run=True, - project_directory=project_directory, - prerelease_packages=prerelease_packages, - ) + temp_dir: str | None = None + if resolve: + manifest_path, temp_dir = await resolve_manifest_path(url) + else: + manifest_path = Path(url) if resolve_local_path(url) is not None else None # type: ignore[assignment] + + manifest_path_str = str(manifest_path) if manifest_path is not None else url + temp_dir_str = temp_dir or '' + + try: + params = SetupParameters( + paths=[manifest_path or url], + dry_run=True, + project_directory=project_directory, + prerelease_packages=prerelease_packages, + ) + + async for event in porringer.sync.execute_stream(params, plugins=discovered): + if isinstance(event, ManifestParsedEvent): + yield PreviewManifestParsed( + manifest=event.manifest, + manifest_path=manifest_path_str, + temp_dir=temp_dir_str, + ) + + elif isinstance(event, PluginsDiscoveredEvent): + availability = {entry.name: entry.available for entry in event.discovered_plugins} + capabilities = {entry.name: entry.capabilities for entry in event.discovered_plugins} + yield PreviewPluginsQueried( + availability=availability, + capabilities=capabilities, + ) + + elif isinstance(event, ManifestLoadedEvent): + yield PreviewReady( + manifest=event.manifest, + manifest_path=manifest_path_str, + temp_dir=temp_dir_str, + ) + + elif isinstance(event, ActionCompletedEvent) and event.action_index is not None: + status = resolve_action_status(event.result, event.action) + yield PreviewActionChecked( + index=event.action_index, + result=event.result, + status=status, + ) + + except BaseException: + if temp_dir: + safe_rmtree(temp_dir) + raise + + +# --------------------------------------------------------------------------- +# Batch preview (convenience wrapper) +# --------------------------------------------------------------------------- + + +async def preview_manifest( + porringer: API, + url: str, + *, + project_directory: Path | None = None, + prerelease_packages: set[str] | None = None, + discovered: DiscoveredPlugins | None = None, +) -> PreviewResult: + """Perform a dry-run preview of a manifest. + Consumes :func:`preview_manifest_stream` and returns a single + :class:`PreviewResult` with all resolved actions. + """ actions: list[ActionInfo] = [] project_name = '' description = '' manifest_key = url - async for event in porringer.sync.execute_stream(params, plugins=discovered): - if isinstance(event, ManifestParsedEvent): + async for event in preview_manifest_stream( + porringer, + url, + project_directory=project_directory, + prerelease_packages=prerelease_packages, + discovered=discovered, + resolve=False, + ): + if isinstance(event, PreviewManifestParsed): meta = event.manifest.metadata if meta: project_name = meta.name or '' description = meta.description or '' manifest_key = str(event.manifest.manifest_path or url) - elif isinstance(event, ManifestLoadedEvent): + elif isinstance(event, PreviewReady): for act in event.manifest.actions: actions.append( ActionInfo( @@ -118,17 +251,6 @@ async def execute_install( Yields ``("action_started", event)``, ``("sub_progress", event)``, ``("action_completed", event)``, etc. so callers can wire up progress tracking without coupling to porringer event kinds. - - Args: - porringer: The porringer API instance. - manifest_path: Path to the manifest file to execute. - project_directory: Optional project directory scope. - strategy: Sync strategy (MINIMAL or LATEST). - prerelease_packages: Optional prerelease overrides. - discovered: Pre-discovered plugins. - - Yields: - ``(stage_name, event)`` tuples for progress tracking. """ params = SetupParameters( paths=[manifest_path], diff --git a/synodic_client/operations/schema.py b/synodic_client/operations/schema.py index 3a97b61..ead7039 100644 --- a/synodic_client/operations/schema.py +++ b/synodic_client/operations/schema.py @@ -10,6 +10,89 @@ from dataclasses import dataclass, field +from porringer.schema import PluginCapability, SetupAction, SetupActionResult, SetupResults, SkipReason +from porringer.schema.plugin import PluginKind + +# --------------------------------------------------------------------------- +# Status resolution helpers +# --------------------------------------------------------------------------- + +SKIP_REASON_LABELS: dict[SkipReason, str] = { + SkipReason.ALREADY_INSTALLED: 'Already installed', + SkipReason.NOT_INSTALLED: 'Not installed', + SkipReason.ALREADY_LATEST: 'Already latest', + SkipReason.NO_PROJECT_DIRECTORY: 'No project directory', + SkipReason.UPDATE_AVAILABLE: 'Update available', +} + + +def skip_reason_label(reason: SkipReason | None) -> str: + """Return a human-readable label for a skip reason.""" + if reason is None: + return 'Skipped' + return SKIP_REASON_LABELS.get(reason, reason.name.replace('_', ' ').capitalize()) + + +def resolve_action_status(result: SetupActionResult, action: SetupAction) -> str: + """Derive a human-readable status string from a dry-run result. + + This is the single source of truth for mapping porringer's + :class:`SetupActionResult` to a display label. + """ + if result.skipped: + return skip_reason_label(result.skip_reason) + if not result.success: + return 'Failed' + if action.kind is None: + return 'Pending' + if action.kind == PluginKind.PROJECT: + return 'Ready' + return 'Needed' + + +# --------------------------------------------------------------------------- +# Preview stream events +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class PreviewManifestParsed: + """Fast event emitted once the manifest JSON is parsed.""" + + manifest: SetupResults + manifest_path: str + temp_dir: str + + +@dataclass(frozen=True, slots=True) +class PreviewPluginsQueried: + """Plugin availability discovered.""" + + availability: dict[str, bool] + capabilities: dict[str, frozenset[PluginCapability]] + + +@dataclass(frozen=True, slots=True) +class PreviewReady: + """All actions resolved — full manifest loaded.""" + + manifest: SetupResults + manifest_path: str + temp_dir: str + + +@dataclass(frozen=True, slots=True) +class PreviewActionChecked: + """A single action's dry-run result has been resolved.""" + + index: int + result: SetupActionResult + status: str + + +PreviewEvent = PreviewManifestParsed | PreviewPluginsQueried | PreviewReady | PreviewActionChecked + + # --------------------------------------------------------------------------- # Project operations # --------------------------------------------------------------------------- diff --git a/tests/unit/qt/test_action_card.py b/tests/unit/qt/test_action_card.py index deec219..b753339 100644 --- a/tests/unit/qt/test_action_card.py +++ b/tests/unit/qt/test_action_card.py @@ -33,6 +33,7 @@ ACTION_CARD_STATUS_UPDATE, ACTION_CARD_STYLE, ) +from synodic_client.operations.schema import resolve_action_status # --------------------------------------------------------------------------- # Helpers @@ -94,6 +95,13 @@ def _make_result( ) +def _check(card: ActionCard, result: SetupActionResult) -> None: + """Call set_check_result with the status resolved from the result.""" + assert card._action is not None + status = resolve_action_status(result, card._action) + card.set_check_result(result, status) + + # --------------------------------------------------------------------------- # ActionCard — skeleton # --------------------------------------------------------------------------- @@ -127,7 +135,7 @@ def test_skeleton_set_check_result_does_nothing() -> None: """set_check_result on skeleton is a no-op.""" card = ActionCard(skeleton=True) result = _make_result() - card.set_check_result(result) + card.set_check_result(result, 'Needed') assert not card.status_text() @staticmethod @@ -239,7 +247,7 @@ def test_needed_status() -> None: card = ActionCard() card.populate(_make_action()) result = _make_result(success=True, skipped=False) - card.set_check_result(result) + _check(card, result) assert card.status_text() == 'Needed' assert ACTION_CARD_STATUS_NEEDED in card._status_label.styleSheet() @@ -253,7 +261,7 @@ def test_already_installed_status() -> None: skip_reason=SkipReason.ALREADY_INSTALLED, installed_version='3.5.2', ) - card.set_check_result(result) + _check(card, result) assert card.status_text() == '\u2713 Already installed' assert ACTION_CARD_STATUS_SATISFIED in card._status_label.styleSheet() @@ -268,7 +276,7 @@ def test_update_available_status() -> None: installed_version='1.0.0', available_version='2.0.0', ) - card.set_check_result(result) + _check(card, result) assert card.status_text() == 'Update available' assert card.is_update_available() assert ACTION_CARD_STATUS_UPDATE in card._status_label.styleSheet() @@ -284,7 +292,7 @@ def test_version_transition_shown() -> None: installed_version='1.0.0', available_version='2.0.0', ) - card.set_check_result(result) + _check(card, result) assert '1.0.0' in card._version_label.text() assert '2.0.0' in card._version_label.text() assert '\u2192' in card._version_label.text() @@ -299,7 +307,7 @@ def test_installed_version_shown() -> None: skip_reason=SkipReason.ALREADY_INSTALLED, installed_version='3.5.2', ) - card.set_check_result(result) + _check(card, result) assert card._version_label.text() == '3.5.2' @staticmethod @@ -312,7 +320,7 @@ def test_available_version_only_shown() -> None: skipped=False, available_version='1.2.0', ) - card.set_check_result(result) + _check(card, result) assert '\u2192 1.2.0' in card._version_label.text() assert 'grey' in card._version_label.styleSheet() @@ -356,7 +364,7 @@ def test_failed_check_shows_failed_status() -> None: skipped=False, message="No SCM plugin was found for ecosystem 'git'.", ) - card.set_check_result(result) + _check(card, result) assert card.status_text() == 'Failed' assert ACTION_CARD_STATUS_FAILED in card._status_label.styleSheet() @@ -368,7 +376,7 @@ def test_failed_check_shows_error_tooltip() -> None: card.populate(action) msg = "SCM environment 'None' is not available" result = _make_result(success=False, skipped=False, message=msg) - card.set_check_result(result) + _check(card, result) assert card._status_label.toolTip() == msg @staticmethod @@ -378,7 +386,7 @@ def test_failed_check_stops_spinner() -> None: card.populate(_make_action()) assert card._checking result = _make_result(success=False, skipped=False, message='error') - card.set_check_result(result) + _check(card, result) assert not card._checking assert not card._spinner_timer.isActive() @@ -388,7 +396,7 @@ def test_failed_check_not_update_available() -> None: card = ActionCard() card.populate(_make_action()) result = _make_result(success=False, skipped=False, message='backend missing') - card.set_check_result(result) + _check(card, result) assert not card.is_update_available() @staticmethod @@ -397,7 +405,7 @@ def test_success_true_still_needed() -> None: card = ActionCard() card.populate(_make_action()) result = _make_result(success=True, skipped=False) - card.set_check_result(result) + _check(card, result) assert card.status_text() == 'Needed' assert ACTION_CARD_STATUS_NEEDED in card._status_label.styleSheet() @@ -571,11 +579,12 @@ def test_finalize_all_checking() -> None: # Simulate: a1 gets a check result, a2 stays as 'Checking…' c1 = card_list.get_card(a1) assert c1 is not None - c1.set_check_result( + _check( + c1, _make_result( skipped=True, skip_reason=SkipReason.ALREADY_INSTALLED, - ) + ), ) card_list.finalize_all_checking() @@ -620,7 +629,7 @@ def test_bare_command_shows_pending_after_check() -> None: assert card.status_text() == 'Pending' result = _make_result(success=True, skipped=False) - card.set_check_result(result) + _check(card, result) assert card.status_text() == 'Pending' assert ACTION_CARD_STATUS_PENDING in card._status_label.styleSheet() @@ -632,7 +641,7 @@ def test_project_shows_ready_after_check() -> None: card.populate(action) result = _make_result(success=True, skipped=False) - card.set_check_result(result) + _check(card, result) assert card.status_text() == 'Ready' assert ACTION_CARD_STATUS_SATISFIED in card._status_label.styleSheet() @@ -642,7 +651,7 @@ def test_package_still_shows_needed() -> None: card = ActionCard() card.populate(_make_action(kind=PluginKind.PACKAGE)) result = _make_result(success=True, skipped=False) - card.set_check_result(result) + _check(card, result) assert card.status_text() == 'Needed' @@ -664,7 +673,7 @@ def test_specifier_available_version_shows_requires() -> None: skipped=False, available_version='>=0.8.0', ) - card.set_check_result(result) + _check(card, result) assert card._version_label.text() == 'requires >=0.8.0' assert 'grey' in card._version_label.styleSheet() @@ -678,7 +687,7 @@ def test_resolved_available_version_shows_arrow() -> None: skipped=False, available_version='1.2.0', ) - card.set_check_result(result) + _check(card, result) assert '\u2192 1.2.0' in card._version_label.text() @staticmethod @@ -692,7 +701,7 @@ def test_satisfied_with_constraint_shows_tooltip() -> None: skip_reason=SkipReason.ALREADY_INSTALLED, installed_version='0.9.1', ) - card.set_check_result(result) + _check(card, result) assert card._version_label.text() == '0.9.1' assert 'satisfies >=0.8.0' in card._version_label.toolTip() @@ -707,7 +716,7 @@ def test_satisfied_without_constraint_no_tooltip() -> None: skip_reason=SkipReason.ALREADY_INSTALLED, installed_version='0.9.1', ) - card.set_check_result(result) + _check(card, result) assert card._version_label.text() == '0.9.1' # No constraint tooltip — only message tooltip may have been set assert 'satisfies' not in (card._version_label.toolTip() or '') @@ -782,7 +791,7 @@ def test_explicit_cli_command_from_result() -> None: action=action, cli_command=('uv', 'tool', 'install', 'ruff'), ) - card.set_check_result(result) + _check(card, result) assert card._command_label.text() == 'uv tool install ruff' @staticmethod @@ -806,7 +815,7 @@ def test_set_check_result_updates_command_label() -> None: action=action, cli_command=('uv', 'tool', 'install', 'ruff'), ) - card.set_check_result(result) + _check(card, result) assert card._command_label.text() == 'uv tool install ruff' assert not card._command_row.isHidden() @@ -864,7 +873,7 @@ def test_spinner_stops_on_check_result() -> None: card = ActionCard() card.populate(_make_action()) assert card._checking - card.set_check_result(_make_result()) + _check(card, _make_result()) assert not card._checking assert not card._spinner_timer.isActive() assert card._spinner_canvas.isHidden() @@ -1019,11 +1028,12 @@ def test_already_latest_shows_satisfied_style() -> None: """ALREADY_LATEST check result uses the satisfied style.""" card = ActionCard() card.populate(_make_action()) - card.set_check_result( + _check( + card, _make_result( skipped=True, skip_reason=SkipReason.ALREADY_LATEST, - ) + ), ) assert card.status_text() == '\u2713 Already latest' assert ACTION_CARD_STATUS_SATISFIED in card._status_label.styleSheet() @@ -1033,12 +1043,13 @@ def test_already_latest_shows_version() -> None: """ALREADY_LATEST preserves the installed version label.""" card = ActionCard() card.populate(_make_action()) - card.set_check_result( + _check( + card, _make_result( skipped=True, skip_reason=SkipReason.ALREADY_LATEST, installed_version='1.2.0', - ) + ), ) assert card._version_label.text() == '1.2.0' @@ -1047,20 +1058,22 @@ def test_already_installed_vs_already_latest() -> None: """ALREADY_INSTALLED and ALREADY_LATEST both use satisfied style.""" card_installed = ActionCard() card_installed.populate(_make_action(package='a')) - card_installed.set_check_result( + _check( + card_installed, _make_result( skipped=True, skip_reason=SkipReason.ALREADY_INSTALLED, - ) + ), ) card_latest = ActionCard() card_latest.populate(_make_action(package='b')) - card_latest.set_check_result( + _check( + card_latest, _make_result( skipped=True, skip_reason=SkipReason.ALREADY_LATEST, - ) + ), ) assert card_installed.status_text() == '\u2713 Already installed' diff --git a/tests/unit/qt/test_install_preview.py b/tests/unit/qt/test_install_preview.py index 662bb3d..4a0840c 100644 --- a/tests/unit/qt/test_install_preview.py +++ b/tests/unit/qt/test_install_preview.py @@ -404,7 +404,7 @@ async def mock_stream(*args: Any, **kwargs: Any) -> Any: porringer.sync.execute_stream = mock_stream ready_calls: list[tuple[object, str, str]] = [] - checked: list[tuple[int, SetupActionResult]] = [] + checked: list[tuple[int, SetupActionResult, str]] = [] finished = False async def _run() -> None: @@ -414,7 +414,7 @@ async def _run() -> None: str(manifest), callbacks=PreviewCallbacks( on_preview_ready=lambda p, m, t: ready_calls.append((p, m, t)), - on_action_checked=lambda row, r: checked.append((row, r)), + on_action_checked=lambda row, r, s: checked.append((row, r, s)), ), ) finished = True @@ -424,7 +424,7 @@ async def _run() -> None: assert len(ready_calls) == 1 assert ready_calls[0][0] is preview assert len(checked) == 1 - assert checked[0] == (0, result) + assert checked[0][:2] == (0, result) assert finished @staticmethod @@ -490,22 +490,22 @@ async def mock_stream(*args: Any, **kwargs: Any) -> Any: porringer.sync.execute_stream = mock_stream - checked: list[tuple[int, SetupActionResult]] = [] + checked: list[tuple[int, SetupActionResult, str]] = [] asyncio.run( run_preview( porringer, str(manifest), callbacks=PreviewCallbacks( - on_action_checked=lambda row, r: checked.append((row, r)), + on_action_checked=lambda row, r, s: checked.append((row, r, s)), ), ), ) 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) + assert checked[0][:2] == (1, result_b) + assert checked[1][:2] == (0, result_a) @staticmethod def test_emits_error_when_dry_run_fails(tmp_path: Path) -> None: @@ -859,7 +859,7 @@ async def mock_stream(*args: Any, **kwargs: Any) -> Any: porringer.sync.execute_stream = mock_stream - checked: list[tuple[int, SetupActionResult]] = [] + checked: list[tuple[int, SetupActionResult, str]] = [] asyncio.run( run_preview( @@ -867,13 +867,13 @@ async def mock_stream(*args: Any, **kwargs: Any) -> Any: str(manifest), config=PreviewConfig(project_directory=tmp_path), callbacks=PreviewCallbacks( - on_action_checked=lambda row, r: checked.append((row, r)), + on_action_checked=lambda row, r, s: checked.append((row, r, s)), ), ), ) assert len(checked) == 1 - assert checked[0] == (0, result) + assert checked[0][:2] == (0, result) assert checked[0][1].skipped is True assert checked[0][1].skip_reason == SkipReason.ALREADY_INSTALLED @@ -905,7 +905,7 @@ async def mock_stream(*args: Any, **kwargs: Any) -> Any: porringer.sync.execute_stream = mock_stream - checked: list[tuple[int, SetupActionResult]] = [] + checked: list[tuple[int, SetupActionResult, str]] = [] asyncio.run( run_preview( @@ -913,7 +913,7 @@ async def mock_stream(*args: Any, **kwargs: Any) -> Any: str(manifest), config=PreviewConfig(project_directory=tmp_path), callbacks=PreviewCallbacks( - on_action_checked=lambda row, r: checked.append((row, r)), + on_action_checked=lambda row, r, s: checked.append((row, r, s)), ), ), ) From b3a6043d26dd06160776ac30486f9acecb3fbc01 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Tue, 17 Mar 2026 10:07:12 -0700 Subject: [PATCH 3/9] More Unified Execution --- docs/development.md | 69 ++++-- synodic_client/application/debug.py | 73 +++--- synodic_client/application/qt.py | 29 +-- synodic_client/application/screen/screen.py | 67 +----- .../screen/tool_update_controller.py | 123 ++++------ .../application/update_controller.py | 18 +- synodic_client/application/workers.py | 226 ++---------------- synodic_client/cli/config.py | 15 +- synodic_client/cli/debug.py | 126 +++++++++- synodic_client/cli/tool.py | 4 +- synodic_client/operations/__init__.py | 25 +- synodic_client/operations/config.py | 21 ++ synodic_client/operations/project.py | 170 ++++++++++++- synodic_client/operations/schema.py | 58 +++++ synodic_client/operations/tool.py | 92 +++---- synodic_client/resolution.py | 75 +----- tests/unit/qt/test_tray_window_show.py | 11 +- tests/unit/qt/test_update_controller.py | 27 ++- tests/unit/test_cli.py | 78 ++++-- tests/unit/test_workers.py | 47 ++-- 20 files changed, 694 insertions(+), 660 deletions(-) diff --git a/docs/development.md b/docs/development.md index 3b125f0..f095776 100644 --- a/docs/development.md +++ b/docs/development.md @@ -26,57 +26,74 @@ The `--dev` flag isolates the development instance from production: ## Debug CLI -With the app running (`pdm dev`), inspect and control it from another terminal: +Debug commands run **headlessly** by default — no running GUI instance +is required. Data operations (``state``, ``list_projects``, +``project_status``, etc.) call the porringer API directly. + +Pass ``--live`` to route a command over IPC to a running GUI instance. +This is required for GUI-control actions (``show_main``, +``check_update``, ``select_project``, etc.) and gives faster responses +for data queries thanks to the GUI's cached plugin discovery. ```shell -pdm run synodic-c debug state --dev # JSON dump of app state, config, update phase, data -pdm run synodic-c debug actions --dev # List available actions with descriptions -pdm run synodic-c debug action --dev # Trigger an action (e.g. check_update, show_main) -pdm run synodic-c debug action --dev # Action with argument (e.g. add_project /path) +# Headless (default) — no GUI needed +pdm run synodic-c debug state --dev +pdm run synodic-c debug actions --dev +pdm run synodic-c debug action list_projects --dev +pdm run synodic-c debug action project_status D:\example --dev +pdm run synodic-c debug action add_project D:\my-project --dev +pdm run synodic-c debug action remove_project D:\my-project --dev + +# Live (IPC to running GUI) — requires `pdm dev` in another terminal +pdm run synodic-c debug state --dev --live +pdm run synodic-c debug action show_main --dev --live +pdm run synodic-c debug action check_update --dev --live +pdm run synodic-c debug action project_status --dev --live # uses selected project +pdm run synodic-c debug action select_project D:\example --dev --live ``` Available actions: `check_update`, `tool_update`, `refresh_data`, `show_main`, `show_settings`, `apply_update`, `list_projects`, `add_project`, `remove_project`, `project_status`, `select_project`. ### Project management actions -| Action | Arg | Description | -|--------|-----|-------------| -| `list_projects` | — | List cached directories with validation status. | -| `add_project` | `` | Add a directory to the cache (no file picker). | -| `remove_project` | `` | Remove a directory from the cache. | -| `project_status` | `[path]` | Per-action preview status. Defaults to selected project. | -| `select_project` | `` | Switch sidebar selection to a project. | +| Action | Arg | Headless | Description | +|--------|-----|----------|-------------| +| `list_projects` | — | ✓ | List cached directories with validation status. | +| `add_project` | `` | ✓ | Add a directory to the cache (no file picker). | +| `remove_project` | `` | ✓ | Remove a directory from the cache. | +| `project_status` | `` | ✓ | Per-action preview status (dry-run). | +| `select_project` | `` | `--live` | Switch sidebar selection to a project. | -Example workflow: +### GUI-only actions (require `--live`) -```shell -pdm run synodic-c debug action list_projects --dev -pdm run synodic-c debug action show_main --dev -pdm run synodic-c debug action project_status --dev # selected project -pdm run synodic-c debug action project_status D:\example --dev # specific project -pdm run synodic-c debug action add_project D:\my-project --dev -pdm run synodic-c debug action remove_project D:\my-project --dev -``` +| Action | Arg | Description | +|--------|-----|-------------| +| `check_update` | — | Trigger a self-update check. | +| `tool_update` | — | Run tool/package updates for all plugins. | +| `refresh_data` | — | Mark cached data as stale. | +| `show_main` | — | Show and raise the main window. | +| `show_settings` | — | Show the settings window. | +| `apply_update` | — | Apply a downloaded update and restart. | Commands route to the controller/service layer (not widgets), so they are stable across UI changes. For production instances, omit `--dev`: ```shell -pdm run synodic-c debug state -pdm run synodic-c debug action check_update +pdm run synodic-c debug state # headless +pdm run synodic-c debug action check_update --live # IPC to running GUI ``` -## IPC Console +## IPC Console (`--live` mode) -The debug CLI communicates with the running GUI over a local named-pipe IPC channel powered by `QLocalServer` / `QLocalSocket` (PySide6). +When ``--live`` is passed, the debug CLI communicates with the running GUI over a local named-pipe IPC channel powered by `QLocalServer` / `QLocalSocket` (PySide6). ### Architecture ``` CLI process GUI process ─────────── ─────────── -synodic-c debug +synodic-c debug --live │ ▼ SingleInstance.send_debug_command(cmd) diff --git a/synodic_client/application/debug.py b/synodic_client/application/debug.py index 6624ae3..95ab61d 100644 --- a/synodic_client/application/debug.py +++ b/synodic_client/application/debug.py @@ -17,6 +17,7 @@ from typing import TYPE_CHECKING from synodic_client.config import is_dev_mode +from synodic_client.operations.schema import DEBUG_ACTIONS if TYPE_CHECKING: from porringer.api import API @@ -32,19 +33,7 @@ logger = logging.getLogger(__name__) -_ACTIONS: dict[str, str] = { - 'check_update': 'Trigger a self-update check.', - 'tool_update': 'Run tool/package updates for all plugins.', - 'refresh_data': 'Mark cached data as stale (next refresh re-fetches).', - 'show_main': 'Show and raise the main window.', - 'show_settings': 'Show the settings window.', - 'apply_update': 'Apply a downloaded update and restart.', - 'list_projects': 'List cached project directories with validation status.', - 'add_project': 'Add a directory to the project cache. Arg: ', - 'remove_project': 'Remove a directory from the project cache. Arg: ', - 'project_status': 'Dump per-action preview status. Arg (optional): ', - 'select_project': 'Select a project in the sidebar. Arg: ', -} +_ACTIONS = DEBUG_ACTIONS @dataclasses.dataclass @@ -164,49 +153,39 @@ def _handle_action(self, name: str, arg: str | None = None) -> str: def _handle_list_projects(self) -> str: """List all cached project directories with validation status.""" - from synodic_client.operations.project import list_projects + from synodic_client.operations.project import run_project_action - projects = list_projects(self._s.porringer) - return json.dumps({'projects': [dataclasses.asdict(p) for p in projects]}) + return json.dumps(run_project_action('list_projects', None, self._s.porringer)) def _handle_add_project(self, arg: str | None) -> str: """Add a directory to the porringer cache.""" - if not arg: - return json.dumps({'error': 'add_project requires a path argument'}) - - from synodic_client.operations.project import add_project + from synodic_client.operations.project import run_project_action - try: - add_project(self._s.porringer, arg) - except (NotADirectoryError, ValueError) as exc: - return json.dumps({'error': str(exc)}) + result = run_project_action('add_project', arg, self._s.porringer) - if self._s.coordinator is not None: - self._s.coordinator.invalidate() - - projects_view = self._s.main_window._projects_view - if projects_view is not None: - projects_view.refresh() + if 'error' not in result: + if self._s.coordinator is not None: + self._s.coordinator.invalidate() + projects_view = self._s.main_window._projects_view + if projects_view is not None: + projects_view.refresh() - return json.dumps({'ok': True, 'action': 'add_project', 'path': arg}) + return json.dumps(result) def _handle_remove_project(self, arg: str | None) -> str: """Remove a directory from the porringer cache.""" - if not arg: - return json.dumps({'error': 'remove_project requires a path argument'}) + from synodic_client.operations.project import run_project_action - from synodic_client.operations.project import remove_project + result = run_project_action('remove_project', arg, self._s.porringer) - remove_project(self._s.porringer, arg) - - if self._s.coordinator is not None: - self._s.coordinator.invalidate() - - projects_view = self._s.main_window._projects_view - if projects_view is not None: - projects_view.refresh() + if 'error' not in result: + if self._s.coordinator is not None: + self._s.coordinator.invalidate() + projects_view = self._s.main_window._projects_view + if projects_view is not None: + projects_view.refresh() - return json.dumps({'ok': True, 'action': 'remove_project', 'path': arg}) + return json.dumps(result) def _handle_project_status(self, arg: str | None) -> str: """Dump per-action preview status for a project.""" @@ -243,9 +222,11 @@ def _handle_project_status(self, arg: str | None) -> str: entry['installer'] = act.installer actions.append(entry) - needed = sum(1 for s in model.action_states if s.status == 'Needed') - satisfied = sum(1 for s in model.action_states if '\u2713' in s.status) - pending = sum(1 for s in model.action_states if s.status == 'Pending') + from synodic_client.operations.schema import classify_status + + needed = sum(1 for s in model.action_states if classify_status(s.status) == 'needed') + satisfied = sum(1 for s in model.action_states if classify_status(s.status) == 'satisfied') + pending = sum(1 for s in model.action_states if classify_status(s.status) == 'pending') upgradable = len(model.upgradable_keys) return json.dumps({ diff --git a/synodic_client/application/qt.py b/synodic_client/application/qt.py index bb7c5ac..43f1799 100644 --- a/synodic_client/application/qt.py +++ b/synodic_client/application/qt.py @@ -13,7 +13,6 @@ import qasync from porringer.api import API -from porringer.schema import LocalConfiguration from PySide6.QtCore import QEvent, QObject, Qt, QTimer from PySide6.QtWidgets import QApplication, QWidget @@ -29,12 +28,9 @@ from synodic_client.client import Client from synodic_client.config import set_dev_mode from synodic_client.logging import configure_logging, log_path, set_debug_level +from synodic_client.operations.bootstrap import init_services from synodic_client.protocol import extract_uri_from_args -from synodic_client.resolution import ( - ResolvedConfig, - resolve_config, - resolve_update_config, -) +from synodic_client.resolution import ResolvedConfig from synodic_client.subprocess_patch import apply as _apply_subprocess_patch from synodic_client.updater import initialize_velopack @@ -42,27 +38,14 @@ def _init_services(logger: logging.Logger) -> tuple[Client, API, ResolvedConfig]: """Create and configure core services. + Delegates to :func:`~synodic_client.operations.bootstrap.init_services` + and adds GUI-specific debug logging. + Returns: A (Client, porringer API, resolved config) tuple. """ - config = resolve_config() - client = Client() - - local_config = LocalConfiguration() - porringer = API(local_config) - - update_config = resolve_update_config(config) - client.initialize_updater(update_config) + client, porringer, config = init_services() - cached_dirs = porringer.cache.list_directories() - - logger.info( - 'Synodic Client v%s started (channel: %s, source: %s, cached_projects: %d)', - client.version, - update_config.channel.name, - update_config.repo_url, - len(cached_dirs), - ) logger.debug( 'Resolved config: update_source=%s update_channel=%s auto_update=%dm tool_update=%dm ' 'auto_apply=%s auto_start=%s debug_logging=%s prerelease_packages=%s plugin_auto_update=%s', diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 6ef1094..511e10a 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -1045,14 +1045,10 @@ async def _gather_project_requirements( """ actions: list[SetupAction] = [] try: + from synodic_client.operations.project import find_manifest + path = Path(directory.path) - filenames = self._porringer.sync.manifest_filenames() - manifest_path: Path | None = None - for fname in filenames: - candidate = path / fname - if candidate.exists(): - manifest_path = candidate - break + manifest_path = find_manifest(self._porringer, path) if manifest_path is None: return actions @@ -1177,19 +1173,10 @@ async def _check_for_updates( if self._coordinator is not None: return await self._check_updates_via_coordinator() - # Legacy per-directory fallback - available: dict[str, dict[str, str]] = {} - - async def _check_one(directory: ManifestDirectory) -> None: - partial = await self._check_directory_updates(directory) - for installer, packages in partial.items(): - available.setdefault(installer, {}).update(packages) - - async with asyncio.TaskGroup() as tg: - for d in directories: - tg.create_task(_check_one(d)) + # Legacy per-directory fallback — delegate to the operations layer + from synodic_client.operations.tool import check_tool_updates - return available + return await check_tool_updates(self._porringer, directories) async def _check_updates_via_coordinator(self) -> dict[str, dict[str, str]]: """Use the coordinator's ``check_updates`` for efficient detection. @@ -1225,48 +1212,6 @@ async def _check_updates_via_coordinator(self) -> dict[str, dict[str, str]]: return available - async def _check_directory_updates( - self, - directory: ManifestDirectory, - ) -> dict[str, dict[str, str]]: - """Check a single directory for available updates (dry-run). - - Legacy fallback used when no coordinator is available. - """ - available: dict[str, dict[str, str]] = {} - try: - path = Path(directory.path) - filenames = self._porringer.sync.manifest_filenames() - manifest_path: Path | None = None - for fname in filenames: - candidate = path / fname - if candidate.exists(): - manifest_path = candidate - break - - if manifest_path is None: - return available - - params = SetupParameters( - paths=[str(manifest_path)], - dry_run=True, - project_directory=path, - ) - async for event in self._porringer.sync.execute_stream(params): - if isinstance(event, ActionCompletedEvent) and event.result.skip_reason == SkipReason.UPDATE_AVAILABLE: - action = event.result.action - if action.installer and action.package: - pkg_name = str(action.package.name) - latest = event.result.available_version or '' - available.setdefault(action.installer, {})[pkg_name] = latest - except Exception: - logger.debug( - 'Could not detect updates for %s', - directory.path, - exc_info=True, - ) - return available - async def _deferred_update_check(self) -> None: """Run update detection in the background, then patch the widget tree. diff --git a/synodic_client/application/screen/tool_update_controller.py b/synodic_client/application/screen/tool_update_controller.py index 321fea8..6eeaac6 100644 --- a/synodic_client/application/screen/tool_update_controller.py +++ b/synodic_client/application/screen/tool_update_controller.py @@ -2,9 +2,8 @@ :class:`ToolUpdateOrchestrator` owns the background tool update lifecycle — periodic polling, single-plugin / single-package updates, -and package removal — delegating actual work to -:func:`~synodic_client.application.workers.run_tool_updates` and -:func:`~synodic_client.application.workers.run_package_remove`. +and package removal — delegating actual work to the operations layer +(``synodic_client.operations.tool``). """ from __future__ import annotations @@ -16,19 +15,21 @@ from typing import TYPE_CHECKING, Any from porringer.api import API -from porringer.core.schema import PackageRef -from porringer.schema.execution import SetupActionResult from PySide6.QtCore import QTimer from PySide6.QtWidgets import QSystemTrayIcon -from synodic_client.application.schema import ToolUpdateResult, UpdateTarget +from synodic_client.application.schema import UpdateTarget from synodic_client.application.screen.screen import MainWindow, ToolsView -from synodic_client.application.workers import ( - run_runtime_package_updates, - run_tool_updates, +from synodic_client.operations.schema import UpdateResult +from synodic_client.operations.tool import ( + parse_plugin_key, + remove_package, + resolve_auto_update_scope, + update_all_tools, + update_runtime_plugin, + update_tool, ) from synodic_client.resolution import ( - resolve_auto_update_scope, resolve_update_config, ) @@ -38,17 +39,6 @@ logger = logging.getLogger(__name__) -def _parse_plugin_key(name: str) -> tuple[str, str | None]: - """Split a composite ``'plugin:tag'`` key into ``(bare_name, tag)``. - - Returns ``(name, None)`` when there is no tag component. - """ - if ':' in name: - bare, tag = name.split(':', 1) - return bare, tag - return name, None - - class ToolUpdateOrchestrator: """Background tool update lifecycle manager. @@ -215,16 +205,16 @@ async def _do_tool_update(self, porringer: API) -> None: all_names = [p.name for p in all_plugins if p.installed] enabled_plugins, include_packages = resolve_auto_update_scope( - config, + config.plugin_auto_update, all_names, ) try: - result = await run_tool_updates( + result = await update_all_tools( porringer, plugins=enabled_plugins, include_packages=include_packages, - discovered_plugins=discovered, + discovered=discovered, ) if coordinator is not None: coordinator.invalidate() @@ -254,7 +244,7 @@ def on_single_plugin_update(self, plugin_name: str) -> None: if tools_view is not None: tools_view.set_plugin_updating(plugin_name, True) - bare_plugin, runtime_tag = _parse_plugin_key(plugin_name) + bare_plugin, runtime_tag = parse_plugin_key(plugin_name) if runtime_tag is not None: self._set_task( self._async_runtime_plugin_update(porringer, plugin_name, bare_plugin, runtime_tag), @@ -285,12 +275,12 @@ async def _async_runtime_plugin_update( include_packages = enabled_pkgs try: - result = await run_runtime_package_updates( + result = await update_runtime_plugin( porringer, plugin_name, runtime_tag, include_packages=include_packages, - discovered_plugins=discovered, + discovered=discovered, ) if coordinator is not None: coordinator.invalidate() @@ -304,25 +294,14 @@ async def _async_runtime_plugin_update( async def _async_single_plugin_update(self, porringer: API, plugin_name: str) -> None: """Run a single-plugin tool update and route results.""" - config = self._store.config - mapping = config.plugin_auto_update or {} - pkg_entry = mapping.get(plugin_name) coordinator = self._window.coordinator discovered = coordinator.discovered_plugins if coordinator is not None else None - # Resolve per-package filtering for this plugin - include_packages: set[str] | None = None - if isinstance(pkg_entry, dict): - enabled_pkgs = {name for name, enabled in pkg_entry.items() if enabled} - if enabled_pkgs: - include_packages = enabled_pkgs - try: - result = await run_tool_updates( + result = await update_all_tools( porringer, plugins={plugin_name}, - include_packages=include_packages, - discovered_plugins=discovered, + discovered=discovered, ) if coordinator is not None: coordinator.invalidate() @@ -361,39 +340,21 @@ async def _async_single_package_update( When *plugin_name* is a composite ``"plugin:tag"`` key the upgrade is scoped to that runtime tag via - ``porringer.package.upgrade(runtime_tag=...)``. + ``update_tool(runtime_tag=...)``. """ coordinator = self._window.coordinator discovered = coordinator.discovered_plugins if coordinator is not None else None target = UpdateTarget(plugin=plugin_name, package=package_name) - bare_plugin, runtime_tag = _parse_plugin_key(plugin_name) - if runtime_tag is not None: - try: - result = await run_runtime_package_updates( - porringer, - bare_plugin, - runtime_tag, - include_packages={package_name}, - discovered_plugins=discovered, - ) - if coordinator is not None: - coordinator.invalidate() - self._on_tool_update_finished(result, target) - except asyncio.CancelledError: - logger.debug('Runtime package update cancelled (shutdown)') - raise - except Exception as exc: - logger.exception('Runtime package update failed') - self._fail_package_update(plugin_name, package_name, f'Update failed: {exc}') - return + bare_plugin, runtime_tag = parse_plugin_key(plugin_name) try: - result = await run_tool_updates( + result = await update_tool( porringer, - plugins={plugin_name}, - include_packages={package_name}, - discovered_plugins=discovered, + bare_plugin, + package_name, + runtime_tag=runtime_tag, + discovered=discovered, ) if coordinator is not None: coordinator.invalidate() @@ -409,7 +370,7 @@ async def _async_single_package_update( def _on_tool_update_finished( self, - result: ToolUpdateResult, + result: UpdateResult, target: UpdateTarget | None = None, ) -> None: """Handle tool update completion. @@ -423,7 +384,7 @@ def _on_tool_update_finished( 'Tool update completed: %d manifest(s), %d updated, %d already latest, %d failed', result.manifests_processed, result.updated, - result.already_latest, + len(result.already_latest), result.failed, ) @@ -487,28 +448,25 @@ async def _async_single_package_remove( coordinator = self._window.coordinator discovered = coordinator.discovered_plugins if coordinator is not None else None - bare_plugin, runtime_tag = _parse_plugin_key(plugin_name) + bare_plugin, runtime_tag = parse_plugin_key(plugin_name) try: - package_ref = PackageRef(name=package_name) - result = await porringer.package.uninstall( + success = await remove_package( + porringer, bare_plugin, - package_ref, + package_name, runtime_tag=runtime_tag, - plugins=discovered, + discovered=discovered, ) logger.info( - 'Removal result for %s/%s: success=%s, skipped=%s, skip_reason=%s, message=%s', + 'Removal result for %s/%s: success=%s', plugin_name, package_name, - result.success, - result.skipped, - result.skip_reason, - result.message, + success, ) if coordinator is not None: coordinator.invalidate() - self._on_package_remove_finished(result, plugin_name, package_name) + self._on_package_remove_finished(success, plugin_name, package_name) except asyncio.CancelledError: logger.debug('Package removal cancelled (shutdown)') raise @@ -523,18 +481,17 @@ async def _async_single_package_remove( def _on_package_remove_finished( self, - result: SetupActionResult, + success: bool, plugin_name: str, package_name: str, ) -> None: """Handle package removal completion.""" - if not result.success or result.skipped: - detail = result.message or 'Unknown error' - logger.warning('Package removal failed for %s/%s: %s', plugin_name, package_name, detail) + if not success: + logger.warning('Package removal failed for %s/%s', plugin_name, package_name) self._fail_package_update( plugin_name, package_name, - f'Could not remove {package_name}: {detail}', + f'Could not remove {package_name}', removing=True, ) return diff --git a/synodic_client/application/update_controller.py b/synodic_client/application/update_controller.py index e1cf84a..b425204 100644 --- a/synodic_client/application/update_controller.py +++ b/synodic_client/application/update_controller.py @@ -29,12 +29,12 @@ UPDATE_STATUS_UP_TO_DATE_STYLE, ) from synodic_client.application.update_model import UpdateModel -from synodic_client.application.workers import check_for_update, download_update +from synodic_client.operations.schema import UpdateCheckResult +from synodic_client.operations.update import check_self_update, download_self_update from synodic_client.resolution import ( ResolvedConfig, resolve_update_config, ) -from synodic_client.schema import UpdateInfo from synodic_client.startup import sync_startup if TYPE_CHECKING: @@ -295,7 +295,7 @@ def _do_check(self, *, silent: bool) -> None: async def _async_check(self, *, silent: bool) -> None: """Run the update check coroutine and route results.""" try: - result = await check_for_update(self._client) + result = await check_self_update(self._client) self._on_check_finished(result, silent=silent) except asyncio.CancelledError: logger.debug('Update check cancelled (shutdown)') @@ -304,14 +304,10 @@ async def _async_check(self, *, silent: bool) -> None: logger.exception('Update check failed') self._on_check_error(str(exc), silent=silent) - def _on_check_finished(self, result: UpdateInfo | None, *, silent: bool = False) -> None: + def _on_check_finished(self, result: UpdateCheckResult, *, silent: bool = False) -> None: """Route the update-check result.""" self._model.set_check_button_enabled(True) - if result is None: - self._report_error('Failed to check for updates.', silent=silent) - return - if result.error: self._report_error(result.error, silent=silent) return @@ -327,7 +323,7 @@ def _on_check_finished(self, result: UpdateInfo | None, *, silent: bool = False) logger.debug('Automatic update check: no update available') return - version = str(result.latest_version) + version = result.version or result.current_version # Already downloaded — restore the ready state without re-downloading if version == self._pending_version: @@ -361,11 +357,11 @@ def _start_download(self, version: str, *, silent: bool = False) -> None: async def _async_download(self, version: str, *, silent: bool = False) -> None: """Run the download coroutine and route results.""" try: - success = await download_update( + dl_result = await download_self_update( self._client, on_progress=self._on_download_progress, ) - self._on_download_finished(success, version, silent=silent) + self._on_download_finished(dl_result.success, version, silent=silent) except asyncio.CancelledError: logger.debug('Update download cancelled (shutdown)') raise diff --git a/synodic_client/application/workers.py b/synodic_client/application/workers.py index 372f8bd..2af7a25 100644 --- a/synodic_client/application/workers.py +++ b/synodic_client/application/workers.py @@ -1,214 +1,20 @@ """Async background workers for the Synodic Client application. -Each coroutine runs on the caller's event loop (typically the qasync -main-thread loop) and communicates results via return values or -callbacks. Blocking calls are wrapped in ``loop.run_in_executor`` -to avoid stalling the GUI. +.. deprecated:: + All worker functions have been moved to the operations layer + (``synodic_client.operations.tool`` and + ``synodic_client.operations.update``). This module is kept for + backward compatibility but will be removed in a future release. """ -import asyncio -import logging -from collections.abc import Callable -from pathlib import Path - -from porringer.api import API -from porringer.backend.command.core.discovery import DiscoveredPlugins -from porringer.core.schema import PackageRef -from porringer.schema import ActionCompletedEvent, SetupParameters, SkipReason, SyncStrategy -from porringer.schema.execution import SetupActionResult - -from synodic_client.application.schema import ToolUpdateResult -from synodic_client.client import Client -from synodic_client.schema import UpdateInfo - -logger = logging.getLogger(__name__) - - -async def check_for_update(client: Client) -> UpdateInfo | None: - """Check for application updates off the main thread. - - Args: - client: The Synodic Client service. - - Returns: - An ``UpdateInfo`` result, or ``None`` when no updater is initialised. - """ - loop = asyncio.get_running_loop() - try: - return await loop.run_in_executor(None, client.check_for_update) - except asyncio.CancelledError: - logger.debug('check_for_update cancelled') - raise - - -async def download_update( - client: Client, - on_progress: Callable[[int], None] | None = None, -) -> bool: - """Download an application update off the main thread. - - Args: - client: The Synodic Client service. - on_progress: Optional callback for download progress (0-100). - Invoked on the event loop thread via - ``call_soon_threadsafe``. - - Returns: - ``True`` if the download succeeded. - """ - loop = asyncio.get_running_loop() - - def _run() -> bool: - def progress_callback(percentage: int) -> None: - if on_progress is not None: - loop.call_soon_threadsafe(on_progress, percentage) - - return client.download_update(progress_callback) - - try: - return await loop.run_in_executor(None, _run) - except asyncio.CancelledError: - logger.debug('download_update cancelled') - raise - - -async def run_tool_updates( - porringer: API, - plugins: set[str] | None = None, - include_packages: set[str] | None = None, - *, - discovered_plugins: DiscoveredPlugins | None = None, -) -> ToolUpdateResult: - """Re-sync all cached project manifests. - - Args: - porringer: The porringer API instance. - plugins: Optional include-set of plugin names. When set, only - actions handled by these plugins are executed. ``None`` - means all plugins. - include_packages: Optional include-set of package names. When - set, only actions whose package name is in this set are - executed. ``None`` means all packages. - discovered_plugins: Pre-discovered plugins to pass through to - porringer, avoiding redundant discovery on each - ``execute_stream`` call. - - Returns: - A :class:`ToolUpdateResult` summarising the run. - """ - loop = asyncio.get_running_loop() - dir_results = await loop.run_in_executor( - None, - lambda: porringer.cache.list_directories(validate=True, check_manifest=True), - ) - - result = ToolUpdateResult() - for dr in dir_results: - if not dr.has_manifest: - logger.debug('Skipping path without manifest: %s', dr.directory.path) - continue - path = Path(dr.directory.path) - params = SetupParameters( - paths=[path], - project_directory=path if path.is_dir() else None, - strategy=SyncStrategy.LATEST, - plugins=plugins, - include_packages=include_packages, - ) - try: - async for event in porringer.sync.execute_stream( - params, - plugins=discovered_plugins, - ): - if not isinstance(event, ActionCompletedEvent): - continue - action_result = event.result - if action_result.skipped: - if action_result.skip_reason in { - SkipReason.ALREADY_LATEST, - SkipReason.ALREADY_INSTALLED, - }: - result.already_latest += 1 - elif action_result.success: - result.updated += 1 - if action_result.action.package: - result.updated_packages.add(str(action_result.action.package.name)) - else: - result.failed += 1 - except asyncio.CancelledError: - logger.debug('run_tool_updates cancelled during manifest processing') - raise - result.manifests_processed += 1 - return result - - -async def run_package_remove( - porringer: API, - plugin_name: str, - package_name: str, - *, - discovered_plugins: DiscoveredPlugins | None = None, -) -> SetupActionResult: - """Uninstall a single package via the porringer API. - - Args: - porringer: The porringer API instance. - plugin_name: The installer plugin name (e.g. ``"pipx"``). - package_name: The package to remove. - discovered_plugins: Pre-discovered plugins to pass through to - porringer, avoiding redundant discovery. - - Returns: - A :class:`SetupActionResult` describing the outcome. - """ - package_ref = PackageRef(name=package_name) - return await porringer.package.uninstall(plugin_name, package_ref, plugins=discovered_plugins) - - -async def run_runtime_package_updates( - porringer: API, - plugin_name: str, - runtime_tag: str, - include_packages: set[str] | None = None, - *, - discovered_plugins: DiscoveredPlugins | None = None, -) -> ToolUpdateResult: - """Upgrade packages for a single plugin scoped to a specific runtime tag. - - Args: - porringer: The porringer API instance. - plugin_name: The installer plugin name (e.g. ``"pipx"``). - runtime_tag: The runtime version tag (e.g. ``"3.12"``). - include_packages: Optional include-set of package names. - discovered_plugins: Pre-discovered plugins to pass through. - - Returns: - A :class:`ToolUpdateResult` summarising the run. - """ - result = ToolUpdateResult() - packages = await porringer.package.list_by_runtime(plugin_name, plugins=discovered_plugins) - if packages is None: - return result - for rt in packages: - if rt.tag != runtime_tag: - continue - for pkg in rt.packages: - pkg_name = str(pkg.name) - if include_packages is not None and pkg_name not in include_packages: - continue - package_ref = PackageRef(name=pkg_name) - action_result = await porringer.package.upgrade( - plugin_name, - package_ref, - runtime_tag=runtime_tag, - plugins=discovered_plugins, - ) - if action_result.skipped: - result.already_latest += 1 - elif action_result.success: - result.updated += 1 - result.updated_packages.add(pkg_name) - else: - result.failed += 1 - break - return result +from synodic_client.operations.tool import update_all_tools as run_tool_updates +from synodic_client.operations.tool import update_runtime_plugin as run_runtime_package_updates +from synodic_client.operations.update import check_self_update as check_for_update +from synodic_client.operations.update import download_self_update as download_update + +__all__ = [ + 'check_for_update', + 'download_update', + 'run_runtime_package_updates', + 'run_tool_updates', +] diff --git a/synodic_client/cli/config.py b/synodic_client/cli/config.py index c44738c..9cd5e86 100644 --- a/synodic_client/cli/config.py +++ b/synodic_client/cli/config.py @@ -32,17 +32,14 @@ def config_get( ] = False, ) -> None: """Print the current value of a config key.""" - import dataclasses + from synodic_client.operations.config import get_config_value - from synodic_client.operations.config import get_config - - config = get_config() - fields = {f.name for f in dataclasses.fields(config)} - if key not in fields: - typer.echo(f'Unknown key: {key!r}. Valid keys: {sorted(fields)}', err=True) - raise typer.Exit(code=1) + try: + value = get_config_value(key) + except KeyError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(code=1) from None - value = getattr(config, key) render({key: value}, as_json=json_output) diff --git a/synodic_client/cli/debug.py b/synodic_client/cli/debug.py index 5ad3c40..97a194d 100644 --- a/synodic_client/cli/debug.py +++ b/synodic_client/cli/debug.py @@ -1,26 +1,37 @@ -"""Debug IPC commands for inspecting a running GUI instance. +"""Debug commands for inspecting and controlling the Synodic Client. - synodic-c debug state - synodic-c debug actions - synodic-c debug action [arg] + synodic-c debug state [--live] + synodic-c debug actions [--live] + synodic-c debug action [arg] [--live] -These commands send commands over the local-socket IPC channel and -require a running Synodic Client GUI instance. +By default commands run headlessly — no running GUI instance is +required. Pass ``--live`` to route the command over the IPC socket +to a running GUI instance (which has cached data and can control +windows). """ from __future__ import annotations +import dataclasses import json +import sys from typing import Annotated import typer +from synodic_client.operations.schema import DEBUG_ACTIONS, GUI_ONLY_ACTIONS + debug_app = typer.Typer( - help='Inspect and control the running Synodic Client instance.', + help='Inspect and control the Synodic Client (headless by default, --live for IPC).', add_completion=False, ) +# --------------------------------------------------------------------------- +# IPC path (--live) +# --------------------------------------------------------------------------- + + def _send_debug(command: str, *, dev: bool) -> None: """Send a debug command to the running instance and print the response.""" from synodic_client.application.instance import SingleInstance @@ -40,6 +51,76 @@ def _send_debug(command: str, *, dev: bool) -> None: raise typer.Exit(code=1) +# --------------------------------------------------------------------------- +# Headless path (default) +# --------------------------------------------------------------------------- + + +def _headless_dispatch(command: str, *, dev: bool) -> None: + """Execute a debug command headlessly and print JSON output.""" + from synodic_client.config import set_dev_mode + + set_dev_mode(dev) + + if command == 'state': + data = _headless_state() + elif command == 'actions': + data = {'actions': DEBUG_ACTIONS} + elif command.startswith('action:'): + remainder = command[len('action:'):] + name, _, arg = remainder.partition(':') + data = _headless_action(name, arg or None) + else: + data = {'error': f'unknown command: {command}'} + + typer.echo(json.dumps(data, indent=2, default=str)) + if 'error' in data: + raise typer.Exit(code=1) + + +def _headless_state() -> dict: + """Build a state dict without Qt.""" + from synodic_client.cli.context import get_services + from synodic_client.config import is_dev_mode + + client, porringer, config = get_services() + cached_dirs = porringer.cache.list_directories() + + return { + 'app': { + 'version': str(client.version), + 'dev_mode': is_dev_mode(), + 'frozen': getattr(sys, 'frozen', False), + 'platform': sys.platform, + 'headless': True, + }, + 'config': dataclasses.asdict(config), + 'data': { + 'directory_count': len(cached_dirs), + }, + } + + +def _headless_action(name: str, arg: str | None) -> dict: + """Dispatch a single debug action headlessly.""" + if name not in DEBUG_ACTIONS: + return {'error': f'unknown action: {name}', 'available': list(DEBUG_ACTIONS)} + + if name in GUI_ONLY_ACTIONS: + return {'error': f'{name} requires --live (targets a running GUI instance)'} + + from synodic_client.cli.context import get_services + from synodic_client.operations.project import run_project_action + + _, porringer, _ = get_services() + return run_project_action(name, arg, porringer) + + +# --------------------------------------------------------------------------- +# CLI subcommands +# --------------------------------------------------------------------------- + + @debug_app.command('state') def debug_state( *, @@ -47,9 +128,16 @@ def debug_state( bool, typer.Option('--dev', help='Target the dev-mode instance.'), ] = False, + live: Annotated[ + bool, + typer.Option('--live', help='Route via IPC to a running GUI instance.'), + ] = False, ) -> None: - """Dump the running application's domain state as JSON.""" - _send_debug('state', dev=dev) + """Dump application domain state as JSON.""" + if live: + _send_debug('state', dev=dev) + else: + _headless_dispatch('state', dev=dev) @debug_app.command('actions') @@ -59,9 +147,16 @@ def debug_actions( bool, typer.Option('--dev', help='Target the dev-mode instance.'), ] = False, + live: Annotated[ + bool, + typer.Option('--live', help='Route via IPC to a running GUI instance.'), + ] = False, ) -> None: """List available debug actions.""" - _send_debug('actions', dev=dev) + if live: + _send_debug('actions', dev=dev) + else: + _headless_dispatch('actions', dev=dev) @debug_app.command('action') @@ -79,7 +174,14 @@ def debug_action( bool, typer.Option('--dev', help='Target the dev-mode instance.'), ] = False, + live: Annotated[ + bool, + typer.Option('--live', help='Route via IPC to a running GUI instance.'), + ] = False, ) -> None: - """Trigger a deterministic action on the running instance.""" + """Trigger a deterministic action on the application.""" command = f'action:{name}:{arg}' if arg else f'action:{name}' - _send_debug(command, dev=dev) + if live: + _send_debug(command, dev=dev) + else: + _headless_dispatch(command, dev=dev) diff --git a/synodic_client/cli/tool.py b/synodic_client/cli/tool.py index 4daa052..1ab1b47 100644 --- a/synodic_client/cli/tool.py +++ b/synodic_client/cli/tool.py @@ -33,9 +33,7 @@ def tool_check( from synodic_client.operations.tool import check_tool_updates _, porringer, _ = get_services() - directories = porringer.cache.list_directories(validate=True, check_manifest=True) - manifest_dirs = [d.directory for d in directories if d.has_manifest] - available = asyncio.run(check_tool_updates(porringer, manifest_dirs)) + available = asyncio.run(check_tool_updates(porringer)) render(available, as_json=json_output) diff --git a/synodic_client/operations/__init__.py b/synodic_client/operations/__init__.py index 13c3c74..ad3af34 100644 --- a/synodic_client/operations/__init__.py +++ b/synodic_client/operations/__init__.py @@ -16,15 +16,24 @@ """ from synodic_client.operations.bootstrap import init_services -from synodic_client.operations.config import get_config, list_config_keys, set_config +from synodic_client.operations.config import get_config, get_config_value, list_config_keys, set_config from synodic_client.operations.install import ( execute_install, preview_manifest, preview_manifest_stream, resolve_manifest_path, ) -from synodic_client.operations.project import add_project, list_projects, remove_project +from synodic_client.operations.project import ( + add_project, + find_manifest, + list_projects, + project_status, + remove_project, + run_project_action, +) from synodic_client.operations.schema import ( + DEBUG_ACTIONS, + GUI_ONLY_ACTIONS, SKIP_REASON_LABELS, ActionInfo, ConfigKeyInfo, @@ -44,14 +53,17 @@ ToolSection, UpdateCheckResult, UpdateResult, + classify_status, resolve_action_status, skip_reason_label, ) from synodic_client.operations.tool import ( check_tool_updates, + parse_plugin_key, remove_package, resolve_auto_update_scope, update_all_tools, + update_runtime_plugin, update_tool, ) from synodic_client.operations.update import apply_self_update, check_self_update, download_self_update @@ -61,6 +73,7 @@ 'init_services', # config 'get_config', + 'get_config_value', 'list_config_keys', 'set_config', # install @@ -70,12 +83,17 @@ 'resolve_manifest_path', # project 'add_project', + 'find_manifest', 'list_projects', + 'project_status', 'remove_project', + 'run_project_action', # schema 'ActionInfo', 'ConfigKeyInfo', + 'DEBUG_ACTIONS', 'DownloadResult', + 'GUI_ONLY_ACTIONS', 'PackageInfo', 'PreviewActionChecked', 'PreviewEvent', @@ -92,13 +110,16 @@ 'ToolSection', 'UpdateCheckResult', 'UpdateResult', + 'classify_status', 'resolve_action_status', 'skip_reason_label', # tool 'check_tool_updates', + 'parse_plugin_key', 'remove_package', 'resolve_auto_update_scope', 'update_all_tools', + 'update_runtime_plugin', 'update_tool', # update 'apply_self_update', diff --git a/synodic_client/operations/config.py b/synodic_client/operations/config.py index bac5699..073478a 100644 --- a/synodic_client/operations/config.py +++ b/synodic_client/operations/config.py @@ -22,6 +22,27 @@ def get_config() -> ResolvedConfig: return resolve_config() +def get_config_value(key: str) -> object: + """Read a single configuration key. + + Args: + key: The :class:`ResolvedConfig` field name. + + Returns: + The current value of the field. + + Raises: + KeyError: If *key* is not a recognised config field. + """ + valid_keys = {f.name for f in dataclasses.fields(ResolvedConfig)} + if key not in valid_keys: + msg = f'Unknown config key: {key!r}. Valid keys: {sorted(valid_keys)}' + raise KeyError(msg) + + config = resolve_config() + return getattr(config, key) + + def set_config(key: str, value: object) -> ResolvedConfig: """Update a single configuration key and return the new config. diff --git a/synodic_client/operations/project.py b/synodic_client/operations/project.py index 36885fb..8efa74d 100644 --- a/synodic_client/operations/project.py +++ b/synodic_client/operations/project.py @@ -7,13 +7,38 @@ from __future__ import annotations +import logging from pathlib import Path from typing import TYPE_CHECKING -from synodic_client.operations.schema import ProjectInfo +from porringer.schema import SkipReason + +from synodic_client.operations.schema import ( + ActionInfo, + ProjectInfo, + ProjectStatus, + StatusSummary, +) if TYPE_CHECKING: from porringer.api import API + from porringer.backend.command.core.discovery import DiscoveredPlugins + +logger = logging.getLogger(__name__) + + +def find_manifest(porringer: API, directory: str | Path) -> Path | None: + """Locate the manifest file inside a project directory. + + Iterates the recognised manifest filenames from porringer and + returns the first candidate that exists on disk, or ``None``. + """ + path = Path(directory) + for fname in porringer.sync.manifest_filenames(): + candidate = path / fname + if candidate.exists(): + return candidate + return None def list_projects(porringer: API) -> list[ProjectInfo]: @@ -85,3 +110,146 @@ def remove_project(porringer: API, path: str | Path) -> None: path: Filesystem path to remove. """ porringer.cache.remove_directory(Path(path)) + + +def run_project_action( + name: str, + arg: str | None, + porringer: API, +) -> dict: + """Execute a project-management debug action headlessly. + + Returns a plain dict suitable for JSON serialization. + Handles ``list_projects``, ``add_project``, ``remove_project``, and + ``project_status``. Unknown actions return ``{'error': …}``. + """ + import asyncio + import dataclasses + + if name == 'list_projects': + projects = list_projects(porringer) + return {'projects': [dataclasses.asdict(p) for p in projects]} + + if name == 'add_project': + if not arg: + return {'error': 'add_project requires a path argument'} + try: + add_project(porringer, arg) + except (NotADirectoryError, ValueError) as exc: + return {'error': str(exc)} + return {'ok': True, 'action': 'add_project', 'path': arg} + + if name == 'remove_project': + if not arg: + return {'error': 'remove_project requires a path argument'} + remove_project(porringer, arg) + return {'ok': True, 'action': 'remove_project', 'path': arg} + + if name == 'project_status': + if not arg: + return {'error': 'project_status requires a path argument in headless mode'} + status = asyncio.run(project_status(porringer, arg)) + return dataclasses.asdict(status) + + return {'error': f'unknown project action: {name}'} + + +async def project_status( + porringer: API, + path: str | Path, + *, + discovered: DiscoveredPlugins | None = None, + fast: bool = False, +) -> ProjectStatus: + """Compute the requirement status for a project directory. + + Discovers the manifest, runs a dry-run preview via + :func:`~synodic_client.operations.install.preview_manifest_stream`, + and returns a :class:`ProjectStatus` with resolved action statuses. + + Args: + porringer: The porringer API instance. + path: Filesystem path of the project directory. + discovered: Pre-discovered plugins (speeds up resolution). + fast: When ``True``, return actions from the manifest parse + without waiting for per-action dry-run checks. + """ + from synodic_client.operations.install import preview_manifest_stream + from synodic_client.operations.schema import ( + PreviewActionChecked, + PreviewManifestParsed, + PreviewReady, + classify_status, + ) + + directory = Path(path) + manifest_path = find_manifest(porringer, directory) + + if manifest_path is None: + return ProjectStatus(path=str(directory), phase='no_manifest') + + actions: list[ActionInfo] = [] + checked_count = 0 + needed = 0 + satisfied = 0 + pending = 0 + upgradable = 0 + + async for event in preview_manifest_stream( + porringer, + str(manifest_path), + project_directory=directory, + discovered=discovered, + resolve=False, + ): + if isinstance(event, (PreviewManifestParsed, PreviewReady)): + if not actions: + actions = [ + ActionInfo( + description=a.description, + kind=a.kind.name if a.kind else None, + package=str(a.package.name) if a.package else None, + constraint=a.package.constraint if a.package else None, + installer=a.installer, + ) + for a in event.manifest.actions + ] + + elif isinstance(event, PreviewActionChecked): + if event.index < len(actions): + old = actions[event.index] + actions[event.index] = ActionInfo( + description=old.description, + kind=old.kind, + status=event.status, + package=old.package, + constraint=old.constraint, + installer=old.installer, + ) + checked_count += 1 + + bucket = classify_status(event.status) + if bucket == 'needed': + needed += 1 + elif bucket == 'satisfied': + satisfied += 1 + elif bucket == 'pending': + pending += 1 + if event.result.skip_reason == SkipReason.UPDATE_AVAILABLE: + upgradable += 1 + + phase = 'ready' if not fast or checked_count > 0 else 'parsed' + + return ProjectStatus( + path=str(directory), + phase=phase, + action_count=len(actions), + checked_count=checked_count, + actions=actions, + summary=StatusSummary( + needed=needed, + satisfied=satisfied, + pending=pending, + upgradable=upgradable, + ), + ) diff --git a/synodic_client/operations/schema.py b/synodic_client/operations/schema.py index ead7039..1f682f5 100644 --- a/synodic_client/operations/schema.py +++ b/synodic_client/operations/schema.py @@ -50,6 +50,22 @@ def resolve_action_status(result: SetupActionResult, action: SetupAction) -> str return 'Needed' +def classify_status(status: str) -> str: + """Classify a resolved status string into a summary bucket. + + Returns one of ``'needed'``, ``'satisfied'``, ``'pending'``, or + ``'unknown'``. Upgradability is determined separately from skip + reason, so it is not included here. + """ + if status == 'Needed': + return 'needed' + if '\u2713' in status or status in {'Already installed', 'Already latest'}: + return 'satisfied' + if status == 'Pending': + return 'pending' + return 'unknown' + + # --------------------------------------------------------------------------- # Preview stream events # --------------------------------------------------------------------------- @@ -206,6 +222,18 @@ class UpdateResult: packages_updated: list[str] = field(default_factory=list) packages_failed: list[str] = field(default_factory=list) already_latest: list[str] = field(default_factory=list) + manifests_processed: int = 0 + updated_packages: set[str] = field(default_factory=set) + + @property + def updated(self) -> int: + """Number of packages successfully updated.""" + return len(self.packages_updated) + + @property + def failed(self) -> int: + """Number of packages that failed to update.""" + return len(self.packages_failed) # --------------------------------------------------------------------------- @@ -261,3 +289,33 @@ class ConfigKeyInfo: type_hint: str = '' description: str = '' current_value: object = None + + +# --------------------------------------------------------------------------- +# Debug actions registry +# --------------------------------------------------------------------------- + +DEBUG_ACTIONS: dict[str, str] = { + 'check_update': 'Trigger a self-update check.', + 'tool_update': 'Run tool/package updates for all plugins.', + 'refresh_data': 'Mark cached data as stale (next refresh re-fetches).', + 'show_main': 'Show and raise the main window.', + 'show_settings': 'Show the settings window.', + 'apply_update': 'Apply a downloaded update and restart.', + 'list_projects': 'List cached project directories with validation status.', + 'add_project': 'Add a directory to the project cache. Arg: ', + 'remove_project': 'Remove a directory from the project cache. Arg: ', + 'project_status': 'Dump per-action preview status. Arg (optional): ', + 'select_project': 'Select a project in the sidebar. Arg: ', +} + +#: Actions that require a live GUI instance (IPC via ``--live``). +GUI_ONLY_ACTIONS: frozenset[str] = frozenset({ + 'check_update', + 'tool_update', + 'refresh_data', + 'show_main', + 'show_settings', + 'apply_update', + 'select_project', +}) diff --git a/synodic_client/operations/tool.py b/synodic_client/operations/tool.py index 4d607ed..3b9be3b 100644 --- a/synodic_client/operations/tool.py +++ b/synodic_client/operations/tool.py @@ -30,6 +30,22 @@ logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# Plugin key parsing +# --------------------------------------------------------------------------- + + +def parse_plugin_key(name: str) -> tuple[str, str | None]: + """Split a composite ``'plugin:tag'`` key into ``(bare_name, tag)``. + + Returns ``(name, None)`` when there is no tag component. + """ + if ':' in name: + bare, tag = name.split(':', 1) + return bare, tag + return name, None + + # --------------------------------------------------------------------------- # Update-check # --------------------------------------------------------------------------- @@ -37,7 +53,7 @@ async def check_tool_updates( porringer: API, - directories: list[ManifestDirectory], + directories: list[ManifestDirectory] | None = None, discovered: DiscoveredPlugins | None = None, ) -> dict[str, dict[str, str]]: """Detect available updates across cached manifests. @@ -45,23 +61,30 @@ async def check_tool_updates( Returns ``{plugin_name: {package_name: latest_version}}`` for packages that have a newer version available. + When *directories* is ``None`` the function fetches and filters + the cached directory list internally. + Args: porringer: The porringer API instance. - directories: Cached project directories to scan. + directories: Cached project directories to scan. ``None`` + means fetch from the cache automatically. discovered: Pre-discovered plugins to avoid redundant discovery. """ + if directories is None: + loop = asyncio.get_running_loop() + dir_results = await loop.run_in_executor( + None, + lambda: porringer.cache.list_directories(validate=True, check_manifest=True), + ) + directories = [dr.directory for dr in dir_results if dr.has_manifest] available: dict[str, dict[str, str]] = {} async def _check_one(directory: ManifestDirectory) -> None: try: + from synodic_client.operations.project import find_manifest + path = Path(directory.path) - filenames = porringer.sync.manifest_filenames() - manifest_path: Path | None = None - for fname in filenames: - candidate = path / fname - if candidate.exists(): - manifest_path = candidate - break + manifest_path = find_manifest(porringer, path) if manifest_path is None: return @@ -132,13 +155,14 @@ async def update_tool( result.already_latest.append(package_name) elif action_result.success: result.packages_updated.append(package_name) + result.updated_packages.add(package_name) else: result.packages_failed.append(package_name) return result # Full-plugin update: re-sync all cached manifests for this plugin. if runtime_tag is not None: - return await _update_runtime_plugin( + return await update_runtime_plugin( porringer, plugin_name, runtime_tag, @@ -159,42 +183,16 @@ async def _update_plugin_via_manifests( discovered: DiscoveredPlugins | None = None, ) -> UpdateResult: """Re-sync cached manifests scoped to a single plugin.""" - result = UpdateResult(plugin=plugin_name) - loop = asyncio.get_running_loop() - dir_results = await loop.run_in_executor( - None, - lambda: porringer.cache.list_directories(validate=True, check_manifest=True), + result = await update_all_tools( + porringer, + plugins={plugin_name}, + discovered=discovered, ) - - for dr in dir_results: - if not dr.has_manifest: - continue - path = Path(dr.directory.path) - params = SetupParameters( - paths=[path], - project_directory=path if path.is_dir() else None, - strategy=SyncStrategy.LATEST, - plugins={plugin_name}, - ) - try: - async for event in porringer.sync.execute_stream(params, plugins=discovered): - if not isinstance(event, ActionCompletedEvent): - continue - ar = event.result - pkg_name = str(ar.action.package.name) if ar.action.package else '' - if ar.skipped: - if ar.skip_reason in {SkipReason.ALREADY_LATEST, SkipReason.ALREADY_INSTALLED}: - result.already_latest.append(pkg_name) - elif ar.success: - result.packages_updated.append(pkg_name) - else: - result.packages_failed.append(pkg_name) - except asyncio.CancelledError: - raise + result.plugin = plugin_name return result -async def _update_runtime_plugin( +async def update_runtime_plugin( porringer: API, plugin_name: str, runtime_tag: str, @@ -225,6 +223,7 @@ async def _update_runtime_plugin( result.already_latest.append(pkg_name) elif ar.success: result.packages_updated.append(pkg_name) + result.updated_packages.add(pkg_name) else: result.packages_failed.append(pkg_name) break @@ -236,6 +235,7 @@ async def remove_package( plugin_name: str, package_name: str, *, + runtime_tag: str | None = None, discovered: DiscoveredPlugins | None = None, ) -> bool: """Uninstall a single package. @@ -244,13 +244,16 @@ async def remove_package( porringer: The porringer API instance. plugin_name: The installer plugin name. package_name: The package to remove. + runtime_tag: Optional runtime tag for per-runtime removal. discovered: Pre-discovered plugins. Returns: ``True`` if the removal succeeded. """ ref = PackageRef(name=package_name) - action_result = await porringer.package.uninstall(plugin_name, ref, plugins=discovered) + action_result = await porringer.package.uninstall( + plugin_name, ref, runtime_tag=runtime_tag, plugins=discovered, + ) return action_result.success @@ -301,10 +304,13 @@ async def update_all_tools( result.already_latest.append(pkg_name) elif ar.success: result.packages_updated.append(pkg_name) + if pkg_name: + result.updated_packages.add(pkg_name) else: result.packages_failed.append(pkg_name) except asyncio.CancelledError: raise + result.manifests_processed += 1 return result diff --git a/synodic_client/resolution.py b/synodic_client/resolution.py index 9bbed91..b1483da 100644 --- a/synodic_client/resolution.py +++ b/synodic_client/resolution.py @@ -191,12 +191,9 @@ def resolve_auto_update_scope( ) -> tuple[set[str] | None, set[str] | None]: """Derive plugin and package include-lists for auto-update. - Walks ``plugin_auto_update`` to determine which plugins and packages - should participate in automatic updates. When a plugin entry is a - nested ``dict[str, bool]``, individual packages can be toggled on or - off. Packages not listed in the config inherit a manifest-aware - default: **ON** if the package appears in *manifest_packages* for - that plugin, **OFF** otherwise. + Convenience wrapper around + :func:`synodic_client.operations.tool.resolve_auto_update_scope` + that extracts ``plugin_auto_update`` from a resolved config. Args: config: A resolved configuration snapshot. @@ -209,68 +206,6 @@ def resolve_auto_update_scope( A ``(enabled_plugins, include_packages)`` tuple. Either element may be ``None`` meaning "no filtering". """ - mapping = config.plugin_auto_update - - # --- Determine enabled plugins --- - disabled_plugins: set[str] = set() - per_package_entries: dict[str, dict[str, bool]] = {} - - if mapping: - for name, value in mapping.items(): - # Skip runtime-scoped composite keys ("plugin:tag"); they - # are only relevant for on-demand runtime updates. - if ':' in name: - continue - if value is False: - disabled_plugins.add(name) - elif isinstance(value, dict): - per_package_entries[name] = value - - enabled_plugins: set[str] | None = None - if disabled_plugins: - enabled_plugins = {n for n in all_plugin_names if n not in disabled_plugins} - - # --- Determine include_packages --- - include_packages = _build_include_packages( - per_package_entries, - manifest_packages, - disabled_plugins, - ) - - return enabled_plugins, include_packages - - -def _build_include_packages( - per_package_entries: dict[str, dict[str, bool]], - manifest_packages: dict[str, set[str]] | None, - disabled_plugins: set[str], -) -> set[str] | None: - """Build the set of package names eligible for auto-update. + from synodic_client.operations.tool import resolve_auto_update_scope as _resolve - Only builds the set when there are per-package overrides or - manifest data that distinguishes global from manifest-required. - - Returns: - A set of package names, or ``None`` when no filtering is needed. - """ - if not per_package_entries and not manifest_packages: - return None - - # Start with manifest-referenced packages (auto-update ON by default) - pkg_set: set[str] = set() - if manifest_packages: - for plugin_name, pkgs in manifest_packages.items(): - if plugin_name not in disabled_plugins: - pkg_set |= pkgs - - # Apply per-package config overrides - for plugin_name, pkg_map in per_package_entries.items(): - if plugin_name in disabled_plugins: - continue - for pkg_name, enabled in pkg_map.items(): - if enabled: - pkg_set.add(pkg_name) - else: - pkg_set.discard(pkg_name) - - return pkg_set or None + return _resolve(config.plugin_auto_update, all_plugin_names, manifest_packages) diff --git a/tests/unit/qt/test_tray_window_show.py b/tests/unit/qt/test_tray_window_show.py index 6c4b449..651254a 100644 --- a/tests/unit/qt/test_tray_window_show.py +++ b/tests/unit/qt/test_tray_window_show.py @@ -7,7 +7,8 @@ import pytest from synodic_client.application.config_store import ConfigStore -from synodic_client.application.schema import ToolUpdateResult, UpdateTarget +from synodic_client.application.schema import UpdateTarget +from synodic_client.operations.schema import UpdateResult from synodic_client.application.screen.tray import TrayScreen from synodic_client.resolution import ResolvedConfig from synodic_client.schema import DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES, DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES @@ -58,21 +59,21 @@ class TestToolUpdateWindowShow: @staticmethod def test_auto_update_does_not_show_window(tray_screen) -> None: """Periodic (automatic) tool update must not bring the window forward.""" - result = ToolUpdateResult(manifests_processed=1, updated=1) + result = UpdateResult(manifests_processed=1, packages_updated=['pkg']) tray_screen._tool_orchestrator._on_tool_update_finished(result) tray_screen._window.show.assert_not_called() @staticmethod def test_manual_plugin_update_shows_window(tray_screen) -> None: """A user-initiated single-plugin update should show the window.""" - result = ToolUpdateResult(manifests_processed=1, updated=1) + result = UpdateResult(manifests_processed=1, packages_updated=['pkg']) tray_screen._tool_orchestrator._on_tool_update_finished(result, UpdateTarget(plugin='pipx')) tray_screen._window.show.assert_called_once() @staticmethod def test_manual_package_update_shows_window(tray_screen) -> None: """A user-initiated single-package update should show the window.""" - result = ToolUpdateResult(manifests_processed=1, updated=1) + result = UpdateResult(manifests_processed=1, packages_updated=['pkg']) tray_screen._tool_orchestrator._on_tool_update_finished( result, UpdateTarget(plugin='pipx', package='ruff'), @@ -82,6 +83,6 @@ def test_manual_package_update_shows_window(tray_screen) -> None: @staticmethod def test_auto_update_with_no_changes_does_not_show(tray_screen) -> None: """An automatic check with nothing to update must stay hidden.""" - result = ToolUpdateResult(manifests_processed=1, already_latest=1) + result = UpdateResult(manifests_processed=1, already_latest=['pkg']) tray_screen._tool_orchestrator._on_tool_update_finished(result) tray_screen._window.show.assert_not_called() diff --git a/tests/unit/qt/test_update_controller.py b/tests/unit/qt/test_update_controller.py index 9e8f38c..8366368 100644 --- a/tests/unit/qt/test_update_controller.py +++ b/tests/unit/qt/test_update_controller.py @@ -5,8 +5,6 @@ from typing import Any from unittest.mock import MagicMock, patch -from packaging.version import Version - from synodic_client.application.config_store import ConfigStore from synodic_client.application.screen.update_banner import UpdateBanner from synodic_client.application.theme import ( @@ -16,11 +14,11 @@ ) from synodic_client.application.update_controller import UpdateController from synodic_client.application.update_model import UpdateModel +from synodic_client.operations.schema import UpdateCheckResult from synodic_client.resolution import ResolvedConfig from synodic_client.schema import ( DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES, DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES, - UpdateInfo, ) # --------------------------------------------------------------------------- @@ -119,24 +117,27 @@ def test_none_result_sets_error_status() -> None: """A None result should set 'Check failed' in red.""" ctrl, _app, _client, banner, model = _make_controller() spy = ModelSpy(model) - ctrl._on_check_finished(None, silent=False) + result = UpdateCheckResult(available=False, current_version='1.0.0', error='Updater is not initialized.') + ctrl._on_check_finished(result, silent=False) assert True in spy.check_button_enabled assert ('Check failed', UPDATE_STATUS_ERROR_STYLE) in spy.status @staticmethod def test_none_result_shows_banner_when_not_silent() -> None: - """A None result with silent=False should show the error banner.""" + """An error result with silent=False should show the error banner.""" ctrl, _app, _client, banner, model = _make_controller() - ctrl._on_check_finished(None, silent=False) + result = UpdateCheckResult(available=False, current_version='1.0.0', error='Updater is not initialized.') + ctrl._on_check_finished(result, silent=False) assert banner.state.name == 'ERROR' @staticmethod def test_none_result_no_banner_when_silent() -> None: - """A None result with silent=True should NOT show the error banner.""" + """An error result with silent=True should NOT show the error banner.""" ctrl, _app, _client, banner, model = _make_controller() - ctrl._on_check_finished(None, silent=True) + result = UpdateCheckResult(available=False, current_version='1.0.0', error='Updater is not initialized.') + ctrl._on_check_finished(result, silent=True) assert banner.state.name == 'HIDDEN' @@ -145,7 +146,7 @@ def test_error_result_sets_error_status() -> None: """An error result should set 'Check failed' status.""" ctrl, _app, _client, banner, model = _make_controller() spy = ModelSpy(model) - result = UpdateInfo(available=False, current_version=Version('1.0.0'), error='No releases found') + result = UpdateCheckResult(available=False, current_version='1.0.0', error='No releases found') ctrl._on_check_finished(result, silent=False) assert ('Check failed', UPDATE_STATUS_ERROR_STYLE) in spy.status @@ -155,7 +156,7 @@ def test_no_update_sets_up_to_date() -> None: """No update available should set 'Up to date' in green.""" ctrl, _app, _client, banner, model = _make_controller() spy = ModelSpy(model) - result = UpdateInfo(available=False, current_version=Version('1.0.0')) + result = UpdateCheckResult(available=False, current_version='1.0.0') ctrl._on_check_finished(result, silent=False) assert ('Up to date', UPDATE_STATUS_UP_TO_DATE_STYLE) in spy.status @@ -165,7 +166,7 @@ def test_update_available_sets_status_and_starts_download() -> None: """Available update should set orange status and start download.""" ctrl, _app, _client, banner, model = _make_controller() spy = ModelSpy(model) - result = UpdateInfo(available=True, current_version=Version('1.0.0'), latest_version=Version('2.0.0')) + result = UpdateCheckResult(available=True, current_version='1.0.0', version='2.0.0') with patch.object(ctrl, '_start_download') as mock_dl: ctrl._on_check_finished(result, silent=False) @@ -480,7 +481,7 @@ def test_persist_updates_store() -> None: def test_on_check_finished_success_syncs_via_store() -> None: """A successful check should persist timestamp via the store.""" ctrl, _app, _client, _banner, model = _make_controller() - result = UpdateInfo(available=False, current_version=Version('1.0.0')) + result = UpdateCheckResult(available=False, current_version='1.0.0') fake_resolved = _make_config(last_client_update='2026-03-09T00:00:00+00:00') with patch.object(ctrl._store, 'update', return_value=fake_resolved) as mock_update: @@ -549,7 +550,7 @@ def test_reinit_then_check_redownloads_same_version() -> None: ) ctrl._reinitialize_updater(new_config) - result = UpdateInfo(available=True, current_version=Version('1.0.0'), latest_version=Version('2.0.0')) + result = UpdateCheckResult(available=True, current_version='1.0.0', version='2.0.0') with patch.object(ctrl, '_start_download') as mock_dl: ctrl._on_check_finished(result, silent=True) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index f0dd38a..3d80a58 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -194,13 +194,13 @@ class TestConfigCli: @staticmethod def test_config_get_unknown_key() -> None: """Config get with unknown key exits code 1.""" - from synodic_client.schema import ResolvedConfig - - mock_config = ResolvedConfig.__new__(ResolvedConfig) - with patch('synodic_client.operations.config.get_config', return_value=mock_config): + with patch( + 'synodic_client.operations.config.get_config_value', + side_effect=KeyError("Unknown config key: 'nonexistent_key_xyz'"), + ): result = runner.invoke(app, ['config', 'get', 'nonexistent_key_xyz']) assert result.exit_code == 1 - assert 'Unknown key' in result.output + assert 'Unknown config key' in result.output @staticmethod def test_config_set_unknown_key() -> None: @@ -284,54 +284,98 @@ class TestDebugCli: """Tests for synodic-c debug sub-commands.""" @staticmethod - def test_debug_state() -> None: - """Debug state prints JSON from IPC.""" + def test_debug_state_live() -> None: + """Debug state --live prints JSON from IPC.""" response = json.dumps({'app': {'version': '1.0.0'}}) with ( patch('synodic_client.application.instance.SingleInstance') as mock_si, patch('synodic_client.config.set_dev_mode'), ): mock_si.send_debug_command.return_value = response - result = runner.invoke(app, ['debug', 'state']) + result = runner.invoke(app, ['debug', 'state', '--live']) assert result.exit_code == 0 data = json.loads(result.output) assert data['app']['version'] == '1.0.0' @staticmethod - def test_debug_actions() -> None: - """Debug actions prints the actions dict.""" + def test_debug_actions_live() -> None: + """Debug actions --live prints the actions dict.""" response = json.dumps({'actions': {'check_update': 'Trigger check.'}}) with ( patch('synodic_client.application.instance.SingleInstance') as mock_si, patch('synodic_client.config.set_dev_mode'), ): mock_si.send_debug_command.return_value = response - result = runner.invoke(app, ['debug', 'actions']) + result = runner.invoke(app, ['debug', 'actions', '--live']) assert result.exit_code == 0 data = json.loads(result.output) assert 'actions' in data @staticmethod - def test_debug_action_with_arg() -> None: - """Debug action sends action::.""" + def test_debug_action_with_arg_live() -> None: + """Debug action --live sends action::.""" response = json.dumps({'ok': True}) with ( patch('synodic_client.application.instance.SingleInstance') as mock_si, patch('synodic_client.config.set_dev_mode'), ): mock_si.send_debug_command.return_value = response - result = runner.invoke(app, ['debug', 'action', 'add_project', '/tmp/foo']) + result = runner.invoke(app, ['debug', 'action', 'add_project', '/tmp/foo', '--live']) assert result.exit_code == 0 mock_si.send_debug_command.assert_called_once_with('action:add_project:/tmp/foo') @staticmethod - def test_debug_error_response_exits_1() -> None: - """An error response causes exit code 1.""" + def test_debug_error_response_exits_1_live() -> None: + """An error response from IPC causes exit code 1.""" response = json.dumps({'error': 'not found'}) with ( patch('synodic_client.application.instance.SingleInstance') as mock_si, patch('synodic_client.config.set_dev_mode'), ): mock_si.send_debug_command.return_value = response - result = runner.invoke(app, ['debug', 'state']) + result = runner.invoke(app, ['debug', 'state', '--live']) assert result.exit_code == 1 + + @staticmethod + def test_debug_actions_headless() -> None: + """Debug actions (headless default) returns the actions dict.""" + with patch('synodic_client.config.set_dev_mode'): + result = runner.invoke(app, ['debug', 'actions']) + assert result.exit_code == 0 + data = json.loads(result.output) + assert 'actions' in data + assert 'project_status' in data['actions'] + + @staticmethod + def test_debug_state_headless() -> None: + """Debug state (headless default) returns app and config sections.""" + with ( + patch('synodic_client.cli.context.get_services') as mock_services, + patch('synodic_client.config.set_dev_mode'), + patch('synodic_client.config.is_dev_mode', return_value=False), + ): + mock_client = MagicMock() + mock_client.version = '1.2.3' + mock_porringer = MagicMock() + mock_porringer.cache.list_directories.return_value = [] + mock_config = MagicMock(spec=[]) # empty spec to make dataclasses.asdict work + mock_services.return_value = (mock_client, mock_porringer, mock_config) + + # dataclasses.asdict requires a real dataclass; patch it + with patch('synodic_client.cli.debug.dataclasses') as mock_dc: + mock_dc.asdict.return_value = {'channel': 'stable'} + result = runner.invoke(app, ['debug', 'state']) + + assert result.exit_code == 0 + data = json.loads(result.output) + assert data['app']['version'] == '1.2.3' + assert data['app']['headless'] is True + + @staticmethod + def test_debug_gui_only_action_headless() -> None: + """GUI-only actions without --live return an error.""" + with patch('synodic_client.config.set_dev_mode'): + result = runner.invoke(app, ['debug', 'action', 'show_main']) + assert result.exit_code == 1 + data = json.loads(result.output) + assert '--live' in data['error'] diff --git a/tests/unit/test_workers.py b/tests/unit/test_workers.py index d664ee0..c67243c 100644 --- a/tests/unit/test_workers.py +++ b/tests/unit/test_workers.py @@ -1,4 +1,4 @@ -"""Tests for ToolUpdateResult dataclass and runtime package workers.""" +"""Tests for UpdateResult dataclass and runtime package operations.""" from __future__ import annotations @@ -10,22 +10,22 @@ from porringer.schema.execution import SetupAction, SetupActionResult from porringer.schema.plugin import RuntimePackageResult -from synodic_client.application.schema import ToolUpdateResult -from synodic_client.application.workers import run_runtime_package_updates +from synodic_client.operations.schema import UpdateResult +from synodic_client.operations.tool import update_runtime_plugin _EXPECTED_RUNTIME_UPGRADES = 2 -class TestToolUpdateResult: - """Tests for the ToolUpdateResult dataclass.""" +class TestUpdateResult: + """Tests for the UpdateResult dataclass.""" @staticmethod def test_defaults() -> None: """Verify all fields start at zero / empty.""" - result = ToolUpdateResult() + result = UpdateResult() assert result.manifests_processed == 0 assert result.updated == 0 - assert result.already_latest == 0 + assert len(result.already_latest) == 0 assert result.failed == 0 assert result.updated_packages == set() @@ -33,41 +33,38 @@ def test_defaults() -> None: def test_fields_are_assignable() -> None: """Verify fields can be set via constructor.""" expected_manifests = 3 - expected_updated = 2 - expected_latest = 1 - expected_failed = 0 expected_packages = {'pdm', 'ruff'} - result = ToolUpdateResult( + result = UpdateResult( manifests_processed=expected_manifests, - updated=expected_updated, - already_latest=expected_latest, - failed=expected_failed, + packages_updated=['a', 'b'], + already_latest=['c'], + packages_failed=[], updated_packages=expected_packages, ) assert result.manifests_processed == expected_manifests - assert result.updated == expected_updated - assert result.already_latest == expected_latest - assert result.failed == expected_failed + assert result.updated == 2 + assert len(result.already_latest) == 1 + assert result.failed == 0 assert result.updated_packages == expected_packages @staticmethod def test_updated_packages_mutation() -> None: """Verify updated_packages is a mutable set.""" - result = ToolUpdateResult() + result = UpdateResult() result.updated_packages.add('uv') assert 'uv' in result.updated_packages @staticmethod def test_independent_set_per_instance() -> None: """Verify each instance gets its own set (field default_factory).""" - a = ToolUpdateResult() - b = ToolUpdateResult() + a = UpdateResult() + b = UpdateResult() a.updated_packages.add('foo') assert 'foo' not in b.updated_packages -class TestRunRuntimePackageUpdates: - """Tests for the run_runtime_package_updates worker.""" +class TestUpdateRuntimePlugin: + """Tests for the update_runtime_plugin operation.""" @staticmethod def test_upgrades_packages_for_matching_tag() -> None: @@ -99,7 +96,7 @@ def test_upgrades_packages_for_matching_tag() -> None: ), ) result = asyncio.run( - run_runtime_package_updates(porringer, 'pipx', '3.12'), + update_runtime_plugin(porringer, 'pipx', '3.12'), ) assert result.updated == _EXPECTED_RUNTIME_UPGRADES assert result.updated_packages == {'pdm', 'ruff'} @@ -122,7 +119,7 @@ def test_skips_non_matching_tag() -> None: ) porringer.package.upgrade = AsyncMock() result = asyncio.run( - run_runtime_package_updates(porringer, 'pipx', '3.12'), + update_runtime_plugin(porringer, 'pipx', '3.12'), ) assert result.updated == 0 porringer.package.upgrade.assert_not_called() @@ -151,7 +148,7 @@ def test_include_packages_filters() -> None: ), ) result = asyncio.run( - run_runtime_package_updates( + update_runtime_plugin( porringer, 'pipx', '3.12', From 6c8aecc2beaf49aee7ddba5b3a50d2755c1085a1 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Tue, 17 Mar 2026 10:07:20 -0700 Subject: [PATCH 4/9] ye --- synodic_client/application/debug.py | 28 +++++++++++---------- synodic_client/application/screen/screen.py | 2 -- synodic_client/cli/debug.py | 2 +- synodic_client/operations/schema.py | 20 ++++++++------- synodic_client/operations/tool.py | 5 +++- tests/unit/qt/test_tray_window_show.py | 2 +- 6 files changed, 32 insertions(+), 27 deletions(-) diff --git a/synodic_client/application/debug.py b/synodic_client/application/debug.py index 95ab61d..7e1d1c4 100644 --- a/synodic_client/application/debug.py +++ b/synodic_client/application/debug.py @@ -229,19 +229,21 @@ def _handle_project_status(self, arg: str | None) -> str: pending = sum(1 for s in model.action_states if classify_status(s.status) == 'pending') upgradable = len(model.upgradable_keys) - return json.dumps({ - 'path': str(target), - 'phase': model.phase.name, - 'action_count': len(model.action_states), - 'checked_count': model.checked_count, - 'actions': actions, - 'summary': { - 'needed': needed, - 'satisfied': satisfied, - 'pending': pending, - 'upgradable': upgradable, - }, - }) + return json.dumps( + { + 'path': str(target), + 'phase': model.phase.name, + 'action_count': len(model.action_states), + 'checked_count': model.checked_count, + 'actions': actions, + 'summary': { + 'needed': needed, + 'satisfied': satisfied, + 'pending': pending, + 'upgradable': upgradable, + }, + } + ) def _handle_select_project(self, arg: str | None) -> str: """Select a project in the sidebar.""" diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 511e10a..8844ea2 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -11,13 +11,11 @@ from porringer.core.plugin_schema.plugin_manager import PluginManager from porringer.core.plugin_schema.project_environment import ProjectEnvironment from porringer.schema import ( - ActionCompletedEvent, ManifestDirectory, ManifestParsedEvent, PluginInfo, SetupAction, SetupParameters, - SkipReason, SyncStrategy, ) from porringer.schema.plugin import PluginKind, RuntimePackageResult diff --git a/synodic_client/cli/debug.py b/synodic_client/cli/debug.py index 97a194d..d0dc428 100644 --- a/synodic_client/cli/debug.py +++ b/synodic_client/cli/debug.py @@ -67,7 +67,7 @@ def _headless_dispatch(command: str, *, dev: bool) -> None: elif command == 'actions': data = {'actions': DEBUG_ACTIONS} elif command.startswith('action:'): - remainder = command[len('action:'):] + remainder = command[len('action:') :] name, _, arg = remainder.partition(':') data = _headless_action(name, arg or None) else: diff --git a/synodic_client/operations/schema.py b/synodic_client/operations/schema.py index 1f682f5..be962b6 100644 --- a/synodic_client/operations/schema.py +++ b/synodic_client/operations/schema.py @@ -310,12 +310,14 @@ class ConfigKeyInfo: } #: Actions that require a live GUI instance (IPC via ``--live``). -GUI_ONLY_ACTIONS: frozenset[str] = frozenset({ - 'check_update', - 'tool_update', - 'refresh_data', - 'show_main', - 'show_settings', - 'apply_update', - 'select_project', -}) +GUI_ONLY_ACTIONS: frozenset[str] = frozenset( + { + 'check_update', + 'tool_update', + 'refresh_data', + 'show_main', + 'show_settings', + 'apply_update', + 'select_project', + } +) diff --git a/synodic_client/operations/tool.py b/synodic_client/operations/tool.py index 3b9be3b..6c0924f 100644 --- a/synodic_client/operations/tool.py +++ b/synodic_client/operations/tool.py @@ -252,7 +252,10 @@ async def remove_package( """ ref = PackageRef(name=package_name) action_result = await porringer.package.uninstall( - plugin_name, ref, runtime_tag=runtime_tag, plugins=discovered, + plugin_name, + ref, + runtime_tag=runtime_tag, + plugins=discovered, ) return action_result.success diff --git a/tests/unit/qt/test_tray_window_show.py b/tests/unit/qt/test_tray_window_show.py index 651254a..b3233db 100644 --- a/tests/unit/qt/test_tray_window_show.py +++ b/tests/unit/qt/test_tray_window_show.py @@ -8,8 +8,8 @@ from synodic_client.application.config_store import ConfigStore from synodic_client.application.schema import UpdateTarget -from synodic_client.operations.schema import UpdateResult from synodic_client.application.screen.tray import TrayScreen +from synodic_client.operations.schema import UpdateResult from synodic_client.resolution import ResolvedConfig from synodic_client.schema import DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES, DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES From 1bf3e9ea3c76c93bf88c0f4a65986b4d661ddd70 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Tue, 17 Mar 2026 10:25:09 -0700 Subject: [PATCH 5/9] test --- tests/unit/qt/test_action_card.py | 2 +- tests/unit/qt/test_gather_packages.py | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/unit/qt/test_action_card.py b/tests/unit/qt/test_action_card.py index b753339..70caece 100644 --- a/tests/unit/qt/test_action_card.py +++ b/tests/unit/qt/test_action_card.py @@ -358,7 +358,7 @@ class TestActionCardCheckFailure: 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')) + card.populate(_make_action(kind=PluginKind.SCM, package='mypackage', installer='git')) result = _make_result( success=False, skipped=False, diff --git a/tests/unit/qt/test_gather_packages.py b/tests/unit/qt/test_gather_packages.py index 75116a4..0c044b9 100644 --- a/tests/unit/qt/test_gather_packages.py +++ b/tests/unit/qt/test_gather_packages.py @@ -374,8 +374,8 @@ def test_project_only_package() -> None: PackageEntry( name='cppython', version='0.9.15.dev3', - project_label='periapsis', - project_path='/projects/periapsis', + project_label='myproject', + project_path='/projects/myproject', ), ] result = ToolsView._build_display_packages(entries, set()) @@ -386,8 +386,8 @@ def test_project_only_package() -> None: assert pkg.is_global is False assert pkg.global_version is None assert len(pkg.project_instances) == 1 - assert pkg.project_instances[0].project_label == 'periapsis' - assert pkg.project_instances[0].project_path == '/projects/periapsis' + assert pkg.project_instances[0].project_label == 'myproject' + assert pkg.project_instances[0].project_path == '/projects/myproject' @staticmethod def test_both_global_and_project() -> None: @@ -417,8 +417,8 @@ def test_transitive_dependency_marked() -> None: PackageEntry( name='cppython', version='0.9.15.dev3', - project_label='periapsis', - project_path='/projects/periapsis', + project_label='myproject', + project_path='/projects/myproject', ), ] # 'cppython' is NOT in the manifest set → transitive @@ -432,8 +432,8 @@ def test_manifest_declared_not_transitive() -> None: PackageEntry( name='cppython', version='0.9.15.dev3', - project_label='periapsis', - project_path='/projects/periapsis', + project_label='myproject', + project_path='/projects/myproject', ), ] result = ToolsView._build_display_packages(entries, {'cppython'}) @@ -512,8 +512,8 @@ class TestProjectChildRow: def _make_instance(*, transitive: bool = False) -> ProjectChildRow: return ProjectChildRow( ProjectInstance( - project_label='periapsis', - project_path='/projects/periapsis', + project_label='myproject', + project_path='/projects/myproject', version='0.9.15.dev3', is_transitive=transitive, ), @@ -530,7 +530,7 @@ def test_navigate_signal_emitted(self) -> None: nav_btns = [w for w in row.findChildren(QPushButton) if w.text() == '\u2192'] assert len(nav_btns) == 1 nav_btns[0].click() - spy.assert_called_once_with('/projects/periapsis') + spy.assert_called_once_with('/projects/myproject') def test_transitive_label_shown(self) -> None: """Transitive instances show a (transitive) label.""" From c20f712a6a583dd0dc619cd89a073fc9968c6457 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Tue, 17 Mar 2026 10:31:39 -0700 Subject: [PATCH 6/9] lint --- synodic_client/application/debug.py | 28 +++++++------- synodic_client/operations/project.py | 57 ++++++++++++++++++---------- synodic_client/operations/schema.py | 20 +++++----- synodic_client/updater.py | 2 +- tests/unit/test_workers.py | 3 +- 5 files changed, 61 insertions(+), 49 deletions(-) diff --git a/synodic_client/application/debug.py b/synodic_client/application/debug.py index 7e1d1c4..95ab61d 100644 --- a/synodic_client/application/debug.py +++ b/synodic_client/application/debug.py @@ -229,21 +229,19 @@ def _handle_project_status(self, arg: str | None) -> str: pending = sum(1 for s in model.action_states if classify_status(s.status) == 'pending') upgradable = len(model.upgradable_keys) - return json.dumps( - { - 'path': str(target), - 'phase': model.phase.name, - 'action_count': len(model.action_states), - 'checked_count': model.checked_count, - 'actions': actions, - 'summary': { - 'needed': needed, - 'satisfied': satisfied, - 'pending': pending, - 'upgradable': upgradable, - }, - } - ) + return json.dumps({ + 'path': str(target), + 'phase': model.phase.name, + 'action_count': len(model.action_states), + 'checked_count': model.checked_count, + 'actions': actions, + 'summary': { + 'needed': needed, + 'satisfied': satisfied, + 'pending': pending, + 'upgradable': upgradable, + }, + }) def _handle_select_project(self, arg: str | None) -> str: """Select a project in the sidebar.""" diff --git a/synodic_client/operations/project.py b/synodic_client/operations/project.py index 8efa74d..fc11546 100644 --- a/synodic_client/operations/project.py +++ b/synodic_client/operations/project.py @@ -8,6 +8,7 @@ from __future__ import annotations import logging +from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING @@ -112,6 +113,33 @@ def remove_project(porringer: API, path: str | Path) -> None: porringer.cache.remove_directory(Path(path)) +def _run_add_project(arg: str | None, porringer: API) -> dict: + if not arg: + return {'error': 'add_project requires a path argument'} + try: + add_project(porringer, arg) + except (NotADirectoryError, ValueError) as exc: + return {'error': str(exc)} + return {'ok': True, 'action': 'add_project', 'path': arg} + + +def _run_remove_project(arg: str | None, porringer: API) -> dict: + if not arg: + return {'error': 'remove_project requires a path argument'} + remove_project(porringer, arg) + return {'ok': True, 'action': 'remove_project', 'path': arg} + + +def _run_project_status(arg: str | None, porringer: API) -> dict: + import asyncio + import dataclasses + + if not arg: + return {'error': 'project_status requires a path argument in headless mode'} + status = asyncio.run(project_status(porringer, arg)) + return dataclasses.asdict(status) + + def run_project_action( name: str, arg: str | None, @@ -123,33 +151,20 @@ def run_project_action( Handles ``list_projects``, ``add_project``, ``remove_project``, and ``project_status``. Unknown actions return ``{'error': …}``. """ - import asyncio import dataclasses if name == 'list_projects': projects = list_projects(porringer) return {'projects': [dataclasses.asdict(p) for p in projects]} - if name == 'add_project': - if not arg: - return {'error': 'add_project requires a path argument'} - try: - add_project(porringer, arg) - except (NotADirectoryError, ValueError) as exc: - return {'error': str(exc)} - return {'ok': True, 'action': 'add_project', 'path': arg} - - if name == 'remove_project': - if not arg: - return {'error': 'remove_project requires a path argument'} - remove_project(porringer, arg) - return {'ok': True, 'action': 'remove_project', 'path': arg} - - if name == 'project_status': - if not arg: - return {'error': 'project_status requires a path argument in headless mode'} - status = asyncio.run(project_status(porringer, arg)) - return dataclasses.asdict(status) + handlers: dict[str, Callable[[str | None, API], dict]] = { + 'add_project': _run_add_project, + 'remove_project': _run_remove_project, + 'project_status': _run_project_status, + } + handler = handlers.get(name) + if handler is not None: + return handler(arg, porringer) return {'error': f'unknown project action: {name}'} diff --git a/synodic_client/operations/schema.py b/synodic_client/operations/schema.py index be962b6..1f682f5 100644 --- a/synodic_client/operations/schema.py +++ b/synodic_client/operations/schema.py @@ -310,14 +310,12 @@ class ConfigKeyInfo: } #: Actions that require a live GUI instance (IPC via ``--live``). -GUI_ONLY_ACTIONS: frozenset[str] = frozenset( - { - 'check_update', - 'tool_update', - 'refresh_data', - 'show_main', - 'show_settings', - 'apply_update', - 'select_project', - } -) +GUI_ONLY_ACTIONS: frozenset[str] = frozenset({ + 'check_update', + 'tool_update', + 'refresh_data', + 'show_main', + 'show_settings', + 'apply_update', + 'select_project', +}) diff --git a/synodic_client/updater.py b/synodic_client/updater.py index 34c8d2a..4d2972e 100644 --- a/synodic_client/updater.py +++ b/synodic_client/updater.py @@ -188,7 +188,7 @@ def check_for_update(self) -> UpdateInfo: # moved past it. A periodic re-check that discovers the # same release must not regress DOWNLOADED → UPDATE_AVAILABLE, # which would cause apply_update_on_exit() to reject the update. - if self._state not in (UpdateState.DOWNLOADED, UpdateState.APPLYING, UpdateState.APPLIED): + if self._state not in {UpdateState.DOWNLOADED, UpdateState.APPLYING, UpdateState.APPLIED}: self._state = UpdateState.UPDATE_AVAILABLE logger.info('Update available: %s -> %s', self._current_version, latest) else: diff --git a/tests/unit/test_workers.py b/tests/unit/test_workers.py index c67243c..74c43d4 100644 --- a/tests/unit/test_workers.py +++ b/tests/unit/test_workers.py @@ -33,6 +33,7 @@ def test_defaults() -> None: def test_fields_are_assignable() -> None: """Verify fields can be set via constructor.""" expected_manifests = 3 + expected_updated = 2 expected_packages = {'pdm', 'ruff'} result = UpdateResult( manifests_processed=expected_manifests, @@ -42,7 +43,7 @@ def test_fields_are_assignable() -> None: updated_packages=expected_packages, ) assert result.manifests_processed == expected_manifests - assert result.updated == 2 + assert result.updated == expected_updated assert len(result.already_latest) == 1 assert result.failed == 0 assert result.updated_packages == expected_packages From 75f683bf5f14f1ad76e4a2453a2d1fcf64a51f90 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Tue, 17 Mar 2026 10:37:48 -0700 Subject: [PATCH 7/9] test fix --- tests/unit/test_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 3d80a58..394460d 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -20,7 +20,7 @@ UpdateResult, ) -runner = CliRunner() +runner = CliRunner(color=False) class TestCli: From 45ab981e186ea70a7010289c07d35e34c1076c04 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Tue, 17 Mar 2026 10:39:46 -0700 Subject: [PATCH 8/9] lint fix --- synodic_client/subprocess_patch.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/synodic_client/subprocess_patch.py b/synodic_client/subprocess_patch.py index 6de7815..255d8a9 100644 --- a/synodic_client/subprocess_patch.py +++ b/synodic_client/subprocess_patch.py @@ -63,6 +63,9 @@ def _inject_hidden_flags(kwargs: dict[str, Any]) -> None: preserved. An existing ``startupinfo`` object is augmented rather than overwritten. """ + if sys.platform != 'win32': + return + kwargs['creationflags'] = kwargs.get('creationflags', 0) | _CREATE_NO_WINDOW startupinfo = kwargs.get('startupinfo') or subprocess.STARTUPINFO() From 36e85691da83a6292b9a0425316e4e5539aef111 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Tue, 17 Mar 2026 10:45:30 -0700 Subject: [PATCH 9/9] te --- synodic_client/subprocess_patch.py | 6 ++++-- tests/unit/test_cli.py | 11 +++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/synodic_client/subprocess_patch.py b/synodic_client/subprocess_patch.py index 255d8a9..746a011 100644 --- a/synodic_client/subprocess_patch.py +++ b/synodic_client/subprocess_patch.py @@ -49,11 +49,13 @@ def apply() -> None: _CREATE_NO_WINDOW: int = 0 _STARTF_USESHOWWINDOW: int = 0 _SW_HIDE: int = 0 +_StartupInfo: type | None = None if sys.platform == 'win32': _CREATE_NO_WINDOW = subprocess.CREATE_NO_WINDOW # 0x0800_0000 _STARTF_USESHOWWINDOW = subprocess.STARTF_USESHOWWINDOW _SW_HIDE = 0 + _StartupInfo = subprocess.STARTUPINFO def _inject_hidden_flags(kwargs: dict[str, Any]) -> None: @@ -63,12 +65,12 @@ def _inject_hidden_flags(kwargs: dict[str, Any]) -> None: preserved. An existing ``startupinfo`` object is augmented rather than overwritten. """ - if sys.platform != 'win32': + if _StartupInfo is None: return kwargs['creationflags'] = kwargs.get('creationflags', 0) | _CREATE_NO_WINDOW - startupinfo = kwargs.get('startupinfo') or subprocess.STARTUPINFO() + startupinfo = kwargs.get('startupinfo') or _StartupInfo() startupinfo.dwFlags |= _STARTF_USESHOWWINDOW startupinfo.wShowWindow = _SW_HIDE kwargs['startupinfo'] = startupinfo diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 394460d..dfb850f 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import re from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -20,7 +21,13 @@ UpdateResult, ) -runner = CliRunner(color=False) +runner = CliRunner() + +_ANSI_RE = re.compile(r'\x1b\[[0-9;]*m') + + +def _strip_ansi(text: str) -> str: + return _ANSI_RE.sub('', text) class TestCli: @@ -38,7 +45,7 @@ def test_help() -> None: """Verify --help shows usage information.""" result = runner.invoke(app, ['--help']) assert result.exit_code == 0 - assert '--uri' in result.output + assert '--uri' in _strip_ansi(result.output) @staticmethod def test_launches_application_without_uri() -> None: