From 4e98cb0fe78709c2b14318b1e1162fcd534454db Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Fri, 27 Feb 2026 10:15:59 -0800 Subject: [PATCH 01/10] More Lint Fixes --- synodic_client/application/screen/install.py | 4 +-- synodic_client/application/screen/screen.py | 28 +++++++++++++------- synodic_client/application/screen/tray.py | 6 ++++- synodic_client/application/workers.py | 5 ++-- tests/unit/qt/test_install_preview.py | 4 +-- 5 files changed, 30 insertions(+), 17 deletions(-) diff --git a/synodic_client/application/screen/install.py b/synodic_client/application/screen/install.py index c69909b..dfa84e5 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -275,7 +275,7 @@ async def run_install( ): cb.on_sub_progress(event.action, event.sub_action) - if event.kind == ProgressEventKind.ACTION_COMPLETED and event.result: + if event.kind == ProgressEventKind.ACTION_COMPLETED and event.result and event.action: collected.append(event.result) if cb.on_progress is not None: cb.on_progress(event.action, event.result) @@ -1260,7 +1260,7 @@ async def _resolve_manifest_path(url: str) -> tuple[Path, str | None]: params = DownloadParameters(url=url, destination=dest, timeout=3) loop = asyncio.get_running_loop() - result = await loop.run_in_executor(None, API.download, params) + result = await loop.run_in_executor(None, lambda: API.download(params)) if not result.success: _safe_rmtree(temp_dir) diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index c6734fa..16ff340 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -366,7 +366,19 @@ async def _async_refresh(self) -> None: try: loop = asyncio.get_running_loop() - plugins, packages_map = await loop.run_in_executor(None, self._fetch_plugin_data) + plugins, directories = await loop.run_in_executor( + None, + self._fetch_plugin_data, + ) + + # Gather packages for updatable plugins (async) + packages_map: dict[str, list[tuple[str, str]]] = {} + for plugin in plugins: + if plugin.kind in _UPDATABLE_KINDS: + packages_map[plugin.name] = await self._gather_packages( + plugin.name, + directories, + ) # Clear existing groups for group in self._groups: @@ -408,15 +420,11 @@ async def _async_refresh(self) -> None: def _fetch_plugin_data( self, - ) -> tuple[list[PluginInfo], dict[str, list[tuple[str, str]]]]: - """Fetch plugin data from porringer (runs in thread-pool executor).""" + ) -> tuple[list[PluginInfo], list[ManifestDirectory]]: + """Fetch plugin list and directories from porringer (sync, run in executor).""" plugins = self._porringer.plugin.list() directories = self._porringer.cache.list_directories() - packages_map: dict[str, list[tuple[str, str]]] = {} - for plugin in plugins: - if plugin.kind in _UPDATABLE_KINDS: - packages_map[plugin.name] = self._gather_packages(plugin.name, directories) - return plugins, packages_map + return plugins, directories @staticmethod def _build_plugin_section( @@ -450,7 +458,7 @@ def _build_plugin_section( parent=parent, ) - def _gather_packages( + async def _gather_packages( self, plugin_name: str, directories: list[ManifestDirectory], @@ -459,7 +467,7 @@ def _gather_packages( packages: list[tuple[str, str]] = [] for directory in directories: try: - pkgs = self._porringer.plugin.list_packages( + pkgs = await self._porringer.plugin.list_packages( plugin_name, Path(directory.path), ) diff --git a/synodic_client/application/screen/tray.py b/synodic_client/application/screen/tray.py index 6a04e5b..21ae244 100644 --- a/synodic_client/application/screen/tray.py +++ b/synodic_client/application/screen/tray.py @@ -5,6 +5,7 @@ from collections.abc import Callable from porringer.api import API +from porringer.schema import PluginInfo from PySide6.QtCore import QTimer from PySide6.QtGui import QAction from PySide6.QtWidgets import ( @@ -321,7 +322,10 @@ async def _do_tool_update(self, porringer: API) -> None: loop = asyncio.get_running_loop() config = self._resolve_config() - all_plugins = await loop.run_in_executor(None, porringer.plugin.list) + def _list_plugins() -> list[PluginInfo]: + return porringer.plugin.list() + + all_plugins = await loop.run_in_executor(None, _list_plugins) all_names = [p.name for p in all_plugins if p.installed] enabled = resolve_enabled_plugins(config, all_names) diff --git a/synodic_client/application/workers.py b/synodic_client/application/workers.py index 025d1be..f4be1e6 100644 --- a/synodic_client/application/workers.py +++ b/synodic_client/application/workers.py @@ -15,18 +15,19 @@ from porringer.schema import SetupParameters, SyncStrategy from synodic_client.client import Client +from synodic_client.updater import UpdateInfo logger = logging.getLogger(__name__) -async def check_for_update(client: Client) -> object: +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. + An ``UpdateInfo`` result, or ``None`` when no updater is initialised. """ loop = asyncio.get_running_loop() return await loop.run_in_executor(None, client.check_for_update) diff --git a/tests/unit/qt/test_install_preview.py b/tests/unit/qt/test_install_preview.py index 65d016b..1b0d3aa 100644 --- a/tests/unit/qt/test_install_preview.py +++ b/tests/unit/qt/test_install_preview.py @@ -424,7 +424,7 @@ async def _run() -> None: assert ready_calls[0][0] is preview assert len(checked) == 1 assert checked[0] == (0, result) - assert finished is True + assert finished @staticmethod def test_emits_finished_for_empty_actions(tmp_path: Path) -> None: @@ -449,7 +449,7 @@ async def _run() -> None: finished = True asyncio.run(_run()) - assert finished is True + assert finished @staticmethod def test_action_checked_maps_correct_rows(tmp_path: Path) -> None: From a0578bae6b667ceb7746f94d4432ff26d4f3e36b Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Fri, 27 Feb 2026 15:20:51 -0800 Subject: [PATCH 02/10] Updated Tool View --- pdm.lock | 8 +- pyproject.toml | 17 +- synodic_client/application/screen/screen.py | 671 ++++++++++++-------- synodic_client/application/screen/tray.py | 40 +- synodic_client/application/theme.py | 84 ++- synodic_client/application/workers.py | 9 +- synodic_client/config.py | 16 +- synodic_client/resolution.py | 76 ++- tests/unit/test_config.py | 12 + tests/unit/test_resolution.py | 86 +++ 10 files changed, 697 insertions(+), 322 deletions(-) diff --git a/pdm.lock b/pdm.lock index d86b255..98bfbe3 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "build", "lint", "test"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:819ce64c96cf4526bcc1f30c8cf820cd22a9196f809ddc3af94f8e5e6c772bae" +content_hash = "sha256:28dd86b49d2f25d216da9e63de3a23753339c7637ce5f3ecd474799f0f710609" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -336,7 +336,7 @@ files = [ [[package]] name = "porringer" -version = "0.2.1.dev56" +version = "0.2.1.dev57" requires_python = ">=3.14" summary = "" groups = ["default"] @@ -349,8 +349,8 @@ dependencies = [ "userpath>=1.9.2", ] files = [ - {file = "porringer-0.2.1.dev56-py3-none-any.whl", hash = "sha256:e855e5582e542f6050b6f976233893dac980241c4b999f3e480cf649b8295b40"}, - {file = "porringer-0.2.1.dev56.tar.gz", hash = "sha256:aca45a1a33a4acb0833a642ca7751aeeca6986aab7405e74544deaf327f669b5"}, + {file = "porringer-0.2.1.dev57-py3-none-any.whl", hash = "sha256:51c5139b3c75ba539b0cfb88c942927197e1d22e0fe93d707c11e0de234970f1"}, + {file = "porringer-0.2.1.dev57.tar.gz", hash = "sha256:85d05058eee705b3f296c880ff64327eb7e8b191319948f3ad3b6c0d4e2bed4d"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 1f2b145..806f2bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ requires-python = ">=3.14, <3.15" dependencies = [ "pyside6>=6.10.2", "packaging>=26.0", - "porringer>=0.2.1.dev56", + "porringer>=0.2.1.dev57", "qasync>=0.28.0", "velopack>=0.0.1444.dev49733", "typer>=0.24.1", @@ -25,9 +25,18 @@ homepage = "https://github.com/synodic/synodic-client" repository = "https://github.com/synodic/synodic-client" [dependency-groups] -build = ["pyinstaller>=6.19.0"] -lint = ["ruff>=0.15.4", "pyrefly>=0.54.0"] -test = ["pytest>=9.0.2", "pytest-cov>=7.0.0", "pytest-mock>=3.15.1"] +build = [ + "pyinstaller>=6.19.0", +] +lint = [ + "ruff>=0.15.4", + "pyrefly>=0.54.0", +] +test = [ + "pytest>=9.0.2", + "pytest-cov>=7.0.0", + "pytest-mock>=3.15.1", +] [project.scripts] synodic-c = "synodic_client.cli:app" diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 16ff340..0002ea9 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -3,26 +3,30 @@ import asyncio import logging from collections import OrderedDict -from dataclasses import dataclass, field from pathlib import Path from porringer.api import API -from porringer.schema import DirectoryValidationResult, ManifestDirectory, PluginInfo +from porringer.schema import ( + DirectoryValidationResult, + ManifestDirectory, + PluginInfo, + ProgressEventKind, + SetupAction, + SetupParameters, +) from porringer.schema.plugin import PluginKind from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QResizeEvent from PySide6.QtWidgets import ( QFileDialog, + QFrame, QHBoxLayout, - QHeaderView, QLabel, QMainWindow, QPushButton, QScrollArea, QSizePolicy, QStackedWidget, - QTableWidget, - QTableWidgetItem, QTabWidget, QVBoxLayout, QWidget, @@ -30,20 +34,25 @@ from synodic_client.application.icon import app_icon from synodic_client.application.screen import plugin_kind_group_label -from synodic_client.application.screen.card import CHEVRON_DOWN, CHEVRON_RIGHT, ClickableHeader from synodic_client.application.screen.install import PreviewPhase, SetupPreviewWidget from synodic_client.application.screen.sidebar import ManifestSidebar from synodic_client.application.screen.spinner import SpinnerWidget from synodic_client.application.screen.update_banner import UpdateBanner from synodic_client.application.theme import ( COMPACT_MARGINS, - LOG_CHEVRON_STYLE, - LOG_SECTION_TITLE_STYLE, MAIN_WINDOW_MIN_SIZE, - PLUGIN_GROUP_HEADER_STYLE, - PLUGIN_GROUP_SECTION_SPACING, - PLUGIN_GROUP_TITLE_STYLE, - PLUGIN_SECTION_HEADER_STYLE, + PLUGIN_KIND_HEADER_STYLE, + PLUGIN_PROVIDER_NAME_STYLE, + PLUGIN_PROVIDER_STATUS_INSTALLED_STYLE, + PLUGIN_PROVIDER_STATUS_MISSING_STYLE, + PLUGIN_PROVIDER_STYLE, + PLUGIN_PROVIDER_VERSION_STYLE, + PLUGIN_ROW_GLOBAL_STYLE, + PLUGIN_ROW_NAME_STYLE, + PLUGIN_ROW_PROJECT_STYLE, + PLUGIN_ROW_STYLE, + PLUGIN_ROW_TOGGLE_STYLE, + PLUGIN_ROW_VERSION_STYLE, PLUGIN_SECTION_SPACING, PLUGIN_TOGGLE_STYLE, PLUGIN_UPDATE_STYLE, @@ -56,242 +65,208 @@ # Plugin kinds that support auto-update and per-plugin upgrade. _UPDATABLE_KINDS = frozenset({PluginKind.TOOL, PluginKind.PACKAGE}) +# Preferred display ordering — Tools first, then alphabetical for the rest. +_KIND_DISPLAY_ORDER: dict[PluginKind, int] = { + PluginKind.TOOL: 0, + PluginKind.PACKAGE: 1, + PluginKind.RUNTIME: 2, + PluginKind.PROJECT: 3, + PluginKind.SCM: 4, +} -@dataclass -class PluginSectionData: - """Data needed to construct a :class:`PluginSection`.""" - name: str - version: str - packages: list[tuple[str, str]] = field(default_factory=list) - auto_update: bool = True - show_controls: bool = False - installed: bool = True +# --------------------------------------------------------------------------- +# Plugin kind header — uppercase section divider +# --------------------------------------------------------------------------- -class PluginSection(QWidget): - """Collapsible section displaying a single plugin and its managed packages.""" +class PluginKindHeader(QLabel): + """Uppercase, muted section divider for a plugin-kind group. - auto_update_toggled = Signal(str, bool) - """Emitted with ``(plugin_name, enabled)`` when the auto-update toggle changes.""" + Displays a label like ``TOOLS`` or ``PACKAGES`` with a subtle bottom + border, matching VS Code's sidebar heading style. + """ - update_requested = Signal(str) - """Emitted with the plugin name when the per-plugin Update button is clicked.""" + def __init__(self, kind: PluginKind, parent: QWidget | None = None) -> None: + super().__init__(plugin_kind_group_label(kind).upper(), parent) + self.setObjectName('pluginKindHeader') + self.setStyleSheet(PLUGIN_KIND_HEADER_STYLE) - def __init__(self, data: PluginSectionData, parent: QWidget | None = None) -> None: - """Initialise the section. - Args: - data: Plugin metadata and package list. - parent: Optional parent widget. - """ - super().__init__(parent) - self._plugin_name = data.name - self._expanded = False +# --------------------------------------------------------------------------- +# Plugin provider header — thin row for the managing plugin +# --------------------------------------------------------------------------- - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - self._header = self._build_header( - data.name, - data.version, - data.auto_update, - data.show_controls, - installed=data.installed, - ) - layout.addWidget(self._header) - self._body = self._build_body(data.packages) - self._body.setVisible(False) - layout.addWidget(self._body) +class PluginProviderHeader(QFrame): + """Thin sub-header row identifying the plugin that provides a set of tools. + + Shows the plugin name, version, installed status, and — for updatable + kinds — ``Auto`` and ``Update`` buttons. + """ + + auto_update_toggled = Signal(str, bool) + """Emitted with ``(plugin_name, enabled)`` when the auto-update toggle changes.""" - # --- Header / body builders --- + update_requested = Signal(str) + """Emitted with the plugin name when the per-plugin *Update* button is clicked.""" - def _build_header( + def __init__( self, - plugin_name: str, - version: str, - auto_update: bool, - show_controls: bool, + plugin: PluginInfo, + auto_update: bool = True, *, - installed: bool = True, - ) -> ClickableHeader: - """Construct the clickable header row.""" - header = ClickableHeader('pluginHeader', PLUGIN_SECTION_HEADER_STYLE) - header.clicked.connect(self._toggle) - - header_layout = header.header_layout + show_controls: bool = False, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.setObjectName('pluginProvider') + self.setStyleSheet(PLUGIN_PROVIDER_STYLE) + self._plugin_name = plugin.name - self._chevron = QLabel(CHEVRON_RIGHT) - self._chevron.setStyleSheet(LOG_CHEVRON_STYLE) - self._chevron.setFixedWidth(14) - header_layout.addWidget(self._chevron) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) - title = QLabel(plugin_name) - title.setStyleSheet(LOG_SECTION_TITLE_STYLE) - header_layout.addWidget(title) + # Plugin name + name_label = QLabel(plugin.name) + name_label.setStyleSheet(PLUGIN_PROVIDER_NAME_STYLE) + layout.addWidget(name_label) - version_label = QLabel(version) - version_label.setStyleSheet('color: grey;') - header_layout.addWidget(version_label) + # Version + version_text = ( + str(plugin.tool_version) + if plugin.tool_version is not None + else 'Installed' + if plugin.installed + else 'Not installed' + ) + version_label = QLabel(version_text) + version_label.setStyleSheet(PLUGIN_PROVIDER_VERSION_STYLE) + layout.addWidget(version_label) + + # Installed indicator + status_label = QLabel('\u25cf' if plugin.installed else '\u25cb') + status_label.setStyleSheet( + PLUGIN_PROVIDER_STATUS_INSTALLED_STYLE if plugin.installed else PLUGIN_PROVIDER_STATUS_MISSING_STYLE + ) + status_label.setToolTip('Installed' if plugin.installed else 'Not installed') + layout.addWidget(status_label) - header_layout.addStretch() + layout.addStretch() + # Auto / Update controls (only for updatable kinds) if show_controls: - self._toggle_btn = QPushButton('Auto') - self._toggle_btn.setCheckable(True) - self._toggle_btn.setChecked(auto_update) - self._toggle_btn.setStyleSheet(PLUGIN_TOGGLE_STYLE) - self._toggle_btn.setToolTip('Enable automatic updates for this plugin') - self._toggle_btn.clicked.connect(self._on_toggle_clicked) - header_layout.addWidget(self._toggle_btn) + toggle_btn = QPushButton('Auto') + toggle_btn.setCheckable(True) + toggle_btn.setChecked(auto_update) + toggle_btn.setStyleSheet(PLUGIN_TOGGLE_STYLE) + toggle_btn.setToolTip('Enable automatic updates for this plugin') + toggle_btn.clicked.connect( + lambda checked: self.auto_update_toggled.emit(self._plugin_name, checked), + ) + layout.addWidget(toggle_btn) update_btn = QPushButton('Update') update_btn.setStyleSheet(PLUGIN_UPDATE_STYLE) - update_btn.setToolTip(f'Upgrade packages via {plugin_name} now') + update_btn.setToolTip(f'Upgrade packages via {plugin.name} now') update_btn.clicked.connect( lambda: self.update_requested.emit(self._plugin_name), ) - header_layout.addWidget(update_btn) + layout.addWidget(update_btn) - if not installed: - self._toggle_btn.setEnabled(False) - self._toggle_btn.setChecked(False) - self._toggle_btn.setToolTip('Not installed \u2014 cannot auto-update') + if not plugin.installed: + toggle_btn.setEnabled(False) + toggle_btn.setChecked(False) + toggle_btn.setToolTip('Not installed \u2014 cannot auto-update') update_btn.setEnabled(False) update_btn.setToolTip('Not installed \u2014 cannot update') - return header - - @staticmethod - def _build_body(packages: list[tuple[str, str]]) -> QWidget: - """Construct the collapsible body with a package table.""" - body = QWidget() - body_layout = QVBoxLayout(body) - body_layout.setContentsMargins(20, 4, 0, 4) - body_layout.setSpacing(2) - - if packages: - table = QTableWidget(len(packages), 2) - table.setHorizontalHeaderLabels(['Package', 'Project']) - table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) - table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) - table.setAlternatingRowColors(True) - table.verticalHeader().setVisible(False) - h = table.horizontalHeader() - h.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) - h.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) - for row, (pkg, proj) in enumerate(packages): - table.setItem(row, 0, QTableWidgetItem(pkg)) - table.setItem(row, 1, QTableWidgetItem(proj)) - body_layout.addWidget(table) - else: - body_layout.addWidget(QLabel('No packages found')) - return body +# --------------------------------------------------------------------------- +# Plugin row — compact package / tool entry +# --------------------------------------------------------------------------- - # --- Collapse / expand --- - def _toggle(self) -> None: - """Toggle the body visibility.""" - self._expanded = not self._expanded - self._body.setVisible(self._expanded) - self._chevron.setText(CHEVRON_DOWN if self._expanded else CHEVRON_RIGHT) - - # --- Callbacks --- - - def _on_toggle_clicked(self, checked: bool) -> None: - """Forward auto-update toggle state change.""" - self.auto_update_toggled.emit(self._plugin_name, checked) +class PluginRow(QFrame): + """Compact row showing an individual package or tool managed by a plugin. + Displays the package name, the project it belongs to, and its version. + The row highlights on hover using VS Code dark-theme colours. -class PluginGroupSection(QWidget): - """Collapsible group of :class:`PluginSection` widgets sharing the same kind. - - The group header displays a human-readable label derived from the - :class:`~porringer.schema.PluginKind`. New kinds are handled - automatically via :func:`plugin_kind_group_label`. + When *show_toggle* is ``True`` an inline **Auto** button lets the user + toggle per-package auto-update. If *is_global* is ``True`` and no + *project* is given, a muted ``(global)`` annotation is shown. """ + auto_update_toggled = Signal(str, str, bool) + """Emitted with ``(plugin_name, package_name, enabled)`` on toggle.""" + def __init__( self, - kind: PluginKind, + name: str, + project: str = '', + version: str = '', + *, + plugin_name: str = '', + auto_update: bool = False, + show_toggle: bool = False, + is_global: bool = False, parent: QWidget | None = None, ) -> None: - """Initialise the group section. - - Args: - kind: The plugin kind this group represents. - parent: Optional parent widget. - """ super().__init__(parent) - self._kind = kind - self._expanded = True - self._sections: list[PluginSection] = [] + self.setObjectName('pluginRow') + self.setStyleSheet(PLUGIN_ROW_STYLE) + self._plugin_name = plugin_name + self._package_name = name - layout = QVBoxLayout(self) + layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - self._header = self._build_header(kind) - layout.addWidget(self._header) - - self._body = QWidget() - self._body_layout = QVBoxLayout(self._body) - self._body_layout.setContentsMargins(8, 0, 0, 0) - self._body_layout.setSpacing(PLUGIN_GROUP_SECTION_SPACING) - layout.addWidget(self._body) - - # --- Header builder --- - - def _build_header(self, kind: PluginKind) -> ClickableHeader: - """Construct the clickable group header row.""" - header = ClickableHeader('pluginGroupHeader', PLUGIN_GROUP_HEADER_STYLE) - header.clicked.connect(self._toggle) - - header_layout = header.header_layout - - self._chevron = QLabel(CHEVRON_DOWN) - self._chevron.setStyleSheet(LOG_CHEVRON_STYLE) - self._chevron.setFixedWidth(14) - header_layout.addWidget(self._chevron) - - title = QLabel(plugin_kind_group_label(kind)) - title.setStyleSheet(PLUGIN_GROUP_TITLE_STYLE) - header_layout.addWidget(title) - - header_layout.addStretch() - return header - - # --- Public helpers --- - - @property - def kind(self) -> PluginKind: - """Return the plugin kind for this group.""" - return self._kind - - @property - def sections(self) -> list[PluginSection]: - """Return the child plugin sections.""" - return list(self._sections) - - def add_section(self, section: PluginSection) -> None: - """Append a :class:`PluginSection` to this group.""" - self._body_layout.addWidget(section) - self._sections.append(section) + layout.setSpacing(10) + + name_label = QLabel(name) + name_label.setStyleSheet(PLUGIN_ROW_NAME_STYLE) + layout.addWidget(name_label) + + if project: + project_label = QLabel(project) + project_label.setStyleSheet(PLUGIN_ROW_PROJECT_STYLE) + layout.addWidget(project_label) + elif is_global: + global_label = QLabel('(global)') + global_label.setStyleSheet(PLUGIN_ROW_GLOBAL_STYLE) + layout.addWidget(global_label) + + layout.addStretch() + + if show_toggle: + toggle_btn = QPushButton('Auto') + toggle_btn.setCheckable(True) + toggle_btn.setChecked(auto_update) + toggle_btn.setStyleSheet(PLUGIN_ROW_TOGGLE_STYLE) + toggle_btn.setToolTip('Auto-update this package') + toggle_btn.clicked.connect( + lambda checked: self.auto_update_toggled.emit( + self._plugin_name, + self._package_name, + checked, + ), + ) + layout.addWidget(toggle_btn) - # --- Collapse / expand --- + if version: + version_label = QLabel(version) + version_label.setStyleSheet(PLUGIN_ROW_VERSION_STYLE) + layout.addWidget(version_label) - def _toggle(self) -> None: - """Toggle the body visibility.""" - self._expanded = not self._expanded - self._body.setVisible(self._expanded) - self._chevron.setText(CHEVRON_DOWN if self._expanded else CHEVRON_RIGHT) +class ToolsView(QWidget): + """Central update hub showing installed tools and packages. -class PluginsView(QWidget): - """Scrollable list of collapsible plugin sections with auto-update controls.""" + Only displays ``TOOL`` and ``PACKAGE`` kind plugins that have a + ``tool_version`` or managed packages. Each tool has ``Auto`` / + ``Update`` controls. Empty plugins are hidden. + """ update_all_requested = Signal() """Emitted when the global *Update All* button is clicked.""" @@ -305,7 +280,7 @@ def __init__( config: ResolvedConfig, parent: QWidget | None = None, ) -> None: - """Initialize the plugins view. + """Initialize the tools view. Args: porringer: The porringer API instance. @@ -315,7 +290,7 @@ def __init__( super().__init__(parent) self._porringer = porringer self._config = config - self._groups: list[PluginGroupSection] = [] + self._section_widgets: list[QWidget] = [] self._refresh_in_progress = False self._init_ui() @@ -324,8 +299,8 @@ def _init_ui(self) -> None: outer = QVBoxLayout(self) outer.setContentsMargins(*COMPACT_MARGINS) - # Loading indicator (shown while data is fetched asynchronously) - self._loading_spinner = SpinnerWidget('Loading plugins\u2026') + # Loading indicator + self._loading_spinner = SpinnerWidget('Loading tools\u2026') outer.addWidget(self._loading_spinner) # Toolbar @@ -354,13 +329,19 @@ def _init_ui(self) -> None: # --- Public API --- def refresh(self) -> None: - """Schedule an asynchronous rebuild of the plugin sections.""" + """Schedule an asynchronous rebuild of the tool list.""" if self._refresh_in_progress: return asyncio.create_task(self._async_refresh()) async def _async_refresh(self) -> None: - """Rebuild the plugin sections from porringer data, grouped by kind.""" + """Rebuild the tool list from porringer data. + + Walks every cached project manifest to determine which packages + are *manifest-referenced* vs. *global*. Manifest packages + default to auto-update **on**; global packages default to **off**. + Per-package toggles honour the nested-dict config shape. + """ self._refresh_in_progress = True self._loading_spinner.start() @@ -368,11 +349,11 @@ async def _async_refresh(self) -> None: loop = asyncio.get_running_loop() plugins, directories = await loop.run_in_executor( None, - self._fetch_plugin_data, + self._fetch_data, ) - # Gather packages for updatable plugins (async) - packages_map: dict[str, list[tuple[str, str]]] = {} + # Gather packages for updatable plugins + packages_map: dict[str, list[tuple[str, str, str]]] = {} for plugin in plugins: if plugin.kind in _UPDATABLE_KINDS: packages_map[plugin.name] = await self._gather_packages( @@ -380,91 +361,147 @@ async def _async_refresh(self) -> None: directories, ) - # Clear existing groups - for group in self._groups: - self._container_layout.removeWidget(group) - group.deleteLater() - self._groups.clear() + # Gather manifest requirements → plugin_name → set of package names + manifest_packages: dict[str, set[str]] = {} + for directory in directories: + actions = await self._gather_project_requirements(directory) + for action in actions: + if action.package and action.installer: + manifest_packages.setdefault(action.installer, set()).add( + str(action.package.name), + ) + + # Clear existing widgets + for widget in self._section_widgets: + self._container_layout.removeWidget(widget) + widget.deleteLater() + self._section_widgets.clear() auto_update_map = self._config.plugin_auto_update or {} - # Bucket plugins by kind, preserving discovery order within each bucket + # Only show TOOL / PACKAGE kinds that have content + updatable = [p for p in plugins if p.kind in _UPDATABLE_KINDS] + + # Bucket by kind kind_buckets: OrderedDict[PluginKind, list[PluginInfo]] = OrderedDict() - for plugin in plugins: - kind_buckets.setdefault(plugin.kind, []).append(plugin) + for plugin in updatable: + has_version = plugin.tool_version is not None + has_packages = bool(packages_map.get(plugin.name)) + if has_version or has_packages: + kind_buckets.setdefault(plugin.kind, []).append(plugin) + + sorted_kinds = sorted( + kind_buckets.keys(), + key=lambda k: _KIND_DISPLAY_ORDER.get(k, 99), + ) + + for kind in sorted_kinds: + bucket = kind_buckets[kind] - for kind, bucket in kind_buckets.items(): - group = PluginGroupSection(kind, parent=self._container) + kind_header = PluginKindHeader(kind, parent=self._container) + idx = self._container_layout.count() - 1 + self._container_layout.insertWidget(idx, kind_header) + self._section_widgets.append(kind_header) for plugin in bucket: - packages = packages_map.get(plugin.name, []) - section = PluginsView._build_plugin_section( + auto_val = auto_update_map.get(plugin.name, True) + provider_checked = auto_val is not False + + provider = PluginProviderHeader( plugin, - packages, - auto_update_map, - parent=group, + provider_checked, + show_controls=True, + parent=self._container, ) - section.auto_update_toggled.connect(self._on_auto_update_toggled) - section.update_requested.connect(self.plugin_update_requested.emit) - group.add_section(section) + provider.auto_update_toggled.connect(self._on_auto_update_toggled) + provider.update_requested.connect(self.plugin_update_requested.emit) + idx = self._container_layout.count() - 1 + self._container_layout.insertWidget(idx, provider) + self._section_widgets.append(provider) + + plugin_manifest = manifest_packages.get(plugin.name, set()) + raw_packages = packages_map.get(plugin.name, []) + + # Merge duplicates: same package from multiple + # directories becomes one row with a combined + # project label. Global packages are always + # deduplicated; manifest packages merge their + # project names with ", ". + merged: OrderedDict[str, tuple[list[str], str, bool]] = OrderedDict() + for pkg_name, proj_name, pkg_version in raw_packages: + is_global = pkg_name not in plugin_manifest + if pkg_name in merged: + existing_projects, _, _ = merged[pkg_name] + if not is_global and proj_name and proj_name not in existing_projects: + existing_projects.append(proj_name) + else: + projects = [] if is_global else ([proj_name] if proj_name else []) + merged[pkg_name] = (projects, pkg_version, is_global) + + if merged: + for pkg_name, ( + projects, + pkg_version, + is_global, + ) in merged.items(): + # Determine per-package auto-update state + if isinstance(auto_val, dict): + pkg_auto = auto_val.get(pkg_name, not is_global) + elif auto_val is False: + pkg_auto = False + else: + pkg_auto = not is_global + + row = PluginRow( + pkg_name, + project=', '.join(projects), + version=pkg_version, + plugin_name=plugin.name, + auto_update=pkg_auto, + show_toggle=True, + is_global=is_global, + parent=self._container, + ) + row.auto_update_toggled.connect( + self._on_package_auto_update_toggled, + ) + idx = self._container_layout.count() - 1 + self._container_layout.insertWidget(idx, row) + self._section_widgets.append(row) + else: + version_text = str(plugin.tool_version) if plugin.tool_version is not None else '' + row = PluginRow( + plugin.name, + version=version_text, + parent=self._container, + ) + idx = self._container_layout.count() - 1 + self._container_layout.insertWidget(idx, row) + self._section_widgets.append(row) - # Insert before the trailing stretch - idx = self._container_layout.count() - 1 - self._container_layout.insertWidget(idx, group) - self._groups.append(group) except Exception: - logger.exception('Failed to refresh plugins') + logger.exception('Failed to refresh tools') finally: self._loading_spinner.stop() self._refresh_in_progress = False - def _fetch_plugin_data( - self, - ) -> tuple[list[PluginInfo], list[ManifestDirectory]]: - """Fetch plugin list and directories from porringer (sync, run in executor).""" + def _fetch_data(self) -> tuple[list[PluginInfo], list[ManifestDirectory]]: + """Fetch plugin list and directories (sync, run in executor).""" plugins = self._porringer.plugin.list() directories = self._porringer.cache.list_directories() return plugins, directories - @staticmethod - def _build_plugin_section( - plugin: PluginInfo, - packages: list[tuple[str, str]], - auto_update_map: dict[str, bool], - *, - parent: QWidget | None = None, - ) -> PluginSection: - """Create a :class:`PluginSection` for a single plugin.""" - installed = plugin.installed - version = ( - str(plugin.tool_version) - if plugin.tool_version is not None - else 'Installed' - if installed - else 'Not installed' - ) - show_controls = plugin.kind in _UPDATABLE_KINDS - auto_update = auto_update_map.get(plugin.name, True) - - return PluginSection( - PluginSectionData( - name=plugin.name, - version=version, - packages=packages, - auto_update=auto_update, - show_controls=show_controls, - installed=installed, - ), - parent=parent, - ) - async def _gather_packages( self, plugin_name: str, directories: list[ManifestDirectory], - ) -> list[tuple[str, str]]: - """Collect packages managed by *plugin_name* across cached projects.""" - packages: list[tuple[str, str]] = [] + ) -> list[tuple[str, str, str]]: + """Collect packages managed by *plugin_name* across cached projects. + + Returns: + A list of ``(package_name, project_label, version)`` tuples. + """ + packages: list[tuple[str, str, str]] = [] for directory in directories: try: pkgs = await self._porringer.plugin.list_packages( @@ -473,7 +510,11 @@ async def _gather_packages( ) for pkg in pkgs: packages.append( - (str(pkg.name), directory.name or str(directory.path)), + ( + str(pkg.name), + directory.name or str(directory.path), + str(pkg.version) if pkg.version else '', + ), ) except Exception: logger.debug( @@ -484,10 +525,46 @@ async def _gather_packages( ) return packages + async def _gather_project_requirements( + self, + directory: ManifestDirectory, + ) -> list[SetupAction]: + """Run a dry-run execute_stream for *directory* and collect actions.""" + actions: list[SetupAction] = [] + 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 actions + + params = SetupParameters( + paths=[str(manifest_path)], + dry_run=True, + project_directory=path, + ) + async for event in self._porringer.sync.execute_stream(params): + if event.kind == ProgressEventKind.MANIFEST_PARSED and event.manifest: + actions.extend(event.manifest.actions) + break + except Exception: + logger.debug( + 'Could not gather requirements for %s', + directory.path, + exc_info=True, + ) + return actions + # --- Callbacks --- def _on_auto_update_toggled(self, plugin_name: str, enabled: bool) -> None: - """Persist the auto-update toggle change to config.""" + """Persist the plugin-level auto-update toggle change to config.""" mapping = dict(self._config.plugin_auto_update or {}) if enabled: @@ -495,11 +572,41 @@ def _on_auto_update_toggled(self, plugin_name: str, enabled: bool) -> None: else: mapping[plugin_name] = False - # Clean up the dict if all plugins are enabled new_value = mapping if mapping else None self._config = update_user_config(plugin_auto_update=new_value) logger.info('Auto-update for %s set to %s', plugin_name, enabled) + def _on_package_auto_update_toggled( + self, + plugin_name: str, + package_name: str, + enabled: bool, + ) -> None: + """Persist a per-package auto-update override to the nested config dict.""" + mapping = dict(self._config.plugin_auto_update or {}) + current = mapping.get(plugin_name) + + if isinstance(current, dict): + pkg_dict: dict[str, bool] = dict(current) + else: + pkg_dict = {} + + pkg_dict[package_name] = enabled + + if pkg_dict: + mapping[plugin_name] = pkg_dict + else: + mapping.pop(plugin_name, None) + + new_value = mapping if mapping else None + self._config = update_user_config(plugin_auto_update=new_value) + logger.info( + 'Auto-update for %s/%s set to %s', + plugin_name, + package_name, + enabled, + ) + class ProjectsView(QWidget): """Widget for managing project directories and previewing their manifests. @@ -712,7 +819,7 @@ class MainWindow(QMainWindow): """Emitted when the user clicks the settings gear button.""" _tabs: QTabWidget | None = None - _plugins_view: PluginsView | None = None + _tools_view: ToolsView | None = None _projects_view: ProjectsView | None = None def __init__( @@ -742,9 +849,9 @@ def porringer(self) -> API | None: return self._porringer @property - def plugins_view(self) -> PluginsView | None: - """Return the plugins view, if initialised.""" - return self._plugins_view + def tools_view(self) -> ToolsView | None: + """Return the tools view, if initialised.""" + return self._tools_view @property def update_banner(self) -> UpdateBanner: @@ -759,8 +866,8 @@ def show(self) -> None: self._projects_view = ProjectsView(self._porringer, self._config, self) self._tabs.addTab(self._projects_view, 'Projects') - self._plugins_view = PluginsView(self._porringer, self._config, self) - self._tabs.addTab(self._plugins_view, 'Plugins') + self._tools_view = ToolsView(self._porringer, self._config, self) + self._tabs.addTab(self._tools_view, 'Tools') gear_btn = QPushButton('\u2699') gear_btn.setStyleSheet(SETTINGS_GEAR_STYLE) @@ -781,8 +888,8 @@ def show(self) -> None: # Paint the window immediately, then refresh data asynchronously super().show() - if self._plugins_view is not None: - self._plugins_view.refresh() + if self._tools_view is not None: + self._tools_view.refresh() if self._projects_view is not None: self._projects_view.refresh() diff --git a/synodic_client/application/screen/tray.py b/synodic_client/application/screen/tray.py index 21ae244..969c8b4 100644 --- a/synodic_client/application/screen/tray.py +++ b/synodic_client/application/screen/tray.py @@ -21,8 +21,8 @@ from synodic_client.client import Client from synodic_client.resolution import ( ResolvedConfig, + resolve_auto_update_scope, resolve_config, - resolve_enabled_plugins, resolve_update_config, ) from synodic_client.updater import UpdateInfo @@ -81,11 +81,11 @@ def __init__( self._tool_update_timer: QTimer | None = None self._restart_tool_update_timer() - # Connect PluginsView signals when available - plugins_view = window.plugins_view - if plugins_view is not None: - plugins_view.update_all_requested.connect(self._on_tool_update) - plugins_view.plugin_update_requested.connect(self._on_single_plugin_update) + # Connect ToolsView signals when available + tools_view = window.tools_view + if tools_view is not None: + tools_view.update_all_requested.connect(self._on_tool_update) + tools_view.plugin_update_requested.connect(self._on_single_plugin_update) # Connect update banner signals self._banner = window.update_banner @@ -327,10 +327,17 @@ def _list_plugins() -> list[PluginInfo]: all_plugins = await loop.run_in_executor(None, _list_plugins) all_names = [p.name for p in all_plugins if p.installed] - enabled = resolve_enabled_plugins(config, all_names) + enabled_plugins, include_packages = resolve_auto_update_scope( + config, + all_names, + ) try: - count = await run_tool_updates(porringer, plugins=enabled) + count = await run_tool_updates( + porringer, + plugins=enabled_plugins, + include_packages=include_packages, + ) self._on_tool_update_finished(count) except Exception as exc: logger.exception('Tool update failed') @@ -350,8 +357,23 @@ def _on_single_plugin_update(self, plugin_name: str) -> None: async def _async_single_plugin_update(self, porringer: API, plugin_name: str) -> None: """Run a single-plugin tool update and route results.""" + config = self._resolve_config() + mapping = config.plugin_auto_update or {} + pkg_entry = mapping.get(plugin_name) + + # 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: - count = await run_tool_updates(porringer, plugins=[plugin_name]) + count = await run_tool_updates( + porringer, + plugins={plugin_name}, + include_packages=include_packages, + ) self._on_tool_update_finished(count) except Exception as exc: logger.exception('Tool update failed') diff --git a/synodic_client/application/theme.py b/synodic_client/application/theme.py index 27be8d6..232c222 100644 --- a/synodic_client/application/theme.py +++ b/synodic_client/application/theme.py @@ -107,25 +107,79 @@ ) # --------------------------------------------------------------------------- -# Plugin section panel +# Plugin panel — modernised flat list # --------------------------------------------------------------------------- -PLUGIN_GROUP_HEADER_STYLE = 'QWidget#pluginGroupHeader { padding: 6px 4px 2px 0px;}' -"""Style for the collapsible group header in the plugins view.""" -PLUGIN_GROUP_TITLE_STYLE = 'font-weight: bold; font-size: 13px;' -"""Style for the group heading label text.""" +# Kind header — uppercase section divider ("TOOLS", "PACKAGES", …) +PLUGIN_KIND_HEADER_STYLE = ( + 'QLabel#pluginKindHeader {' + ' font-size: 11px;' + ' font-weight: bold;' + ' color: #808080;' + ' text-transform: uppercase;' + ' padding: 10px 4px 4px 4px;' + ' border-bottom: 1px solid palette(mid);' + '}' +) +"""Uppercase, muted section divider for each plugin-kind group.""" -PLUGIN_GROUP_SECTION_SPACING = 2 -"""Pixels between plugin sections within a group.""" +PLUGIN_KIND_HEADER_SPACING = 6 +"""Pixels below a kind header before the first provider row.""" -PLUGIN_SECTION_HEADER_STYLE = ( - 'QWidget#pluginHeader {' - ' background: palette(midlight);' - ' border: 1px solid palette(mid);' - ' border-radius: 3px;' - ' padding: 4px 8px;' +# Provider sub-header — thin row showing the managing plugin +PLUGIN_PROVIDER_STYLE = 'QFrame#pluginProvider { background: transparent; padding: 2px 8px 2px 4px;}' +"""Subtle sub-header row for the plugin that manages a set of tools.""" + +PLUGIN_PROVIDER_NAME_STYLE = 'font-size: 12px; font-weight: bold; color: #cccccc;' +"""Provider name (e.g. "uv", "pip").""" + +PLUGIN_PROVIDER_VERSION_STYLE = 'font-size: 11px; color: #808080;' +"""Provider version text.""" + +PLUGIN_PROVIDER_STATUS_INSTALLED_STYLE = 'font-size: 10px; color: #89d185;' +"""Green dot / label for installed providers.""" + +PLUGIN_PROVIDER_STATUS_MISSING_STYLE = 'font-size: 10px; color: #f48771;' +"""Red-orange dot / label for missing providers.""" + +# Compact tool / package row +PLUGIN_ROW_STYLE = ( + 'QFrame#pluginRow {' + ' background: transparent;' + ' border-radius: 4px;' + ' padding: 3px 8px 3px 20px;' + '}' + 'QFrame#pluginRow:hover {' + ' background: #2a2d2e;' '}' ) +"""Compact row for an individual tool or package managed by a plugin.""" + +PLUGIN_ROW_NAME_STYLE = 'font-size: 12px; color: #cccccc;' +"""Package / tool name in a row.""" + +PLUGIN_ROW_PROJECT_STYLE = 'font-size: 11px; color: #808080;' +"""Project directory association in a row.""" + +PLUGIN_ROW_VERSION_STYLE = 'font-size: 11px; color: grey;' +"""Version text in a row.""" + +PLUGIN_ROW_GLOBAL_STYLE = 'font-size: 11px; color: #808080; font-style: italic;' +"""Muted italic annotation label for non-manifest (global) packages.""" + +PLUGIN_ROW_TOGGLE_STYLE = ( + 'QPushButton { padding: 1px 4px; border: 1px solid palette(mid); border-radius: 2px;' + ' font-size: 10px; min-width: 36px; max-width: 36px; }' + 'QPushButton:checked { background: #89d185; color: black; }' + 'QPushButton:disabled { color: palette(mid); border-color: palette(mid); background: transparent; }' + 'QPushButton:checked:disabled { background: transparent; color: palette(mid); }' +) +"""Small inline auto-update toggle for individual package rows.""" + +PLUGIN_ROW_SPACING = 1 +"""Pixels between individual tool/package rows.""" + +# Retained from previous design — auto-update & per-plugin update buttons PLUGIN_TOGGLE_STYLE = ( 'QPushButton { padding: 2px 8px; border: 1px solid palette(mid); border-radius: 3px;' ' min-width: 60px; max-width: 60px; }' @@ -140,8 +194,8 @@ 'QPushButton:disabled { color: palette(mid); border-color: palette(mid); background: transparent; }' ) -PLUGIN_SECTION_SPACING = 4 -"""Pixels between plugin sections in the scroll area.""" +PLUGIN_SECTION_SPACING = 2 +"""Pixels between provider groups in the scroll area.""" # --------------------------------------------------------------------------- # Card-based layout diff --git a/synodic_client/application/workers.py b/synodic_client/application/workers.py index f4be1e6..4540bb4 100644 --- a/synodic_client/application/workers.py +++ b/synodic_client/application/workers.py @@ -62,15 +62,19 @@ def progress_callback(percentage: int) -> None: async def run_tool_updates( porringer: API, - plugins: list[str] | None = None, + plugins: set[str] | None = None, + include_packages: set[str] | None = None, ) -> int: """Re-sync all cached project manifests. Args: porringer: The porringer API instance. - plugins: Optional include-list of plugin names. When set, only + 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. Returns: Number of manifests processed. @@ -94,6 +98,7 @@ async def run_tool_updates( project_directory=path if path.is_dir() else None, strategy=SyncStrategy.LATEST, plugins=plugins, + include_packages=include_packages, ) async for _event in porringer.sync.execute_stream(params): pass # consume events to completion diff --git a/synodic_client/config.py b/synodic_client/config.py index 40bc96b..47ffa27 100644 --- a/synodic_client/config.py +++ b/synodic_client/config.py @@ -104,10 +104,18 @@ class UserConfig(BaseModel): # 0 disables automatic checking. None uses the default (20 minutes). tool_update_interval_minutes: int | None = None - # Per-plugin auto-update toggle. Maps plugin name to enabled state. - # None or absent means all plugins auto-update. Explicitly False - # entries disable auto-update for that plugin. - plugin_auto_update: dict[str, bool] | None = None + # Per-plugin and per-package auto-update toggle. + # + # Maps plugin name to: + # - ``True`` — all packages under this plugin auto-update (default). + # - ``False`` — the entire plugin is disabled from auto-update. + # - ``dict[str, bool]`` — per-package overrides within this plugin. + # Packages with ``True`` auto-update; ``False`` are skipped. + # Packages not listed inherit the manifest-aware default (ON for + # manifest-referenced packages, OFF for global packages). + # + # ``None`` or absent means all plugins auto-update with manifest-aware defaults. + plugin_auto_update: dict[str, bool | dict[str, bool]] | None = None # Check for updates during dry-run previews. When True the preview # will query package indices for newer versions. diff --git a/synodic_client/resolution.py b/synodic_client/resolution.py index 6a3614a..96b267e 100644 --- a/synodic_client/resolution.py +++ b/synodic_client/resolution.py @@ -58,7 +58,7 @@ class ResolvedConfig: update_channel: str auto_update_interval_minutes: int tool_update_interval_minutes: int - plugin_auto_update: dict[str, bool] | None + plugin_auto_update: dict[str, bool | dict[str, bool]] | None detect_updates: bool prerelease_packages: dict[str, list[str]] | None auto_start: bool @@ -258,8 +258,80 @@ def resolve_enabled_plugins( if not mapping: return None - disabled = {name for name, enabled in mapping.items() if not enabled} + disabled = {name for name, enabled in mapping.items() if enabled is False} if not disabled: return None return [n for n in all_plugin_names if n not in disabled] + + +def resolve_auto_update_scope( + config: ResolvedConfig, + all_plugin_names: list[str], + manifest_packages: dict[str, set[str]] | None = None, +) -> 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. + + Args: + config: A resolved configuration snapshot. + all_plugin_names: Every known (installed) plugin name. + manifest_packages: Mapping of ``plugin_name`` → set of package + names declared in cached manifests. ``None`` means treat + all packages as manifest-referenced (conservative default). + + Returns: + 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(): + 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 --- + # Only build the set when there are per-package overrides or + # manifest data that distinguishes global from manifest-required. + include_packages: set[str] | None = None + + if per_package_entries or manifest_packages: + # 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 in disabled_plugins: + continue + 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) + + if pkg_set: + include_packages = pkg_set + + return enabled_plugins, include_packages diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index b356f39..b96e28f 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -67,6 +67,18 @@ def test_plugin_auto_update_round_trip() -> None: restored = UserConfig.model_validate(data) assert restored.plugin_auto_update == mapping + @staticmethod + def test_plugin_auto_update_nested_dict_round_trip() -> None: + """Verify nested per-package dict survives JSON round-trip.""" + mapping: dict[str, bool | dict[str, bool]] = { + 'uv': {'cppython': True, 'ruff': False}, + 'pip': False, + } + original = UserConfig(plugin_auto_update=mapping) + data = json.loads(original.model_dump_json()) + restored = UserConfig.model_validate(data) + assert restored.plugin_auto_update == mapping + @staticmethod def test_auto_start_round_trip() -> None: """Verify auto_start survives JSON round-trip.""" diff --git a/tests/unit/test_resolution.py b/tests/unit/test_resolution.py index e8f79a5..89782b4 100644 --- a/tests/unit/test_resolution.py +++ b/tests/unit/test_resolution.py @@ -9,6 +9,7 @@ from synodic_client.config import BuildConfig, UserConfig from synodic_client.resolution import ( ResolvedConfig, + resolve_auto_update_scope, resolve_config, resolve_enabled_plugins, resolve_update_config, @@ -257,6 +258,91 @@ def test_empty_mapping_returns_none() -> None: result = resolve_enabled_plugins(config, ['pip']) assert result is None + @staticmethod + def test_nested_dict_is_not_false() -> None: + """Verify a nested dict entry is not treated as disabled.""" + config = _make_resolved(plugin_auto_update={'uv': {'ruff': True}}) + result = resolve_enabled_plugins(config, ['uv', 'pip']) + # 'uv' has a dict value (not False) so it should still be enabled + assert result is None + + +# --------------------------------------------------------------------------- +# resolve_auto_update_scope +# --------------------------------------------------------------------------- + + +class TestResolveAutoUpdateScope: + """Tests for resolve_auto_update_scope.""" + + @staticmethod + def test_no_mapping_returns_none_pair() -> None: + """Verify (None, None) when plugin_auto_update is unset.""" + config = _make_resolved() + plugins, packages = resolve_auto_update_scope(config, ['pip', 'uv']) + assert plugins is None + assert packages is None + + @staticmethod + def test_all_enabled_returns_none_pair() -> None: + """Verify (None, None) when all entries are True.""" + config = _make_resolved(plugin_auto_update={'pip': True, 'uv': True}) + plugins, packages = resolve_auto_update_scope(config, ['pip', 'uv']) + assert plugins is None + assert packages is None + + @staticmethod + def test_plugin_disabled() -> None: + """Verify a disabled plugin is excluded.""" + config = _make_resolved(plugin_auto_update={'pip': False}) + plugins, packages = resolve_auto_update_scope(config, ['pip', 'uv']) + assert plugins is not None + assert 'pip' not in plugins + assert 'uv' in plugins + # No per-package filtering needed + assert packages is None + + @staticmethod + def test_nested_dict_filters_packages() -> None: + """Verify nested dict creates a package allowlist.""" + config = _make_resolved( + plugin_auto_update={'uv': {'ruff': True, 'mypy': False}}, + ) + plugins, packages = resolve_auto_update_scope( + config, + ['uv', 'pip'], + manifest_packages={'uv': {'ruff', 'black'}}, + ) + # 'uv' is not disabled at plugin level + assert plugins is None or 'uv' in plugins + # Package allowlist: ruff = True (explicit), black = True (manifest default), + # mypy = False (explicit) → only ruff and black + assert packages is not None + assert 'ruff' in packages + assert 'black' in packages + assert 'mypy' not in packages + + @staticmethod + def test_mixed_entries() -> None: + """Verify a mix of bool and dict entries.""" + config = _make_resolved( + plugin_auto_update={ + 'pip': False, + 'uv': {'ruff': True}, + }, + ) + plugins, packages = resolve_auto_update_scope( + config, + ['pip', 'uv', 'git'], + ) + assert plugins is not None + assert 'pip' not in plugins + assert 'uv' in plugins + assert 'git' in plugins + # 'uv' has a nested dict → package filtering + assert packages is not None + assert 'ruff' in packages + # --------------------------------------------------------------------------- # resolve_update_config From 69f04d0cc66097c744679e5bf9beb1ba550257ad Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Fri, 27 Feb 2026 22:08:10 -0800 Subject: [PATCH 03/10] Package Removal --- pdm.lock | 8 +- pyproject.toml | 2 +- synodic_client/application/screen/screen.py | 730 +++++++++++++++++-- synodic_client/application/screen/spinner.py | 42 +- synodic_client/application/screen/tray.py | 146 +++- synodic_client/application/theme.py | 19 + synodic_client/application/workers.py | 81 +- tests/unit/qt/test_gather_packages.py | 308 ++++++++ tests/unit/qt/test_update_feedback.py | 379 ++++++++++ tests/unit/test_workers.py | 50 ++ 10 files changed, 1671 insertions(+), 94 deletions(-) create mode 100644 tests/unit/qt/test_gather_packages.py create mode 100644 tests/unit/qt/test_update_feedback.py create mode 100644 tests/unit/test_workers.py diff --git a/pdm.lock b/pdm.lock index 98bfbe3..66b6d0c 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "build", "lint", "test"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:28dd86b49d2f25d216da9e63de3a23753339c7637ce5f3ecd474799f0f710609" +content_hash = "sha256:8cb6a031f9e0ae7078a726009b6eb88911d1784f4b50be154877d084e3c65fef" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -336,7 +336,7 @@ files = [ [[package]] name = "porringer" -version = "0.2.1.dev57" +version = "0.2.1.dev59" requires_python = ">=3.14" summary = "" groups = ["default"] @@ -349,8 +349,8 @@ dependencies = [ "userpath>=1.9.2", ] files = [ - {file = "porringer-0.2.1.dev57-py3-none-any.whl", hash = "sha256:51c5139b3c75ba539b0cfb88c942927197e1d22e0fe93d707c11e0de234970f1"}, - {file = "porringer-0.2.1.dev57.tar.gz", hash = "sha256:85d05058eee705b3f296c880ff64327eb7e8b191319948f3ad3b6c0d4e2bed4d"}, + {file = "porringer-0.2.1.dev59-py3-none-any.whl", hash = "sha256:d8287ea5bff5e678e3a45d6f0a235fa39c359e95db819edb2c95951b7b805549"}, + {file = "porringer-0.2.1.dev59.tar.gz", hash = "sha256:5a7248c30d549d6696ca7fb2a00377146848b5266171519d6a3ed7ca9bfbfe3d"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 806f2bb..ffb7753 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ requires-python = ">=3.14, <3.15" dependencies = [ "pyside6>=6.10.2", "packaging>=26.0", - "porringer>=0.2.1.dev57", + "porringer>=0.2.1.dev59", "qasync>=0.28.0", "velopack>=0.0.1444.dev49733", "typer>=0.24.1", diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 0002ea9..4beefce 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -3,6 +3,7 @@ import asyncio import logging from collections import OrderedDict +from dataclasses import dataclass, field from pathlib import Path from porringer.api import API @@ -13,10 +14,11 @@ ProgressEventKind, SetupAction, SetupParameters, + SkipReason, ) from porringer.schema.plugin import PluginKind -from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QResizeEvent +from PySide6.QtCore import QRect, Qt, QTimer, Signal +from PySide6.QtGui import QPainter, QPen from PySide6.QtWidgets import ( QFileDialog, QFrame, @@ -25,7 +27,6 @@ QMainWindow, QPushButton, QScrollArea, - QSizePolicy, QStackedWidget, QTabWidget, QVBoxLayout, @@ -48,10 +49,13 @@ PLUGIN_PROVIDER_STYLE, PLUGIN_PROVIDER_VERSION_STYLE, PLUGIN_ROW_GLOBAL_STYLE, + PLUGIN_ROW_HOST_STYLE, PLUGIN_ROW_NAME_STYLE, PLUGIN_ROW_PROJECT_STYLE, + PLUGIN_ROW_REMOVE_STYLE, PLUGIN_ROW_STYLE, PLUGIN_ROW_TOGGLE_STYLE, + PLUGIN_ROW_UPDATE_STYLE, PLUGIN_ROW_VERSION_STYLE, PLUGIN_SECTION_SPACING, PLUGIN_TOGGLE_STYLE, @@ -65,6 +69,12 @@ # Plugin kinds that support auto-update and per-plugin upgrade. _UPDATABLE_KINDS = frozenset({PluginKind.TOOL, PluginKind.PACKAGE}) +# Inline row-spinner constants +_ROW_SPINNER_SIZE = 12 +_ROW_SPINNER_PEN = 2 +_ROW_SPINNER_INTERVAL = 50 +_ROW_SPINNER_ARC = 90 + # Preferred display ordering — Tools first, then alphabetical for the rest. _KIND_DISPLAY_ORDER: dict[PluginKind, int] = { PluginKind.TOOL: 0, @@ -75,6 +85,109 @@ } +# --------------------------------------------------------------------------- +# Data models for package gathering and display +# --------------------------------------------------------------------------- + + +@dataclass(slots=True) +class PackageEntry: + """A single package returned by a gather query. + + Replaces ad-hoc tuples returned by ``_gather_packages`` and + ``_gather_tool_plugins``. + """ + + name: str + """Package name (e.g. ``"pdm"``, ``"ruff"``).""" + + project_label: str = '' + """Human-readable project directory label, or empty for global packages.""" + + version: str = '' + """Installed version string, or empty if unknown.""" + + host_tool: str = '' + """Name of the host package when injected (e.g. ``"pdm"``), otherwise empty.""" + + project_path: str = '' + """Directory path string for project-scoped packages, or empty for global ones.""" + + +@dataclass(slots=True) +class MergedPackage: + """Accumulated view of a package after deduplication across directories. + + Used during ``_async_refresh`` to merge multiple + :class:`PackageEntry` instances referencing the same package name + into a single row description. + """ + + projects: list[str] = field(default_factory=list) + """Display names of the projects referencing this package.""" + + project_paths: list[str] = field(default_factory=list) + """Filesystem paths of the project directories.""" + + version: str = '' + """Installed version string.""" + + is_global: bool = False + """``True`` when the package is not referenced by any project manifest.""" + + host_tool: str = '' + """Host-tool annotation for injected packages.""" + + +# --------------------------------------------------------------------------- +# _RowSpinner — tiny inline spinner for plugin rows +# --------------------------------------------------------------------------- + + +class _RowSpinner(QWidget): + """Tiny spinning arc shown inline while checking for updates.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._angle = 0 + self.setFixedSize(_ROW_SPINNER_SIZE, _ROW_SPINNER_SIZE) + self._timer = QTimer(self) + self._timer.setInterval(_ROW_SPINNER_INTERVAL) + self._timer.timeout.connect(self._tick) + self.hide() + + def paintEvent(self, _event: object) -> None: + """Draw the muted track and animated highlight arc.""" + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + m = _ROW_SPINNER_PEN // 2 + 1 + rect = QRect(m, m, _ROW_SPINNER_SIZE - 2 * m, _ROW_SPINNER_SIZE - 2 * m) + for colour, span in ((self.palette().mid(), 360), (self.palette().highlight(), _ROW_SPINNER_ARC)): + pen = QPen(colour, _ROW_SPINNER_PEN) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + if span == 360: + painter.drawEllipse(rect) + else: + painter.drawArc(rect, self._angle * 16, span * 16) + painter.end() + + def _tick(self) -> None: + self._angle = (self._angle - 10) % 360 + self.update() + + def start(self) -> None: + """Show the spinner and start the animation.""" + self._angle = 0 + self.show() + self._timer.start() + + def stop(self) -> None: + """Stop the animation and hide.""" + self._timer.stop() + self.hide() + + # --------------------------------------------------------------------------- # Plugin kind header — uppercase section divider # --------------------------------------------------------------------------- @@ -102,7 +215,8 @@ class PluginProviderHeader(QFrame): """Thin sub-header row identifying the plugin that provides a set of tools. Shows the plugin name, version, installed status, and — for updatable - kinds — ``Auto`` and ``Update`` buttons. + kinds — ``Auto`` and ``Update`` buttons. The ``Update`` button is + only visible when *has_updates* is ``True``. """ auto_update_toggled = Signal(str, bool) @@ -117,12 +231,15 @@ def __init__( auto_update: bool = True, *, show_controls: bool = False, + has_updates: bool = False, parent: QWidget | None = None, ) -> None: super().__init__(parent) self.setObjectName('pluginProvider') self.setStyleSheet(PLUGIN_PROVIDER_STYLE) self._plugin_name = plugin.name + self._update_btn: QPushButton | None = None + self._checking_spinner: _RowSpinner | None = None layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -167,12 +284,17 @@ def __init__( ) layout.addWidget(toggle_btn) + self._checking_spinner = _RowSpinner(self) + layout.addWidget(self._checking_spinner) + update_btn = QPushButton('Update') update_btn.setStyleSheet(PLUGIN_UPDATE_STYLE) update_btn.setToolTip(f'Upgrade packages via {plugin.name} now') update_btn.clicked.connect( lambda: self.update_requested.emit(self._plugin_name), ) + update_btn.setVisible(has_updates) + self._update_btn = update_btn layout.addWidget(update_btn) if not plugin.installed: @@ -182,6 +304,28 @@ def __init__( update_btn.setEnabled(False) update_btn.setToolTip('Not installed \u2014 cannot update') + def set_updating(self, updating: bool) -> None: + """Toggle the button between *Updating…* and *Update* states.""" + if self._update_btn is None: + return + if updating: + self._update_btn.setText('Updating\u2026') + self._update_btn.setEnabled(False) + else: + self._update_btn.setText('Update') + self._update_btn.setEnabled(True) + + def set_checking(self, checking: bool) -> None: + """Show or hide the inline checking spinner.""" + if self._checking_spinner is None: + return + if checking: + self._checking_spinner.start() + if self._update_btn is not None: + self._update_btn.hide() + else: + self._checking_spinner.stop() + # --------------------------------------------------------------------------- # Plugin row — compact package / tool entry @@ -197,11 +341,26 @@ class PluginRow(QFrame): When *show_toggle* is ``True`` an inline **Auto** button lets the user toggle per-package auto-update. If *is_global* is ``True`` and no *project* is given, a muted ``(global)`` annotation is shown. + + When *host_tool* is non-empty a muted ``→ `` label appears + after the name indicating the package is injected into that host. + + When *has_update* is ``True`` a small inline **Update** button appears + so the user can upgrade this specific package on demand. """ auto_update_toggled = Signal(str, str, bool) """Emitted with ``(plugin_name, package_name, enabled)`` on toggle.""" + update_requested = Signal(str, str) + """Emitted with ``(plugin_name, package_name)`` when update is clicked.""" + + remove_requested = Signal(str, str) + """Emitted with ``(plugin_name, package_name)`` when remove is clicked.""" + + navigate_to_project = Signal(str) + """Emitted with a project path when a manifest-managed package tooltip link is clicked.""" + def __init__( self, name: str, @@ -211,7 +370,10 @@ def __init__( plugin_name: str = '', auto_update: bool = False, show_toggle: bool = False, + has_update: bool = False, is_global: bool = False, + host_tool: str = '', + project_paths: list[str] | None = None, parent: QWidget | None = None, ) -> None: super().__init__(parent) @@ -219,6 +381,11 @@ def __init__( self.setStyleSheet(PLUGIN_ROW_STYLE) self._plugin_name = plugin_name self._package_name = name + self._update_btn: QPushButton | None = None + self._remove_btn: QPushButton | None = None + self._checking_spinner: _RowSpinner | None = None + self._host_label: QLabel | None = None + self._project_paths: list[str] = project_paths or [] layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -228,6 +395,11 @@ def __init__( name_label.setStyleSheet(PLUGIN_ROW_NAME_STYLE) layout.addWidget(name_label) + if host_tool: + self._host_label = QLabel(f'\u2192 {host_tool}') + self._host_label.setStyleSheet(PLUGIN_ROW_HOST_STYLE) + layout.addWidget(self._host_label) + if project: project_label = QLabel(project) project_label.setStyleSheet(PLUGIN_ROW_PROJECT_STYLE) @@ -254,11 +426,75 @@ def __init__( ) layout.addWidget(toggle_btn) + self._checking_spinner = _RowSpinner(self) + layout.addWidget(self._checking_spinner) + + if has_update: + update_btn = QPushButton('Update') + update_btn.setStyleSheet(PLUGIN_ROW_UPDATE_STYLE) + update_btn.setToolTip(f'Update {name}') + update_btn.clicked.connect( + lambda: self.update_requested.emit(self._plugin_name, self._package_name), + ) + self._update_btn = update_btn + layout.addWidget(update_btn) + if version: version_label = QLabel(version) version_label.setStyleSheet(PLUGIN_ROW_VERSION_STYLE) layout.addWidget(version_label) + # Remove button — always present, enabled only for global packages + remove_btn = QPushButton('\u00d7') + remove_btn.setFixedSize(18, 18) + remove_btn.setStyleSheet(PLUGIN_ROW_REMOVE_STYLE) + remove_btn.setCursor(Qt.CursorShape.PointingHandCursor) + if is_global: + remove_btn.setToolTip(f'Remove {name}') + remove_btn.clicked.connect( + lambda: self.remove_requested.emit(self._plugin_name, self._package_name), + ) + else: + remove_btn.setEnabled(False) + tooltip = f"Managed by project '{project}'" if project else 'Managed by a project manifest' + remove_btn.setToolTip(tooltip) + remove_btn.setCursor(Qt.CursorShape.ArrowCursor) + self._remove_btn = remove_btn + layout.addWidget(remove_btn) + + def set_updating(self, updating: bool) -> None: + """Toggle the button between *Updating…* and *Update* states.""" + if self._update_btn is None: + return + if updating: + self._update_btn.setText('Updating\u2026') + self._update_btn.setEnabled(False) + else: + self._update_btn.setText('Update') + self._update_btn.setEnabled(True) + + def set_checking(self, checking: bool) -> None: + """Show or hide the inline checking spinner.""" + if self._checking_spinner is None: + return + if checking: + self._checking_spinner.start() + if self._update_btn is not None: + self._update_btn.hide() + else: + self._checking_spinner.stop() + + def set_removing(self, removing: bool) -> None: + """Toggle the remove button between *Removing…* and *×* states.""" + if self._remove_btn is None: + return + if removing: + self._remove_btn.setText('Removing\u2026') + self._remove_btn.setEnabled(False) + else: + self._remove_btn.setText('\u00d7') + self._remove_btn.setEnabled(True) + class ToolsView(QWidget): """Central update hub showing installed tools and packages. @@ -274,6 +510,15 @@ class ToolsView(QWidget): plugin_update_requested = Signal(str) """Emitted with a plugin name when its per-plugin *Update* button is clicked.""" + package_update_requested = Signal(str, str) + """Emitted with ``(plugin_name, package_name)`` for a per-package update.""" + + package_remove_requested = Signal(str, str) + """Emitted with ``(plugin_name, package_name)`` for a per-package removal.""" + + navigate_to_project_requested = Signal(str) + """Emitted with a project path string to navigate to the Projects tab.""" + def __init__( self, porringer: API, @@ -292,6 +537,10 @@ def __init__( self._config = config self._section_widgets: list[QWidget] = [] self._refresh_in_progress = False + self._check_in_progress = False + self._updates_checked = False + self._updates_available: dict[str, set[str]] = {} + self._directories: list[ManifestDirectory] = [] self._init_ui() def _init_ui(self) -> None: @@ -299,13 +548,16 @@ def _init_ui(self) -> None: outer = QVBoxLayout(self) outer.setContentsMargins(*COMPACT_MARGINS) - # Loading indicator - self._loading_spinner = SpinnerWidget('Loading tools\u2026') - outer.addWidget(self._loading_spinner) - # Toolbar toolbar = QHBoxLayout() toolbar.addStretch() + + check_btn = QPushButton('Check for Updates') + check_btn.setToolTip('Scan all manifests for available package updates') + check_btn.clicked.connect(self._on_check_for_updates) + toolbar.addWidget(check_btn) + self._check_btn = check_btn + update_all_btn = QPushButton('Update All') update_all_btn.setToolTip('Upgrade all auto-update-enabled plugins now') update_all_btn.clicked.connect(self.update_all_requested.emit) @@ -326,6 +578,8 @@ def _init_ui(self) -> None: self._scroll.setWidget(self._container) outer.addWidget(self._scroll) + self._loading_spinner = SpinnerWidget('Loading tools\u2026', parent=self) + # --- Public API --- def refresh(self) -> None: @@ -341,9 +595,15 @@ async def _async_refresh(self) -> None: are *manifest-referenced* vs. *global*. Manifest packages default to auto-update **on**; global packages default to **off**. Per-package toggles honour the nested-dict config shape. + + Package listing and manifest requirement gathering run in + parallel via ``asyncio.TaskGroup`` to avoid O(P×D) serial + round-trips. Update-availability detection is deferred to a + background task so the widget tree renders immediately. """ self._refresh_in_progress = True self._loading_spinner.start() + directories: list[ManifestDirectory] = [] try: loop = asyncio.get_running_loop() @@ -351,26 +611,51 @@ async def _async_refresh(self) -> None: None, self._fetch_data, ) + self._directories = directories - # Gather packages for updatable plugins - packages_map: dict[str, list[tuple[str, str, str]]] = {} - for plugin in plugins: - if plugin.kind in _UPDATABLE_KINDS: - packages_map[plugin.name] = await self._gather_packages( - plugin.name, - directories, - ) - - # Gather manifest requirements → plugin_name → set of package names + # Gather packages and manifest requirements — in parallel + updatable_plugins = [p for p in plugins if p.kind in _UPDATABLE_KINDS] + packages_map: dict[str, list[tuple[str, str, str, str]]] = {} manifest_packages: dict[str, set[str]] = {} - for directory in directories: - actions = await self._gather_project_requirements(directory) - for action in actions: + requirement_actions: list[list[SetupAction]] = [] + + async with asyncio.TaskGroup() as tg: + # Per-plugin package listing + pkg_tasks = { + plugin.name: tg.create_task( + self._gather_packages(plugin.name, directories), + ) + for plugin in updatable_plugins + } + # Per-directory manifest requirement gathering + req_tasks = [tg.create_task(self._gather_project_requirements(d)) for d in directories] + # Natively-managed tool plugins (e.g. pdm self add) + tool_plugins_task = tg.create_task(self._gather_tool_plugins()) + + for name, task in pkg_tasks.items(): + packages_map[name] = task.result() + + # Merge tool-managed sub-plugins into the environment plugin + # that owns the host tool (e.g. cppython → pipx's pdm entry). + tool_plugins = tool_plugins_task.result() + for host_tool, sub_packages in tool_plugins.items(): + for _env_name, env_packages in packages_map.items(): + if any(entry.name == host_tool for entry in env_packages): + env_packages.extend(sub_packages) + break + + for task in req_tasks: + for action in task.result(): if action.package and action.installer: manifest_packages.setdefault(action.installer, set()).add( str(action.package.name), ) + # Snapshot current update availability for widget creation. + # If not yet checked, schedule a background detection task + # *after* the widget tree renders so the UI appears fast. + need_deferred_check = not self._updates_checked + # Clear existing widgets for widget in self._section_widgets: self._container_layout.removeWidget(widget) @@ -407,10 +692,12 @@ async def _async_refresh(self) -> None: auto_val = auto_update_map.get(plugin.name, True) provider_checked = auto_val is not False + plugin_updates = self._updates_available.get(plugin.name, set()) provider = PluginProviderHeader( plugin, provider_checked, show_controls=True, + has_updates=bool(plugin_updates), parent=self._container, ) provider.auto_update_toggled.connect(self._on_auto_update_toggled) @@ -427,44 +714,59 @@ async def _async_refresh(self) -> None: # project label. Global packages are always # deduplicated; manifest packages merge their # project names with ", ". - merged: OrderedDict[str, tuple[list[str], str, bool]] = OrderedDict() - for pkg_name, proj_name, pkg_version in raw_packages: - is_global = pkg_name not in plugin_manifest - if pkg_name in merged: - existing_projects, _, _ = merged[pkg_name] - if not is_global and proj_name and proj_name not in existing_projects: - existing_projects.append(proj_name) + merged: OrderedDict[str, MergedPackage] = OrderedDict() + for entry in raw_packages: + is_global = entry.name not in plugin_manifest + if entry.name in merged: + existing = merged[entry.name] + if not is_global and entry.project_label and entry.project_label not in existing.projects: + existing.projects.append(entry.project_label) + if not is_global and entry.project_path and entry.project_path not in existing.project_paths: + existing.project_paths.append(entry.project_path) else: - projects = [] if is_global else ([proj_name] if proj_name else []) - merged[pkg_name] = (projects, pkg_version, is_global) + merged[entry.name] = MergedPackage( + projects=[] if is_global else ([entry.project_label] if entry.project_label else []), + project_paths=[] if is_global else ([entry.project_path] if entry.project_path else []), + version=entry.version, + is_global=is_global, + host_tool=entry.host_tool, + ) if merged: - for pkg_name, ( - projects, - pkg_version, - is_global, - ) in merged.items(): + for pkg_name, pkg in merged.items(): # Determine per-package auto-update state if isinstance(auto_val, dict): - pkg_auto = auto_val.get(pkg_name, not is_global) + pkg_auto = auto_val.get(pkg_name, not pkg.is_global) elif auto_val is False: pkg_auto = False else: - pkg_auto = not is_global + pkg_auto = not pkg.is_global row = PluginRow( pkg_name, - project=', '.join(projects), - version=pkg_version, + project=', '.join(pkg.projects), + version=pkg.version, plugin_name=plugin.name, auto_update=pkg_auto, show_toggle=True, - is_global=is_global, + has_update=pkg_name in plugin_updates, + is_global=pkg.is_global, + host_tool=pkg.host_tool, + project_paths=pkg.project_paths, parent=self._container, ) row.auto_update_toggled.connect( self._on_package_auto_update_toggled, ) + row.update_requested.connect( + self.package_update_requested.emit, + ) + row.remove_requested.connect( + self.package_remove_requested.emit, + ) + row.navigate_to_project.connect( + self.navigate_to_project_requested.emit, + ) idx = self._container_layout.count() - 1 self._container_layout.insertWidget(idx, row) self._section_widgets.append(row) @@ -481,10 +783,16 @@ async def _async_refresh(self) -> None: except Exception: logger.exception('Failed to refresh tools') + need_deferred_check = False finally: self._loading_spinner.stop() self._refresh_in_progress = False + # Fire-and-forget: detect updates in the background, then patch + # the just-rendered widget tree with update badges. + if need_deferred_check: + asyncio.create_task(self._deferred_update_check(directories)) + def _fetch_data(self) -> tuple[list[PluginInfo], list[ManifestDirectory]]: """Fetch plugin list and directories (sync, run in executor).""" plugins = self._porringer.plugin.list() @@ -495,27 +803,54 @@ async def _gather_packages( self, plugin_name: str, directories: list[ManifestDirectory], - ) -> list[tuple[str, str, str]]: - """Collect packages managed by *plugin_name* across cached projects. + ) -> list[PackageEntry]: + """Collect packages managed by *plugin_name*. + + A global query (``project_path=None``) is always issued so that + globally-scoped plugins (pipx, apt, brew) report their packages + — including injected packages — even when no directories are + cached. Per-directory queries run in parallel alongside it to + capture project-scoped packages. Returns: - A list of ``(package_name, project_label, version)`` tuples. + A list of :class:`PackageEntry` instances. """ - packages: list[tuple[str, str, str]] = [] - for directory in directories: + packages: list[PackageEntry] = [] + + async def _list_global() -> None: + try: + pkgs = await self._porringer.plugin.list_packages(plugin_name) + packages.extend( + PackageEntry( + name=str(pkg.name), + version=str(pkg.version) if pkg.version else '', + host_tool=pkg.relation.host if pkg.relation else '', + ) + for pkg in pkgs + ) + except Exception: + logger.debug( + 'Could not list global packages for %s', + plugin_name, + exc_info=True, + ) + + async def _list_one(directory: ManifestDirectory) -> None: try: pkgs = await self._porringer.plugin.list_packages( plugin_name, Path(directory.path), ) - for pkg in pkgs: - packages.append( - ( - str(pkg.name), - directory.name or str(directory.path), - str(pkg.version) if pkg.version else '', - ), + packages.extend( + PackageEntry( + name=str(pkg.name), + project_label=directory.name or str(directory.path), + version=str(pkg.version) if pkg.version else '', + host_tool=pkg.relation.host if pkg.relation else '', + project_path=str(directory.path), ) + for pkg in pkgs + ) except Exception: logger.debug( 'Could not list packages for %s in %s', @@ -523,8 +858,83 @@ async def _gather_packages( directory.path, exc_info=True, ) + + async with asyncio.TaskGroup() as tg: + tg.create_task(_list_global()) + for d in directories: + tg.create_task(_list_one(d)) return packages + # ------------------------------------------------------------------ + # PluginManager sub-plugin discovery + # ------------------------------------------------------------------ + + async def _gather_tool_plugins( + self, + ) -> dict[str, list[PackageEntry]]: + """Query :class:`PluginManager` instances for natively managed sub-plugins. + + Discovers project-environment plugins that implement the + ``PluginManager`` protocol (e.g. PDM, Poetry) and calls + ``installed_plugins()`` on each. Each returned + :class:`Package` carries a :class:`PackageRelation` whose + *host* field identifies the parent tool. + + Returns: + A dict mapping host-tool name (e.g. ``"pdm"``) to a list of + :class:`PackageEntry` instances. + """ + results: dict[str, list[PackageEntry]] = {} + + loop = asyncio.get_running_loop() + managers = await loop.run_in_executor(None, self._discover_plugin_managers) + + async def _query(tool_name: str, manager: object) -> None: + try: + plugins = await manager.installed_plugins() # type: ignore[union-attr] + results[tool_name] = [ + PackageEntry( + name=str(pkg.name), + version=str(pkg.version) if pkg.version else '', + host_tool=pkg.relation.host if pkg.relation else tool_name, + ) + for pkg in plugins + ] + except Exception: + logger.debug( + 'Could not list plugins for %s', + tool_name, + exc_info=True, + ) + + async with asyncio.TaskGroup() as tg: + for tool_name, manager in managers.items(): + tg.create_task(_query(tool_name, manager)) + + return results + + @staticmethod + def _discover_plugin_managers() -> dict[str, object]: + """Discover project-environment plugins implementing ``PluginManager`` (sync). + + Imports are deferred to avoid hard-coupling the screen module to + porringer backend internals at import time. + + Returns: + A dict mapping tool name to :class:`PluginManager` instance. + """ + from porringer.backend.builder import Builder + from porringer.core.plugin_schema.plugin_manager import PluginManager + from porringer.core.plugin_schema.project_environment import ProjectEnvironment + + project_types = Builder.find_plugins('project_environment', ProjectEnvironment) + instances = Builder.build_plugins(project_types) + managers: dict[str, object] = {} + for info, inst in zip(project_types, instances, strict=True): + if isinstance(inst, PluginManager) and type(inst).is_available(): + managers[type(inst).tool_name()] = inst + return managers + async def _gather_project_requirements( self, directory: ManifestDirectory, @@ -607,6 +1017,203 @@ def _on_package_auto_update_toggled( enabled, ) + def _on_check_for_updates(self) -> None: + """Start an inline update check with per-row spinners.""" + if self._check_in_progress or self._refresh_in_progress: + return + self._check_btn.setEnabled(False) + self._check_btn.setText('Checking\u2026') + self._set_all_checking(True) + asyncio.create_task(self._run_inline_update_check()) + + async def _run_inline_update_check(self) -> None: + """Check for updates with inline spinners (no overlay / rebuild).""" + self._check_in_progress = True + try: + self._updates_available = await self._check_for_updates(self._directories) + self._updates_checked = True + self._apply_update_badges() + except Exception: + logger.debug('Inline update check failed', exc_info=True) + finally: + self._set_all_checking(False) + self._check_btn.setEnabled(True) + self._check_btn.setText('Check for Updates') + self._check_in_progress = False + + async def _check_for_updates( + self, + directories: list[ManifestDirectory], + ) -> dict[str, set[str]]: + """Detect available updates across cached manifests via dry-run. + + All directories are checked in parallel via ``asyncio.TaskGroup``. + + Returns a mapping of ``{plugin_name: {package_names…}}`` for + packages that have a newer version available. + """ + available: dict[str, set[str]] = {} + + async def _check_one(directory: ManifestDirectory) -> None: + partial = await self._check_directory_updates(directory) + for installer, packages in partial.items(): + available.setdefault(installer, set()).update(packages) + + async with asyncio.TaskGroup() as tg: + for d in directories: + tg.create_task(_check_one(d)) + + return available + + async def _check_directory_updates( + self, + directory: ManifestDirectory, + ) -> dict[str, set[str]]: + """Check a single directory for available updates (dry-run).""" + available: dict[str, set[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, + detect_updates=True, + project_directory=path, + ) + async for event in self._porringer.sync.execute_stream(params): + if ( + event.kind == ProgressEventKind.ACTION_COMPLETED + and event.result is not None + and event.result.skip_reason == SkipReason.UPDATE_AVAILABLE + ): + action = event.result.action + if action.installer and action.package: + available.setdefault(action.installer, set()).add( + str(action.package.name), + ) + except Exception: + logger.debug( + 'Could not detect updates for %s', + directory.path, + exc_info=True, + ) + return available + + async def _deferred_update_check( + self, + directories: list[ManifestDirectory], + ) -> None: + """Run update detection in the background, then patch the widget tree. + + Called after the initial render so the user sees the tool list + immediately while update badges are populated asynchronously. + Inline per-row spinners provide visual feedback. + """ + self._check_in_progress = True + self._check_btn.setEnabled(False) + self._check_btn.setText('Checking\u2026') + self._set_all_checking(True) + try: + self._updates_available = await self._check_for_updates(directories) + self._updates_checked = True + self._apply_update_badges() + except Exception: + logger.debug('Deferred update check failed', exc_info=True) + finally: + self._set_all_checking(False) + self._check_btn.setEnabled(True) + self._check_btn.setText('Check for Updates') + self._check_in_progress = False + + def _apply_update_badges(self) -> None: + """Walk existing widgets and show/hide Update buttons based on detection results.""" + current_plugin: str = '' + for widget in self._section_widgets: + if isinstance(widget, PluginProviderHeader): + current_plugin = widget._plugin_name + plugin_updates = self._updates_available.get(current_plugin, set()) + has = bool(plugin_updates) + if widget._update_btn is not None: + widget._update_btn.setVisible(has) + elif isinstance(widget, PluginRow) and widget._plugin_name: + plugin_updates = self._updates_available.get(widget._plugin_name, set()) + has = widget._package_name in plugin_updates + if widget._update_btn is not None: + widget._update_btn.setVisible(has) + elif has: + # Need to create the button that wasn't built at render time + self._inject_update_button(widget) + + def _inject_update_button(self, row: PluginRow) -> None: + """Dynamically add an Update button to a row that was built without one.""" + update_btn = QPushButton('Update') + update_btn.setStyleSheet(PLUGIN_ROW_UPDATE_STYLE) + update_btn.setToolTip(f'Update {row._package_name}') + update_btn.clicked.connect( + lambda: row.update_requested.emit(row._plugin_name, row._package_name), + ) + row._update_btn = update_btn + # Insert before the version label (last widget) if present, else append + layout = row.layout() + if layout is not None: + count = layout.count() + layout.insertWidget(max(count - 1, 0), update_btn) + + def _set_all_checking(self, checking: bool) -> None: + """Show or hide inline checking spinners on all plugin rows.""" + for widget in self._section_widgets: + if isinstance(widget, (PluginProviderHeader, PluginRow)): + widget.set_checking(checking) + + def set_plugin_updating(self, plugin_name: str, updating: bool) -> None: + """Toggle the *Updating…* state on the header for *plugin_name*.""" + for widget in self._section_widgets: + if isinstance(widget, PluginProviderHeader) and widget._plugin_name == plugin_name: + widget.set_updating(updating) + break + + def set_package_updating( + self, + plugin_name: str, + package_name: str, + updating: bool, + ) -> None: + """Toggle the *Updating…* state on a specific package row.""" + for widget in self._section_widgets: + if ( + isinstance(widget, PluginRow) + and widget._plugin_name == plugin_name + and widget._package_name == package_name + ): + widget.set_updating(updating) + break + + def set_package_removing( + self, + plugin_name: str, + package_name: str, + removing: bool, + ) -> None: + """Toggle the *Removing…* state on a specific package row.""" + for widget in self._section_widgets: + if ( + isinstance(widget, PluginRow) + and widget._plugin_name == plugin_name + and widget._package_name == package_name + ): + widget.set_removing(removing) + break + class ProjectsView(QWidget): """Widget for managing project directories and previewing their manifests. @@ -662,16 +1269,7 @@ def _init_ui(self) -> None: outer.addLayout(right, stretch=1) - # Floating overlay spinner — positioned in resizeEvent self._loading_spinner = SpinnerWidget('Loading projects\u2026', parent=self) - self._loading_spinner.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - self._loading_spinner.raise_() - - # ------------------------------------------------------------------ - def resizeEvent(self, event: QResizeEvent) -> None: - """Keep the overlay spinner filling the entire view.""" - super().resizeEvent(event) - self._loading_spinner.setGeometry(self.rect()) # --- Public API --- @@ -748,7 +1346,6 @@ async def _async_refresh(self) -> None: logger.exception('Failed to refresh projects') finally: self._loading_spinner.stop() - self._loading_spinner.lower() self._sidebar.set_enabled(True) self._refresh_in_progress = False @@ -869,6 +1466,9 @@ def show(self) -> None: self._tools_view = ToolsView(self._porringer, self._config, self) self._tabs.addTab(self._tools_view, 'Tools') + # Navigate-to-project: switch to Projects tab and select directory + self._tools_view.navigate_to_project_requested.connect(self._navigate_to_project) + gear_btn = QPushButton('\u2699') gear_btn.setStyleSheet(SETTINGS_GEAR_STYLE) gear_btn.setToolTip('Settings') @@ -893,6 +1493,12 @@ def show(self) -> None: if self._projects_view is not None: self._projects_view.refresh() + def _navigate_to_project(self, path_str: str) -> None: + """Switch to the Projects tab and select the given directory.""" + if self._tabs is not None and self._projects_view is not None: + self._tabs.setCurrentIndex(0) + self._projects_view._sidebar.select(Path(path_str)) + class Screen: """Screen class for the Synodic Client application.""" diff --git a/synodic_client/application/screen/spinner.py b/synodic_client/application/screen/spinner.py index fe121dd..6600f2c 100644 --- a/synodic_client/application/screen/spinner.py +++ b/synodic_client/application/screen/spinner.py @@ -2,13 +2,18 @@ Provides :class:`SpinnerWidget` — a palette-aware spinning arc with an optional text label. Call ``start()`` to show and ``stop()`` to hide. + +When constructed with a *parent*, the spinner automatically installs +itself as a floating overlay that tracks the parent's geometry. +Consumers never need to override ``resizeEvent``, manage z-order, or +set a size policy — just call ``start()`` / ``stop()``. """ from __future__ import annotations -from PySide6.QtCore import QRect, Qt, QTimer +from PySide6.QtCore import QEvent, QRect, Qt, QTimer from PySide6.QtGui import QPainter, QPen -from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget +from PySide6.QtWidgets import QHBoxLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget _SIZE = 24 _PEN = 3 @@ -53,9 +58,11 @@ def tick(self) -> None: class SpinnerWidget(QWidget): """Animated spinner circle with optional text label. - The widget centres itself in whatever space the parent layout - provides — callers just need ``layout.addWidget(spinner)`` (with an - optional stretch factor for vertical centering in empty areas). + When a *parent* is provided the widget configures itself as a + floating overlay that fills the parent's geometry automatically. + No ``resizeEvent`` override, ``setSizePolicy``, ``raise_()``, or + ``lower()`` call is needed by the consumer — just ``start()`` and + ``stop()``. """ def __init__(self, text: str = '', parent: QWidget | None = None) -> None: @@ -63,7 +70,8 @@ def __init__(self, text: str = '', parent: QWidget | None = None) -> None: Args: text: Optional label shown beside the spinner arc. - parent: Optional parent widget. + parent: Optional parent widget. When set, the spinner + becomes a floating overlay that tracks the parent size. """ super().__init__(parent) self.hide() @@ -89,13 +97,31 @@ def __init__(self, text: str = '', parent: QWidget | None = None) -> None: outer.addLayout(row) outer.addStretch() + # Auto-overlay: track parent geometry via event filter + if parent is not None: + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + parent.installEventFilter(self) + self.setGeometry(parent.rect()) + + # -- Event filter (overlay geometry tracking) -------------------------- + + def eventFilter(self, obj: object, event: QEvent) -> bool: + """Resize to match the parent whenever it resizes.""" + if event.type() == QEvent.Type.Resize and obj is self.parent(): + self.setGeometry(self.parent().rect()) # type: ignore[union-attr] + return False + + # -- Public API -------------------------------------------------------- + def start(self) -> None: - """Show the widget and start the animation.""" + """Show the overlay and start the animation.""" + self.raise_() self.show() self._canvas._angle = 0 self._timer.start() def stop(self) -> None: - """Stop the animation and hide the widget.""" + """Stop the animation, hide, and move below siblings.""" self._timer.stop() self.hide() + self.lower() diff --git a/synodic_client/application/screen/tray.py b/synodic_client/application/screen/tray.py index 969c8b4..90bbcbf 100644 --- a/synodic_client/application/screen/tray.py +++ b/synodic_client/application/screen/tray.py @@ -17,7 +17,13 @@ from synodic_client.application.icon import app_icon from synodic_client.application.screen.screen import MainWindow from synodic_client.application.screen.settings import SettingsWindow -from synodic_client.application.workers import check_for_update, download_update, run_tool_updates +from synodic_client.application.workers import ( + ToolUpdateResult, + check_for_update, + download_update, + run_package_remove, + run_tool_updates, +) from synodic_client.client import Client from synodic_client.resolution import ( ResolvedConfig, @@ -86,6 +92,8 @@ def __init__( if tools_view is not None: tools_view.update_all_requested.connect(self._on_tool_update) tools_view.plugin_update_requested.connect(self._on_single_plugin_update) + tools_view.package_update_requested.connect(self._on_single_package_update) + tools_view.package_remove_requested.connect(self._on_single_package_remove) # Connect update banner signals self._banner = window.update_banner @@ -333,12 +341,12 @@ def _list_plugins() -> list[PluginInfo]: ) try: - count = await run_tool_updates( + result = await run_tool_updates( porringer, plugins=enabled_plugins, include_packages=include_packages, ) - self._on_tool_update_finished(count) + self._on_tool_update_finished(result) except Exception as exc: logger.exception('Tool update failed') self._on_tool_update_error(str(exc)) @@ -351,6 +359,9 @@ def _on_single_plugin_update(self, plugin_name: str) -> None: return logger.info('Starting update for plugin: %s', plugin_name) + tools_view = self._window.tools_view + if tools_view is not None: + tools_view.set_plugin_updating(plugin_name, True) self._tool_task = asyncio.create_task( self._async_single_plugin_update(porringer, plugin_name), ) @@ -369,19 +380,85 @@ async def _async_single_plugin_update(self, porringer: API, plugin_name: str) -> include_packages = enabled_pkgs try: - count = await run_tool_updates( + result = await run_tool_updates( porringer, plugins={plugin_name}, include_packages=include_packages, ) - self._on_tool_update_finished(count) + self._on_tool_update_finished(result, updating_plugin=plugin_name) except Exception as exc: logger.exception('Tool update failed') self._on_tool_update_error(str(exc)) + tools_view = self._window.tools_view + if tools_view is not None: + tools_view.set_plugin_updating(plugin_name, False) + + def _on_single_package_update(self, plugin_name: str, package_name: str) -> None: + """Upgrade a single package managed by *plugin_name*.""" + porringer = self._window.porringer + if porringer is None: + logger.warning('Single package update skipped: porringer not available') + return + + logger.info('Starting update for %s/%s', plugin_name, package_name) + tools_view = self._window.tools_view + if tools_view is not None: + tools_view.set_package_updating(plugin_name, package_name, True) + self._tool_task = asyncio.create_task( + self._async_single_package_update(porringer, plugin_name, package_name), + ) + + async def _async_single_package_update( + self, + porringer: API, + plugin_name: str, + package_name: str, + ) -> None: + """Run a single-package tool update and route results.""" + try: + result = await run_tool_updates( + porringer, + plugins={plugin_name}, + include_packages={package_name}, + ) + self._on_tool_update_finished( + result, + updating_package=(plugin_name, package_name), + ) + except Exception as exc: + logger.exception('Package update failed') + self._on_tool_update_error(str(exc)) + tools_view = self._window.tools_view + if tools_view is not None: + tools_view.set_package_updating(plugin_name, package_name, False) - def _on_tool_update_finished(self, count: int) -> None: + def _on_tool_update_finished( + self, + result: ToolUpdateResult, + *, + updating_plugin: str | None = None, + updating_package: tuple[str, str] | None = None, + ) -> None: """Handle tool update completion.""" - logger.info('Tool update completed: %d manifest(s) processed', count) + logger.info( + 'Tool update completed: %d manifest(s), %d updated, %d already latest, %d failed', + result.manifests_processed, + result.updated, + result.already_latest, + result.failed, + ) + + # Clear updating state on widgets + tools_view = self._window.tools_view + if tools_view is not None: + if updating_plugin is not None: + tools_view.set_plugin_updating(updating_plugin, False) + if updating_package is not None: + tools_view.set_package_updating(*updating_package, False) + # Refresh to pick up version changes and re-detect updates + tools_view._updates_checked = False + tools_view.refresh() + self._window.show() def _on_tool_update_error(self, error: str) -> None: @@ -393,6 +470,61 @@ def _on_tool_update_error(self, error: str) -> None: QSystemTrayIcon.MessageIcon.Warning, ) + # -- Package removal -- + + def _on_single_package_remove(self, plugin_name: str, package_name: str) -> None: + """Remove a single global package managed by *plugin_name*.""" + porringer = self._window.porringer + if porringer is None: + logger.warning('Package remove skipped: porringer not available') + return + + logger.info('Starting removal for %s/%s', plugin_name, package_name) + tools_view = self._window.tools_view + if tools_view is not None: + tools_view.set_package_removing(plugin_name, package_name, True) + self._tool_task = asyncio.create_task( + self._async_single_package_remove(porringer, plugin_name, package_name), + ) + + async def _async_single_package_remove( + self, + porringer: API, + plugin_name: str, + package_name: str, + ) -> None: + """Run a single-package removal and route results.""" + try: + result = await run_package_remove(porringer, plugin_name, package_name) + self._on_package_remove_finished(result, plugin_name, package_name) + except Exception as exc: + logger.exception('Package removal failed') + self.tray.showMessage( + 'Package Removal Error', + f'Failed to remove {package_name}: {exc}', + QSystemTrayIcon.MessageIcon.Warning, + ) + tools_view = self._window.tools_view + if tools_view is not None: + tools_view.set_package_removing(plugin_name, package_name, False) + + def _on_package_remove_finished( + self, + result: object, + plugin_name: str, + package_name: str, + ) -> None: + """Handle package removal completion.""" + logger.info('Package removal completed for %s/%s', plugin_name, package_name) + + tools_view = self._window.tools_view + if tools_view is not None: + tools_view.set_package_removing(plugin_name, package_name, False) + tools_view._updates_checked = False + tools_view.refresh() + + self._window.show() + # -- Self-update download & apply -- def _start_download(self, version: str) -> None: diff --git a/synodic_client/application/theme.py b/synodic_client/application/theme.py index 232c222..b4009ad 100644 --- a/synodic_client/application/theme.py +++ b/synodic_client/application/theme.py @@ -167,6 +167,9 @@ PLUGIN_ROW_GLOBAL_STYLE = 'font-size: 11px; color: #808080; font-style: italic;' """Muted italic annotation label for non-manifest (global) packages.""" +PLUGIN_ROW_HOST_STYLE = 'font-size: 11px; color: #808080;' +"""Host-tool annotation label (e.g. "→ pdm") for injected packages.""" + PLUGIN_ROW_TOGGLE_STYLE = ( 'QPushButton { padding: 1px 4px; border: 1px solid palette(mid); border-radius: 2px;' ' font-size: 10px; min-width: 36px; max-width: 36px; }' @@ -176,6 +179,22 @@ ) """Small inline auto-update toggle for individual package rows.""" +PLUGIN_ROW_UPDATE_STYLE = ( + 'QPushButton { padding: 1px 4px; border: 1px solid palette(mid); border-radius: 2px;' + ' font-size: 10px; min-width: 48px; max-width: 60px; }' + 'QPushButton:disabled { color: palette(mid); border-color: palette(mid); background: transparent; }' +) +"""Small inline update button for individual package rows.""" + +PLUGIN_ROW_REMOVE_STYLE = ( + 'QPushButton { border: none; font-size: 12px; color: #808080;' + ' padding: 0px 2px; min-width: 18px; max-width: 18px; }' + 'QPushButton:hover { color: #f48771; }' + 'QPushButton:pressed { color: #d4d4d4; }' + 'QPushButton:disabled { color: palette(mid); }' +) +"""Small inline remove (×) button for individual package rows.""" + PLUGIN_ROW_SPACING = 1 """Pixels between individual tool/package rows.""" diff --git a/synodic_client/application/workers.py b/synodic_client/application/workers.py index 4540bb4..c3f7fde 100644 --- a/synodic_client/application/workers.py +++ b/synodic_client/application/workers.py @@ -9,10 +9,13 @@ import asyncio import logging from collections.abc import Callable +from dataclasses import dataclass, field from pathlib import Path from porringer.api import API -from porringer.schema import SetupParameters, SyncStrategy +from porringer.core.schema import PackageRef +from porringer.schema import ProgressEventKind, SetupParameters, SkipReason, SyncStrategy +from porringer.schema.execution import SetupActionResult from synodic_client.client import Client from synodic_client.updater import UpdateInfo @@ -60,11 +63,23 @@ def progress_callback(percentage: int) -> None: return await loop.run_in_executor(None, _run) +@dataclass(slots=True) +class ToolUpdateResult: + """Summary of a tool-update run across cached manifests.""" + + manifests_processed: int = 0 + updated: int = 0 + already_latest: int = 0 + failed: int = 0 + updated_packages: set[str] = field(default_factory=set) + """Package names that were successfully upgraded.""" + + async def run_tool_updates( porringer: API, plugins: set[str] | None = None, include_packages: set[str] | None = None, -) -> int: +) -> ToolUpdateResult: """Re-sync all cached project manifests. Args: @@ -77,19 +92,25 @@ async def run_tool_updates( executed. ``None`` means all packages. Returns: - Number of manifests processed. + A :class:`ToolUpdateResult` summarising the run. """ loop = asyncio.get_running_loop() directories = await loop.run_in_executor(None, porringer.cache.list_directories) # Check all directories for manifests in parallel paths = [Path(d.path) for d in directories] - has_results = await asyncio.gather( - *(loop.run_in_executor(None, porringer.sync.has_manifest, p) for p in paths), - ) + has_map: dict[Path, bool] = {} - count = 0 - for path, has in zip(paths, has_results, strict=True): + async def _check_manifest(p: Path) -> None: + has_map[p] = await loop.run_in_executor(None, porringer.sync.has_manifest, p) + + async with asyncio.TaskGroup() as tg: + for p in paths: + tg.create_task(_check_manifest(p)) + + result = ToolUpdateResult() + for path in paths: + has = has_map[path] if not has: logger.debug('Skipping path without manifest: %s', path) continue @@ -100,7 +121,43 @@ async def run_tool_updates( plugins=plugins, include_packages=include_packages, ) - async for _event in porringer.sync.execute_stream(params): - pass # consume events to completion - count += 1 - return count + async for event in porringer.sync.execute_stream(params): + if event.kind == ProgressEventKind.ACTION_COMPLETED and event.result is not None: + action_result = event.result + if action_result.skipped: + if ( + action_result.skip_reason == SkipReason.ALREADY_LATEST + or action_result.skip_reason == 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 + result.manifests_processed += 1 + return result + + +async def run_package_remove( + porringer: API, + plugin_name: str, + package_name: str, +) -> SetupActionResult: + """Uninstall a single package off the main thread. + + Args: + porringer: The porringer API instance. + plugin_name: The installer plugin name (e.g. ``"pipx"``). + package_name: The package to remove. + + Returns: + A :class:`SetupActionResult` describing the outcome. + """ + loop = asyncio.get_running_loop() + package_ref = PackageRef(name=package_name) + return await loop.run_in_executor( + None, + lambda: porringer.uninstall(plugin_name, package_ref), + ) diff --git a/tests/unit/qt/test_gather_packages.py b/tests/unit/qt/test_gather_packages.py new file mode 100644 index 0000000..c911164 --- /dev/null +++ b/tests/unit/qt/test_gather_packages.py @@ -0,0 +1,308 @@ +"""Tests for ToolsView._gather_packages global + per-directory queries.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +from porringer.core.schema import Package, PackageRelation, PackageRelationKind +from porringer.schema import ManifestDirectory + +from synodic_client.application.screen.screen import PackageEntry, ToolsView +from synodic_client.resolution import ResolvedConfig + + +def _make_config() -> ResolvedConfig: + """Build a minimal ResolvedConfig for tests.""" + return ResolvedConfig( + update_source=None, + update_channel='stable', + auto_update_interval_minutes=60, + tool_update_interval_minutes=60, + plugin_auto_update=None, + detect_updates=False, + prerelease_packages=None, + auto_start=False, + ) + + +def _make_porringer() -> MagicMock: + """Build a MagicMock standing in for the porringer API.""" + mock = MagicMock() + mock.plugin.list.return_value = [] + mock.plugin.list_packages = AsyncMock(return_value=[]) + mock.cache.list_directories.return_value = [] + return mock + + +# --------------------------------------------------------------------------- +# _gather_packages +# --------------------------------------------------------------------------- + + +class TestGatherPackages: + """Verify that _gather_packages issues a global query alongside per-directory ones.""" + + @staticmethod + def test_global_query_returns_packages_with_no_directories() -> None: + """Packages from the global query appear even when no directories are cached.""" + porringer = _make_porringer() + porringer.plugin.list_packages = AsyncMock( + return_value=[ + Package(name='pdm', version='2.22.4'), + Package(name='cppython', version='0.5.0'), + ], + ) + + view = ToolsView(porringer, _make_config()) + result = asyncio.run(view._gather_packages('pipx', [])) + + names = {e.name for e in result} + + @staticmethod + def test_global_query_returns_packages_with_empty_project_path() -> None: + """Packages from the global query should have an empty project_path.""" + porringer = _make_porringer() + porringer.plugin.list_packages = AsyncMock( + return_value=[ + Package(name='pdm', version='2.22.4'), + ], + ) + + view = ToolsView(porringer, _make_config()) + result = asyncio.run(view._gather_packages('pipx', [])) + + matching = [e for e in result if e.name == 'pdm'] + assert len(matching) == 1 + assert matching[0].project_path == '', 'global packages should have empty project_path' + + @staticmethod + def test_global_query_called_without_project_path() -> None: + """The global query must call list_packages with no project_path arg.""" + porringer = _make_porringer() + porringer.plugin.list_packages = AsyncMock(return_value=[]) + + view = ToolsView(porringer, _make_config()) + asyncio.run(view._gather_packages('pipx', [])) + + # At least one call should have been made with only plugin_name (no path) + calls = porringer.plugin.list_packages.call_args_list + global_calls = [c for c in calls if len(c.args) == 1 or (len(c.args) >= 2 and c.args[1] is None)] + assert len(global_calls) >= 1, f'Expected a global call (no project_path), got: {calls}' + + @staticmethod + def test_per_directory_queries_still_work() -> None: + """Per-directory queries continue to run alongside the global query.""" + porringer = _make_porringer() + + async def _mock_list(plugin_name: str, project_path: Path | None = None) -> list[Package]: + if project_path is None: + return [Package(name='pdm', version='2.22.4')] + return [Package(name='mylib', version='1.0.0')] + + porringer.plugin.list_packages = AsyncMock(side_effect=_mock_list) + + directory = ManifestDirectory(path=Path('/fake/project')) + view = ToolsView(porringer, _make_config()) + result = asyncio.run(view._gather_packages('pipx', [directory])) + + names = {entry.name for entry in result} + assert 'pdm' in names, 'global package should be present' + assert 'mylib' in names, 'per-directory package should be present' + + @staticmethod + def test_per_directory_packages_carry_project_path() -> None: + """Per-directory packages should include the directory path as project_path.""" + porringer = _make_porringer() + + async def _mock_list(plugin_name: str, project_path: Path | None = None) -> list[Package]: + if project_path is None: + return [] + return [Package(name='mylib', version='1.0.0')] + + porringer.plugin.list_packages = AsyncMock(side_effect=_mock_list) + + directory = ManifestDirectory(path=Path('/fake/project')) + view = ToolsView(porringer, _make_config()) + result = asyncio.run(view._gather_packages('pipx', [directory])) + + matching = [e for e in result if e.name == 'mylib'] + assert len(matching) == 1 + assert matching[0].project_path == str(Path('/fake/project')), 'per-directory package should carry project path' + + @staticmethod + def test_global_packages_have_empty_project_label() -> None: + """Packages from the global query should have an empty project label.""" + porringer = _make_porringer() + porringer.plugin.list_packages = AsyncMock( + return_value=[Package(name='cppython', version='0.5.0')], + ) + + view = ToolsView(porringer, _make_config()) + result = asyncio.run(view._gather_packages('pipx', [])) + + matching = [e for e in result if e.name == 'cppython'] + assert len(matching) == 1 + assert matching[0].project_label == '', 'global packages should have empty project label' + assert matching[0].version == '0.5.0' + + @staticmethod + def test_global_query_failure_does_not_block_directory_queries() -> None: + """If the global query fails, per-directory results still come through.""" + porringer = _make_porringer() + call_count = 0 + + async def _mock_list(plugin_name: str, project_path: Path | None = None) -> list[Package]: + nonlocal call_count + call_count += 1 + if project_path is None: + raise RuntimeError('global query failed') + return [Package(name='django', version='5.0')] + + porringer.plugin.list_packages = AsyncMock(side_effect=_mock_list) + + directory = ManifestDirectory(path=Path('/fake/project')) + view = ToolsView(porringer, _make_config()) + result = asyncio.run(view._gather_packages('pipx', [directory])) + + names = {entry.name for entry in result} + assert 'django' in names + assert call_count == 2 # one global + one directory + + @staticmethod + def test_relation_host_extracted_into_host_tool() -> None: + """Packages with a PackageRelation populate the host_tool element.""" + porringer = _make_porringer() + porringer.plugin.list_packages = AsyncMock( + return_value=[ + Package( + name='cppython', + version='0.5.0', + relation=PackageRelation( + host='pdm', + kind=PackageRelationKind.INJECTED, + ), + ), + Package(name='pdm', version='2.22.4'), + ], + ) + + view = ToolsView(porringer, _make_config()) + result = asyncio.run(view._gather_packages('pipx', [])) + + by_name = {entry.name: entry.host_tool for entry in result} + assert by_name['cppython'] == 'pdm', 'injected package should carry host' + assert by_name['pdm'] == '', 'non-injected package should have empty host' + + +# --------------------------------------------------------------------------- +# _gather_tool_plugins +# --------------------------------------------------------------------------- + + +class TestGatherToolPlugins: + """Verify that _gather_tool_plugins discovers PluginManager sub-plugins.""" + + @staticmethod + def test_returns_plugins_keyed_by_host_tool(monkeypatch) -> None: + """installed_plugins() results are keyed by tool name and returned as PackageEntry.""" + porringer = _make_porringer() + view = ToolsView(porringer, _make_config()) + + # Mock _discover_plugin_managers to return a fake manager + mock_manager = MagicMock() + mock_manager.installed_plugins = AsyncMock( + return_value=[ + Package( + name='cppython', + version='0.5.0', + relation=PackageRelation(host='pdm', kind=PackageRelationKind.PLUGIN), + ), + ], + ) + monkeypatch.setattr( + ToolsView, + '_discover_plugin_managers', + staticmethod(lambda: {'pdm': mock_manager}), + ) + + result = asyncio.run(view._gather_tool_plugins()) + + assert 'pdm' in result + assert len(result['pdm']) == 1 + entry = result['pdm'][0] + assert entry.name == 'cppython' + assert entry.project_label == '' + assert entry.version == '0.5.0' + assert entry.host_tool == 'pdm' + + @staticmethod + def test_empty_when_no_managers(monkeypatch) -> None: + """Returns empty dict when no PluginManager instances are discovered.""" + porringer = _make_porringer() + view = ToolsView(porringer, _make_config()) + monkeypatch.setattr( + ToolsView, + '_discover_plugin_managers', + staticmethod(lambda: {}), + ) + + result = asyncio.run(view._gather_tool_plugins()) + assert result == {} + + @staticmethod + def test_manager_failure_does_not_propagate(monkeypatch) -> None: + """A failing installed_plugins() call produces an empty entry, not an exception.""" + porringer = _make_porringer() + view = ToolsView(porringer, _make_config()) + + mock_manager = MagicMock() + mock_manager.installed_plugins = AsyncMock(side_effect=RuntimeError('boom')) + monkeypatch.setattr( + ToolsView, + '_discover_plugin_managers', + staticmethod(lambda: {'pdm': mock_manager}), + ) + + result = asyncio.run(view._gather_tool_plugins()) + # Should not raise; failed manager produces no entry + assert 'pdm' not in result + + @staticmethod + def test_multiple_managers(monkeypatch) -> None: + """Multiple PluginManager instances are queried in parallel.""" + porringer = _make_porringer() + view = ToolsView(porringer, _make_config()) + + mgr_pdm = MagicMock() + mgr_pdm.installed_plugins = AsyncMock( + return_value=[ + Package( + name='cppython', + version='0.5.0', + relation=PackageRelation(host='pdm', kind=PackageRelationKind.PLUGIN), + ), + ], + ) + mgr_poetry = MagicMock() + mgr_poetry.installed_plugins = AsyncMock( + return_value=[ + Package( + name='poetry-plugin-export', + version='1.8.0', + relation=PackageRelation(host='poetry', kind=PackageRelationKind.PLUGIN), + ), + ], + ) + monkeypatch.setattr( + ToolsView, + '_discover_plugin_managers', + staticmethod(lambda: {'pdm': mgr_pdm, 'poetry': mgr_poetry}), + ) + + result = asyncio.run(view._gather_tool_plugins()) + assert 'pdm' in result + assert 'poetry' in result + assert result['pdm'][0].name == 'cppython' + assert result['poetry'][0].name == 'poetry-plugin-export' diff --git a/tests/unit/qt/test_update_feedback.py b/tests/unit/qt/test_update_feedback.py new file mode 100644 index 0000000..2abe68d --- /dev/null +++ b/tests/unit/qt/test_update_feedback.py @@ -0,0 +1,379 @@ +"""Tests for update-detection and feedback on ToolsView widgets.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from packaging.version import Version +from porringer.schema import PluginInfo +from porringer.schema.plugin import PluginKind + +from synodic_client.application.screen.screen import PluginProviderHeader, PluginRow + + +def _make_plugin( + name: str = 'pipx', + kind: PluginKind = PluginKind.TOOL, + installed: bool = True, + tool_version: str | None = '1.0.0', +) -> PluginInfo: + """Build a minimal PluginInfo for tests.""" + return PluginInfo( + name=name, + kind=kind, + version=Version('0.1.0'), + installed=installed, + tool_version=Version(tool_version) if tool_version else None, + ) + + +# --------------------------------------------------------------------------- +# PluginProviderHeader +# --------------------------------------------------------------------------- + + +class TestPluginProviderHeaderUpdates: + """Tests for the update-visibility and feedback on PluginProviderHeader.""" + + @staticmethod + def test_update_button_hidden_by_default() -> None: + """Update button should be invisible when has_updates is False.""" + header = PluginProviderHeader( + _make_plugin(), + auto_update=True, + show_controls=True, + has_updates=False, + ) + assert header._update_btn is not None + assert header._update_btn.isHidden() + + @staticmethod + def test_update_button_visible_when_updates_available() -> None: + """Update button should be visible when has_updates is True.""" + header = PluginProviderHeader( + _make_plugin(), + auto_update=True, + show_controls=True, + has_updates=True, + ) + assert header._update_btn is not None + assert not header._update_btn.isHidden() + + @staticmethod + def test_set_updating_true_disables_button() -> None: + """set_updating(True) should show 'Updating…' and disable.""" + header = PluginProviderHeader( + _make_plugin(), + auto_update=True, + show_controls=True, + has_updates=True, + ) + header.set_updating(True) + assert header._update_btn is not None + assert header._update_btn.text() == 'Updating\u2026' + assert not header._update_btn.isEnabled() + + @staticmethod + def test_set_updating_false_restores_button() -> None: + """set_updating(False) should restore 'Update' and re-enable.""" + header = PluginProviderHeader( + _make_plugin(), + auto_update=True, + show_controls=True, + has_updates=True, + ) + header.set_updating(True) + header.set_updating(False) + assert header._update_btn is not None + assert header._update_btn.text() == 'Update' + assert header._update_btn.isEnabled() + + @staticmethod + def test_set_updating_noop_without_controls() -> None: + """set_updating should be a no-op when controls are not shown.""" + header = PluginProviderHeader( + _make_plugin(), + auto_update=True, + show_controls=False, + ) + # Should not raise + header.set_updating(True) + assert header._update_btn is None + + @staticmethod + def test_update_requested_signal() -> None: + """Clicking the update button emits update_requested(plugin_name).""" + header = PluginProviderHeader( + _make_plugin(name='uv'), + auto_update=True, + show_controls=True, + has_updates=True, + ) + spy = MagicMock() + header.update_requested.connect(spy) + assert header._update_btn is not None + header._update_btn.click() + spy.assert_called_once_with('uv') + + @staticmethod + def test_set_checking_shows_spinner() -> None: + """set_checking(True) starts the inline spinner and hides update button.""" + header = PluginProviderHeader( + _make_plugin(), + auto_update=True, + show_controls=True, + has_updates=True, + ) + header.set_checking(True) + assert header._checking_spinner is not None + assert not header._checking_spinner.isHidden() + assert header._update_btn is not None + assert header._update_btn.isHidden() + + @staticmethod + def test_set_checking_false_hides_spinner() -> None: + """set_checking(False) stops the spinner.""" + header = PluginProviderHeader( + _make_plugin(), + auto_update=True, + show_controls=True, + has_updates=True, + ) + header.set_checking(True) + header.set_checking(False) + assert header._checking_spinner is not None + assert header._checking_spinner.isHidden() + + @staticmethod + def test_set_checking_noop_without_controls() -> None: + """set_checking is a no-op when controls are not shown.""" + header = PluginProviderHeader( + _make_plugin(), + auto_update=True, + show_controls=False, + ) + header.set_checking(True) + assert header._checking_spinner is None + + +# --------------------------------------------------------------------------- +# PluginRow +# --------------------------------------------------------------------------- + + +class TestPluginRowUpdates: + """Tests for the per-package update button on PluginRow.""" + + @staticmethod + def test_no_update_button_by_default() -> None: + """With has_update=False the row has no update button.""" + row = PluginRow('pdm', plugin_name='pipx', show_toggle=True) + assert row._update_btn is None + + @staticmethod + def test_update_button_visible_when_has_update() -> None: + """With has_update=True the row shows an inline update button.""" + row = PluginRow( + 'pdm', + plugin_name='pipx', + show_toggle=True, + has_update=True, + ) + assert row._update_btn is not None + assert not row._update_btn.isHidden() + + @staticmethod + def test_set_updating_true_disables() -> None: + """set_updating(True) shows 'Updating…' and disables.""" + row = PluginRow( + 'pdm', + plugin_name='pipx', + show_toggle=True, + has_update=True, + ) + row.set_updating(True) + assert row._update_btn is not None + assert row._update_btn.text() == 'Updating\u2026' + assert not row._update_btn.isEnabled() + + @staticmethod + def test_set_updating_false_restores() -> None: + """set_updating(False) restores 'Update' and re-enables.""" + row = PluginRow( + 'pdm', + plugin_name='pipx', + show_toggle=True, + has_update=True, + ) + row.set_updating(True) + row.set_updating(False) + assert row._update_btn is not None + assert row._update_btn.text() == 'Update' + assert row._update_btn.isEnabled() + + @staticmethod + def test_update_requested_signal() -> None: + """Clicking update emits update_requested(plugin_name, package_name).""" + row = PluginRow( + 'pdm', + plugin_name='pipx', + show_toggle=True, + has_update=True, + ) + spy = MagicMock() + row.update_requested.connect(spy) + assert row._update_btn is not None + row._update_btn.click() + spy.assert_called_once_with('pipx', 'pdm') + + @staticmethod + def test_set_updating_noop_without_button() -> None: + """set_updating is a no-op when no update button exists.""" + row = PluginRow('pdm', plugin_name='pipx', show_toggle=True) + # Should not raise + row.set_updating(True) + assert row._update_btn is None + + @staticmethod + def test_set_checking_shows_spinner() -> None: + """set_checking(True) starts the inline spinner and hides update button.""" + row = PluginRow( + 'pdm', + plugin_name='pipx', + show_toggle=True, + has_update=True, + ) + row.set_checking(True) + assert row._checking_spinner is not None + assert not row._checking_spinner.isHidden() + assert row._update_btn is not None + assert row._update_btn.isHidden() + + @staticmethod + def test_set_checking_false_hides_spinner() -> None: + """set_checking(False) stops the spinner.""" + row = PluginRow( + 'pdm', + plugin_name='pipx', + show_toggle=True, + has_update=True, + ) + row.set_checking(True) + row.set_checking(False) + assert row._checking_spinner is not None + assert row._checking_spinner.isHidden() + + @staticmethod + def test_set_checking_noop_without_toggle() -> None: + """set_checking is a no-op when show_toggle is False (no spinner created).""" + row = PluginRow('pdm', plugin_name='pipx') + row.set_checking(True) + assert row._checking_spinner is None + + @staticmethod + def test_host_tool_label_shown_when_set() -> None: + """A host_tool value adds a '\u2192 ' label after the name.""" + row = PluginRow('cppython', plugin_name='pipx', host_tool='pdm') + assert row._host_label is not None + assert row._host_label.text() == '\u2192 pdm' + assert not row._host_label.isHidden() + + @staticmethod + def test_host_tool_label_absent_when_empty() -> None: + """No host label is created when host_tool is empty.""" + row = PluginRow('pdm', plugin_name='pipx') + assert row._host_label is None + + +# --------------------------------------------------------------------------- +# PluginRow — remove button +# --------------------------------------------------------------------------- + + +class TestPluginRowRemove: + """Tests for the per-package remove button on PluginRow.""" + + @staticmethod + def test_remove_button_present() -> None: + """A remove button is always created on PluginRow.""" + row = PluginRow('pdm', plugin_name='pipx', is_global=True) + assert row._remove_btn is not None + + @staticmethod + def test_remove_button_enabled_for_global() -> None: + """The remove button is enabled when is_global=True.""" + row = PluginRow('pdm', plugin_name='pipx', is_global=True) + assert row._remove_btn is not None + assert row._remove_btn.isEnabled() + + @staticmethod + def test_remove_button_disabled_for_manifest() -> None: + """The remove button is disabled when is_global=False (manifest-referenced).""" + row = PluginRow('pdm', plugin_name='pipx', is_global=False, project='myproject') + assert row._remove_btn is not None + assert not row._remove_btn.isEnabled() + + @staticmethod + def test_remove_button_tooltip_global() -> None: + """Tooltip for global packages says 'Remove '.""" + row = PluginRow('pdm', plugin_name='pipx', is_global=True) + assert row._remove_btn is not None + assert 'Remove pdm' in row._remove_btn.toolTip() + + @staticmethod + def test_remove_button_tooltip_manifest() -> None: + """Tooltip for manifest packages mentions the project name.""" + row = PluginRow('pdm', plugin_name='pipx', is_global=False, project='myproject') + assert row._remove_btn is not None + assert 'myproject' in row._remove_btn.toolTip() + + @staticmethod + def test_remove_requested_signal() -> None: + """Clicking remove emits remove_requested(plugin_name, package_name).""" + row = PluginRow('pdm', plugin_name='pipx', is_global=True) + spy = MagicMock() + row.remove_requested.connect(spy) + assert row._remove_btn is not None + row._remove_btn.click() + spy.assert_called_once_with('pipx', 'pdm') + + @staticmethod + def test_remove_signal_not_emitted_when_disabled() -> None: + """Clicking a disabled remove button does not emit remove_requested.""" + row = PluginRow('pdm', plugin_name='pipx', is_global=False, project='myproject') + spy = MagicMock() + row.remove_requested.connect(spy) + assert row._remove_btn is not None + row._remove_btn.click() + spy.assert_not_called() + + @staticmethod + def test_set_removing_true() -> None: + """set_removing(True) shows 'Removing\u2026' and disables.""" + row = PluginRow('pdm', plugin_name='pipx', is_global=True) + row.set_removing(True) + assert row._remove_btn is not None + assert row._remove_btn.text() == 'Removing\u2026' + assert not row._remove_btn.isEnabled() + + @staticmethod + def test_set_removing_false() -> None: + """set_removing(False) restores '\u00d7' and re-enables.""" + row = PluginRow('pdm', plugin_name='pipx', is_global=True) + row.set_removing(True) + row.set_removing(False) + assert row._remove_btn is not None + assert row._remove_btn.text() == '\u00d7' + assert row._remove_btn.isEnabled() + + @staticmethod + def test_project_paths_stored() -> None: + """PluginRow stores project_paths for navigation.""" + row = PluginRow( + 'pdm', + plugin_name='pipx', + is_global=False, + project='myproject', + project_paths=['/fake/project'], + ) + assert row._project_paths == ['/fake/project'] diff --git a/tests/unit/test_workers.py b/tests/unit/test_workers.py new file mode 100644 index 0000000..e29ef1f --- /dev/null +++ b/tests/unit/test_workers.py @@ -0,0 +1,50 @@ +"""Tests for ToolUpdateResult dataclass in workers module.""" + +from __future__ import annotations + +from synodic_client.application.workers import ToolUpdateResult + + +class TestToolUpdateResult: + """Tests for the ToolUpdateResult dataclass.""" + + @staticmethod + def test_defaults() -> None: + """Verify all fields start at zero / empty.""" + result = ToolUpdateResult() + assert result.manifests_processed == 0 + assert result.updated == 0 + assert result.already_latest == 0 + assert result.failed == 0 + assert result.updated_packages == set() + + @staticmethod + def test_fields_are_assignable() -> None: + """Verify fields can be set via constructor.""" + result = ToolUpdateResult( + manifests_processed=3, + updated=2, + already_latest=1, + failed=0, + updated_packages={'pdm', 'ruff'}, + ) + assert result.manifests_processed == 3 + assert result.updated == 2 + assert result.already_latest == 1 + assert result.failed == 0 + assert result.updated_packages == {'pdm', 'ruff'} + + @staticmethod + def test_updated_packages_mutation() -> None: + """Verify updated_packages is a mutable set.""" + result = ToolUpdateResult() + 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.updated_packages.add('foo') + assert 'foo' not in b.updated_packages From f65fbab1cfd411c4fef7c8cc79b5269669300e6b Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Sat, 28 Feb 2026 00:34:49 -0800 Subject: [PATCH 04/10] Fix Injection Readout --- AGENTS.md | 2 +- synodic_client/application/screen/screen.py | 599 +++++++++++--------- synodic_client/application/workers.py | 8 +- synodic_client/resolution.py | 64 ++- tests/unit/qt/test_gather_packages.py | 23 +- tests/unit/qt/test_update_feedback.py | 102 ++-- tests/unit/test_workers.py | 25 +- 7 files changed, 477 insertions(+), 346 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5b88636..06b1b6d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,3 @@ # AGENTS.md -This repository doesn't contain any agent specific instructions other than its [README.md](README.md) and its linked resources. +This repository doesn't contain any agent specific instructions other than its [README.md](README.md), required development documentation, and its linked resources. diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 4beefce..26a1512 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -7,6 +7,9 @@ from pathlib import Path from porringer.api import API +from porringer.backend.builder import Builder +from porringer.core.plugin_schema.plugin_manager import PluginManager +from porringer.core.plugin_schema.project_environment import ProjectEnvironment from porringer.schema import ( DirectoryValidationResult, ManifestDirectory, @@ -74,6 +77,7 @@ _ROW_SPINNER_PEN = 2 _ROW_SPINNER_INTERVAL = 50 _ROW_SPINNER_ARC = 90 +_FULL_CIRCLE_DEG = 360 # Preferred display ordering — Tools first, then alphabetical for the rest. _KIND_DISPLAY_ORDER: dict[PluginKind, int] = { @@ -139,6 +143,59 @@ class MergedPackage: """Host-tool annotation for injected packages.""" +@dataclass(slots=True) +class PluginRowData: + """Bundled display data for constructing a :class:`PluginRow`. + + Groups the many display parameters into a single object + to keep the constructor signature concise. + """ + + name: str + """Package or tool name.""" + + project: str = '' + """Comma-separated project labels, or empty for global / bare rows.""" + + version: str = '' + """Installed version string.""" + + plugin_name: str = '' + """Name of the managing plugin (e.g. ``"pipx"``).""" + + auto_update: bool = False + """Current per-package auto-update toggle state.""" + + show_toggle: bool = False + """Whether to show the inline *Auto* toggle button.""" + + has_update: bool = False + """Whether an update is available for this package.""" + + is_global: bool = False + """``True`` when the package is globally installed.""" + + host_tool: str = '' + """Host-tool name for injected packages.""" + + project_paths: list[str] = field(default_factory=list) + """Filesystem paths for project-scoped packages.""" + + +@dataclass(slots=True) +class _RefreshData: + """Internal data bundle returned by :meth:`ToolsView._gather_refresh_data`.""" + + plugins: list[PluginInfo] + """All discovered plugins.""" + + packages_map: dict[str, list[PackageEntry]] + """Mapping of plugin name → gathered packages.""" + + manifest_packages: dict[str, set[str]] + """Mapping of plugin name → manifest-referenced package names.""" + + # --------------------------------------------------------------------------- # _RowSpinner — tiny inline spinner for plugin rows # --------------------------------------------------------------------------- @@ -162,11 +219,11 @@ def paintEvent(self, _event: object) -> None: painter.setRenderHint(QPainter.RenderHint.Antialiasing) m = _ROW_SPINNER_PEN // 2 + 1 rect = QRect(m, m, _ROW_SPINNER_SIZE - 2 * m, _ROW_SPINNER_SIZE - 2 * m) - for colour, span in ((self.palette().mid(), 360), (self.palette().highlight(), _ROW_SPINNER_ARC)): + for colour, span in ((self.palette().mid(), _FULL_CIRCLE_DEG), (self.palette().highlight(), _ROW_SPINNER_ARC)): pen = QPen(colour, _ROW_SPINNER_PEN) pen.setCapStyle(Qt.PenCapStyle.RoundCap) painter.setPen(pen) - if span == 360: + if span == _FULL_CIRCLE_DEG: painter.drawEllipse(rect) else: painter.drawArc(rect, self._angle * 16, span * 16) @@ -201,6 +258,7 @@ class PluginKindHeader(QLabel): """ def __init__(self, kind: PluginKind, parent: QWidget | None = None) -> None: + """Initialize the kind header with an uppercase label.""" super().__init__(plugin_kind_group_label(kind).upper(), parent) self.setObjectName('pluginKindHeader') self.setStyleSheet(PLUGIN_KIND_HEADER_STYLE) @@ -234,6 +292,7 @@ def __init__( has_updates: bool = False, parent: QWidget | None = None, ) -> None: + """Initialize the provider header with plugin info and optional controls.""" super().__init__(parent) self.setObjectName('pluginProvider') self.setStyleSheet(PLUGIN_PROVIDER_STYLE) @@ -363,100 +422,108 @@ class PluginRow(QFrame): def __init__( self, - name: str, - project: str = '', - version: str = '', + data: PluginRowData, *, - plugin_name: str = '', - auto_update: bool = False, - show_toggle: bool = False, - has_update: bool = False, - is_global: bool = False, - host_tool: str = '', - project_paths: list[str] | None = None, parent: QWidget | None = None, ) -> None: + """Initialize a plugin row from bundled display data.""" super().__init__(parent) self.setObjectName('pluginRow') self.setStyleSheet(PLUGIN_ROW_STYLE) - self._plugin_name = plugin_name - self._package_name = name + self._plugin_name = data.plugin_name + self._package_name = data.name self._update_btn: QPushButton | None = None self._remove_btn: QPushButton | None = None self._checking_spinner: _RowSpinner | None = None self._host_label: QLabel | None = None - self._project_paths: list[str] = project_paths or [] + self._project_paths: list[str] = list(data.project_paths) layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(10) - name_label = QLabel(name) + self._build_name_section(layout, data) + layout.addStretch() + self._build_controls(layout, data) + + # --- PluginRow construction helpers --- + + def _build_name_section(self, layout: QHBoxLayout, data: PluginRowData) -> None: + """Add the name, optional host-tool arrow, and project/global labels.""" + name_label = QLabel(data.name) name_label.setStyleSheet(PLUGIN_ROW_NAME_STYLE) layout.addWidget(name_label) - if host_tool: - self._host_label = QLabel(f'\u2192 {host_tool}') + if data.host_tool: + self._host_label = QLabel(f'\u2192 {data.host_tool}') self._host_label.setStyleSheet(PLUGIN_ROW_HOST_STYLE) layout.addWidget(self._host_label) - if project: - project_label = QLabel(project) + if data.project: + project_label = QLabel(data.project) project_label.setStyleSheet(PLUGIN_ROW_PROJECT_STYLE) layout.addWidget(project_label) - elif is_global: + elif data.is_global: global_label = QLabel('(global)') global_label.setStyleSheet(PLUGIN_ROW_GLOBAL_STYLE) layout.addWidget(global_label) - layout.addStretch() - - if show_toggle: - toggle_btn = QPushButton('Auto') - toggle_btn.setCheckable(True) - toggle_btn.setChecked(auto_update) - toggle_btn.setStyleSheet(PLUGIN_ROW_TOGGLE_STYLE) - toggle_btn.setToolTip('Auto-update this package') - toggle_btn.clicked.connect( - lambda checked: self.auto_update_toggled.emit( - self._plugin_name, - self._package_name, - checked, - ), - ) - layout.addWidget(toggle_btn) - - self._checking_spinner = _RowSpinner(self) - layout.addWidget(self._checking_spinner) - - if has_update: - update_btn = QPushButton('Update') - update_btn.setStyleSheet(PLUGIN_ROW_UPDATE_STYLE) - update_btn.setToolTip(f'Update {name}') - update_btn.clicked.connect( - lambda: self.update_requested.emit(self._plugin_name, self._package_name), - ) - self._update_btn = update_btn - layout.addWidget(update_btn) - - if version: - version_label = QLabel(version) + def _build_controls(self, layout: QHBoxLayout, data: PluginRowData) -> None: + """Add toggle, update, version, and remove controls.""" + if data.show_toggle: + self._build_toggle(layout, data) + if data.has_update: + self._build_update_button(layout, data) + if data.version: + version_label = QLabel(data.version) version_label.setStyleSheet(PLUGIN_ROW_VERSION_STYLE) layout.addWidget(version_label) + self._build_remove_button(layout, data) + + def _build_toggle(self, layout: QHBoxLayout, data: PluginRowData) -> None: + """Add the auto-update toggle and inline checking spinner.""" + toggle_btn = QPushButton('Auto') + toggle_btn.setCheckable(True) + toggle_btn.setChecked(data.auto_update) + toggle_btn.setStyleSheet(PLUGIN_ROW_TOGGLE_STYLE) + toggle_btn.setToolTip('Auto-update this package') + toggle_btn.clicked.connect( + lambda checked: self.auto_update_toggled.emit( + self._plugin_name, + self._package_name, + checked, + ), + ) + layout.addWidget(toggle_btn) - # Remove button — always present, enabled only for global packages + self._checking_spinner = _RowSpinner(self) + layout.addWidget(self._checking_spinner) + + def _build_update_button(self, layout: QHBoxLayout, data: PluginRowData) -> None: + """Add the per-package update button.""" + update_btn = QPushButton('Update') + update_btn.setStyleSheet(PLUGIN_ROW_UPDATE_STYLE) + update_btn.setToolTip(f'Update {data.name}') + update_btn.clicked.connect( + lambda: self.update_requested.emit(self._plugin_name, self._package_name), + ) + self._update_btn = update_btn + layout.addWidget(update_btn) + + def _build_remove_button(self, layout: QHBoxLayout, data: PluginRowData) -> None: + """Add the remove button — enabled only for global packages.""" remove_btn = QPushButton('\u00d7') remove_btn.setFixedSize(18, 18) remove_btn.setStyleSheet(PLUGIN_ROW_REMOVE_STYLE) remove_btn.setCursor(Qt.CursorShape.PointingHandCursor) - if is_global: - remove_btn.setToolTip(f'Remove {name}') + if data.is_global: + remove_btn.setToolTip(f'Remove {data.name}') remove_btn.clicked.connect( lambda: self.remove_requested.emit(self._plugin_name, self._package_name), ) else: remove_btn.setEnabled(False) - tooltip = f"Managed by project '{project}'" if project else 'Managed by a project manifest' + tooltip = f"Managed by project '{data.project}'" if data.project else 'Managed by a project manifest' remove_btn.setToolTip(tooltip) remove_btn.setCursor(Qt.CursorShape.ArrowCursor) self._remove_btn = remove_btn @@ -591,196 +658,18 @@ def refresh(self) -> None: async def _async_refresh(self) -> None: """Rebuild the tool list from porringer data. - Walks every cached project manifest to determine which packages - are *manifest-referenced* vs. *global*. Manifest packages - default to auto-update **on**; global packages default to **off**. - Per-package toggles honour the nested-dict config shape. - - Package listing and manifest requirement gathering run in - parallel via ``asyncio.TaskGroup`` to avoid O(P×D) serial - round-trips. Update-availability detection is deferred to a + Fetches plugins and packages in parallel, then builds the + widget tree. Update-availability detection is deferred to a background task so the widget tree renders immediately. """ self._refresh_in_progress = True self._loading_spinner.start() - directories: list[ManifestDirectory] = [] + need_deferred_check = False try: - loop = asyncio.get_running_loop() - plugins, directories = await loop.run_in_executor( - None, - self._fetch_data, - ) - self._directories = directories - - # Gather packages and manifest requirements — in parallel - updatable_plugins = [p for p in plugins if p.kind in _UPDATABLE_KINDS] - packages_map: dict[str, list[tuple[str, str, str, str]]] = {} - manifest_packages: dict[str, set[str]] = {} - requirement_actions: list[list[SetupAction]] = [] - - async with asyncio.TaskGroup() as tg: - # Per-plugin package listing - pkg_tasks = { - plugin.name: tg.create_task( - self._gather_packages(plugin.name, directories), - ) - for plugin in updatable_plugins - } - # Per-directory manifest requirement gathering - req_tasks = [tg.create_task(self._gather_project_requirements(d)) for d in directories] - # Natively-managed tool plugins (e.g. pdm self add) - tool_plugins_task = tg.create_task(self._gather_tool_plugins()) - - for name, task in pkg_tasks.items(): - packages_map[name] = task.result() - - # Merge tool-managed sub-plugins into the environment plugin - # that owns the host tool (e.g. cppython → pipx's pdm entry). - tool_plugins = tool_plugins_task.result() - for host_tool, sub_packages in tool_plugins.items(): - for _env_name, env_packages in packages_map.items(): - if any(entry.name == host_tool for entry in env_packages): - env_packages.extend(sub_packages) - break - - for task in req_tasks: - for action in task.result(): - if action.package and action.installer: - manifest_packages.setdefault(action.installer, set()).add( - str(action.package.name), - ) - - # Snapshot current update availability for widget creation. - # If not yet checked, schedule a background detection task - # *after* the widget tree renders so the UI appears fast. + data = await self._gather_refresh_data() need_deferred_check = not self._updates_checked - - # Clear existing widgets - for widget in self._section_widgets: - self._container_layout.removeWidget(widget) - widget.deleteLater() - self._section_widgets.clear() - - auto_update_map = self._config.plugin_auto_update or {} - - # Only show TOOL / PACKAGE kinds that have content - updatable = [p for p in plugins if p.kind in _UPDATABLE_KINDS] - - # Bucket by kind - kind_buckets: OrderedDict[PluginKind, list[PluginInfo]] = OrderedDict() - for plugin in updatable: - has_version = plugin.tool_version is not None - has_packages = bool(packages_map.get(plugin.name)) - if has_version or has_packages: - kind_buckets.setdefault(plugin.kind, []).append(plugin) - - sorted_kinds = sorted( - kind_buckets.keys(), - key=lambda k: _KIND_DISPLAY_ORDER.get(k, 99), - ) - - for kind in sorted_kinds: - bucket = kind_buckets[kind] - - kind_header = PluginKindHeader(kind, parent=self._container) - idx = self._container_layout.count() - 1 - self._container_layout.insertWidget(idx, kind_header) - self._section_widgets.append(kind_header) - - for plugin in bucket: - auto_val = auto_update_map.get(plugin.name, True) - provider_checked = auto_val is not False - - plugin_updates = self._updates_available.get(plugin.name, set()) - provider = PluginProviderHeader( - plugin, - provider_checked, - show_controls=True, - has_updates=bool(plugin_updates), - parent=self._container, - ) - provider.auto_update_toggled.connect(self._on_auto_update_toggled) - provider.update_requested.connect(self.plugin_update_requested.emit) - idx = self._container_layout.count() - 1 - self._container_layout.insertWidget(idx, provider) - self._section_widgets.append(provider) - - plugin_manifest = manifest_packages.get(plugin.name, set()) - raw_packages = packages_map.get(plugin.name, []) - - # Merge duplicates: same package from multiple - # directories becomes one row with a combined - # project label. Global packages are always - # deduplicated; manifest packages merge their - # project names with ", ". - merged: OrderedDict[str, MergedPackage] = OrderedDict() - for entry in raw_packages: - is_global = entry.name not in plugin_manifest - if entry.name in merged: - existing = merged[entry.name] - if not is_global and entry.project_label and entry.project_label not in existing.projects: - existing.projects.append(entry.project_label) - if not is_global and entry.project_path and entry.project_path not in existing.project_paths: - existing.project_paths.append(entry.project_path) - else: - merged[entry.name] = MergedPackage( - projects=[] if is_global else ([entry.project_label] if entry.project_label else []), - project_paths=[] if is_global else ([entry.project_path] if entry.project_path else []), - version=entry.version, - is_global=is_global, - host_tool=entry.host_tool, - ) - - if merged: - for pkg_name, pkg in merged.items(): - # Determine per-package auto-update state - if isinstance(auto_val, dict): - pkg_auto = auto_val.get(pkg_name, not pkg.is_global) - elif auto_val is False: - pkg_auto = False - else: - pkg_auto = not pkg.is_global - - row = PluginRow( - pkg_name, - project=', '.join(pkg.projects), - version=pkg.version, - plugin_name=plugin.name, - auto_update=pkg_auto, - show_toggle=True, - has_update=pkg_name in plugin_updates, - is_global=pkg.is_global, - host_tool=pkg.host_tool, - project_paths=pkg.project_paths, - parent=self._container, - ) - row.auto_update_toggled.connect( - self._on_package_auto_update_toggled, - ) - row.update_requested.connect( - self.package_update_requested.emit, - ) - row.remove_requested.connect( - self.package_remove_requested.emit, - ) - row.navigate_to_project.connect( - self.navigate_to_project_requested.emit, - ) - idx = self._container_layout.count() - 1 - self._container_layout.insertWidget(idx, row) - self._section_widgets.append(row) - else: - version_text = str(plugin.tool_version) if plugin.tool_version is not None else '' - row = PluginRow( - plugin.name, - version=version_text, - parent=self._container, - ) - idx = self._container_layout.count() - 1 - self._container_layout.insertWidget(idx, row) - self._section_widgets.append(row) - + self._build_widget_tree(data) except Exception: logger.exception('Failed to refresh tools') need_deferred_check = False @@ -791,7 +680,214 @@ async def _async_refresh(self) -> None: # Fire-and-forget: detect updates in the background, then patch # the just-rendered widget tree with update badges. if need_deferred_check: - asyncio.create_task(self._deferred_update_check(directories)) + asyncio.create_task(self._deferred_update_check(self._directories)) + + # ------------------------------------------------------------------ + # _async_refresh helper methods + # ------------------------------------------------------------------ + + async def _gather_refresh_data(self) -> _RefreshData: + """Fetch plugins, packages, and manifest requirements in parallel. + + Returns: + A :class:`_RefreshData` bundle containing all data needed + to build the widget tree. + """ + plugins, directories = await asyncio.get_running_loop().run_in_executor( + None, + self._fetch_data, + ) + self._directories = directories + + updatable_plugins = [p for p in plugins if p.kind in _UPDATABLE_KINDS] + + async with asyncio.TaskGroup() as tg: + pkg_tasks = { + plugin.name: tg.create_task( + self._gather_packages(plugin.name, directories), + ) + for plugin in updatable_plugins + } + req_tasks = [tg.create_task(self._gather_project_requirements(d)) for d in directories] + tool_plugins_task = tg.create_task(self._gather_tool_plugins()) + + packages_map = {name: task.result() for name, task in pkg_tasks.items()} + + # Merge tool-managed sub-plugins into the environment plugin + # that owns the host tool (e.g. cppython → pipx's pdm entry). + tool_plugins = tool_plugins_task.result() + for host_tool, sub_packages in tool_plugins.items(): + for env_packages in packages_map.values(): + if any(entry.name == host_tool for entry in env_packages): + env_packages.extend(sub_packages) + break + + manifest_packages = self._collect_manifest_packages(req_tasks) + + return _RefreshData( + plugins=plugins, + packages_map=packages_map, + manifest_packages=manifest_packages, + ) + + @staticmethod + def _collect_manifest_packages( + req_tasks: list[asyncio.Task[list[SetupAction]]], + ) -> dict[str, set[str]]: + """Extract manifest package names from completed requirement tasks.""" + manifest_packages: dict[str, set[str]] = {} + for task in req_tasks: + for action in task.result(): + if action.package and action.installer: + manifest_packages.setdefault(action.installer, set()).add( + str(action.package.name), + ) + return manifest_packages + + def _build_widget_tree(self, data: _RefreshData) -> None: + """Clear existing widgets and rebuild the tool/package tree.""" + self._clear_section_widgets() + + auto_update_map = self._config.plugin_auto_update or {} + kind_buckets = self._bucket_by_kind(data.plugins, data.packages_map) + + sorted_kinds = sorted( + kind_buckets, + key=lambda k: _KIND_DISPLAY_ORDER.get(k, 99), + ) + + for kind in sorted_kinds: + self._insert_section_widget(PluginKindHeader(kind, parent=self._container)) + for plugin in kind_buckets[kind]: + self._build_plugin_section(plugin, data, auto_update_map) + + def _clear_section_widgets(self) -> None: + """Remove and delete all current section widgets.""" + for widget in self._section_widgets: + self._container_layout.removeWidget(widget) + widget.deleteLater() + self._section_widgets.clear() + + @staticmethod + def _bucket_by_kind( + plugins: list[PluginInfo], + packages_map: dict[str, list[PackageEntry]], + ) -> OrderedDict[PluginKind, list[PluginInfo]]: + """Group updatable plugins by kind, filtering out empty entries.""" + buckets: OrderedDict[PluginKind, list[PluginInfo]] = OrderedDict() + for plugin in plugins: + if plugin.kind not in _UPDATABLE_KINDS: + continue + has_content = plugin.tool_version is not None or bool(packages_map.get(plugin.name)) + if has_content: + buckets.setdefault(plugin.kind, []).append(plugin) + return buckets + + def _build_plugin_section( + self, + plugin: PluginInfo, + data: _RefreshData, + auto_update_map: dict[str, bool | dict[str, bool]], + ) -> None: + """Build the provider header and package rows for a single plugin.""" + auto_val = auto_update_map.get(plugin.name, True) + plugin_updates = self._updates_available.get(plugin.name, set()) + + provider = PluginProviderHeader( + plugin, + auto_val is not False, + show_controls=True, + has_updates=bool(plugin_updates), + parent=self._container, + ) + provider.auto_update_toggled.connect(self._on_auto_update_toggled) + provider.update_requested.connect(self.plugin_update_requested.emit) + self._insert_section_widget(provider) + + plugin_manifest = data.manifest_packages.get(plugin.name, set()) + raw_packages = data.packages_map.get(plugin.name, []) + merged = self._merge_raw_packages(raw_packages, plugin_manifest) + + if merged: + for pkg_name, pkg in merged.items(): + pkg_auto = self._resolve_package_auto_update(auto_val, pkg_name, pkg.is_global) + row = self._create_connected_row( + PluginRowData( + name=pkg_name, + project=', '.join(pkg.projects), + version=pkg.version, + plugin_name=plugin.name, + auto_update=pkg_auto, + show_toggle=True, + has_update=pkg_name in plugin_updates, + is_global=pkg.is_global, + host_tool=pkg.host_tool, + project_paths=list(pkg.project_paths), + ), + ) + self._insert_section_widget(row) + else: + version_text = str(plugin.tool_version) if plugin.tool_version is not None else '' + row = PluginRow(PluginRowData(name=plugin.name, version=version_text), parent=self._container) + self._insert_section_widget(row) + + @staticmethod + def _merge_raw_packages( + raw_packages: list[PackageEntry], + plugin_manifest: set[str], + ) -> OrderedDict[str, MergedPackage]: + """Deduplicate packages across directories into a merged view. + + Same package from multiple directories becomes one row with a + combined project label. Global packages are always deduplicated; + manifest packages merge their project names. + """ + merged: OrderedDict[str, MergedPackage] = OrderedDict() + for entry in raw_packages: + is_global = entry.name not in plugin_manifest + if entry.name in merged: + existing = merged[entry.name] + if not is_global and entry.project_label and entry.project_label not in existing.projects: + existing.projects.append(entry.project_label) + if not is_global and entry.project_path and entry.project_path not in existing.project_paths: + existing.project_paths.append(entry.project_path) + else: + merged[entry.name] = MergedPackage( + projects=([] if is_global else ([entry.project_label] if entry.project_label else [])), + project_paths=([] if is_global else ([entry.project_path] if entry.project_path else [])), + version=entry.version, + is_global=is_global, + host_tool=entry.host_tool, + ) + return merged + + @staticmethod + def _resolve_package_auto_update( + auto_val: bool | dict[str, bool], + pkg_name: str, + is_global: bool, + ) -> bool: + """Determine the effective auto-update setting for a single package.""" + if isinstance(auto_val, dict): + return auto_val.get(pkg_name, not is_global) + if auto_val is False: + return False + return not is_global + + def _create_connected_row(self, data: PluginRowData) -> PluginRow: + """Create a :class:`PluginRow` and wire all its signals.""" + row = PluginRow(data, parent=self._container) + row.auto_update_toggled.connect(self._on_package_auto_update_toggled) + row.update_requested.connect(self.package_update_requested.emit) + row.remove_requested.connect(self.package_remove_requested.emit) + row.navigate_to_project.connect(self.navigate_to_project_requested.emit) + return row + + def _insert_section_widget(self, widget: QWidget) -> None: + """Append a widget to the container layout above the stretch.""" + idx = self._container_layout.count() - 1 + self._container_layout.insertWidget(idx, widget) + self._section_widgets.append(widget) def _fetch_data(self) -> tuple[list[PluginInfo], list[ManifestDirectory]]: """Fetch plugin list and directories (sync, run in executor).""" @@ -917,22 +1013,15 @@ async def _query(tool_name: str, manager: object) -> None: def _discover_plugin_managers() -> dict[str, object]: """Discover project-environment plugins implementing ``PluginManager`` (sync). - Imports are deferred to avoid hard-coupling the screen module to - porringer backend internals at import time. - Returns: A dict mapping tool name to :class:`PluginManager` instance. """ - from porringer.backend.builder import Builder - from porringer.core.plugin_schema.plugin_manager import PluginManager - from porringer.core.plugin_schema.project_environment import ProjectEnvironment - project_types = Builder.find_plugins('project_environment', ProjectEnvironment) instances = Builder.build_plugins(project_types) managers: dict[str, object] = {} - for info, inst in zip(project_types, instances, strict=True): - if isinstance(inst, PluginManager) and type(inst).is_available(): - managers[type(inst).tool_name()] = inst + for _info, inst in zip(project_types, instances, strict=True): + if isinstance(inst, PluginManager) and inst.is_available(): + managers[inst.tool_name()] = inst return managers async def _gather_project_requirements( @@ -1154,7 +1243,8 @@ def _apply_update_badges(self) -> None: # Need to create the button that wasn't built at render time self._inject_update_button(widget) - def _inject_update_button(self, row: PluginRow) -> None: + @staticmethod + def _inject_update_button(row: PluginRow) -> None: """Dynamically add an Update button to a row that was built without one.""" update_btn = QPushButton('Update') update_btn.setStyleSheet(PLUGIN_ROW_UPDATE_STYLE) @@ -1165,9 +1255,8 @@ def _inject_update_button(self, row: PluginRow) -> None: row._update_btn = update_btn # Insert before the version label (last widget) if present, else append layout = row.layout() - if layout is not None: - count = layout.count() - layout.insertWidget(max(count - 1, 0), update_btn) + if isinstance(layout, QHBoxLayout): + layout.insertWidget(max(layout.count() - 1, 0), update_btn) def _set_all_checking(self, checking: bool) -> None: """Show or hide inline checking spinners on all plugin rows.""" diff --git a/synodic_client/application/workers.py b/synodic_client/application/workers.py index c3f7fde..e5a2625 100644 --- a/synodic_client/application/workers.py +++ b/synodic_client/application/workers.py @@ -125,10 +125,10 @@ async def _check_manifest(p: Path) -> None: if event.kind == ProgressEventKind.ACTION_COMPLETED and event.result is not None: action_result = event.result if action_result.skipped: - if ( - action_result.skip_reason == SkipReason.ALREADY_LATEST - or action_result.skip_reason == SkipReason.ALREADY_INSTALLED - ): + if action_result.skip_reason in { + SkipReason.ALREADY_LATEST, + SkipReason.ALREADY_INSTALLED, + }: result.already_latest += 1 elif action_result.success: result.updated += 1 diff --git a/synodic_client/resolution.py b/synodic_client/resolution.py index 96b267e..de1c8bf 100644 --- a/synodic_client/resolution.py +++ b/synodic_client/resolution.py @@ -308,30 +308,46 @@ def resolve_auto_update_scope( enabled_plugins = {n for n in all_plugin_names if n not in disabled_plugins} # --- Determine include_packages --- - # Only build the set when there are per-package overrides or - # manifest data that distinguishes global from manifest-required. - include_packages: set[str] | None = None - - if per_package_entries or manifest_packages: - # 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 in disabled_plugins: - continue - pkg_set |= pkgs + include_packages = _build_include_packages( + per_package_entries, + manifest_packages, + disabled_plugins, + ) + + return enabled_plugins, include_packages - # 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) - if pkg_set: - include_packages = pkg_set +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. - return enabled_plugins, include_packages + 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 diff --git a/tests/unit/qt/test_gather_packages.py b/tests/unit/qt/test_gather_packages.py index c911164..b4b04c7 100644 --- a/tests/unit/qt/test_gather_packages.py +++ b/tests/unit/qt/test_gather_packages.py @@ -9,7 +9,7 @@ from porringer.core.schema import Package, PackageRelation, PackageRelationKind from porringer.schema import ManifestDirectory -from synodic_client.application.screen.screen import PackageEntry, ToolsView +from synodic_client.application.screen.screen import ToolsView from synodic_client.resolution import ResolvedConfig @@ -58,7 +58,7 @@ def test_global_query_returns_packages_with_no_directories() -> None: view = ToolsView(porringer, _make_config()) result = asyncio.run(view._gather_packages('pipx', [])) - names = {e.name for e in result} + assert {e.name for e in result} == {'pdm', 'cppython'} @staticmethod def test_global_query_returns_packages_with_empty_project_path() -> None: @@ -75,7 +75,7 @@ def test_global_query_returns_packages_with_empty_project_path() -> None: matching = [e for e in result if e.name == 'pdm'] assert len(matching) == 1 - assert matching[0].project_path == '', 'global packages should have empty project_path' + assert not matching[0].project_path, 'global packages should have empty project_path' @staticmethod def test_global_query_called_without_project_path() -> None: @@ -88,7 +88,13 @@ def test_global_query_called_without_project_path() -> None: # At least one call should have been made with only plugin_name (no path) calls = porringer.plugin.list_packages.call_args_list - global_calls = [c for c in calls if len(c.args) == 1 or (len(c.args) >= 2 and c.args[1] is None)] + plugin_name_only = 1 + min_args_with_path = 2 + global_calls = [ + c + for c in calls + if len(c.args) == plugin_name_only or (len(c.args) >= min_args_with_path and c.args[1] is None) + ] assert len(global_calls) >= 1, f'Expected a global call (no project_path), got: {calls}' @staticmethod @@ -144,7 +150,7 @@ def test_global_packages_have_empty_project_label() -> None: matching = [e for e in result if e.name == 'cppython'] assert len(matching) == 1 - assert matching[0].project_label == '', 'global packages should have empty project label' + assert not matching[0].project_label, 'global packages should have empty project label' assert matching[0].version == '0.5.0' @staticmethod @@ -168,7 +174,8 @@ async def _mock_list(plugin_name: str, project_path: Path | None = None) -> list names = {entry.name for entry in result} assert 'django' in names - assert call_count == 2 # one global + one directory + expected_calls = 2 # one global + one directory + assert call_count == expected_calls @staticmethod def test_relation_host_extracted_into_host_tool() -> None: @@ -193,7 +200,7 @@ def test_relation_host_extracted_into_host_tool() -> None: by_name = {entry.name: entry.host_tool for entry in result} assert by_name['cppython'] == 'pdm', 'injected package should carry host' - assert by_name['pdm'] == '', 'non-injected package should have empty host' + assert not by_name['pdm'], 'non-injected package should have empty host' # --------------------------------------------------------------------------- @@ -233,7 +240,7 @@ def test_returns_plugins_keyed_by_host_tool(monkeypatch) -> None: assert len(result['pdm']) == 1 entry = result['pdm'][0] assert entry.name == 'cppython' - assert entry.project_label == '' + assert not entry.project_label assert entry.version == '0.5.0' assert entry.host_tool == 'pdm' diff --git a/tests/unit/qt/test_update_feedback.py b/tests/unit/qt/test_update_feedback.py index 2abe68d..f408ba7 100644 --- a/tests/unit/qt/test_update_feedback.py +++ b/tests/unit/qt/test_update_feedback.py @@ -8,7 +8,7 @@ from porringer.schema import PluginInfo from porringer.schema.plugin import PluginKind -from synodic_client.application.screen.screen import PluginProviderHeader, PluginRow +from synodic_client.application.screen.screen import PluginProviderHeader, PluginRow, PluginRowData def _make_plugin( @@ -167,17 +167,19 @@ class TestPluginRowUpdates: @staticmethod def test_no_update_button_by_default() -> None: """With has_update=False the row has no update button.""" - row = PluginRow('pdm', plugin_name='pipx', show_toggle=True) + row = PluginRow(PluginRowData(name='pdm', plugin_name='pipx', show_toggle=True)) assert row._update_btn is None @staticmethod def test_update_button_visible_when_has_update() -> None: """With has_update=True the row shows an inline update button.""" row = PluginRow( - 'pdm', - plugin_name='pipx', - show_toggle=True, - has_update=True, + PluginRowData( + name='pdm', + plugin_name='pipx', + show_toggle=True, + has_update=True, + ) ) assert row._update_btn is not None assert not row._update_btn.isHidden() @@ -186,10 +188,12 @@ def test_update_button_visible_when_has_update() -> None: def test_set_updating_true_disables() -> None: """set_updating(True) shows 'Updating…' and disables.""" row = PluginRow( - 'pdm', - plugin_name='pipx', - show_toggle=True, - has_update=True, + PluginRowData( + name='pdm', + plugin_name='pipx', + show_toggle=True, + has_update=True, + ) ) row.set_updating(True) assert row._update_btn is not None @@ -200,10 +204,12 @@ def test_set_updating_true_disables() -> None: def test_set_updating_false_restores() -> None: """set_updating(False) restores 'Update' and re-enables.""" row = PluginRow( - 'pdm', - plugin_name='pipx', - show_toggle=True, - has_update=True, + PluginRowData( + name='pdm', + plugin_name='pipx', + show_toggle=True, + has_update=True, + ) ) row.set_updating(True) row.set_updating(False) @@ -215,10 +221,12 @@ def test_set_updating_false_restores() -> None: def test_update_requested_signal() -> None: """Clicking update emits update_requested(plugin_name, package_name).""" row = PluginRow( - 'pdm', - plugin_name='pipx', - show_toggle=True, - has_update=True, + PluginRowData( + name='pdm', + plugin_name='pipx', + show_toggle=True, + has_update=True, + ) ) spy = MagicMock() row.update_requested.connect(spy) @@ -229,7 +237,7 @@ def test_update_requested_signal() -> None: @staticmethod def test_set_updating_noop_without_button() -> None: """set_updating is a no-op when no update button exists.""" - row = PluginRow('pdm', plugin_name='pipx', show_toggle=True) + row = PluginRow(PluginRowData(name='pdm', plugin_name='pipx', show_toggle=True)) # Should not raise row.set_updating(True) assert row._update_btn is None @@ -238,10 +246,12 @@ def test_set_updating_noop_without_button() -> None: def test_set_checking_shows_spinner() -> None: """set_checking(True) starts the inline spinner and hides update button.""" row = PluginRow( - 'pdm', - plugin_name='pipx', - show_toggle=True, - has_update=True, + PluginRowData( + name='pdm', + plugin_name='pipx', + show_toggle=True, + has_update=True, + ) ) row.set_checking(True) assert row._checking_spinner is not None @@ -253,10 +263,12 @@ def test_set_checking_shows_spinner() -> None: def test_set_checking_false_hides_spinner() -> None: """set_checking(False) stops the spinner.""" row = PluginRow( - 'pdm', - plugin_name='pipx', - show_toggle=True, - has_update=True, + PluginRowData( + name='pdm', + plugin_name='pipx', + show_toggle=True, + has_update=True, + ) ) row.set_checking(True) row.set_checking(False) @@ -266,14 +278,14 @@ def test_set_checking_false_hides_spinner() -> None: @staticmethod def test_set_checking_noop_without_toggle() -> None: """set_checking is a no-op when show_toggle is False (no spinner created).""" - row = PluginRow('pdm', plugin_name='pipx') + row = PluginRow(PluginRowData(name='pdm', plugin_name='pipx')) row.set_checking(True) assert row._checking_spinner is None @staticmethod def test_host_tool_label_shown_when_set() -> None: """A host_tool value adds a '\u2192 ' label after the name.""" - row = PluginRow('cppython', plugin_name='pipx', host_tool='pdm') + row = PluginRow(PluginRowData(name='cppython', plugin_name='pipx', host_tool='pdm')) assert row._host_label is not None assert row._host_label.text() == '\u2192 pdm' assert not row._host_label.isHidden() @@ -281,7 +293,7 @@ def test_host_tool_label_shown_when_set() -> None: @staticmethod def test_host_tool_label_absent_when_empty() -> None: """No host label is created when host_tool is empty.""" - row = PluginRow('pdm', plugin_name='pipx') + row = PluginRow(PluginRowData(name='pdm', plugin_name='pipx')) assert row._host_label is None @@ -296,41 +308,41 @@ class TestPluginRowRemove: @staticmethod def test_remove_button_present() -> None: """A remove button is always created on PluginRow.""" - row = PluginRow('pdm', plugin_name='pipx', is_global=True) + row = PluginRow(PluginRowData(name='pdm', plugin_name='pipx', is_global=True)) assert row._remove_btn is not None @staticmethod def test_remove_button_enabled_for_global() -> None: """The remove button is enabled when is_global=True.""" - row = PluginRow('pdm', plugin_name='pipx', is_global=True) + row = PluginRow(PluginRowData(name='pdm', plugin_name='pipx', is_global=True)) assert row._remove_btn is not None assert row._remove_btn.isEnabled() @staticmethod def test_remove_button_disabled_for_manifest() -> None: """The remove button is disabled when is_global=False (manifest-referenced).""" - row = PluginRow('pdm', plugin_name='pipx', is_global=False, project='myproject') + row = PluginRow(PluginRowData(name='pdm', plugin_name='pipx', is_global=False, project='myproject')) assert row._remove_btn is not None assert not row._remove_btn.isEnabled() @staticmethod def test_remove_button_tooltip_global() -> None: """Tooltip for global packages says 'Remove '.""" - row = PluginRow('pdm', plugin_name='pipx', is_global=True) + row = PluginRow(PluginRowData(name='pdm', plugin_name='pipx', is_global=True)) assert row._remove_btn is not None assert 'Remove pdm' in row._remove_btn.toolTip() @staticmethod def test_remove_button_tooltip_manifest() -> None: """Tooltip for manifest packages mentions the project name.""" - row = PluginRow('pdm', plugin_name='pipx', is_global=False, project='myproject') + row = PluginRow(PluginRowData(name='pdm', plugin_name='pipx', is_global=False, project='myproject')) assert row._remove_btn is not None assert 'myproject' in row._remove_btn.toolTip() @staticmethod def test_remove_requested_signal() -> None: """Clicking remove emits remove_requested(plugin_name, package_name).""" - row = PluginRow('pdm', plugin_name='pipx', is_global=True) + row = PluginRow(PluginRowData(name='pdm', plugin_name='pipx', is_global=True)) spy = MagicMock() row.remove_requested.connect(spy) assert row._remove_btn is not None @@ -340,7 +352,7 @@ def test_remove_requested_signal() -> None: @staticmethod def test_remove_signal_not_emitted_when_disabled() -> None: """Clicking a disabled remove button does not emit remove_requested.""" - row = PluginRow('pdm', plugin_name='pipx', is_global=False, project='myproject') + row = PluginRow(PluginRowData(name='pdm', plugin_name='pipx', is_global=False, project='myproject')) spy = MagicMock() row.remove_requested.connect(spy) assert row._remove_btn is not None @@ -350,7 +362,7 @@ def test_remove_signal_not_emitted_when_disabled() -> None: @staticmethod def test_set_removing_true() -> None: """set_removing(True) shows 'Removing\u2026' and disables.""" - row = PluginRow('pdm', plugin_name='pipx', is_global=True) + row = PluginRow(PluginRowData(name='pdm', plugin_name='pipx', is_global=True)) row.set_removing(True) assert row._remove_btn is not None assert row._remove_btn.text() == 'Removing\u2026' @@ -359,7 +371,7 @@ def test_set_removing_true() -> None: @staticmethod def test_set_removing_false() -> None: """set_removing(False) restores '\u00d7' and re-enables.""" - row = PluginRow('pdm', plugin_name='pipx', is_global=True) + row = PluginRow(PluginRowData(name='pdm', plugin_name='pipx', is_global=True)) row.set_removing(True) row.set_removing(False) assert row._remove_btn is not None @@ -370,10 +382,12 @@ def test_set_removing_false() -> None: def test_project_paths_stored() -> None: """PluginRow stores project_paths for navigation.""" row = PluginRow( - 'pdm', - plugin_name='pipx', - is_global=False, - project='myproject', - project_paths=['/fake/project'], + PluginRowData( + name='pdm', + plugin_name='pipx', + is_global=False, + project='myproject', + project_paths=['/fake/project'], + ) ) assert row._project_paths == ['/fake/project'] diff --git a/tests/unit/test_workers.py b/tests/unit/test_workers.py index e29ef1f..7fcd00d 100644 --- a/tests/unit/test_workers.py +++ b/tests/unit/test_workers.py @@ -21,18 +21,23 @@ def test_defaults() -> None: @staticmethod 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( - manifests_processed=3, - updated=2, - already_latest=1, - failed=0, - updated_packages={'pdm', 'ruff'}, + manifests_processed=expected_manifests, + updated=expected_updated, + already_latest=expected_latest, + failed=expected_failed, + updated_packages=expected_packages, ) - assert result.manifests_processed == 3 - assert result.updated == 2 - assert result.already_latest == 1 - assert result.failed == 0 - assert result.updated_packages == {'pdm', 'ruff'} + 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_packages == expected_packages @staticmethod def test_updated_packages_mutation() -> None: From 593ec1e675dc5304f7fecc6b81b87341b6647946 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Sat, 28 Feb 2026 11:50:16 -0800 Subject: [PATCH 05/10] Async Uninstall --- pdm.lock | 8 +++--- pyproject.toml | 17 +++--------- synodic_client/application/screen/__init__.py | 1 + synodic_client/application/screen/install.py | 3 +-- synodic_client/application/screen/screen.py | 8 +++--- synodic_client/application/screen/spinner.py | 5 ++-- synodic_client/application/screen/tray.py | 27 +++++++++++++++++-- synodic_client/application/workers.py | 8 ++---- tests/unit/qt/test_install_preview.py | 21 ++++++++------- tests/unit/qt/test_logging.py | 4 +-- 10 files changed, 57 insertions(+), 45 deletions(-) diff --git a/pdm.lock b/pdm.lock index 66b6d0c..792661e 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "build", "lint", "test"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:8cb6a031f9e0ae7078a726009b6eb88911d1784f4b50be154877d084e3c65fef" +content_hash = "sha256:a65c7d0d3d0719f592850450aadcc93fe5a908bf863efc17ba770b2ce65cbd92" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -336,7 +336,7 @@ files = [ [[package]] name = "porringer" -version = "0.2.1.dev59" +version = "0.2.1.dev60" requires_python = ">=3.14" summary = "" groups = ["default"] @@ -349,8 +349,8 @@ dependencies = [ "userpath>=1.9.2", ] files = [ - {file = "porringer-0.2.1.dev59-py3-none-any.whl", hash = "sha256:d8287ea5bff5e678e3a45d6f0a235fa39c359e95db819edb2c95951b7b805549"}, - {file = "porringer-0.2.1.dev59.tar.gz", hash = "sha256:5a7248c30d549d6696ca7fb2a00377146848b5266171519d6a3ed7ca9bfbfe3d"}, + {file = "porringer-0.2.1.dev60-py3-none-any.whl", hash = "sha256:e42e6b8cf9e7c517be441e14eadce57621e9bb6d99473c7b667ca102aca063f8"}, + {file = "porringer-0.2.1.dev60.tar.gz", hash = "sha256:10e220c729835ce37dee36579a3fb9acfaefec41fd1d3dc1830df84a2e8098d1"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index ffb7753..17dc46e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ requires-python = ">=3.14, <3.15" dependencies = [ "pyside6>=6.10.2", "packaging>=26.0", - "porringer>=0.2.1.dev59", + "porringer>=0.2.1.dev60", "qasync>=0.28.0", "velopack>=0.0.1444.dev49733", "typer>=0.24.1", @@ -25,18 +25,9 @@ homepage = "https://github.com/synodic/synodic-client" repository = "https://github.com/synodic/synodic-client" [dependency-groups] -build = [ - "pyinstaller>=6.19.0", -] -lint = [ - "ruff>=0.15.4", - "pyrefly>=0.54.0", -] -test = [ - "pytest>=9.0.2", - "pytest-cov>=7.0.0", - "pytest-mock>=3.15.1", -] +build = ["pyinstaller>=6.19.0"] +lint = ["ruff>=0.15.4", "pyrefly>=0.54.0"] +test = ["pytest>=9.0.2", "pytest-cov>=7.0.0", "pytest-mock>=3.15.1"] [project.scripts] synodic-c = "synodic_client.cli:app" diff --git a/synodic_client/application/screen/__init__.py b/synodic_client/application/screen/__init__.py index e4b639f..38036f8 100644 --- a/synodic_client/application/screen/__init__.py +++ b/synodic_client/application/screen/__init__.py @@ -43,6 +43,7 @@ def plugin_kind_group_label(kind: PluginKind) -> str: 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', diff --git a/synodic_client/application/screen/install.py b/synodic_client/application/screen/install.py index dfa84e5..4b2b48b 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -1259,8 +1259,7 @@ async def _resolve_manifest_path(url: str) -> tuple[Path, str | None]: dest = Path(temp_dir) / 'porringer.json' params = DownloadParameters(url=url, destination=dest, timeout=3) - loop = asyncio.get_running_loop() - result = await loop.run_in_executor(None, lambda: API.download(params)) + result = await API.download(params) if not result.success: _safe_rmtree(temp_dir) diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 26a1512..84ab9df 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -985,9 +985,9 @@ async def _gather_tool_plugins( loop = asyncio.get_running_loop() managers = await loop.run_in_executor(None, self._discover_plugin_managers) - async def _query(tool_name: str, manager: object) -> None: + async def _query(tool_name: str, manager: PluginManager) -> None: try: - plugins = await manager.installed_plugins() # type: ignore[union-attr] + plugins = await manager.installed_plugins() results[tool_name] = [ PackageEntry( name=str(pkg.name), @@ -1010,7 +1010,7 @@ async def _query(tool_name: str, manager: object) -> None: return results @staticmethod - def _discover_plugin_managers() -> dict[str, object]: + def _discover_plugin_managers() -> dict[str, PluginManager]: """Discover project-environment plugins implementing ``PluginManager`` (sync). Returns: @@ -1018,7 +1018,7 @@ def _discover_plugin_managers() -> dict[str, object]: """ project_types = Builder.find_plugins('project_environment', ProjectEnvironment) instances = Builder.build_plugins(project_types) - managers: dict[str, object] = {} + managers: dict[str, PluginManager] = {} for _info, inst in zip(project_types, instances, strict=True): if isinstance(inst, PluginManager) and inst.is_available(): managers[inst.tool_name()] = inst diff --git a/synodic_client/application/screen/spinner.py b/synodic_client/application/screen/spinner.py index 6600f2c..b775fd0 100644 --- a/synodic_client/application/screen/spinner.py +++ b/synodic_client/application/screen/spinner.py @@ -107,8 +107,9 @@ def __init__(self, text: str = '', parent: QWidget | None = None) -> None: def eventFilter(self, obj: object, event: QEvent) -> bool: """Resize to match the parent whenever it resizes.""" - if event.type() == QEvent.Type.Resize and obj is self.parent(): - self.setGeometry(self.parent().rect()) # type: ignore[union-attr] + parent = self.parent() + if event.type() == QEvent.Type.Resize and obj is parent and isinstance(parent, QWidget): + self.setGeometry(parent.rect()) return False # -- Public API -------------------------------------------------------- diff --git a/synodic_client/application/screen/tray.py b/synodic_client/application/screen/tray.py index 90bbcbf..cbf43fc 100644 --- a/synodic_client/application/screen/tray.py +++ b/synodic_client/application/screen/tray.py @@ -6,6 +6,7 @@ from porringer.api import API from porringer.schema import PluginInfo +from porringer.schema.execution import SetupActionResult from PySide6.QtCore import QTimer from PySide6.QtGui import QAction from PySide6.QtWidgets import ( @@ -496,6 +497,15 @@ async def _async_single_package_remove( """Run a single-package removal and route results.""" try: result = await run_package_remove(porringer, plugin_name, package_name) + logger.info( + 'Removal result for %s/%s: success=%s, skipped=%s, skip_reason=%s, message=%s', + plugin_name, + package_name, + result.success, + result.skipped, + result.skip_reason, + result.message, + ) self._on_package_remove_finished(result, plugin_name, package_name) except Exception as exc: logger.exception('Package removal failed') @@ -510,14 +520,27 @@ async def _async_single_package_remove( def _on_package_remove_finished( self, - result: object, + result: SetupActionResult, plugin_name: str, package_name: str, ) -> None: """Handle package removal completion.""" + tools_view = self._window.tools_view + + 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) + self.tray.showMessage( + 'Package Removal Failed', + f'Could not remove {package_name}: {detail}', + QSystemTrayIcon.MessageIcon.Warning, + ) + if tools_view is not None: + tools_view.set_package_removing(plugin_name, package_name, False) + return + logger.info('Package removal completed for %s/%s', plugin_name, package_name) - tools_view = self._window.tools_view if tools_view is not None: tools_view.set_package_removing(plugin_name, package_name, False) tools_view._updates_checked = False diff --git a/synodic_client/application/workers.py b/synodic_client/application/workers.py index e5a2625..fcc1117 100644 --- a/synodic_client/application/workers.py +++ b/synodic_client/application/workers.py @@ -145,7 +145,7 @@ async def run_package_remove( plugin_name: str, package_name: str, ) -> SetupActionResult: - """Uninstall a single package off the main thread. + """Uninstall a single package via the porringer API. Args: porringer: The porringer API instance. @@ -155,9 +155,5 @@ async def run_package_remove( Returns: A :class:`SetupActionResult` describing the outcome. """ - loop = asyncio.get_running_loop() package_ref = PackageRef(name=package_name) - return await loop.run_in_executor( - None, - lambda: porringer.uninstall(plugin_name, package_ref), - ) + return await porringer.uninstall(plugin_name, package_ref) diff --git a/tests/unit/qt/test_install_preview.py b/tests/unit/qt/test_install_preview.py index 1b0d3aa..33942c1 100644 --- a/tests/unit/qt/test_install_preview.py +++ b/tests/unit/qt/test_install_preview.py @@ -320,16 +320,17 @@ class TestPreviewWorker: def test_emits_error_on_download_failure(monkeypatch: pytest.MonkeyPatch) -> None: """Verify run_preview raises when download fails.""" porringer = MagicMock() - monkeypatch.setattr( - _DOWNLOAD_PATCH, - lambda params, progress_callback=None: DownloadResult( + + async def _mock_download(params: Any, progress_callback: Any = None) -> DownloadResult: + return DownloadResult( success=False, path=None, verified=False, size=0, message='Network error', - ), - ) + ) + + monkeypatch.setattr(_DOWNLOAD_PATCH, _mock_download) with pytest.raises(RuntimeError, match='Network error'): asyncio.run(run_preview(porringer, 'https://example.com/bad.json')) @@ -342,16 +343,16 @@ def test_emits_preview_ready_on_success(monkeypatch: pytest.MonkeyPatch, tmp_pat dest = tmp_path / 'porringer.json' dest.write_text('{}') - monkeypatch.setattr( - _DOWNLOAD_PATCH, - lambda params, progress_callback=None: DownloadResult( + async def _mock_download(params: Any, progress_callback: Any = None) -> DownloadResult: + return DownloadResult( success=True, path=dest, verified=True, size=100, message='OK', - ), - ) + ) + + monkeypatch.setattr(_DOWNLOAD_PATCH, _mock_download) expected = SetupResults(actions=[]) manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=expected) diff --git a/tests/unit/qt/test_logging.py b/tests/unit/qt/test_logging.py index c753c33..98fa158 100644 --- a/tests/unit/qt/test_logging.py +++ b/tests/unit/qt/test_logging.py @@ -184,8 +184,8 @@ def test_normal_build_sets_porringer_info(tmp_path: Path) -> None: # Ensure frozen is not set had_frozen = hasattr(sys, 'frozen') + old_frozen = getattr(sys, 'frozen', None) if had_frozen: - old_frozen = sys.frozen # type: ignore[attr-defined] delattr(sys, 'frozen') try: @@ -194,7 +194,7 @@ def test_normal_build_sets_porringer_info(tmp_path: Path) -> None: assert porringer_logger.level == logging.INFO finally: if had_frozen: - sys.frozen = old_frozen # type: ignore[attr-defined] + sys.__dict__['frozen'] = old_frozen # Clean up for h in list(app_logger.handlers): From 9a2ed851bba8390f3e38701ecbbae78bf33b1c1c Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Sat, 28 Feb 2026 17:38:12 -0800 Subject: [PATCH 06/10] Better Removal Errors --- pdm.lock | 8 +-- pyproject.toml | 17 ++++-- synodic_client/application/screen/screen.py | 61 +++++++++++++++++++++ synodic_client/application/screen/tray.py | 36 ++++++------ synodic_client/application/theme.py | 3 + 5 files changed, 97 insertions(+), 28 deletions(-) diff --git a/pdm.lock b/pdm.lock index 792661e..57038bc 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "build", "lint", "test"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:a65c7d0d3d0719f592850450aadcc93fe5a908bf863efc17ba770b2ce65cbd92" +content_hash = "sha256:ad5b1ddc4e8c12acee60c3dcaa577ecebe70401cf2240091a8e0ac960721a2e1" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -336,7 +336,7 @@ files = [ [[package]] name = "porringer" -version = "0.2.1.dev60" +version = "0.2.1.dev61" requires_python = ">=3.14" summary = "" groups = ["default"] @@ -349,8 +349,8 @@ dependencies = [ "userpath>=1.9.2", ] files = [ - {file = "porringer-0.2.1.dev60-py3-none-any.whl", hash = "sha256:e42e6b8cf9e7c517be441e14eadce57621e9bb6d99473c7b667ca102aca063f8"}, - {file = "porringer-0.2.1.dev60.tar.gz", hash = "sha256:10e220c729835ce37dee36579a3fb9acfaefec41fd1d3dc1830df84a2e8098d1"}, + {file = "porringer-0.2.1.dev61-py3-none-any.whl", hash = "sha256:347208f4c10e90b88206ddc076c2d0e2dd31d3036b7ab63483f21effd577d2ab"}, + {file = "porringer-0.2.1.dev61.tar.gz", hash = "sha256:e4c809b20508f844260a36cee08cfb2de1c1929ab001e6fe0f976d97c53cdbff"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 17dc46e..dadf288 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ requires-python = ">=3.14, <3.15" dependencies = [ "pyside6>=6.10.2", "packaging>=26.0", - "porringer>=0.2.1.dev60", + "porringer>=0.2.1.dev61", "qasync>=0.28.0", "velopack>=0.0.1444.dev49733", "typer>=0.24.1", @@ -25,9 +25,18 @@ homepage = "https://github.com/synodic/synodic-client" repository = "https://github.com/synodic/synodic-client" [dependency-groups] -build = ["pyinstaller>=6.19.0"] -lint = ["ruff>=0.15.4", "pyrefly>=0.54.0"] -test = ["pytest>=9.0.2", "pytest-cov>=7.0.0", "pytest-mock>=3.15.1"] +build = [ + "pyinstaller>=6.19.0", +] +lint = [ + "ruff>=0.15.4", + "pyrefly>=0.54.0", +] +test = [ + "pytest>=9.0.2", + "pytest-cov>=7.0.0", + "pytest-mock>=3.15.1", +] [project.scripts] synodic-c = "synodic_client.cli:app" diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 84ab9df..cf7fbde 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -51,6 +51,7 @@ PLUGIN_PROVIDER_STATUS_MISSING_STYLE, PLUGIN_PROVIDER_STYLE, PLUGIN_PROVIDER_VERSION_STYLE, + PLUGIN_ROW_ERROR_STYLE, PLUGIN_ROW_GLOBAL_STYLE, PLUGIN_ROW_HOST_STYLE, PLUGIN_ROW_NAME_STYLE, @@ -331,6 +332,12 @@ def __init__( layout.addStretch() + # Transient inline error label (hidden by default) + self._status_label = QLabel() + self._status_label.setStyleSheet(PLUGIN_ROW_ERROR_STYLE) + self._status_label.hide() + layout.addWidget(self._status_label) + # Auto / Update controls (only for updatable kinds) if show_controls: toggle_btn = QPushButton('Auto') @@ -385,6 +392,16 @@ def set_checking(self, checking: bool) -> None: else: self._checking_spinner.stop() + def set_error(self, message: str) -> None: + """Show a transient inline error that auto-hides after ~5 seconds.""" + self._status_label.setText(message) + self._status_label.show() + QTimer.singleShot(5000, self._status_label.hide) + + def clear_error(self) -> None: + """Immediately hide the inline error label.""" + self._status_label.hide() + # --------------------------------------------------------------------------- # Plugin row — compact package / tool entry @@ -478,6 +495,13 @@ def _build_controls(self, layout: QHBoxLayout, data: PluginRowData) -> None: version_label = QLabel(data.version) version_label.setStyleSheet(PLUGIN_ROW_VERSION_STYLE) layout.addWidget(version_label) + + # Transient inline error label (hidden by default) + self._status_label = QLabel() + self._status_label.setStyleSheet(PLUGIN_ROW_ERROR_STYLE) + self._status_label.hide() + layout.addWidget(self._status_label) + self._build_remove_button(layout, data) def _build_toggle(self, layout: QHBoxLayout, data: PluginRowData) -> None: @@ -562,6 +586,16 @@ def set_removing(self, removing: bool) -> None: self._remove_btn.setText('\u00d7') self._remove_btn.setEnabled(True) + def set_error(self, message: str) -> None: + """Show a transient inline error that auto-hides after ~5 seconds.""" + self._status_label.setText(message) + self._status_label.show() + QTimer.singleShot(5000, self._status_label.hide) + + def clear_error(self) -> None: + """Immediately hide the inline error label.""" + self._status_label.hide() + class ToolsView(QWidget): """Central update hub showing installed tools and packages. @@ -1303,6 +1337,29 @@ def set_package_removing( widget.set_removing(removing) break + def set_package_error( + self, + plugin_name: str, + package_name: str, + message: str, + ) -> None: + """Show a transient inline error on a specific package row.""" + for widget in self._section_widgets: + if ( + isinstance(widget, PluginRow) + and widget._plugin_name == plugin_name + and widget._package_name == package_name + ): + widget.set_error(message) + break + + def set_plugin_error(self, plugin_name: str, message: str) -> None: + """Show a transient inline error on the header for *plugin_name*.""" + for widget in self._section_widgets: + if isinstance(widget, PluginProviderHeader) and widget._plugin_name == plugin_name: + widget.set_error(message) + break + class ProjectsView(QWidget): """Widget for managing project directories and previewing their manifests. @@ -1504,6 +1561,9 @@ class MainWindow(QMainWindow): settings_requested = Signal() """Emitted when the user clicks the settings gear button.""" + tools_view_created = Signal(ToolsView) + """Emitted once when the :class:`ToolsView` is lazily initialised.""" + _tabs: QTabWidget | None = None _tools_view: ToolsView | None = None _projects_view: ProjectsView | None = None @@ -1554,6 +1614,7 @@ def show(self) -> None: self._tools_view = ToolsView(self._porringer, self._config, self) self._tabs.addTab(self._tools_view, 'Tools') + self.tools_view_created.emit(self._tools_view) # Navigate-to-project: switch to Projects tab and select directory self._tools_view.navigate_to_project_requested.connect(self._navigate_to_project) diff --git a/synodic_client/application/screen/tray.py b/synodic_client/application/screen/tray.py index cbf43fc..4a4b31e 100644 --- a/synodic_client/application/screen/tray.py +++ b/synodic_client/application/screen/tray.py @@ -16,7 +16,7 @@ ) from synodic_client.application.icon import app_icon -from synodic_client.application.screen.screen import MainWindow +from synodic_client.application.screen.screen import MainWindow, ToolsView from synodic_client.application.screen.settings import SettingsWindow from synodic_client.application.workers import ( ToolUpdateResult, @@ -88,13 +88,8 @@ def __init__( self._tool_update_timer: QTimer | None = None self._restart_tool_update_timer() - # Connect ToolsView signals when available - tools_view = window.tools_view - if tools_view is not None: - tools_view.update_all_requested.connect(self._on_tool_update) - tools_view.plugin_update_requested.connect(self._on_single_plugin_update) - tools_view.package_update_requested.connect(self._on_single_package_update) - tools_view.package_remove_requested.connect(self._on_single_package_remove) + # Connect ToolsView signals — deferred because ToolsView is created lazily + window.tools_view_created.connect(self._connect_tools_view) # Connect update banner signals self._banner = window.update_banner @@ -129,6 +124,15 @@ def _build_menu(self, app: QApplication, window: MainWindow) -> None: self.tray.setContextMenu(self.menu) + # -- Deferred ToolsView wiring -- + + def _connect_tools_view(self, tools_view: ToolsView) -> None: + """Wire ToolsView signals once the view is lazily created.""" + tools_view.update_all_requested.connect(self._on_tool_update) + tools_view.plugin_update_requested.connect(self._on_single_plugin_update) + tools_view.package_update_requested.connect(self._on_single_package_update) + tools_view.package_remove_requested.connect(self._on_single_package_remove) + # -- Config helpers -- def _resolve_config(self) -> ResolvedConfig: @@ -389,10 +393,10 @@ async def _async_single_plugin_update(self, porringer: API, plugin_name: str) -> self._on_tool_update_finished(result, updating_plugin=plugin_name) except Exception as exc: logger.exception('Tool update failed') - self._on_tool_update_error(str(exc)) tools_view = self._window.tools_view if tools_view is not None: tools_view.set_plugin_updating(plugin_name, False) + tools_view.set_plugin_error(plugin_name, f'Update failed: {exc}') def _on_single_package_update(self, plugin_name: str, package_name: str) -> None: """Upgrade a single package managed by *plugin_name*.""" @@ -428,10 +432,10 @@ async def _async_single_package_update( ) except Exception as exc: logger.exception('Package update failed') - self._on_tool_update_error(str(exc)) tools_view = self._window.tools_view if tools_view is not None: tools_view.set_package_updating(plugin_name, package_name, False) + tools_view.set_package_error(plugin_name, package_name, f'Update failed: {exc}') def _on_tool_update_finished( self, @@ -509,14 +513,10 @@ async def _async_single_package_remove( self._on_package_remove_finished(result, plugin_name, package_name) except Exception as exc: logger.exception('Package removal failed') - self.tray.showMessage( - 'Package Removal Error', - f'Failed to remove {package_name}: {exc}', - QSystemTrayIcon.MessageIcon.Warning, - ) tools_view = self._window.tools_view if tools_view is not None: tools_view.set_package_removing(plugin_name, package_name, False) + tools_view.set_package_error(plugin_name, package_name, f'Failed to remove {package_name}: {exc}') def _on_package_remove_finished( self, @@ -530,13 +530,9 @@ def _on_package_remove_finished( 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) - self.tray.showMessage( - 'Package Removal Failed', - f'Could not remove {package_name}: {detail}', - QSystemTrayIcon.MessageIcon.Warning, - ) if tools_view is not None: tools_view.set_package_removing(plugin_name, package_name, False) + tools_view.set_package_error(plugin_name, package_name, f'Could not remove {package_name}: {detail}') return logger.info('Package removal completed for %s/%s', plugin_name, package_name) diff --git a/synodic_client/application/theme.py b/synodic_client/application/theme.py index b4009ad..96e5cf0 100644 --- a/synodic_client/application/theme.py +++ b/synodic_client/application/theme.py @@ -195,6 +195,9 @@ ) """Small inline remove (×) button for individual package rows.""" +PLUGIN_ROW_ERROR_STYLE = 'font-size: 11px; color: #f48771;' +"""Transient inline error label shown on a row after a failed action.""" + PLUGIN_ROW_SPACING = 1 """Pixels between individual tool/package rows.""" From 53a33b5095487337fdee7dea1f6c76a39043ae89 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Mon, 2 Mar 2026 11:44:22 -0800 Subject: [PATCH 07/10] Only Show Window On Manual Updates --- pdm.lock | 8 +-- pyproject.toml | 2 +- synodic_client/application/screen/screen.py | 21 +++---- synodic_client/application/screen/tray.py | 14 ++--- tests/unit/qt/test_gather_packages.py | 2 +- tests/unit/qt/test_tray_window_show.py | 68 +++++++++++++++++++++ 6 files changed, 90 insertions(+), 25 deletions(-) create mode 100644 tests/unit/qt/test_tray_window_show.py diff --git a/pdm.lock b/pdm.lock index 57038bc..c7b2873 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "build", "lint", "test"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:ad5b1ddc4e8c12acee60c3dcaa577ecebe70401cf2240091a8e0ac960721a2e1" +content_hash = "sha256:995f0f6259b50fd9d675397778c6e67d831a3a59661672d51abe2649e8c3f1e2" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -336,7 +336,7 @@ files = [ [[package]] name = "porringer" -version = "0.2.1.dev61" +version = "0.2.1.dev67" requires_python = ">=3.14" summary = "" groups = ["default"] @@ -349,8 +349,8 @@ dependencies = [ "userpath>=1.9.2", ] files = [ - {file = "porringer-0.2.1.dev61-py3-none-any.whl", hash = "sha256:347208f4c10e90b88206ddc076c2d0e2dd31d3036b7ab63483f21effd577d2ab"}, - {file = "porringer-0.2.1.dev61.tar.gz", hash = "sha256:e4c809b20508f844260a36cee08cfb2de1c1929ab001e6fe0f976d97c53cdbff"}, + {file = "porringer-0.2.1.dev67-py3-none-any.whl", hash = "sha256:a1548af376478de3c3c3d40743fec62c8acd6fe3c342122dee37d835883c8338"}, + {file = "porringer-0.2.1.dev67.tar.gz", hash = "sha256:f249cc9b8f45a2844aef8403fd7b623546515864cebeedcd3caec6cb4e35d43f"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index dadf288..cdd2a21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ requires-python = ">=3.14, <3.15" dependencies = [ "pyside6>=6.10.2", "packaging>=26.0", - "porringer>=0.2.1.dev61", + "porringer>=0.2.1.dev67", "qasync>=0.28.0", "velopack>=0.0.1444.dev49733", "typer>=0.24.1", diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index cf7fbde..8a1ece4 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -1,6 +1,7 @@ """Screen class for the Synodic Client application.""" import asyncio +import contextlib import logging from collections import OrderedDict from dataclasses import dataclass, field @@ -727,10 +728,7 @@ async def _gather_refresh_data(self) -> _RefreshData: A :class:`_RefreshData` bundle containing all data needed to build the widget tree. """ - plugins, directories = await asyncio.get_running_loop().run_in_executor( - None, - self._fetch_data, - ) + plugins, directories = await self._fetch_data() self._directories = directories updatable_plugins = [p for p in plugins if p.kind in _UPDATABLE_KINDS] @@ -923,9 +921,9 @@ def _insert_section_widget(self, widget: QWidget) -> None: self._container_layout.insertWidget(idx, widget) self._section_widgets.append(widget) - def _fetch_data(self) -> tuple[list[PluginInfo], list[ManifestDirectory]]: - """Fetch plugin list and directories (sync, run in executor).""" - plugins = self._porringer.plugin.list() + async def _fetch_data(self) -> tuple[list[PluginInfo], list[ManifestDirectory]]: + """Fetch plugin list and directories.""" + plugins = await self._porringer.plugin.list() directories = self._porringer.cache.list_directories() return plugins, directories @@ -1082,10 +1080,11 @@ async def _gather_project_requirements( dry_run=True, project_directory=path, ) - async for event in self._porringer.sync.execute_stream(params): - if event.kind == ProgressEventKind.MANIFEST_PARSED and event.manifest: - actions.extend(event.manifest.actions) - break + async with contextlib.aclosing(self._porringer.sync.execute_stream(params)) as stream: + async for event in stream: + if event.kind == ProgressEventKind.MANIFEST_PARSED and event.manifest: + actions.extend(event.manifest.actions) + break except Exception: logger.debug( 'Could not gather requirements for %s', diff --git a/synodic_client/application/screen/tray.py b/synodic_client/application/screen/tray.py index 4a4b31e..484fa19 100644 --- a/synodic_client/application/screen/tray.py +++ b/synodic_client/application/screen/tray.py @@ -5,7 +5,6 @@ from collections.abc import Callable from porringer.api import API -from porringer.schema import PluginInfo from porringer.schema.execution import SetupActionResult from PySide6.QtCore import QTimer from PySide6.QtGui import QAction @@ -332,13 +331,9 @@ def _on_tool_update(self) -> None: async def _do_tool_update(self, porringer: API) -> None: """Resolve enabled plugins off-thread, then run the tool update.""" - loop = asyncio.get_running_loop() config = self._resolve_config() - def _list_plugins() -> list[PluginInfo]: - return porringer.plugin.list() - - all_plugins = await loop.run_in_executor(None, _list_plugins) + all_plugins = await porringer.plugin.list() all_names = [p.name for p in all_plugins if p.installed] enabled_plugins, include_packages = resolve_auto_update_scope( config, @@ -390,7 +385,7 @@ async def _async_single_plugin_update(self, porringer: API, plugin_name: str) -> plugins={plugin_name}, include_packages=include_packages, ) - self._on_tool_update_finished(result, updating_plugin=plugin_name) + self._on_tool_update_finished(result, updating_plugin=plugin_name, manual=True) except Exception as exc: logger.exception('Tool update failed') tools_view = self._window.tools_view @@ -429,6 +424,7 @@ async def _async_single_package_update( self._on_tool_update_finished( result, updating_package=(plugin_name, package_name), + manual=True, ) except Exception as exc: logger.exception('Package update failed') @@ -443,6 +439,7 @@ def _on_tool_update_finished( *, updating_plugin: str | None = None, updating_package: tuple[str, str] | None = None, + manual: bool = False, ) -> None: """Handle tool update completion.""" logger.info( @@ -464,7 +461,8 @@ def _on_tool_update_finished( tools_view._updates_checked = False tools_view.refresh() - self._window.show() + if manual: + self._window.show() def _on_tool_update_error(self, error: str) -> None: """Handle tool update error.""" diff --git a/tests/unit/qt/test_gather_packages.py b/tests/unit/qt/test_gather_packages.py index b4b04c7..0ab459f 100644 --- a/tests/unit/qt/test_gather_packages.py +++ b/tests/unit/qt/test_gather_packages.py @@ -30,7 +30,7 @@ def _make_config() -> ResolvedConfig: def _make_porringer() -> MagicMock: """Build a MagicMock standing in for the porringer API.""" mock = MagicMock() - mock.plugin.list.return_value = [] + mock.plugin.list = AsyncMock(return_value=[]) mock.plugin.list_packages = AsyncMock(return_value=[]) mock.cache.list_directories.return_value = [] return mock diff --git a/tests/unit/qt/test_tray_window_show.py b/tests/unit/qt/test_tray_window_show.py new file mode 100644 index 0000000..a1d8f23 --- /dev/null +++ b/tests/unit/qt/test_tray_window_show.py @@ -0,0 +1,68 @@ +"""Tests that the tray only brings the window to the front on manual actions.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from synodic_client.application.workers import ToolUpdateResult + + +@pytest.fixture() +def tray_screen(): + """Build a minimal ``TrayScreen`` with mocked collaborators.""" + with patch('synodic_client.application.screen.tray.resolve_config'), \ + patch('synodic_client.application.screen.tray.resolve_update_config') as mock_ucfg: + # Disable timers by setting intervals to 0 + mock_ucfg.return_value = MagicMock( + auto_update_interval_minutes=0, + tool_update_interval_minutes=0, + ) + + from synodic_client.application.screen.tray import TrayScreen + + app = MagicMock() + client = MagicMock() + window = MagicMock() + # SettingsWindow expects a ResolvedConfig – pass a mock + with patch('synodic_client.application.screen.tray.SettingsWindow'): + ts = TrayScreen(app, client, window) + + return ts + + +class TestToolUpdateWindowShow: + """_on_tool_update_finished should only show the window for manual updates.""" + + @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) + tray_screen._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) + tray_screen._on_tool_update_finished(result, updating_plugin='pipx', manual=True) + 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) + tray_screen._on_tool_update_finished( + result, + updating_package=('pipx', 'ruff'), + manual=True, + ) + tray_screen._window.show.assert_called_once() + + @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) + tray_screen._on_tool_update_finished(result) + tray_screen._window.show.assert_not_called() From d334167e245a78091925fb5a1052406ba5f07cf5 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Mon, 2 Mar 2026 11:44:36 -0800 Subject: [PATCH 08/10] Shared Data Cache --- pdm.lock | 8 +- pyproject.toml | 19 +- synodic_client/application/data.py | 192 +++++++++++++++++++ synodic_client/application/screen/install.py | 12 +- synodic_client/application/screen/screen.py | 159 +++++++++++---- synodic_client/application/screen/tray.py | 34 +++- synodic_client/application/workers.py | 41 ++-- tests/unit/qt/test_gather_packages.py | 6 +- tests/unit/qt/test_install_preview.py | 12 +- tests/unit/qt/test_tray_window_show.py | 11 +- tool/scripts/setup_dev.py | 2 +- 11 files changed, 409 insertions(+), 87 deletions(-) create mode 100644 synodic_client/application/data.py diff --git a/pdm.lock b/pdm.lock index c7b2873..e1924b9 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "build", "lint", "test"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:995f0f6259b50fd9d675397778c6e67d831a3a59661672d51abe2649e8c3f1e2" +content_hash = "sha256:096960706d2cc280fb94479f6e26ffd584bc09e3aa0dca9111d9983eff43fd29" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -336,7 +336,7 @@ files = [ [[package]] name = "porringer" -version = "0.2.1.dev67" +version = "0.2.1.dev68" requires_python = ">=3.14" summary = "" groups = ["default"] @@ -349,8 +349,8 @@ dependencies = [ "userpath>=1.9.2", ] files = [ - {file = "porringer-0.2.1.dev67-py3-none-any.whl", hash = "sha256:a1548af376478de3c3c3d40743fec62c8acd6fe3c342122dee37d835883c8338"}, - {file = "porringer-0.2.1.dev67.tar.gz", hash = "sha256:f249cc9b8f45a2844aef8403fd7b623546515864cebeedcd3caec6cb4e35d43f"}, + {file = "porringer-0.2.1.dev68-py3-none-any.whl", hash = "sha256:98bddfa30b88094f64a7c012fc48ab7a2ec0c9cff27a6870a904c8d4eb0ee5d1"}, + {file = "porringer-0.2.1.dev68.tar.gz", hash = "sha256:1632b9fb802a670657e15166a3ee84d397eea67086d0421ff5b186cb684729f8"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index cdd2a21..5860b65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ requires-python = ">=3.14, <3.15" dependencies = [ "pyside6>=6.10.2", "packaging>=26.0", - "porringer>=0.2.1.dev67", + "porringer>=0.2.1.dev68", "qasync>=0.28.0", "velopack>=0.0.1444.dev49733", "typer>=0.24.1", @@ -25,18 +25,9 @@ homepage = "https://github.com/synodic/synodic-client" repository = "https://github.com/synodic/synodic-client" [dependency-groups] -build = [ - "pyinstaller>=6.19.0", -] -lint = [ - "ruff>=0.15.4", - "pyrefly>=0.54.0", -] -test = [ - "pytest>=9.0.2", - "pytest-cov>=7.0.0", - "pytest-mock>=3.15.1", -] +build = ["pyinstaller>=6.19.0"] +lint = ["ruff>=0.15.4", "pyrefly>=0.54.0"] +test = ["pytest>=9.0.2", "pytest-cov>=7.0.0", "pytest-mock>=3.15.1"] [project.scripts] synodic-c = "synodic_client.cli:app" @@ -93,7 +84,7 @@ write_template = "__version__ = '{}'\n" allow-prereleases = true [tool.pdm.scripts] -analyze = "ruff check synodic_client tests" +analyze = "ruff check" dev = { call = "tool.scripts.dev:main" } format = "ruff format" lint = { composite = ["analyze", "format", "type-check"] } diff --git a/synodic_client/application/data.py b/synodic_client/application/data.py new file mode 100644 index 0000000..f43d587 --- /dev/null +++ b/synodic_client/application/data.py @@ -0,0 +1,192 @@ +"""Shared data coordinator for the Synodic Client application. + +Centralises porringer API calls so that plugin discovery, directory +listing, and runtime context resolution happen once per refresh cycle +and the results are reused by every consumer (ToolsView, ProjectsView, +TrayScreen, install workers). + +The coordinator follows an *invalidate-on-mutation* strategy: callers +that modify state (install, uninstall, add/remove directory) call +:meth:`invalidate` to force the next :meth:`refresh` to re-fetch. +""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass, field + +from porringer.api import API +from porringer.backend.command.core.discovery import DiscoveredPlugins +from porringer.core.plugin_schema.plugin_manager import PluginManager +from porringer.schema import ( + CheckParameters, + CheckResult, + DirectoryValidationResult, + ManifestDirectory, + PluginInfo, +) + +logger = logging.getLogger(__name__) + + +@dataclass(slots=True) +class Snapshot: + """Immutable bundle of data produced by a single refresh cycle. + + All fields are populated by :meth:`DataCoordinator.refresh` and + remain stable until the next refresh. + """ + + plugins: list[PluginInfo] = field(default_factory=list) + """All discovered plugins with install status and version info.""" + + directories: list[ManifestDirectory] = field(default_factory=list) + """Cached project directories (un-validated).""" + + validated_directories: list[DirectoryValidationResult] = field(default_factory=list) + """Cached directories with ``exists`` / ``has_manifest`` validation.""" + + discovered: DiscoveredPlugins | None = None + """Full plugin discovery result including runtime context.""" + + plugin_managers: dict[str, PluginManager] = field(default_factory=dict) + """Project-environment plugins implementing the ``PluginManager`` protocol.""" + + +class DataCoordinator: + """Single source of truth for porringer data across the application. + + Usage:: + + coordinator = DataCoordinator(porringer) + snapshot = await coordinator.refresh() # first load + # … later, after an install … + coordinator.invalidate() + snapshot = await coordinator.refresh() # re-fetches everything + + The coordinator caches the most recent :class:`Snapshot` so that + synchronous property access (``coordinator.snapshot``) is available + between refresh cycles. + """ + + def __init__(self, porringer: API) -> None: + """Initialize the coordinator with a porringer API instance.""" + self._porringer = porringer + self._snapshot: Snapshot = Snapshot() + self._stale = True + self._refresh_lock = asyncio.Lock() + + # -- Public API -------------------------------------------------------- + + @property + def snapshot(self) -> Snapshot: + """Return the most recent snapshot (may be empty before first refresh).""" + return self._snapshot + + @property + def discovered_plugins(self) -> DiscoveredPlugins | None: + """Shortcut to the current ``DiscoveredPlugins`` instance.""" + return self._snapshot.discovered + + def invalidate(self) -> None: + """Mark the cached data as stale. + + The next call to :meth:`refresh` will re-fetch everything from + porringer. This is a lightweight O(1) flag-flip. + """ + self._stale = True + + async def refresh(self, *, force: bool = False) -> Snapshot: + """Fetch fresh data from porringer if stale (or *force* is set). + + Multiple concurrent callers are coalesced via an ``asyncio.Lock`` + so that only one discovery + listing round-trip runs at a time. + + Returns: + The populated :class:`Snapshot`. + """ + if not self._stale and not force: + return self._snapshot + + async with self._refresh_lock: + # Double-check after acquiring the lock — another coroutine + # may have already refreshed while we were waiting. + if not self._stale and not force: + return self._snapshot + + self._snapshot = await self._fetch() + self._stale = False + return self._snapshot + + async def check_updates( + self, + plugins: list[str] | None = None, + ) -> list[CheckResult]: + """Run update detection using the cached ``DiscoveredPlugins``. + + Args: + plugins: Optional include-set of plugin names. ``None`` + means all plugins. + + Returns: + A list of :class:`CheckResult` per plugin. + """ + params = CheckParameters(plugins=plugins) + return await self._porringer.sync.check_updates( + params, + plugins=self._snapshot.discovered, + ) + + # -- Internals --------------------------------------------------------- + + async def _fetch(self) -> Snapshot: + """Run the full discovery + listing pipeline. + + 1. ``API.discover_plugins()`` — plugin entry-points + runtime + context in one shot. + 2. ``PluginCommands.list()`` — installed status + versions, + passing the already-discovered ``DiscoveredPlugins``. + 3. ``cache.list_directories(validate=True, check_manifest=True)`` + — directory listing with validation baked in. + 4. Filter ``project_environments`` for ``PluginManager`` instances. + + All blocking calls are dispatched via ``asyncio.to_thread``. + """ + loop = asyncio.get_running_loop() + + # Step 1: discover all plugins + resolve runtime context + discovered = await API.discover_plugins() + + # Step 2 + 3 in parallel: plugin list + validated directories + plugins_task = asyncio.create_task( + self._porringer.plugin.list(plugins=discovered), + ) + dirs_future = loop.run_in_executor( + None, + lambda: self._porringer.cache.list_directories( + validate=True, + check_manifest=True, + ), + ) + + plugins = await plugins_task + validated = await dirs_future + + # Step 4: extract PluginManager instances from project_environments + managers: dict[str, PluginManager] = {} + for _name, env in discovered.project_environments.items(): + if isinstance(env, PluginManager) and env.is_available(): + managers[env.tool_name()] = env + + # Derive the un-validated directory list for callers that only + # need path + name (e.g. _gather_packages). + directories = [r.directory for r in validated] + + return Snapshot( + plugins=plugins, + directories=directories, + validated_directories=validated, + discovered=discovered, + plugin_managers=managers, + ) diff --git a/synodic_client/application/screen/install.py b/synodic_client/application/screen/install.py index 4b2b48b..70b35c5 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -24,6 +24,7 @@ from urllib.request import url2pathname from porringer.api import API +from porringer.backend.command.core.discovery import DiscoveredPlugins from porringer.schema import ( DownloadParameters, ProgressEvent, @@ -230,6 +231,8 @@ async def run_install( manifest_path: Path, config: InstallConfig | None = None, callbacks: InstallCallbacks | None = None, + *, + plugins: DiscoveredPlugins | None = None, ) -> SetupResults: """Execute setup actions via porringer and stream progress. @@ -243,6 +246,8 @@ async def run_install( 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`. @@ -259,7 +264,7 @@ async def run_install( collected: list[SetupActionResult] = [] manifest_result: SetupResults | None = None - async for event in porringer.sync.execute_stream(params): + async for event in porringer.sync.execute_stream(params, plugins=plugins): if event.kind == ProgressEventKind.MANIFEST_LOADED and event.manifest: manifest_result = event.manifest actions = list(event.manifest.actions) @@ -1313,6 +1318,7 @@ async def run_preview( *, config: PreviewConfig | None = None, callbacks: PreviewCallbacks | None = None, + plugins: DiscoveredPlugins | None = None, ) -> None: """Download a manifest and perform a dry-run preview. @@ -1330,6 +1336,8 @@ async def run_preview( 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. """ logger.info('run_preview starting for: %s', url) temp_dir: str | None = None @@ -1350,7 +1358,7 @@ async def run_preview( temp_dir_str = temp_dir or '' manifest_path_str = str(manifest_path) - async for event in porringer.sync.execute_stream(setup_params): + async for event in porringer.sync.execute_stream(setup_params, plugins=plugins): _dispatch_preview_event( event, manifest_path_str, diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 8a1ece4..58c1aac 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -1,7 +1,6 @@ """Screen class for the Synodic Client application.""" import asyncio -import contextlib import logging from collections import OrderedDict from dataclasses import dataclass, field @@ -12,13 +11,13 @@ from porringer.core.plugin_schema.plugin_manager import PluginManager from porringer.core.plugin_schema.project_environment import ProjectEnvironment from porringer.schema import ( - DirectoryValidationResult, ManifestDirectory, PluginInfo, ProgressEventKind, SetupAction, SetupParameters, SkipReason, + SyncStrategy, ) from porringer.schema.plugin import PluginKind from PySide6.QtCore import QRect, Qt, QTimer, Signal @@ -37,6 +36,7 @@ QWidget, ) +from synodic_client.application.data import DataCoordinator from synodic_client.application.icon import app_icon from synodic_client.application.screen import plugin_kind_group_label from synodic_client.application.screen.install import PreviewPhase, SetupPreviewWidget @@ -626,6 +626,8 @@ def __init__( porringer: API, config: ResolvedConfig, parent: QWidget | None = None, + *, + coordinator: DataCoordinator | None = None, ) -> None: """Initialize the tools view. @@ -633,10 +635,14 @@ def __init__( porringer: The porringer API instance. config: Resolved configuration (for auto-update toggles). parent: Optional parent widget. + coordinator: Shared data coordinator. When provided, the + view delegates plugin/directory fetching to the + coordinator instead of calling porringer directly. """ super().__init__(parent) self._porringer = porringer self._config = config + self._coordinator = coordinator self._section_widgets: list[QWidget] = [] self._refresh_in_progress = False self._check_in_progress = False @@ -922,9 +928,12 @@ def _insert_section_widget(self, widget: QWidget) -> None: self._section_widgets.append(widget) async def _fetch_data(self) -> tuple[list[PluginInfo], list[ManifestDirectory]]: - """Fetch plugin list and directories.""" + """Fetch plugin list and directories via the coordinator (or direct fallback).""" + if self._coordinator is not None: + snapshot = await self._coordinator.refresh() + return snapshot.plugins, snapshot.directories plugins = await self._porringer.plugin.list() - directories = self._porringer.cache.list_directories() + directories = [r.directory for r in self._porringer.cache.list_directories()] return plugins, directories async def _gather_packages( @@ -944,10 +953,14 @@ async def _gather_packages( A list of :class:`PackageEntry` instances. """ packages: list[PackageEntry] = [] + discovered = self._coordinator.discovered_plugins if self._coordinator else None async def _list_global() -> None: try: - pkgs = await self._porringer.plugin.list_packages(plugin_name) + pkgs = await self._porringer.plugin.list_packages( + plugin_name, + plugins=discovered, + ) packages.extend( PackageEntry( name=str(pkg.name), @@ -968,6 +981,7 @@ async def _list_one(directory: ManifestDirectory) -> None: pkgs = await self._porringer.plugin.list_packages( plugin_name, Path(directory.path), + plugins=discovered, ) packages.extend( PackageEntry( @@ -1002,11 +1016,8 @@ async def _gather_tool_plugins( ) -> dict[str, list[PackageEntry]]: """Query :class:`PluginManager` instances for natively managed sub-plugins. - Discovers project-environment plugins that implement the - ``PluginManager`` protocol (e.g. PDM, Poetry) and calls - ``installed_plugins()`` on each. Each returned - :class:`Package` carries a :class:`PackageRelation` whose - *host* field identifies the parent tool. + Uses the coordinator's pre-discovered ``plugin_managers`` when + available, avoiding redundant ``Builder.find_plugins`` calls. Returns: A dict mapping host-tool name (e.g. ``"pdm"``) to a list of @@ -1014,8 +1025,12 @@ async def _gather_tool_plugins( """ results: dict[str, list[PackageEntry]] = {} - loop = asyncio.get_running_loop() - managers = await loop.run_in_executor(None, self._discover_plugin_managers) + if self._coordinator is not None: + managers = self._coordinator.snapshot.plugin_managers + else: + # Fallback: discover from scratch (legacy path / tests) + loop = asyncio.get_running_loop() + managers = await loop.run_in_executor(None, self._discover_plugin_managers) async def _query(tool_name: str, manager: PluginManager) -> None: try: @@ -1045,6 +1060,8 @@ async def _query(tool_name: str, manager: PluginManager) -> None: def _discover_plugin_managers() -> dict[str, PluginManager]: """Discover project-environment plugins implementing ``PluginManager`` (sync). + Fallback for when no ``DataCoordinator`` is available. + Returns: A dict mapping tool name to :class:`PluginManager` instance. """ @@ -1060,7 +1077,13 @@ async def _gather_project_requirements( self, directory: ManifestDirectory, ) -> list[SetupAction]: - """Run a dry-run execute_stream for *directory* and collect actions.""" + """Load the manifest for *directory* and return its actions. + + When a :class:`DataCoordinator` is available the efficient + ``async_load_manifest`` path is used (no streaming, no + ``aclosing`` needed). The legacy ``execute_stream`` path is + kept as a fallback for tests and headless usage. + """ actions: list[SetupAction] = [] try: path = Path(directory.path) @@ -1075,13 +1098,24 @@ async def _gather_project_requirements( if manifest_path is None: return actions - params = SetupParameters( - paths=[str(manifest_path)], - dry_run=True, - project_directory=path, - ) - async with contextlib.aclosing(self._porringer.sync.execute_stream(params)) as stream: - async for event in stream: + discovered = self._coordinator.discovered_plugins if self._coordinator else None + + if discovered is not None: + # Fast path: single-shot manifest load + result = await self._porringer.sync.async_load_manifest( + manifest_path, + SyncStrategy.MINIMAL, + plugins=discovered, + ) + actions.extend(result.actions) + else: + # Legacy path: stream and break after first parse + params = SetupParameters( + paths=[str(manifest_path)], + dry_run=True, + project_directory=path, + ) + async for event in self._porringer.sync.execute_stream(params): if event.kind == ProgressEventKind.MANIFEST_PARSED and event.manifest: actions.extend(event.manifest.actions) break @@ -1167,13 +1201,20 @@ async def _check_for_updates( self, directories: list[ManifestDirectory], ) -> dict[str, set[str]]: - """Detect available updates across cached manifests via dry-run. + """Detect available updates across cached manifests. - All directories are checked in parallel via ``asyncio.TaskGroup``. + When a :class:`DataCoordinator` is available the efficient + ``check_updates()`` API is used (single call, no streaming). + Falls back to per-directory ``execute_stream`` dry-runs + otherwise. Returns a mapping of ``{plugin_name: {package_names…}}`` for packages that have a newer version available. """ + if self._coordinator is not None: + return await self._check_updates_via_coordinator() + + # Legacy per-directory fallback available: dict[str, set[str]] = {} async def _check_one(directory: ManifestDirectory) -> None: @@ -1187,11 +1228,26 @@ async def _check_one(directory: ManifestDirectory) -> None: return available + async def _check_updates_via_coordinator(self) -> dict[str, set[str]]: + """Use the coordinator's ``check_updates`` for efficient detection.""" + assert self._coordinator is not None + results = await self._coordinator.check_updates() + available: dict[str, set[str]] = {} + for cr in results: + if cr.success: + updated = {pi.name for pi in cr.packages if pi.update_available} + if updated: + available[cr.plugin] = updated + return available + async def _check_directory_updates( self, directory: ManifestDirectory, ) -> dict[str, set[str]]: - """Check a single directory for available updates (dry-run).""" + """Check a single directory for available updates (dry-run). + + Legacy fallback used when no coordinator is available. + """ available: dict[str, set[str]] = {} try: path = Path(directory.path) @@ -1369,17 +1425,27 @@ class ProjectsView(QWidget): in parallel on first refresh; switching between them is instant. """ - def __init__(self, porringer: API, config: ResolvedConfig, parent: QWidget | None = None) -> None: + def __init__( + self, + porringer: API, + config: ResolvedConfig, + parent: QWidget | None = None, + *, + coordinator: DataCoordinator | None = None, + ) -> None: """Initialize the projects view. Args: porringer: The porringer API instance. config: Resolved configuration. parent: Optional parent widget. + coordinator: Shared data coordinator for validated directory + data. """ super().__init__(parent) self._porringer = porringer self._config = config + self._coordinator = coordinator self._refresh_in_progress = False self._pending_select: Path | None = None self._widgets: dict[Path, SetupPreviewWidget] = {} @@ -1434,17 +1500,24 @@ async def _async_refresh(self) -> None: previous = self._pending_select or self._sidebar.selected_path self._pending_select = None - loop = asyncio.get_running_loop() - results: list[DirectoryValidationResult] = await loop.run_in_executor( - None, - lambda: self._porringer.cache.validate_directories(check_manifest=True), - ) + if self._coordinator is not None: + snapshot = await self._coordinator.refresh() + results = snapshot.validated_directories + else: + loop = asyncio.get_running_loop() + results = await loop.run_in_executor( + None, + lambda: self._porringer.cache.list_directories( + validate=True, + check_manifest=True, + ), + ) directories: list[tuple[Path, str, bool]] = [] current_paths: set[Path] = set() for result in results: d = result.directory - valid = result.exists and result.has_manifest is not False + valid = bool(result.exists and result.has_manifest is not False) path = Path(d.path) directories.append((path, d.name or '', valid)) current_paths.add(path) @@ -1532,6 +1605,8 @@ def _on_add(self) -> None: except ValueError: logger.debug('Directory already cached: %s', directory) + if self._coordinator is not None: + self._coordinator.invalidate() self._pending_select = directory self.refresh() @@ -1547,10 +1622,14 @@ def _on_remove(self, path: Path) -> None: widget.reset() widget.deleteLater() + if self._coordinator is not None: + self._coordinator.invalidate() self.refresh() def _on_install_finished(self, _results: object) -> None: """Refresh after a successful install.""" + if self._coordinator is not None: + self._coordinator.invalidate() self.refresh() @@ -1581,6 +1660,7 @@ def __init__( super().__init__() self._porringer = porringer self._config = config + self._coordinator: DataCoordinator | None = DataCoordinator(porringer) if porringer is not None else None self.setWindowTitle('Synodic Client') self.setMinimumSize(*MAIN_WINDOW_MIN_SIZE) self.setWindowIcon(app_icon()) @@ -1593,6 +1673,11 @@ def porringer(self) -> API | None: """Return the porringer API instance, if available.""" return self._porringer + @property + def coordinator(self) -> DataCoordinator | None: + """Return the shared data coordinator, if available.""" + return self._coordinator + @property def tools_view(self) -> ToolsView | None: """Return the tools view, if initialised.""" @@ -1608,10 +1693,20 @@ def show(self) -> None: if self._tabs is None and self._porringer is not None and self._config is not None: self._tabs = QTabWidget(self) - self._projects_view = ProjectsView(self._porringer, self._config, self) + self._projects_view = ProjectsView( + self._porringer, + self._config, + self, + coordinator=self._coordinator, + ) self._tabs.addTab(self._projects_view, 'Projects') - self._tools_view = ToolsView(self._porringer, self._config, self) + self._tools_view = ToolsView( + self._porringer, + self._config, + self, + coordinator=self._coordinator, + ) self._tabs.addTab(self._tools_view, 'Tools') self.tools_view_created.emit(self._tools_view) diff --git a/synodic_client/application/screen/tray.py b/synodic_client/application/screen/tray.py index 484fa19..20d87c3 100644 --- a/synodic_client/application/screen/tray.py +++ b/synodic_client/application/screen/tray.py @@ -332,8 +332,16 @@ def _on_tool_update(self) -> None: async def _do_tool_update(self, porringer: API) -> None: """Resolve enabled plugins off-thread, then run the tool update.""" config = self._resolve_config() + coordinator = self._window.coordinator + + if coordinator is not None: + snapshot = await coordinator.refresh() + all_plugins = snapshot.plugins + discovered = snapshot.discovered + else: + all_plugins = await porringer.plugin.list() + discovered = None - all_plugins = await porringer.plugin.list() all_names = [p.name for p in all_plugins if p.installed] enabled_plugins, include_packages = resolve_auto_update_scope( config, @@ -345,7 +353,10 @@ async def _do_tool_update(self, porringer: API) -> None: porringer, plugins=enabled_plugins, include_packages=include_packages, + discovered_plugins=discovered, ) + if coordinator is not None: + coordinator.invalidate() self._on_tool_update_finished(result) except Exception as exc: logger.exception('Tool update failed') @@ -371,6 +382,8 @@ async def _async_single_plugin_update(self, porringer: API, plugin_name: str) -> config = self._resolve_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 @@ -384,7 +397,10 @@ async def _async_single_plugin_update(self, porringer: API, plugin_name: str) -> porringer, plugins={plugin_name}, include_packages=include_packages, + discovered_plugins=discovered, ) + if coordinator is not None: + coordinator.invalidate() self._on_tool_update_finished(result, updating_plugin=plugin_name, manual=True) except Exception as exc: logger.exception('Tool update failed') @@ -415,12 +431,17 @@ async def _async_single_package_update( package_name: str, ) -> None: """Run a single-package tool update and route results.""" + coordinator = self._window.coordinator + discovered = coordinator.discovered_plugins if coordinator is not None else None try: result = await run_tool_updates( porringer, plugins={plugin_name}, include_packages={package_name}, + discovered_plugins=discovered, ) + if coordinator is not None: + coordinator.invalidate() self._on_tool_update_finished( result, updating_package=(plugin_name, package_name), @@ -497,8 +518,15 @@ async def _async_single_package_remove( package_name: str, ) -> None: """Run a single-package removal and route results.""" + coordinator = self._window.coordinator + discovered = coordinator.discovered_plugins if coordinator is not None else None try: - result = await run_package_remove(porringer, plugin_name, package_name) + result = await run_package_remove( + porringer, + plugin_name, + package_name, + discovered_plugins=discovered, + ) logger.info( 'Removal result for %s/%s: success=%s, skipped=%s, skip_reason=%s, message=%s', plugin_name, @@ -508,6 +536,8 @@ async def _async_single_package_remove( result.skip_reason, result.message, ) + if coordinator is not None: + coordinator.invalidate() self._on_package_remove_finished(result, plugin_name, package_name) except Exception as exc: logger.exception('Package removal failed') diff --git a/synodic_client/application/workers.py b/synodic_client/application/workers.py index fcc1117..3c2fae5 100644 --- a/synodic_client/application/workers.py +++ b/synodic_client/application/workers.py @@ -13,6 +13,7 @@ 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 ProgressEventKind, SetupParameters, SkipReason, SyncStrategy from porringer.schema.execution import SetupActionResult @@ -79,6 +80,8 @@ 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. @@ -90,30 +93,25 @@ async def run_tool_updates( 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() - directories = await loop.run_in_executor(None, porringer.cache.list_directories) - - # Check all directories for manifests in parallel - paths = [Path(d.path) for d in directories] - has_map: dict[Path, bool] = {} - - async def _check_manifest(p: Path) -> None: - has_map[p] = await loop.run_in_executor(None, porringer.sync.has_manifest, p) - - async with asyncio.TaskGroup() as tg: - for p in paths: - tg.create_task(_check_manifest(p)) + dir_results = await loop.run_in_executor( + None, + lambda: porringer.cache.list_directories(validate=True, check_manifest=True), + ) result = ToolUpdateResult() - for path in paths: - has = has_map[path] - if not has: - logger.debug('Skipping path without manifest: %s', path) + 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, @@ -121,7 +119,10 @@ async def _check_manifest(p: Path) -> None: plugins=plugins, include_packages=include_packages, ) - async for event in porringer.sync.execute_stream(params): + async for event in porringer.sync.execute_stream( + params, + plugins=discovered_plugins, + ): if event.kind == ProgressEventKind.ACTION_COMPLETED and event.result is not None: action_result = event.result if action_result.skipped: @@ -144,6 +145,8 @@ 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. @@ -151,9 +154,11 @@ async def run_package_remove( 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.uninstall(plugin_name, package_ref) + return await porringer.uninstall(plugin_name, package_ref, plugins=discovered_plugins) diff --git a/tests/unit/qt/test_gather_packages.py b/tests/unit/qt/test_gather_packages.py index 0ab459f..99b9021 100644 --- a/tests/unit/qt/test_gather_packages.py +++ b/tests/unit/qt/test_gather_packages.py @@ -102,7 +102,7 @@ def test_per_directory_queries_still_work() -> None: """Per-directory queries continue to run alongside the global query.""" porringer = _make_porringer() - async def _mock_list(plugin_name: str, project_path: Path | None = None) -> list[Package]: + async def _mock_list(plugin_name: str, project_path: Path | None = None, **kwargs) -> list[Package]: if project_path is None: return [Package(name='pdm', version='2.22.4')] return [Package(name='mylib', version='1.0.0')] @@ -122,7 +122,7 @@ def test_per_directory_packages_carry_project_path() -> None: """Per-directory packages should include the directory path as project_path.""" porringer = _make_porringer() - async def _mock_list(plugin_name: str, project_path: Path | None = None) -> list[Package]: + async def _mock_list(plugin_name: str, project_path: Path | None = None, **kwargs) -> list[Package]: if project_path is None: return [] return [Package(name='mylib', version='1.0.0')] @@ -159,7 +159,7 @@ def test_global_query_failure_does_not_block_directory_queries() -> None: porringer = _make_porringer() call_count = 0 - async def _mock_list(plugin_name: str, project_path: Path | None = None) -> list[Package]: + async def _mock_list(plugin_name: str, project_path: Path | None = None, **kwargs) -> list[Package]: nonlocal call_count call_count += 1 if project_path is None: diff --git a/tests/unit/qt/test_install_preview.py b/tests/unit/qt/test_install_preview.py index 33942c1..60cc851 100644 --- a/tests/unit/qt/test_install_preview.py +++ b/tests/unit/qt/test_install_preview.py @@ -196,7 +196,7 @@ def test_worker_passes_prerelease_packages() -> None: captured_params: list[Any] = [] - async def mock_stream(params: Any) -> Any: + async def mock_stream(params: Any, **kwargs: Any) -> Any: captured_params.append(params) yield manifest_event @@ -224,7 +224,7 @@ def test_worker_omits_prerelease_when_none() -> None: captured_params: list[Any] = [] - async def mock_stream(params: Any) -> Any: + async def mock_stream(params: Any, **kwargs: Any) -> Any: captured_params.append(params) yield manifest_event @@ -683,7 +683,7 @@ def test_passes_detect_updates_and_prerelease_packages(tmp_path: Path) -> None: captured_params: list[Any] = [] - async def mock_stream(params: Any) -> Any: + async def mock_stream(params: Any, **kwargs: Any) -> Any: captured_params.append(params) yield manifest_event @@ -716,7 +716,7 @@ def test_defaults_detect_updates_true(tmp_path: Path) -> None: captured_params: list[Any] = [] - async def mock_stream(params: Any) -> Any: + async def mock_stream(params: Any, **kwargs: Any) -> Any: captured_params.append(params) yield manifest_event @@ -766,7 +766,7 @@ def test_file_path_forwards_parent_as_project_directory(tmp_path: Path) -> None: captured_params: list[Any] = [] - async def mock_stream(params: Any) -> Any: + async def mock_stream(params: Any, **kwargs: Any) -> Any: captured_params.append(params) yield manifest_event @@ -795,7 +795,7 @@ def test_directory_path_forwarded_directly(tmp_path: Path) -> None: captured_params: list[Any] = [] - async def mock_stream(params: Any) -> Any: + async def mock_stream(params: Any, **kwargs: Any) -> Any: captured_params.append(params) yield manifest_event diff --git a/tests/unit/qt/test_tray_window_show.py b/tests/unit/qt/test_tray_window_show.py index a1d8f23..ab8d07e 100644 --- a/tests/unit/qt/test_tray_window_show.py +++ b/tests/unit/qt/test_tray_window_show.py @@ -6,22 +6,23 @@ import pytest +from synodic_client.application.screen.tray import TrayScreen from synodic_client.application.workers import ToolUpdateResult -@pytest.fixture() +@pytest.fixture def tray_screen(): """Build a minimal ``TrayScreen`` with mocked collaborators.""" - with patch('synodic_client.application.screen.tray.resolve_config'), \ - patch('synodic_client.application.screen.tray.resolve_update_config') as mock_ucfg: + with ( + patch('synodic_client.application.screen.tray.resolve_config'), + patch('synodic_client.application.screen.tray.resolve_update_config') as mock_ucfg, + ): # Disable timers by setting intervals to 0 mock_ucfg.return_value = MagicMock( auto_update_interval_minutes=0, tool_update_interval_minutes=0, ) - from synodic_client.application.screen.tray import TrayScreen - app = MagicMock() client = MagicMock() window = MagicMock() diff --git a/tool/scripts/setup_dev.py b/tool/scripts/setup_dev.py index 1abd639..3840a20 100644 --- a/tool/scripts/setup_dev.py +++ b/tool/scripts/setup_dev.py @@ -34,7 +34,7 @@ def main() -> None: porringer = API(local_config) cached = porringer.cache.list_directories() - registered = {d.path.resolve(): d for d in cached} + registered = {dr.directory.path.resolve(): dr.directory for dr in cached} example_dirs = {child.resolve() for child in _EXAMPLES_DIR.iterdir() if child.is_dir()} # --- Prune stale entries whose directories no longer exist under examples/ --- From 778db1af852b81b700943f8e55e4be660021e2f8 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Mon, 2 Mar 2026 18:03:15 -0800 Subject: [PATCH 09/10] Fix Project Merging --- pdm.lock | 28 ++++---- pyproject.toml | 17 +++-- synodic_client/application/screen/install.py | 3 + synodic_client/application/screen/screen.py | 23 +++++- uninstall_debug.txt | 74 ++++++++++++++++++++ 5 files changed, 126 insertions(+), 19 deletions(-) create mode 100644 uninstall_debug.txt diff --git a/pdm.lock b/pdm.lock index e1924b9..3bece04 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "build", "lint", "test"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:096960706d2cc280fb94479f6e26ffd584bc09e3aa0dca9111d9983eff43fd29" +content_hash = "sha256:4f869c706d2d3e53762814d8c7e4076d92eb7718ef7c88ebbf1379cefcaa907b" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -336,7 +336,7 @@ files = [ [[package]] name = "porringer" -version = "0.2.1.dev68" +version = "0.2.1.dev71" requires_python = ">=3.14" summary = "" groups = ["default"] @@ -349,8 +349,8 @@ dependencies = [ "userpath>=1.9.2", ] files = [ - {file = "porringer-0.2.1.dev68-py3-none-any.whl", hash = "sha256:98bddfa30b88094f64a7c012fc48ab7a2ec0c9cff27a6870a904c8d4eb0ee5d1"}, - {file = "porringer-0.2.1.dev68.tar.gz", hash = "sha256:1632b9fb802a670657e15166a3ee84d397eea67086d0421ff5b186cb684729f8"}, + {file = "porringer-0.2.1.dev71-py3-none-any.whl", hash = "sha256:727367880f0e3b419cce2f58fb7efd81b55e756291bc3ebf5fca577531068061"}, + {file = "porringer-0.2.1.dev71.tar.gz", hash = "sha256:e8aeaa446639f34a95f18a11c35b6c71fb9fe1a29d0a9e31cc2bfebba30f369e"}, ] [[package]] @@ -471,20 +471,20 @@ files = [ [[package]] name = "pyrefly" -version = "0.54.0" +version = "0.55.0" requires_python = ">=3.8" summary = "A fast type checker and language server for Python with powerful IDE features" groups = ["lint"] files = [ - {file = "pyrefly-0.54.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:58a3f092b6dc25ef79b2dc6c69a40f36784ca157c312bfc0baea463926a9db6d"}, - {file = "pyrefly-0.54.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:615081414106dd95873bc39c3a4bed68754c6cc24a8177ac51d22f88f88d3eb3"}, - {file = "pyrefly-0.54.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbcaf20f5fe585079079a95205c1f3cd4542d17228cdf1df560288880623b70"}, - {file = "pyrefly-0.54.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66d5da116c0d34acfbd66663addd3ca8aa78a636f6692a66e078126d3620a883"}, - {file = "pyrefly-0.54.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef3ac27f1a4baaf67aead64287d3163350844794aca6315ad1a9650b16ec26a"}, - {file = "pyrefly-0.54.0-py3-none-win32.whl", hash = "sha256:7d607d72200a8afbd2db10bfefb40160a7a5d709d207161c21649cedd5cfc09a"}, - {file = "pyrefly-0.54.0-py3-none-win_amd64.whl", hash = "sha256:fd416f04f89309385696f685bd5c9141011f18c8072f84d31ca20c748546e791"}, - {file = "pyrefly-0.54.0-py3-none-win_arm64.whl", hash = "sha256:f06ab371356c7b1925e0bffe193b738797e71e5dbbff7fb5a13f90ee7521211d"}, - {file = "pyrefly-0.54.0.tar.gz", hash = "sha256:c6663be64d492f0d2f2a411ada9f28a6792163d34133639378b7f3dd9a8dca94"}, + {file = "pyrefly-0.55.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:49aafcefe5e2dd4256147db93e5b0ada42bff7d9a60db70e03d1f7055338eec9"}, + {file = "pyrefly-0.55.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2827426e6b28397c13badb93c0ede0fb0f48046a7a89e3d774cda04e8e2067cd"}, + {file = "pyrefly-0.55.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7346b2d64dc575bd61aa3bca854fbf8b5a19a471cbdb45e0ca1e09861b63488c"}, + {file = "pyrefly-0.55.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:233b861b4cff008b1aff62f4f941577ed752e4d0060834229eb9b6826e6973c9"}, + {file = "pyrefly-0.55.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5aa85657d76da1d25d081a49f0e33c8fc3ec91c1a0f185a8ed393a5a3d9e178"}, + {file = "pyrefly-0.55.0-py3-none-win32.whl", hash = "sha256:23f786a78536a56fed331b245b7d10ec8945bebee7b723491c8d66fdbc155fe6"}, + {file = "pyrefly-0.55.0-py3-none-win_amd64.whl", hash = "sha256:d465b49e999b50eeb069ad23f0f5710651cad2576f9452a82991bef557df91ee"}, + {file = "pyrefly-0.55.0-py3-none-win_arm64.whl", hash = "sha256:732ff490e0e863b296e7c0b2471e08f8ba7952f9fa6e9de09d8347fd67dde77f"}, + {file = "pyrefly-0.55.0.tar.gz", hash = "sha256:434c3282532dd4525c4840f2040ed0eb79b0ec8224fe18d957956b15471f2441"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 5860b65..a6bd48f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ requires-python = ">=3.14, <3.15" dependencies = [ "pyside6>=6.10.2", "packaging>=26.0", - "porringer>=0.2.1.dev68", + "porringer>=0.2.1.dev71", "qasync>=0.28.0", "velopack>=0.0.1444.dev49733", "typer>=0.24.1", @@ -25,9 +25,18 @@ homepage = "https://github.com/synodic/synodic-client" repository = "https://github.com/synodic/synodic-client" [dependency-groups] -build = ["pyinstaller>=6.19.0"] -lint = ["ruff>=0.15.4", "pyrefly>=0.54.0"] -test = ["pytest>=9.0.2", "pytest-cov>=7.0.0", "pytest-mock>=3.15.1"] +build = [ + "pyinstaller>=6.19.0", +] +lint = [ + "ruff>=0.15.4", + "pyrefly>=0.55.0", +] +test = [ + "pytest>=9.0.2", + "pytest-cov>=7.0.0", + "pytest-mock>=3.15.1", +] [project.scripts] synodic-c = "synodic_client.cli:app" diff --git a/synodic_client/application/screen/install.py b/synodic_client/application/screen/install.py index 70b35c5..aa33889 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -345,6 +345,7 @@ def __init__( self._porringer = porringer self._show_close = show_close self._config = config + self._discovered_plugins: DiscoveredPlugins | None = None self._model = PreviewModel() self._task: asyncio.Task[None] | None = None @@ -710,6 +711,7 @@ async def _run_preview_task( on_preview_ready=self._on_preview_resolved, on_action_checked=self._on_action_checked, ), + plugins=self._discovered_plugins, ) self._on_preview_finished() except asyncio.CancelledError: @@ -734,6 +736,7 @@ async def _run_install_task( on_sub_progress=self._on_sub_progress, on_progress=self._on_action_progress, ), + plugins=self._discovered_plugins, ) self._on_install_finished(results) except asyncio.CancelledError: diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 58c1aac..39a4d8b 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -879,16 +879,28 @@ def _merge_raw_packages( Same package from multiple directories becomes one row with a combined project label. Global packages are always deduplicated; manifest packages merge their project names. + + A package is considered "global" (and therefore removable from + the global environment) only when it is **not** declared in any + manifest **and** was found by the global (non-project-scoped) + query. Transitive dependencies discovered exclusively inside a + project venv are *not* globally removable — the uninstall API + targets the global Python, where those packages do not exist. """ merged: OrderedDict[str, MergedPackage] = OrderedDict() for entry in raw_packages: - is_global = entry.name not in plugin_manifest + in_manifest = entry.name in plugin_manifest + from_project = bool(entry.project_path) + is_global = not in_manifest and not from_project if entry.name in merged: existing = merged[entry.name] if not is_global and entry.project_label and entry.project_label not in existing.projects: existing.projects.append(entry.project_label) if not is_global and entry.project_path and entry.project_path not in existing.project_paths: existing.project_paths.append(entry.project_path) + # Promote to global if any entry was discovered globally + if is_global and not existing.is_global: + existing.is_global = True else: merged[entry.name] = MergedPackage( projects=([] if is_global else ([entry.project_label] if entry.project_label else [])), @@ -1530,6 +1542,9 @@ async def _async_refresh(self) -> None: widget.reset() widget.deleteLater() + # Grab pre-discovered plugins so each widget can skip redundant discovery + discovered = snapshot.discovered if self._coordinator is not None else None + # Create new widgets for new directories for path, _name, valid in directories: if path not in self._widgets and valid: @@ -1539,6 +1554,7 @@ async def _async_refresh(self) -> None: show_close=False, config=self._config, ) + widget._discovered_plugins = discovered widget.install_finished.connect(self._on_install_finished) widget.phase_changed.connect( lambda phase, p=path: self._on_widget_phase_changed(p, phase), @@ -1550,6 +1566,11 @@ async def _async_refresh(self) -> None: self._sidebar.set_directories(directories) self._sidebar.select(previous) + # Push latest discovered plugins to all existing widgets + if discovered is not None: + for w in self._widgets.values(): + w._discovered_plugins = discovered + # Load all stacked widgets in parallel for path, _name, valid in directories: widget = self._widgets.get(path) diff --git a/uninstall_debug.txt b/uninstall_debug.txt new file mode 100644 index 0000000..404cabd --- /dev/null +++ b/uninstall_debug.txt @@ -0,0 +1,74 @@ +.venv\Scripts\python.exe : DEBUG:asyncio:Using proactor: IocpProactor +At line:1 char:1 ++ .venv\Scripts\python.exe -c " ++ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + CategoryInfo : NotSpecified: (DEBUG:asyncio:U...r: IocpProactor + :String) [], RemoteException + + FullyQualifiedErrorId : NativeCommandError + +DEBUG:porringer.backend.builder:Entry points for porringer.environment: +['apt', 'brew', 'bun', 'deno', 'npm', 'pim', 'pip', 'pipx', 'pnpm', 'pyenv', +'uv', 'winget'] +DEBUG:porringer.backend.builder:environment plugin found: apt +DEBUG:porringer.backend.builder:environment plugin found: brew +DEBUG:porringer.backend.builder:environment plugin found: bun +DEBUG:porringer.backend.builder:environment plugin found: deno +DEBUG:porringer.backend.builder:environment plugin found: npm +DEBUG:porringer.backend.builder:environment plugin found: pim +DEBUG:porringer.backend.builder:environment plugin found: pip +DEBUG:porringer.backend.builder:environment plugin found: pipx +DEBUG:porringer.backend.builder:environment plugin found: pnpm +DEBUG:porringer.backend.builder:environment plugin found: pyenv +DEBUG:porringer.backend.builder:environment plugin found: uv +DEBUG:porringer.backend.builder:environment plugin found: winget +DEBUG:porringer.backend.builder:Plugin 'pim' dependency on 'winget' satisfied +DEBUG:porringer.backend.builder:Entry points for +porringer.project_environment: ['bun-project', 'deno-project', 'npm-project', +'pdm', 'pnpm-project', 'poetry', 'uv-project', 'yarn-project'] +DEBUG:porringer.backend.builder:project_environment plugin found: bun-project +DEBUG:porringer.backend.builder:project_environment plugin found: deno-project +DEBUG:porringer.backend.builder:project_environment plugin found: npm-project +DEBUG:porringer.backend.builder:project_environment plugin found: pdm +DEBUG:porringer.backend.builder:project_environment plugin found: pnpm-project +DEBUG:porringer.backend.builder:project_environment plugin found: poetry +DEBUG:porringer.backend.builder:project_environment plugin found: uv-project +DEBUG:porringer.backend.builder:project_environment plugin found: yarn-project +DEBUG:porringer.backend.builder:Entry points for porringer.scm: ['git'] +DEBUG:porringer.backend.builder:scm plugin found: git +INFO:porringer.backend.command.core.discovery:Plugin discovery: 12 +environments, 8 project, 1 scm +DEBUG:porringer.backend.command.core.discovery:Discovered plugins ù +environments: ['apt', 'brew', 'bun', 'deno', 'npm', 'pim', 'pip', 'pipx', +'pnpm', 'pyenv', 'uv', 'winget'], project: ['bun-project', 'deno-project', +'npm-project', 'pdm', 'pnpm-project', 'poetry', 'uv-project', 'yarn-project'], +scm: ['git'] +DEBUG:porringer.core.path:System PATH already in sync +DEBUG:porringer.backend.builder:RuntimeProvider 'pim' reported 1 tag(s): +['3.14-64'] +DEBUG:porringer.backend.builder:Resolved runtime 'python' via provider 'pim': +tag=3.14-64 +path=C:\Users\asher\AppData\Local\Python\pythoncore-3.14-64\python.exe +DEBUG:porringer.backend.builder:RuntimeProvider 'pyenv' is not available; +skipping +DEBUG:porringer.backend.builder:resolve_runtime_context complete: {'python': +'C:\\Users\\asher\\AppData\\Local\\Python\\pythoncore-3.14-64\\python.exe'} +DEBUG:porringer.api:discover_plugins: runtime_context={'python': +'C:\\Users\\asher\\AppData\\Local\\Python\\pythoncore-3.14-64\\python.exe'} +DEBUG:porringer.api:uninstall requested: plugin=pip package=cppython +dry_run=True +DEBUG:porringer.python_environment:python_command: using runtime override +C:\Users\asher\AppData\Local\Python\pythoncore-3.14-64\python.exe for +kind=python +DEBUG:porringer.pip.packages:listing packages via: +C:\Users\asher\AppData\Local\Python\pythoncore-3.14-64\python.exe +DEBUG:porringer.backend.command.core.resolution:packages query for pip +returned 43 entries +DEBUG:porringer.backend.command.core.resolution:is_package_installed('cppython' +): found=False matched=None +DEBUG:porringer.backend.command.core.resolution:resolved to skip: +reason=SkipReason.NOT_INSTALLED message='cppython' is not installed +=== Runtime Context === +executables: {'python': 'C:\\Users\\asher\\AppData\\Local\\Python\\pythoncore-3.14-64\\python.exe'} +=== Uninstall Result === +success=True skipped=True skip_reason=SkipReason.NOT_INSTALLED +message='cppython' is not installed From 33e8af04d7a8539bcf251092843777b9cc5d36ab Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Mon, 2 Mar 2026 19:40:14 -0800 Subject: [PATCH 10/10] Search --- synodic_client/application/screen/screen.py | 429 ++++++++++++++++--- synodic_client/application/theme.py | 70 ++++ tests/unit/qt/test_gather_packages.py | 442 +++++++++++++++++++- 3 files changed, 885 insertions(+), 56 deletions(-) diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 39a4d8b..400d919 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -27,6 +27,7 @@ QFrame, QHBoxLayout, QLabel, + QLineEdit, QMainWindow, QPushButton, QScrollArea, @@ -45,6 +46,8 @@ from synodic_client.application.screen.update_banner import UpdateBanner from synodic_client.application.theme import ( COMPACT_MARGINS, + FILTER_CHIP_SPACING, + FILTER_CHIP_STYLE, MAIN_WINDOW_MIN_SIZE, PLUGIN_KIND_HEADER_STYLE, PLUGIN_PROVIDER_NAME_STYLE, @@ -65,6 +68,13 @@ PLUGIN_SECTION_SPACING, PLUGIN_TOGGLE_STYLE, PLUGIN_UPDATE_STYLE, + PROJECT_CHILD_NAME_STYLE, + PROJECT_CHILD_NAV_STYLE, + PROJECT_CHILD_PROJECT_STYLE, + PROJECT_CHILD_ROW_STYLE, + PROJECT_CHILD_TRANSITIVE_STYLE, + PROJECT_CHILD_VERSION_STYLE, + SEARCH_INPUT_STYLE, SETTINGS_GEAR_STYLE, ) from synodic_client.resolution import ResolvedConfig, update_user_config @@ -121,29 +131,52 @@ class PackageEntry: @dataclass(slots=True) -class MergedPackage: - """Accumulated view of a package after deduplication across directories. +class ProjectInstance: + """A single project-scoped occurrence of a package. - Used during ``_async_refresh`` to merge multiple - :class:`PackageEntry` instances referencing the same package name - into a single row description. + Represents the package as found in one project venv. Multiple + instances may exist when the same package appears in several + cached projects. """ - projects: list[str] = field(default_factory=list) - """Display names of the projects referencing this package.""" + project_label: str + """Human-readable project directory label.""" - project_paths: list[str] = field(default_factory=list) - """Filesystem paths of the project directories.""" + project_path: str + """Filesystem path of the project directory.""" version: str = '' - """Installed version string.""" + """Installed version string in this project.""" + + is_transitive: bool = False + """``True`` when the package is not declared in the project manifest.""" + + +@dataclass(slots=True) +class DisplayPackage: + """Two-tier view of a package for the ToolsView widget tree. + + Replaces :class:`MergedPackage` with an explicit global/project + split. The ``global_version`` indicates whether the package is + installed in the global environment; ``project_instances`` lists + each project venv where it was found. + """ + + name: str + """Package name (e.g. ``"ruff"``).""" + + global_version: str | None = None + """Version in the global environment, or ``None`` when not global.""" is_global: bool = False - """``True`` when the package is not referenced by any project manifest.""" + """``True`` when the package is installed globally.""" host_tool: str = '' """Host-tool annotation for injected packages.""" + project_instances: list[ProjectInstance] = field(default_factory=list) + """Project-scoped occurrences of this package.""" + @dataclass(slots=True) class PluginRowData: @@ -598,6 +631,109 @@ def clear_error(self) -> None: self._status_label.hide() +# --------------------------------------------------------------------------- +# Project child row — indented sub-row for project-scoped packages +# --------------------------------------------------------------------------- + + +class ProjectChildRow(QFrame): + """Indented sub-row showing a project-scoped instance of a package. + + Displays the project label, version, and an optional ``(transitive)`` + annotation for packages not declared in the project manifest. + A small navigate button switches to the Projects tab. + + These rows appear directly below the parent :class:`PluginRow` and + are read-only — no update, remove, or auto-update controls. + """ + + navigate_to_project = Signal(str) + """Emitted with a project path when the navigate button is clicked.""" + + def __init__( + self, + project: ProjectInstance, + *, + package_name: str = '', + parent: QWidget | None = None, + ) -> None: + """Initialize the project child row. + + Args: + project: The project instance data to display. + package_name: Package name (displayed dimmed). + parent: Optional parent widget. + """ + super().__init__(parent) + self.setObjectName('projectChildRow') + self.setStyleSheet(PROJECT_CHILD_ROW_STYLE) + self._project_path = project.project_path + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(8) + + # Package name (dimmed) + if package_name: + name_label = QLabel(package_name) + name_label.setStyleSheet(PROJECT_CHILD_NAME_STYLE) + layout.addWidget(name_label) + + # Project label + project_label = QLabel(project.project_label) + project_label.setStyleSheet(PROJECT_CHILD_PROJECT_STYLE) + layout.addWidget(project_label) + + # Transitive annotation + if project.is_transitive: + transitive_label = QLabel('(transitive)') + transitive_label.setStyleSheet(PROJECT_CHILD_TRANSITIVE_STYLE) + layout.addWidget(transitive_label) + + layout.addStretch() + + # Version + if project.version: + version_label = QLabel(project.version) + version_label.setStyleSheet(PROJECT_CHILD_VERSION_STYLE) + layout.addWidget(version_label) + + # Navigate button + nav_btn = QPushButton('\u2192') + nav_btn.setFixedSize(18, 18) + nav_btn.setStyleSheet(PROJECT_CHILD_NAV_STYLE) + nav_btn.setCursor(Qt.CursorShape.PointingHandCursor) + nav_btn.setToolTip(f'Open project: {project.project_label}') + nav_btn.clicked.connect(lambda: self.navigate_to_project.emit(self._project_path)) + layout.addWidget(nav_btn) + + +# --------------------------------------------------------------------------- +# Filter chip — toggleable pill for plugin filtering +# --------------------------------------------------------------------------- + + +class FilterChip(QPushButton): + """Small toggleable pill button representing a single plugin filter. + + All chips start *checked* (active). The user deselects chips to + hide packages from that plugin — subtractive filtering. + """ + + toggled_with_name = Signal(str, bool) + """Emitted with ``(plugin_name, checked)`` when the chip is toggled.""" + + def __init__(self, plugin_name: str, parent: QWidget | None = None) -> None: + """Initialize a filter chip for the given plugin name.""" + super().__init__(plugin_name, parent) + self._plugin_name = plugin_name + self.setCheckable(True) + self.setChecked(True) + self.setStyleSheet(FILTER_CHIP_STYLE) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.toggled.connect(lambda checked: self.toggled_with_name.emit(self._plugin_name, checked)) + + class ToolsView(QWidget): """Central update hub showing installed tools and packages. @@ -644,6 +780,8 @@ def __init__( self._config = config self._coordinator = coordinator self._section_widgets: list[QWidget] = [] + self._filter_chips: dict[str, FilterChip] = {} + self._deselected_plugins: set[str] = set() self._refresh_in_progress = False self._check_in_progress = False self._updates_checked = False @@ -656,8 +794,16 @@ def _init_ui(self) -> None: outer = QVBoxLayout(self) outer.setContentsMargins(*COMPACT_MARGINS) - # Toolbar + # Toolbar — search input left, action buttons right toolbar = QHBoxLayout() + + self._search_input = QLineEdit() + self._search_input.setPlaceholderText('Search packages\u2026') + self._search_input.setClearButtonEnabled(True) + self._search_input.setStyleSheet(SEARCH_INPUT_STYLE) + self._search_input.textChanged.connect(self._apply_filter) + toolbar.addWidget(self._search_input) + toolbar.addStretch() check_btn = QPushButton('Check for Updates') @@ -672,6 +818,14 @@ def _init_ui(self) -> None: toolbar.addWidget(update_all_btn) outer.addLayout(toolbar) + # Filter chips row — auto-populated from discovered plugins + chip_container = QWidget() + self._chip_layout = QHBoxLayout(chip_container) + self._chip_layout.setContentsMargins(0, 0, 0, 0) + self._chip_layout.setSpacing(FILTER_CHIP_SPACING) + self._chip_layout.addStretch() + outer.addWidget(chip_container) + # Scroll area self._scroll = QScrollArea() self._scroll.setWidgetResizable(True) @@ -799,6 +953,9 @@ def _build_widget_tree(self, data: _RefreshData) -> None: for plugin in kind_buckets[kind]: self._build_plugin_section(plugin, data, auto_update_map) + self._rebuild_chips() + self._apply_filter() + def _clear_section_widgets(self) -> None: """Remove and delete all current section widgets.""" for widget in self._section_widgets: @@ -827,7 +984,14 @@ def _build_plugin_section( data: _RefreshData, auto_update_map: dict[str, bool | dict[str, bool]], ) -> None: - """Build the provider header and package rows for a single plugin.""" + """Build the provider header and package rows for a single plugin. + + For each package a top-level :class:`PluginRow` is created for + the global instance (with update/remove/toggle controls). + Directly below it, indented :class:`ProjectChildRow` widgets + show each project-scoped occurrence — read-only with a navigate + button to switch to the Projects tab. + """ auto_val = auto_update_map.get(plugin.name, True) plugin_updates = self._updates_available.get(plugin.name, set()) @@ -844,72 +1008,88 @@ def _build_plugin_section( plugin_manifest = data.manifest_packages.get(plugin.name, set()) raw_packages = data.packages_map.get(plugin.name, []) - merged = self._merge_raw_packages(raw_packages, plugin_manifest) + display_packages = self._build_display_packages(raw_packages, plugin_manifest) - if merged: - for pkg_name, pkg in merged.items(): - pkg_auto = self._resolve_package_auto_update(auto_val, pkg_name, pkg.is_global) + if display_packages: + for pkg in display_packages: + pkg_auto = self._resolve_package_auto_update(auto_val, pkg.name, pkg.is_global) row = self._create_connected_row( PluginRowData( - name=pkg_name, - project=', '.join(pkg.projects), - version=pkg.version, + name=pkg.name, + version=pkg.global_version or '', plugin_name=plugin.name, auto_update=pkg_auto, show_toggle=True, - has_update=pkg_name in plugin_updates, + has_update=pkg.name in plugin_updates, is_global=pkg.is_global, host_tool=pkg.host_tool, - project_paths=list(pkg.project_paths), ), ) self._insert_section_widget(row) + + # Project child rows — always expanded inline + for proj in pkg.project_instances: + child = ProjectChildRow( + proj, + package_name='' if pkg.is_global else pkg.name, + parent=self._container, + ) + child.navigate_to_project.connect(self.navigate_to_project_requested.emit) + self._insert_section_widget(child) else: version_text = str(plugin.tool_version) if plugin.tool_version is not None else '' row = PluginRow(PluginRowData(name=plugin.name, version=version_text), parent=self._container) self._insert_section_widget(row) @staticmethod - def _merge_raw_packages( + def _build_display_packages( raw_packages: list[PackageEntry], plugin_manifest: set[str], - ) -> OrderedDict[str, MergedPackage]: - """Deduplicate packages across directories into a merged view. - - Same package from multiple directories becomes one row with a - combined project label. Global packages are always deduplicated; - manifest packages merge their project names. - - A package is considered "global" (and therefore removable from - the global environment) only when it is **not** declared in any - manifest **and** was found by the global (non-project-scoped) - query. Transitive dependencies discovered exclusively inside a - project venv are *not* globally removable — the uninstall API - targets the global Python, where those packages do not exist. + ) -> list[DisplayPackage]: + """Build a two-tier display model from raw package entries. + + Each unique package name produces one :class:`DisplayPackage`. + Global entries (no ``project_path``) set the global version; + project-scoped entries become :class:`ProjectInstance` children. + A project-scoped package is marked *transitive* when it is not + declared in any manifest for this plugin. + + Returns: + An ordered list of :class:`DisplayPackage` instances, with + globals first, then project-only packages. """ - merged: OrderedDict[str, MergedPackage] = OrderedDict() + by_name: OrderedDict[str, DisplayPackage] = OrderedDict() for entry in raw_packages: - in_manifest = entry.name in plugin_manifest from_project = bool(entry.project_path) - is_global = not in_manifest and not from_project - if entry.name in merged: - existing = merged[entry.name] - if not is_global and entry.project_label and entry.project_label not in existing.projects: - existing.projects.append(entry.project_label) - if not is_global and entry.project_path and entry.project_path not in existing.project_paths: - existing.project_paths.append(entry.project_path) - # Promote to global if any entry was discovered globally - if is_global and not existing.is_global: - existing.is_global = True - else: - merged[entry.name] = MergedPackage( - projects=([] if is_global else ([entry.project_label] if entry.project_label else [])), - project_paths=([] if is_global else ([entry.project_path] if entry.project_path else [])), - version=entry.version, - is_global=is_global, + + if entry.name not in by_name: + by_name[entry.name] = DisplayPackage( + name=entry.name, host_tool=entry.host_tool, ) - return merged + + dp = by_name[entry.name] + + if not from_project: + # Global entry + dp.is_global = True + dp.global_version = entry.version + else: + # Project-scoped entry + is_transitive = entry.name not in plugin_manifest + # Deduplicate by project_path + existing_paths = {pi.project_path for pi in dp.project_instances} + if entry.project_path not in existing_paths: + dp.project_instances.append( + ProjectInstance( + project_label=entry.project_label, + project_path=entry.project_path, + version=entry.version, + is_transitive=is_transitive, + ), + ) + + return list(by_name.values()) @staticmethod def _resolve_package_auto_update( @@ -924,6 +1104,145 @@ def _resolve_package_auto_update( return False return not is_global + # ------------------------------------------------------------------ + # Search & filter + # ------------------------------------------------------------------ + + def _rebuild_chips(self) -> None: + """Rebuild the filter chip row from currently visible plugin providers. + + Preserves previous deselection state: if a chip was unchecked + before a refresh, it stays unchecked (subtractive model). + """ + # Remove old chips + for chip in self._filter_chips.values(): + self._chip_layout.removeWidget(chip) + chip.deleteLater() + self._filter_chips.clear() + + # Collect unique plugin names in order + seen: set[str] = set() + plugin_names: list[str] = [] + for widget in self._section_widgets: + if isinstance(widget, PluginProviderHeader): + name = widget._plugin_name + if name not in seen: + seen.add(name) + plugin_names.append(name) + + # Create chips — insert before the trailing stretch + stretch_idx = self._chip_layout.count() - 1 + for name in plugin_names: + chip = FilterChip(name, parent=self._chip_layout.parentWidget()) + chip.setChecked(name not in self._deselected_plugins) + chip.toggled_with_name.connect(self._on_chip_toggled) + self._chip_layout.insertWidget(stretch_idx, chip) + self._filter_chips[name] = chip + stretch_idx += 1 + + def _on_chip_toggled(self, plugin_name: str, checked: bool) -> None: + """Track deselected plugins and reapply the filter.""" + if checked: + self._deselected_plugins.discard(plugin_name) + else: + self._deselected_plugins.add(plugin_name) + self._apply_filter() + + def _active_chip_plugins(self) -> set[str] | None: + """Return the set of plugin names whose chips are checked. + + Returns ``None`` when no chips exist yet (initial state before + any refresh), meaning all plugins should be shown. + """ + if not self._filter_chips: + return None + return {name for name, chip in self._filter_chips.items() if chip.isChecked()} + + def _apply_filter(self, _text: str | None = None) -> None: + """Show/hide section widgets based on search text and active chips. + + A single pass walks ``_section_widgets`` tracking the current + plugin and kind. Visibility rules: + + * **PluginProviderHeader** — visible when its plugin is in the + active chip set **and** at least one child row matches the + search text. + * **PluginRow** — visible when its plugin is active **and** its + package name or plugin name contains the search text. + * **ProjectChildRow** — follows its parent :class:`PluginRow`. + * **PluginKindHeader** — visible when at least one child + provider in its kind group is visible. + + After the pass, kind headers with no visible children are hidden. + """ + query = self._search_input.text().strip().lower() + active = self._active_chip_plugins() + all_active = active is None # None → no chips yet, show all + + current_kind_header: PluginKindHeader | None = None + kind_has_visible = False + current_provider: PluginProviderHeader | None = None + provider_has_visible_child = False + parent_row_visible = False + + for widget in self._section_widgets: + if isinstance(widget, PluginKindHeader): + # Finalise previous kind + if current_kind_header is not None: + # Finalise last provider of previous kind + if current_provider is not None: + current_provider.setVisible(provider_has_visible_child) + if provider_has_visible_child: + kind_has_visible = True + current_kind_header.setVisible(kind_has_visible) + + current_kind_header = widget + kind_has_visible = False + current_provider = None + provider_has_visible_child = False + + elif isinstance(widget, PluginProviderHeader): + # Finalise previous provider + if current_provider is not None: + current_provider.setVisible(provider_has_visible_child) + if provider_has_visible_child: + kind_has_visible = True + + current_provider = widget + provider_has_visible_child = False + plugin_name = widget._plugin_name + plugin_active = all_active or (active is not None and plugin_name in active) + + if not plugin_active: + # Entire provider hidden + widget.setVisible(False) + provider_has_visible_child = False + + elif isinstance(widget, PluginRow): + plugin_name = widget._plugin_name + plugin_active = all_active or (active is not None and plugin_name in active) + if not plugin_active: + widget.setVisible(False) + parent_row_visible = False + continue + + name_match = not query or query in widget._package_name.lower() or query in plugin_name.lower() + widget.setVisible(name_match) + parent_row_visible = name_match + if name_match: + provider_has_visible_child = True + + elif isinstance(widget, ProjectChildRow): + widget.setVisible(parent_row_visible) + + # Finalise last provider and kind + if current_provider is not None: + current_provider.setVisible(provider_has_visible_child) + if provider_has_visible_child: + kind_has_visible = True + if current_kind_header is not None: + current_kind_header.setVisible(kind_has_visible) + def _create_connected_row(self, data: PluginRowData) -> PluginRow: """Create a :class:`PluginRow` and wire all its signals.""" row = PluginRow(data, parent=self._container) diff --git a/synodic_client/application/theme.py b/synodic_client/application/theme.py index 96e5cf0..db91cde 100644 --- a/synodic_client/application/theme.py +++ b/synodic_client/application/theme.py @@ -201,6 +201,76 @@ PLUGIN_ROW_SPACING = 1 """Pixels between individual tool/package rows.""" +# Project child row — indented sub-row for project-scoped package instances +PROJECT_CHILD_ROW_STYLE = ( + 'QFrame#projectChildRow {' + ' background: transparent;' + ' border-radius: 4px;' + ' padding: 2px 8px 2px 40px;' + '}' + 'QFrame#projectChildRow:hover {' + ' background: #252628;' + '}' +) +"""Indented row showing a project-scoped instance of a package.""" + +PROJECT_CHILD_NAME_STYLE = 'font-size: 11px; color: #999999;' +"""Dimmed package name for project child rows.""" + +PROJECT_CHILD_PROJECT_STYLE = 'font-size: 11px; color: #808080;' +"""Project label for project child rows.""" + +PROJECT_CHILD_VERSION_STYLE = 'font-size: 10px; color: #707070;' +"""Version text for project child rows.""" + +PROJECT_CHILD_TRANSITIVE_STYLE = 'font-size: 10px; color: #666666; font-style: italic;' +"""Dimmed italic label for transitive (non-manifest) dependencies.""" + +PROJECT_CHILD_NAV_STYLE = ( + 'QPushButton { border: none; font-size: 11px; color: #808080;' + ' padding: 0px 2px; min-width: 18px; max-width: 18px; }' + 'QPushButton:hover { color: #3794ff; }' + 'QPushButton:pressed { color: #d4d4d4; }' +) +"""Navigate arrow button that switches to the Projects tab.""" + +# Search & filter — toolbar search input and plugin filter chips +SEARCH_INPUT_STYLE = ( + 'QLineEdit {' + ' background: #1e1e1e;' + ' border: 1px solid palette(mid);' + ' border-radius: 3px;' + ' color: #cccccc;' + ' font-size: 12px;' + ' padding: 2px 6px;' + ' min-width: 200px;' + ' max-width: 300px;' + '}' + 'QLineEdit:focus { border-color: #3794ff; }' +) +"""Dark search input for the ToolsView toolbar.""" + +FILTER_CHIP_STYLE = ( + 'QPushButton {' + ' border: 1px solid palette(mid);' + ' border-radius: 10px;' + ' padding: 1px 8px;' + ' font-size: 10px;' + ' color: #808080;' + ' background: transparent;' + '}' + 'QPushButton:checked {' + ' background: #094771;' + ' border-color: #3794ff;' + ' color: #cccccc;' + '}' + 'QPushButton:hover { color: #cccccc; }' +) +"""Toggleable pill chip for plugin filter in the ToolsView toolbar.""" + +FILTER_CHIP_SPACING = 4 +"""Pixels between filter chips.""" + # Retained from previous design — auto-update & per-plugin update buttons PLUGIN_TOGGLE_STYLE = ( 'QPushButton { padding: 2px 8px; border: 1px solid palette(mid); border-radius: 3px;' diff --git a/tests/unit/qt/test_gather_packages.py b/tests/unit/qt/test_gather_packages.py index 99b9021..ad4ea11 100644 --- a/tests/unit/qt/test_gather_packages.py +++ b/tests/unit/qt/test_gather_packages.py @@ -9,7 +9,7 @@ from porringer.core.schema import Package, PackageRelation, PackageRelationKind from porringer.schema import ManifestDirectory -from synodic_client.application.screen.screen import ToolsView +from synodic_client.application.screen.screen import PackageEntry, ProjectChildRow, ToolsView from synodic_client.resolution import ResolvedConfig @@ -313,3 +313,443 @@ def test_multiple_managers(monkeypatch) -> None: assert 'poetry' in result assert result['pdm'][0].name == 'cppython' assert result['poetry'][0].name == 'poetry-plugin-export' + + +# --------------------------------------------------------------------------- +# _build_display_packages +# --------------------------------------------------------------------------- + + +class TestBuildDisplayPackages: + """Verify the two-tier display model built from raw PackageEntry lists.""" + + @staticmethod + def test_global_only_package() -> None: + """A package from the global query has is_global=True and no project instances.""" + entries = [PackageEntry(name='ruff', version='0.8.0')] + result = ToolsView._build_display_packages(entries, set()) + + assert len(result) == 1 + pkg = result[0] + assert pkg.name == 'ruff' + assert pkg.is_global is True + assert pkg.global_version == '0.8.0' + assert pkg.project_instances == [] + + @staticmethod + def test_project_only_package() -> None: + """A package found only in a project venv is not global.""" + entries = [ + PackageEntry( + name='cppython', + version='0.9.15.dev3', + project_label='periapsis', + project_path='/projects/periapsis', + ), + ] + result = ToolsView._build_display_packages(entries, set()) + + assert len(result) == 1 + pkg = result[0] + assert pkg.name == 'cppython' + 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' + + @staticmethod + def test_both_global_and_project() -> None: + """A package found globally AND in a project has both fields set.""" + entries = [ + PackageEntry(name='ruff', version='0.8.0'), + PackageEntry( + name='ruff', + version='0.7.0', + project_label='myproject', + project_path='/projects/myproject', + ), + ] + result = ToolsView._build_display_packages(entries, set()) + + assert len(result) == 1 + pkg = result[0] + assert pkg.is_global is True + assert pkg.global_version == '0.8.0' + assert len(pkg.project_instances) == 1 + assert pkg.project_instances[0].version == '0.7.0' + + @staticmethod + def test_transitive_dependency_marked() -> None: + """Project-scoped packages not in the manifest are marked transitive.""" + entries = [ + PackageEntry( + name='cppython', + version='0.9.15.dev3', + project_label='periapsis', + project_path='/projects/periapsis', + ), + ] + # 'cppython' is NOT in the manifest set → transitive + result = ToolsView._build_display_packages(entries, set()) + assert result[0].project_instances[0].is_transitive is True + + @staticmethod + def test_manifest_declared_not_transitive() -> None: + """Project-scoped packages in the manifest are NOT marked transitive.""" + entries = [ + PackageEntry( + name='cppython', + version='0.9.15.dev3', + project_label='periapsis', + project_path='/projects/periapsis', + ), + ] + result = ToolsView._build_display_packages(entries, {'cppython'}) + assert result[0].project_instances[0].is_transitive is False + + @staticmethod + def test_multiple_projects_same_package() -> None: + """Same package in multiple projects creates multiple ProjectInstances.""" + entries = [ + PackageEntry( + name='requests', + version='2.31.0', + project_label='project-a', + project_path='/projects/a', + ), + PackageEntry( + name='requests', + version='2.30.0', + project_label='project-b', + project_path='/projects/b', + ), + ] + result = ToolsView._build_display_packages(entries, set()) + + assert len(result) == 1 + pkg = result[0] + assert len(pkg.project_instances) == 2 + labels = {pi.project_label for pi in pkg.project_instances} + assert labels == {'project-a', 'project-b'} + + @staticmethod + def test_deduplicates_same_project_path() -> None: + """Duplicate entries for the same project_path produce one instance.""" + entries = [ + PackageEntry( + name='ruff', + version='0.8.0', + project_label='myproject', + project_path='/projects/myproject', + ), + PackageEntry( + name='ruff', + version='0.8.0', + project_label='myproject', + project_path='/projects/myproject', + ), + ] + result = ToolsView._build_display_packages(entries, set()) + assert len(result[0].project_instances) == 1 + + @staticmethod + def test_host_tool_preserved() -> None: + """The host_tool from the first entry is carried through.""" + entries = [ + PackageEntry(name='cppython', version='0.5.0', host_tool='pdm'), + ] + result = ToolsView._build_display_packages(entries, set()) + assert result[0].host_tool == 'pdm' + + @staticmethod + def test_empty_input() -> None: + """Empty input returns empty list.""" + result = ToolsView._build_display_packages([], set()) + assert result == [] + + +# --------------------------------------------------------------------------- +# ProjectChildRow +# --------------------------------------------------------------------------- + + +class TestProjectChildRow: + """Verify the ProjectChildRow widget renders and emits signals.""" + + @staticmethod + def _make_instance(*, transitive: bool = False) -> ProjectChildRow: + from synodic_client.application.screen.screen import ProjectInstance + + return ProjectChildRow( + ProjectInstance( + project_label='periapsis', + project_path='/projects/periapsis', + version='0.9.15.dev3', + is_transitive=transitive, + ), + package_name='cppython', + ) + + def test_navigate_signal_emitted(self) -> None: + """Clicking the navigate button emits the project path.""" + row = self._make_instance() + spy = MagicMock() + row.navigate_to_project.connect(spy) + + # Find the navigate button (→) + from PySide6.QtWidgets import QPushButton + + 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') + + def test_transitive_label_shown(self) -> None: + """Transitive instances show a (transitive) label.""" + row = self._make_instance(transitive=True) + labels = [w for w in row.findChildren(QLabel) if w.text() == '(transitive)'] + assert len(labels) == 1 + + def test_transitive_label_hidden_when_not_transitive(self) -> None: + """Non-transitive instances do not show a (transitive) label.""" + row = self._make_instance(transitive=False) + labels = [w for w in row.findChildren(QLabel) if w.text() == '(transitive)'] + assert len(labels) == 0 + + +from PySide6.QtWidgets import QLabel + +# --------------------------------------------------------------------------- +# FilterChip +# --------------------------------------------------------------------------- + + +class TestFilterChip: + """Verify the FilterChip widget renders and emits toggled signals.""" + + @staticmethod + def test_chip_starts_checked() -> None: + """Filter chips start in the checked (active) state.""" + from synodic_client.application.screen.screen import FilterChip + + chip = FilterChip('pipx') + assert chip.isChecked() + assert chip.text() == 'pipx' + + @staticmethod + def test_toggling_emits_signal() -> None: + """Toggling a chip emits the plugin name and new state.""" + from synodic_client.application.screen.screen import FilterChip + + chip = FilterChip('uv') + spy = MagicMock() + chip.toggled_with_name.connect(spy) + + chip.setChecked(False) + spy.assert_called_once_with('uv', False) + + @staticmethod + def test_recheck_emits_true() -> None: + """Re-checking a chip emits True.""" + from synodic_client.application.screen.screen import FilterChip + + chip = FilterChip('pip') + spy = MagicMock() + chip.setChecked(False) + chip.toggled_with_name.connect(spy) + chip.setChecked(True) + spy.assert_called_once_with('pip', True) + + +# --------------------------------------------------------------------------- +# Search & filter integration (ToolsView._apply_filter) +# --------------------------------------------------------------------------- + + +class TestSearchFilter: + """Verify the search and filter logic on ToolsView.""" + + @staticmethod + def _make_view() -> ToolsView: + """Build a ToolsView with a mock porringer.""" + porringer = _make_porringer() + config = _make_config() + return ToolsView(porringer, config) + + @staticmethod + def _populate_section_widgets(view: ToolsView) -> None: + """Manually inject section widgets simulating a two-plugin tree. + + Structure: + KindHeader(TOOL) + ProviderHeader(pipx) + PluginRow(ruff, plugin=pipx) + PluginRow(pdm, plugin=pipx) + ProviderHeader(uv) + PluginRow(mypy, plugin=uv) + """ + from packaging.version import Version + from porringer.schema.plugin import PluginInfo, PluginKind + + from synodic_client.application.screen.screen import ( + PluginKindHeader, + PluginProviderHeader, + PluginRow, + PluginRowData, + ) + + kind_hdr = PluginKindHeader(PluginKind.TOOL) + view._section_widgets.append(kind_hdr) + view._container_layout.insertWidget(0, kind_hdr) + + pipx_info = PluginInfo( + name='pipx', kind=PluginKind.TOOL, version=Version('0.1.0'), installed=True, tool_version=Version('1.0.0') + ) + pipx_hdr = PluginProviderHeader(pipx_info, True) + view._section_widgets.append(pipx_hdr) + view._container_layout.insertWidget(1, pipx_hdr) + + ruff_row = PluginRow(PluginRowData(name='ruff', plugin_name='pipx')) + view._section_widgets.append(ruff_row) + view._container_layout.insertWidget(2, ruff_row) + + pdm_row = PluginRow(PluginRowData(name='pdm', plugin_name='pipx')) + view._section_widgets.append(pdm_row) + view._container_layout.insertWidget(3, pdm_row) + + uv_info = PluginInfo( + name='uv', kind=PluginKind.TOOL, version=Version('0.1.0'), installed=True, tool_version=Version('2.0.0') + ) + uv_hdr = PluginProviderHeader(uv_info, True) + view._section_widgets.append(uv_hdr) + view._container_layout.insertWidget(4, uv_hdr) + + mypy_row = PluginRow(PluginRowData(name='mypy', plugin_name='uv')) + view._section_widgets.append(mypy_row) + view._container_layout.insertWidget(5, mypy_row) + + # Build chips + view._rebuild_chips() + + def test_search_hides_non_matching_rows(self) -> None: + """Typing a search term hides rows whose names don't match.""" + view = self._make_view() + self._populate_section_widgets(view) + + view._search_input.setText('ruff') + + from synodic_client.application.screen.screen import PluginRow + + visible_rows = [w for w in view._section_widgets if isinstance(w, PluginRow) and not w.isHidden()] + assert len(visible_rows) == 1 + assert visible_rows[0]._package_name == 'ruff' + + def test_empty_search_shows_all(self) -> None: + """Clearing the search bar makes all rows visible again.""" + view = self._make_view() + self._populate_section_widgets(view) + + view._search_input.setText('ruff') + view._search_input.setText('') + + from synodic_client.application.screen.screen import PluginRow + + visible_rows = [w for w in view._section_widgets if isinstance(w, PluginRow) and not w.isHidden()] + assert len(visible_rows) == 3 + + def test_chip_deselection_hides_plugin(self) -> None: + """Deselecting a chip hides all rows from that plugin.""" + view = self._make_view() + self._populate_section_widgets(view) + + view._filter_chips['pipx'].setChecked(False) + + from synodic_client.application.screen.screen import PluginRow + + visible_rows = [w for w in view._section_widgets if isinstance(w, PluginRow) and not w.isHidden()] + assert len(visible_rows) == 1 + assert visible_rows[0]._package_name == 'mypy' + + def test_chip_reselection_restores(self) -> None: + """Re-checking a chip restores the plugin's rows.""" + view = self._make_view() + self._populate_section_widgets(view) + + view._filter_chips['pipx'].setChecked(False) + view._filter_chips['pipx'].setChecked(True) + + from synodic_client.application.screen.screen import PluginRow + + visible_rows = [w for w in view._section_widgets if isinstance(w, PluginRow) and not w.isHidden()] + assert len(visible_rows) == 3 + + def test_search_plus_chip_filter(self) -> None: + """Search and chip filtering compose — only matching rows in active plugins survive.""" + view = self._make_view() + self._populate_section_widgets(view) + + view._filter_chips['uv'].setChecked(False) + view._search_input.setText('pdm') + + from synodic_client.application.screen.screen import PluginRow + + visible_rows = [w for w in view._section_widgets if isinstance(w, PluginRow) and not w.isHidden()] + assert len(visible_rows) == 1 + assert visible_rows[0]._package_name == 'pdm' + + def test_kind_header_hidden_when_no_children_visible(self) -> None: + """Kind headers hide when all their children are filtered out.""" + view = self._make_view() + self._populate_section_widgets(view) + + # Hide both plugins + view._filter_chips['pipx'].setChecked(False) + view._filter_chips['uv'].setChecked(False) + + from synodic_client.application.screen.screen import PluginKindHeader + + hidden_kinds = [w for w in view._section_widgets if isinstance(w, PluginKindHeader) and w.isHidden()] + assert len(hidden_kinds) == 1 + + def test_provider_hidden_when_search_matches_nothing(self) -> None: + """A provider header hides when no child rows match the search.""" + view = self._make_view() + self._populate_section_widgets(view) + + view._search_input.setText('mypy') + + from synodic_client.application.screen.screen import PluginProviderHeader + + visible_providers = [ + w for w in view._section_widgets if isinstance(w, PluginProviderHeader) and not w.isHidden() + ] + assert len(visible_providers) == 1 + assert visible_providers[0]._plugin_name == 'uv' + + def test_search_matches_plugin_name(self) -> None: + """Searching by plugin name shows all rows from that plugin.""" + view = self._make_view() + self._populate_section_widgets(view) + + view._search_input.setText('pipx') + + from synodic_client.application.screen.screen import PluginRow + + visible_rows = [w for w in view._section_widgets if isinstance(w, PluginRow) and not w.isHidden()] + assert len(visible_rows) == 2 + names = {w._package_name for w in visible_rows} + assert names == {'ruff', 'pdm'} + + def test_deselected_chips_preserved_across_rebuild(self) -> None: + """_rebuild_chips preserves deselected state from self._deselected_plugins.""" + view = self._make_view() + self._populate_section_widgets(view) + + view._filter_chips['pipx'].setChecked(False) + assert 'pipx' in view._deselected_plugins + + # Simulate refresh by rebuilding chips + view._rebuild_chips() + assert not view._filter_chips['pipx'].isChecked() + assert view._filter_chips['uv'].isChecked()