From cf523b8dd8f6b02363efce12501ea06ed3370b66 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Wed, 4 Mar 2026 20:17:59 -0800 Subject: [PATCH 1/6] Update settings.py --- synodic_client/application/screen/settings.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/synodic_client/application/screen/settings.py b/synodic_client/application/screen/settings.py index 40bd4c0..f63127c 100644 --- a/synodic_client/application/screen/settings.py +++ b/synodic_client/application/screen/settings.py @@ -312,8 +312,10 @@ def show_restart_button(self) -> None: self._restart_btn.show() def show(self) -> None: - """Sync controls from config, then show the window.""" + """Sync controls from config, size to content, then show the window.""" self.sync_from_config() + # Let the layout determine the ideal size, clamped to the minimum. + self.adjustSize() super().show() self.raise_() self.activateWindow() From 08dba9847066b6153385fc7081b32b688207b484 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Wed, 4 Mar 2026 20:34:31 -0800 Subject: [PATCH 2/6] Update spinner.py --- synodic_client/application/screen/spinner.py | 1 + 1 file changed, 1 insertion(+) diff --git a/synodic_client/application/screen/spinner.py b/synodic_client/application/screen/spinner.py index 4d582bc..6969732 100644 --- a/synodic_client/application/screen/spinner.py +++ b/synodic_client/application/screen/spinner.py @@ -127,6 +127,7 @@ def __init__(self, text: str = '', parent: QWidget | None = None) -> None: # Auto-overlay: track parent geometry via event filter if parent is not None: + self.setAutoFillBackground(True) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) parent.installEventFilter(self) self.setGeometry(parent.rect()) From 21423b124b3da17058e6d7b3f1f1e3a95d21308a Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Wed, 4 Mar 2026 21:25:01 -0800 Subject: [PATCH 3/6] Improved Search Filter --- synodic_client/application/screen/screen.py | 130 ++++++++++++++++++-- synodic_client/application/theme.py | 16 +++ tests/unit/qt/test_gather_packages.py | 96 +++++++++++++++ 3 files changed, 232 insertions(+), 10 deletions(-) diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 6933b47..a03bb1f 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -20,14 +20,15 @@ SyncStrategy, ) from porringer.schema.plugin import PluginKind -from PySide6.QtCore import Qt, QTimer, Signal -from PySide6.QtGui import QShowEvent +from PySide6.QtCore import QEasingCurve, QPropertyAnimation, Qt, QTimer, Signal +from PySide6.QtGui import QKeySequence, QShowEvent from PySide6.QtWidgets import ( QHBoxLayout, QLineEdit, QMainWindow, QPushButton, QScrollArea, + QShortcut, QTabWidget, QVBoxLayout, QWidget, @@ -54,6 +55,9 @@ from synodic_client.application.theme import ( COMPACT_MARGINS, FILTER_CHIP_SPACING, + FILTER_PANEL_ANIMATION_MS, + FILTER_TOGGLE_ACTIVE_STYLE, + FILTER_TOGGLE_STYLE, MAIN_WINDOW_MIN_SIZE, PLUGIN_ROW_STATUS_AVAILABLE_STYLE, PLUGIN_ROW_STATUS_UP_TO_DATE_STYLE, @@ -139,15 +143,15 @@ def _init_ui(self) -> None: outer = QVBoxLayout(self) outer.setContentsMargins(*COMPACT_MARGINS) - # Toolbar — search input left, action buttons right + # Toolbar — filter toggle 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) + self._filter_btn = QPushButton('\U0001f50d') + self._filter_btn.setToolTip('Filter packages (Ctrl+F)') + self._filter_btn.setFlat(True) + self._filter_btn.setStyleSheet(FILTER_TOGGLE_STYLE) + self._filter_btn.clicked.connect(self._toggle_filter_panel) + toolbar.addWidget(self._filter_btn) toolbar.addStretch() @@ -163,13 +167,40 @@ def _init_ui(self) -> None: toolbar.addWidget(update_all_btn) outer.addLayout(toolbar) + # Collapsible filter panel — search input + chip row + self._filter_panel = QWidget() + self._filter_panel.setMaximumHeight(0) + self._filter_panel.setVisible(False) + filter_layout = QVBoxLayout(self._filter_panel) + filter_layout.setContentsMargins(0, 4, 0, 4) + filter_layout.setSpacing(4) + + 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) + self._search_input.installEventFilter(self) + filter_layout.addWidget(self._search_input) + # 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) + filter_layout.addWidget(chip_container) + + outer.addWidget(self._filter_panel) + + # Animation for filter panel slide-in / slide-out + self._filter_anim = QPropertyAnimation(self._filter_panel, b'maximumHeight') + self._filter_anim.setEasingCurve(QEasingCurve.Type.OutCubic) + self._filter_anim.setDuration(FILTER_PANEL_ANIMATION_MS) + self._filter_panel_open = False + + # Ctrl+F shortcut to toggle filter panel + QShortcut(QKeySequence.StandardKey.Find, self, self._toggle_filter_panel) # Scroll area self._scroll = QScrollArea() @@ -492,6 +523,83 @@ def _on_chip_toggled(self, plugin_name: str, checked: bool) -> None: self._deselected_plugins.add(plugin_name) self._apply_filter() + # ------------------------------------------------------------------ + # Filter panel toggle & animation + # ------------------------------------------------------------------ + + @property + def _has_active_filter(self) -> bool: + """Return whether any search text or deselected chip is active.""" + return bool(self._search_input.text().strip()) or bool(self._deselected_plugins) + + def _toggle_filter_panel(self) -> None: + """Slide the filter panel open or closed.""" + if self._filter_panel_open: + self._close_filter_panel() + else: + self._open_filter_panel() + + def _open_filter_panel(self) -> None: + """Slide the filter panel in and focus the search input.""" + if self._filter_panel_open: + return + self._filter_panel_open = True + self._filter_panel.setVisible(True) + self._filter_panel.adjustSize() + target = self._filter_panel.sizeHint().height() + self._filter_anim.stop() + self._filter_anim.setStartValue(self._filter_panel.maximumHeight()) + self._filter_anim.setEndValue(target) + self._filter_anim.start() + self._search_input.setFocus() + + def _close_filter_panel(self) -> None: + """Slide the filter panel out and return focus to the toggle button.""" + if not self._filter_panel_open: + return + self._filter_panel_open = False + self._filter_anim.stop() + self._filter_anim.setStartValue(self._filter_panel.maximumHeight()) + self._filter_anim.setEndValue(0) + self._filter_anim.finished.connect( + self._on_filter_panel_closed, + type=Qt.ConnectionType.SingleShotConnection, + ) + self._filter_anim.start() + + def _on_filter_panel_closed(self) -> None: + """Hide the panel widget after slide-out completes.""" + if not self._filter_panel_open: + self._filter_panel.setVisible(False) + self._filter_btn.setFocus() + + def _update_filter_badge(self) -> None: + """Swap the toggle-button style to indicate active filters.""" + style = FILTER_TOGGLE_ACTIVE_STYLE if self._has_active_filter else FILTER_TOGGLE_STYLE + self._filter_btn.setStyleSheet(style) + + def _clear_active_filters(self) -> None: + """Reset search text and re-check all deselected chips.""" + self._search_input.clear() + for name in list(self._deselected_plugins): + chip = self._filter_chips.get(name) + if chip is not None: + chip.setChecked(True) + + def eventFilter(self, obj: object, event: object) -> bool: + """Handle Escape in the search input to clear filters / close panel.""" + from PySide6.QtCore import QEvent + from PySide6.QtGui import QKeyEvent + + if obj is self._search_input and isinstance(event, QKeyEvent): + if event.type() == QEvent.Type.KeyPress and event.key() == Qt.Key.Key_Escape: + if self._has_active_filter: + self._clear_active_filters() + else: + self._close_filter_panel() + return True + return super().eventFilter(obj, event) + def _active_chip_plugins(self) -> set[str] | None: """Return the set of plugin names whose chips are checked. @@ -570,6 +678,8 @@ def _apply_filter(self, _text: str | None = None) -> None: if current_kind_header is not None: current_kind_header.setVisible(kind_has_visible) + self._update_filter_badge() + 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 d457d01..9c09f1a 100644 --- a/synodic_client/application/theme.py +++ b/synodic_client/application/theme.py @@ -310,6 +310,22 @@ FILTER_CHIP_SPACING = 4 """Pixels between filter chips.""" +FILTER_PANEL_ANIMATION_MS = 200 +"""Duration of the filter panel slide-in / slide-out animation (ms).""" + +FILTER_TOGGLE_STYLE = ( + 'QPushButton { border: none; font-size: 16px; padding: 2px 6px; }' + 'QPushButton:hover { background: palette(midlight); border-radius: 3px; }' +) +"""Default style for the filter toggle button in the ToolsView toolbar.""" + +FILTER_TOGGLE_ACTIVE_STYLE = ( + 'QPushButton { border: none; font-size: 16px; padding: 2px 6px;' + ' border-bottom: 2px solid #3794ff; }' + 'QPushButton:hover { background: palette(midlight); border-radius: 3px; }' +) +"""Filter toggle button style when an active filter is in effect.""" + # 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 0627f41..7e97c21 100644 --- a/tests/unit/qt/test_gather_packages.py +++ b/tests/unit/qt/test_gather_packages.py @@ -735,3 +735,99 @@ def test_deselected_chips_preserved_across_rebuild(self) -> None: view._rebuild_chips() assert not view._filter_chips['pipx'].isChecked() assert view._filter_chips['uv'].isChecked() + + +# --------------------------------------------------------------------------- +# Filter panel toggle (ToolsView collapsible filter) +# --------------------------------------------------------------------------- + + +class TestFilterPanel: + """Verify the collapsible filter panel toggle behaviour.""" + + @staticmethod + def _make_view() -> ToolsView: + """Build a ToolsView with a mock porringer.""" + porringer = _make_porringer() + config = _make_config() + return ToolsView(porringer, config) + + def test_panel_starts_collapsed(self) -> None: + """The filter panel is hidden and has zero max-height on init.""" + view = self._make_view() + assert not view._filter_panel.isVisible() + assert view._filter_panel.maximumHeight() == 0 + assert not view._filter_panel_open + + def test_toggle_opens_panel(self) -> None: + """Calling _toggle_filter_panel opens a collapsed panel.""" + view = self._make_view() + view._toggle_filter_panel() + assert view._filter_panel_open + assert view._filter_panel.isVisible() + + def test_toggle_closes_open_panel(self) -> None: + """Calling _toggle_filter_panel on an open panel closes it.""" + view = self._make_view() + view._open_filter_panel() + view._toggle_filter_panel() + assert not view._filter_panel_open + + def test_badge_inactive_by_default(self) -> None: + """The filter badge is inactive when no filters are set.""" + from synodic_client.application.theme import FILTER_TOGGLE_STYLE + + view = self._make_view() + assert view._filter_btn.styleSheet() == FILTER_TOGGLE_STYLE + + def test_badge_active_with_search_text(self) -> None: + """The filter badge activates when search text is entered.""" + from synodic_client.application.theme import FILTER_TOGGLE_ACTIVE_STYLE + + view = self._make_view() + view._search_input.setText('ruff') + assert view._filter_btn.styleSheet() == FILTER_TOGGLE_ACTIVE_STYLE + + def test_badge_active_with_deselected_chip(self) -> None: + """The filter badge activates when a chip is deselected.""" + from synodic_client.application.theme import FILTER_TOGGLE_ACTIVE_STYLE + + view = self._make_view() + # Manually inject a deselected plugin to test badge without full tree + view._deselected_plugins.add('test-plugin') + view._update_filter_badge() + assert view._filter_btn.styleSheet() == FILTER_TOGGLE_ACTIVE_STYLE + + def test_badge_clears_when_filters_removed(self) -> None: + """The filter badge deactivates when all filters are cleared.""" + from synodic_client.application.theme import FILTER_TOGGLE_STYLE + + view = self._make_view() + view._search_input.setText('ruff') + view._search_input.clear() + assert view._filter_btn.styleSheet() == FILTER_TOGGLE_STYLE + + def test_clear_active_filters_resets_search(self) -> None: + """_clear_active_filters clears search text and rechecks chips.""" + view = self._make_view() + view._search_input.setText('some query') + view._deselected_plugins.add('fake') + view._clear_active_filters() + assert view._search_input.text() == '' + + def test_has_active_filter_false_by_default(self) -> None: + """_has_active_filter is False when no filters are set.""" + view = self._make_view() + assert not view._has_active_filter + + def test_has_active_filter_true_with_text(self) -> None: + """_has_active_filter is True when search text is non-empty.""" + view = self._make_view() + view._search_input.setText('x') + assert view._has_active_filter + + def test_has_active_filter_true_with_deselected(self) -> None: + """_has_active_filter is True when plugins are deselected.""" + view = self._make_view() + view._deselected_plugins.add('p') + assert view._has_active_filter From 5046f9f9134fef7705dfcf6ead67364dbefe2ab8 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Thu, 5 Mar 2026 12:44:49 -0800 Subject: [PATCH 4/6] Multiple Runtime Support --- pdm.lock | 46 +- pyproject.toml | 4 +- .../application/screen/plugin_row.py | 12 + synodic_client/application/screen/schema.py | 7 + synodic_client/application/screen/screen.py | 195 ++++++++- synodic_client/application/theme.py | 10 + tests/unit/qt/test_gather_packages.py | 407 +++++++++++++++++- 7 files changed, 639 insertions(+), 42 deletions(-) diff --git a/pdm.lock b/pdm.lock index 8f7cd12..e8387be 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:cd2a7f2a6059a13c3a552d045427b10e910ff85fe5700695de8b84909e64c96a" +content_hash = "sha256:8f6349b298fe09fa365c1c799eba84f7e77bb71e1a082d8c7d15e057ed5d6b57" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -336,7 +336,7 @@ files = [ [[package]] name = "porringer" -version = "0.2.1.dev74" +version = "0.2.1.dev75" requires_python = ">=3.14" summary = "" groups = ["default"] @@ -349,8 +349,8 @@ dependencies = [ "userpath>=1.9.2", ] files = [ - {file = "porringer-0.2.1.dev74-py3-none-any.whl", hash = "sha256:c082f235d918f16f3c4bb824848f7ffe0117d2db1e408daa47b037eabf420205"}, - {file = "porringer-0.2.1.dev74.tar.gz", hash = "sha256:9c2d8b8c392021aa6b9cc4c7dc47c3f24b03e372f7551ba6b08f6bdcf49b54b3"}, + {file = "porringer-0.2.1.dev75-py3-none-any.whl", hash = "sha256:3934b4d332fe2f4473d015bd13c377154be6b39d5dbbb99c5c25705943d10c1a"}, + {file = "porringer-0.2.1.dev75.tar.gz", hash = "sha256:a2fc29a06ce81bee7080d99f58fe51de7cc18509651f4ca1b1fabf786d0f6bb1"}, ] [[package]] @@ -631,29 +631,29 @@ files = [ [[package]] name = "ruff" -version = "0.15.4" +version = "0.15.5" requires_python = ">=3.7" summary = "An extremely fast Python linter and code formatter, written in Rust." groups = ["lint"] files = [ - {file = "ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0"}, - {file = "ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992"}, - {file = "ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba"}, - {file = "ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75"}, - {file = "ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac"}, - {file = "ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a"}, - {file = "ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85"}, - {file = "ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db"}, - {file = "ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec"}, - {file = "ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f"}, - {file = "ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338"}, - {file = "ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc"}, - {file = "ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68"}, - {file = "ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3"}, - {file = "ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22"}, - {file = "ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f"}, - {file = "ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453"}, - {file = "ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1"}, + {file = "ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c"}, + {file = "ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080"}, + {file = "ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010"}, + {file = "ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65"}, + {file = "ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440"}, + {file = "ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204"}, + {file = "ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8"}, + {file = "ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681"}, + {file = "ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a"}, + {file = "ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca"}, + {file = "ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd"}, + {file = "ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d"}, + {file = "ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752"}, + {file = "ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2"}, + {file = "ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74"}, + {file = "ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe"}, + {file = "ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b"}, + {file = "ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 8991276..c1a65eb 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.dev74", + "porringer>=0.2.1.dev75", "qasync>=0.28.0", "velopack>=0.0.1444.dev49733", "typer>=0.24.1", @@ -29,7 +29,7 @@ build = [ "pyinstaller>=6.19.0", ] lint = [ - "ruff>=0.15.4", + "ruff>=0.15.5", "pyrefly>=0.55.0", ] test = [ diff --git a/synodic_client/application/screen/plugin_row.py b/synodic_client/application/screen/plugin_row.py index e83cb56..2c5aea6 100644 --- a/synodic_client/application/screen/plugin_row.py +++ b/synodic_client/application/screen/plugin_row.py @@ -25,6 +25,8 @@ FILTER_CHIP_STYLE, PLUGIN_KIND_HEADER_STYLE, PLUGIN_PROVIDER_NAME_STYLE, + PLUGIN_PROVIDER_RUNTIME_TAG_DEFAULT_STYLE, + PLUGIN_PROVIDER_RUNTIME_TAG_STYLE, PLUGIN_PROVIDER_STATUS_INSTALLED_STYLE, PLUGIN_PROVIDER_STATUS_MISSING_STYLE, PLUGIN_PROVIDER_STYLE, @@ -133,6 +135,7 @@ def __init__( *, show_controls: bool = False, has_updates: bool = False, + runtime_label: str = '', parent: QWidget | None = None, ) -> None: """Initialize the provider header with plugin info and optional controls.""" @@ -152,6 +155,15 @@ def __init__( name_label.setStyleSheet(PLUGIN_PROVIDER_NAME_STYLE) layout.addWidget(name_label) + # Runtime tag pill (when per-runtime) + if runtime_label: + is_default = '(default)' in runtime_label + tag = QLabel(runtime_label) + tag.setStyleSheet( + PLUGIN_PROVIDER_RUNTIME_TAG_DEFAULT_STYLE if is_default else PLUGIN_PROVIDER_RUNTIME_TAG_STYLE + ) + layout.addWidget(tag) + # Version version_text = ( str(plugin.tool_version) diff --git a/synodic_client/application/screen/schema.py b/synodic_client/application/screen/schema.py index 0de9e19..acba46a 100644 --- a/synodic_client/application/screen/schema.py +++ b/synodic_client/application/screen/schema.py @@ -23,6 +23,7 @@ SubActionProgress, SyncStrategy, ) +from porringer.schema.plugin import RuntimePackageResult from synodic_client.application.screen.action_card import action_key from synodic_client.application.uri import normalize_manifest_key @@ -162,6 +163,12 @@ class _RefreshData: manifest_packages: dict[str, set[str]] """Mapping of plugin name → manifest-referenced package names.""" + runtime_packages: dict[str, list[RuntimePackageResult]] = field(default_factory=dict) + """Mapping of plugin name → per-runtime package results (RuntimeConsumer plugins only).""" + + default_runtime_executable: Path | None = None + """Executable path of the resolved default runtime, if any.""" + # --------------------------------------------------------------------------- # Install preview data models (from install.py) diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index a03bb1f..ba8362c 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -20,15 +20,15 @@ SyncStrategy, ) from porringer.schema.plugin import PluginKind +from porringer.utility.exception import PluginError from PySide6.QtCore import QEasingCurve, QPropertyAnimation, Qt, QTimer, Signal -from PySide6.QtGui import QKeySequence, QShowEvent +from PySide6.QtGui import QKeySequence, QShortcut, QShowEvent from PySide6.QtWidgets import ( QHBoxLayout, QLineEdit, QMainWindow, QPushButton, QScrollArea, - QShortcut, QTabWidget, QVBoxLayout, QWidget, @@ -70,7 +70,10 @@ logger = logging.getLogger(__name__) # Plugin kinds that support auto-update and per-plugin upgrade. -_UPDATABLE_KINDS = frozenset({PluginKind.TOOL, PluginKind.PACKAGE}) +_UPDATABLE_KINDS = frozenset({PluginKind.TOOL, PluginKind.PACKAGE, PluginKind.RUNTIME}) + +# Kinds whose packages are inherently global (no per-directory queries). +_GLOBAL_ONLY_KINDS = frozenset({PluginKind.RUNTIME}) # Preferred display ordering — Tools first, then alphabetical for the rest. _KIND_DISPLAY_ORDER: dict[PluginKind, int] = { @@ -267,6 +270,14 @@ async def _async_refresh(self) -> None: async def _gather_refresh_data(self) -> _RefreshData: """Fetch plugins, packages, and manifest requirements in parallel. + For PACKAGE-kind plugins that are ``RuntimeConsumer`` instances, + per-runtime package queries are attempted via + ``list_packages_by_runtime``. Plugins that succeed are excluded + from the regular global package query (their global packages come + from the per-runtime results); venv-scoped packages are still + gathered via the standard ``_gather_packages`` path with + ``skip_global=True``. + Returns: A :class:`_RefreshData` bundle containing all data needed to build the widget tree. @@ -275,21 +286,50 @@ async def _gather_refresh_data(self) -> _RefreshData: self._directories = directories updatable_plugins = [p for p in plugins if p.kind in _UPDATABLE_KINDS] + discovered = self._coordinator.discovered_plugins if self._coordinator else None + + # --- Per-runtime probing for PACKAGE-kind plugins --- + runtime_packages: dict[str, list] = {} + runtime_probed: set[str] = set() + + package_plugins = [p for p in updatable_plugins if p.kind == PluginKind.PACKAGE] + if package_plugins and discovered is not None: + probe_tasks: dict[str, asyncio.Task] = {} + async with asyncio.TaskGroup() as tg: + for plugin in package_plugins: + probe_tasks[plugin.name] = tg.create_task( + self._gather_runtime_packages(plugin.name, discovered), + ) + for name, task in probe_tasks.items(): + result = task.result() + if result is not None: + runtime_packages[name] = result + runtime_probed.add(name) + # --- Standard package queries --- async with asyncio.TaskGroup() as tg: - pkg_tasks = { - plugin.name: tg.create_task( - self._gather_packages(plugin.name, directories), - ) - for plugin in updatable_plugins - } + pkg_tasks: dict[str, asyncio.Task] = {} + for plugin in updatable_plugins: + if plugin.name in runtime_probed: + # Only gather venv/project packages (skip global) + if directories: + pkg_tasks[plugin.name] = tg.create_task( + self._gather_packages(plugin.name, directories, skip_global=True), + ) + else: + pkg_tasks[plugin.name] = tg.create_task( + self._gather_packages( + plugin.name, + [] if plugin.kind in _GLOBAL_ONLY_KINDS else directories, + ), + ) 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). + # 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(): @@ -299,10 +339,17 @@ async def _gather_refresh_data(self) -> _RefreshData: manifest_packages = self._collect_manifest_packages(req_tasks) + # Extract default runtime executable + default_runtime_executable = None + if discovered is not None and discovered.runtime_context is not None: + default_runtime_executable = discovered.runtime_context.get('python') + return _RefreshData( plugins=plugins, packages_map=packages_map, manifest_packages=manifest_packages, + runtime_packages=runtime_packages, + default_runtime_executable=default_runtime_executable, ) @staticmethod @@ -324,7 +371,11 @@ def _build_widget_tree(self, data: _RefreshData) -> None: self._clear_section_widgets() auto_update_map = self._config.plugin_auto_update or {} - kind_buckets = self._bucket_by_kind(data.plugins, data.packages_map) + kind_buckets = self._bucket_by_kind( + data.plugins, + data.packages_map, + data.runtime_packages, + ) sorted_kinds = sorted( kind_buckets, @@ -334,7 +385,14 @@ def _build_widget_tree(self, data: _RefreshData) -> None: 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) + if plugin.name in data.runtime_packages: + self._build_runtime_sections(plugin, data, auto_update_map) + # Also emit venv packages (if any) as a separate + # provider header without a runtime tag. + if data.packages_map.get(plugin.name): + self._build_plugin_section(plugin, data, auto_update_map) + else: + self._build_plugin_section(plugin, data, auto_update_map) self._rebuild_chips() self._apply_filter() @@ -350,17 +408,99 @@ def _clear_section_widgets(self) -> None: def _bucket_by_kind( plugins: list[PluginInfo], packages_map: dict[str, list[PackageEntry]], + runtime_packages: dict[str, list] | None = None, ) -> OrderedDict[PluginKind, list[PluginInfo]]: """Group updatable plugins by kind, filtering out empty entries.""" buckets: OrderedDict[PluginKind, list[PluginInfo]] = OrderedDict() + rp = runtime_packages or {} 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)) + has_content = ( + plugin.tool_version is not None or bool(packages_map.get(plugin.name)) or bool(rp.get(plugin.name)) + ) if has_content: buckets.setdefault(plugin.kind, []).append(plugin) return buckets + def _build_runtime_sections( + self, + plugin: PluginInfo, + data: _RefreshData, + auto_update_map: dict[str, bool | dict[str, bool]], + ) -> None: + """Build per-runtime provider headers and package rows. + + Each ``RuntimePackageResult`` becomes a separate + :class:`PluginProviderHeader` with a runtime tag pill. + The default runtime (matched by executable) is placed first. + """ + from porringer.schema.plugin import RuntimePackageResult + + runtime_results: list[RuntimePackageResult] = data.runtime_packages[plugin.name] + if not runtime_results: + return + + auto_val = auto_update_map.get(plugin.name, True) + plugin_updates = self._updates_available.get(plugin.name, {}) + tool_timestamps = self._config.last_tool_updates or {} + default_exe = data.default_runtime_executable + + # Sort: default runtime first, then descending by tag + def _sort_key(rt: RuntimePackageResult) -> tuple[int, str]: + is_default = 1 if (default_exe is not None and rt.executable == default_exe) else 0 + return (-is_default, rt.tag) + + sorted_results = sorted(runtime_results, key=_sort_key) + + for rt in sorted_results: + is_default = default_exe is not None and rt.executable == default_exe + tag_text = f'Python {rt.tag}' + if is_default: + tag_text += ' (default)' + + provider = PluginProviderHeader( + plugin, + auto_val is not False, + show_controls=True, + has_updates=bool(plugin_updates), + runtime_label=tag_text, + 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) + + # Convert RuntimePackageResult.packages to PackageEntry list + raw_packages = [ + 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 rt.packages + ] + plugin_manifest = data.manifest_packages.get(plugin.name, set()) + display_packages = self._build_display_packages(raw_packages, plugin_manifest) + + for pkg in display_packages: + pkg_auto = self._resolve_package_auto_update(auto_val, pkg.name, pkg.is_global) + ts_key = f'{plugin.name}/{pkg.name}' + row = self._create_connected_row( + PluginRowData( + 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, + is_global=True, + host_tool=pkg.host_tool, + last_updated=tool_timestamps.get(ts_key, ''), + ), + ) + self._insert_section_widget(row) + def _build_plugin_section( self, plugin: PluginInfo, @@ -708,6 +848,8 @@ async def _gather_packages( self, plugin_name: str, directories: list[ManifestDirectory], + *, + skip_global: bool = False, ) -> list[PackageEntry]: """Collect packages managed by *plugin_name*. @@ -770,12 +912,35 @@ async def _list_one(directory: ManifestDirectory) -> None: ) async with asyncio.TaskGroup() as tg: - tg.create_task(_list_global()) + if not skip_global: + tg.create_task(_list_global()) for d in directories: tg.create_task(_list_one(d)) return packages - # ------------------------------------------------------------------ + async def _gather_runtime_packages(self, plugin_name: str, discovered) -> list | None: + """Try ``list_packages_by_runtime`` for *plugin_name*. + + Returns the list of :class:`RuntimePackageResult` on success, + or ``None`` when the plugin is not a ``RuntimeConsumer``. + """ + try: + return await self._porringer.plugin.list_packages_by_runtime( + plugin_name, + plugins=discovered, + ) + except PluginError: + return None + except Exception: + logger.debug( + 'Per-runtime probe failed for %s', + plugin_name, + exc_info=True, + ) + return None + + # ------------------------------------------------------------------ + # PluginManager sub-plugin discovery # ------------------------------------------------------------------ diff --git a/synodic_client/application/theme.py b/synodic_client/application/theme.py index 9c09f1a..473dc0c 100644 --- a/synodic_client/application/theme.py +++ b/synodic_client/application/theme.py @@ -142,6 +142,16 @@ PLUGIN_PROVIDER_STATUS_MISSING_STYLE = 'font-size: 10px; color: #f48771;' """Red-orange dot / label for missing providers.""" +PLUGIN_PROVIDER_RUNTIME_TAG_STYLE = ( + 'QLabel { font-size: 10px; color: #7fb3e0; background: #1e3a5f; border-radius: 8px; padding: 1px 6px; }' +) +"""Pill-shaped runtime tag for per-runtime provider headers.""" + +PLUGIN_PROVIDER_RUNTIME_TAG_DEFAULT_STYLE = ( + 'QLabel { font-size: 10px; color: #89d185; background: #1e3a2f; border-radius: 8px; padding: 1px 6px; }' +) +"""Pill-shaped runtime tag highlighted for the default runtime.""" + # Compact tool / package row PLUGIN_ROW_STYLE = ( 'QFrame#pluginRow {' diff --git a/tests/unit/qt/test_gather_packages.py b/tests/unit/qt/test_gather_packages.py index 7e97c21..d31c188 100644 --- a/tests/unit/qt/test_gather_packages.py +++ b/tests/unit/qt/test_gather_packages.py @@ -9,7 +9,7 @@ from packaging.version import Version from porringer.core.schema import Package, PackageRelation, PackageRelationKind from porringer.schema import ManifestDirectory -from porringer.schema.plugin import PluginInfo, PluginKind +from porringer.schema.plugin import PluginInfo, PluginKind, RuntimePackageResult from PySide6.QtWidgets import QLabel, QPushButton from synodic_client.application.screen.plugin_row import ( @@ -19,7 +19,7 @@ PluginRow, ProjectChildRow, ) -from synodic_client.application.screen.schema import PackageEntry, PluginRowData, ProjectInstance +from synodic_client.application.screen.schema import PackageEntry, PluginRowData, ProjectInstance, _RefreshData from synodic_client.application.screen.screen import ToolsView from synodic_client.resolution import ResolvedConfig @@ -831,3 +831,406 @@ def test_has_active_filter_true_with_deselected(self) -> None: view = self._make_view() view._deselected_plugins.add('p') assert view._has_active_filter + + +# --------------------------------------------------------------------------- +# RUNTIME plugin support +# --------------------------------------------------------------------------- + + +class TestRuntimePluginSupport: + """Verify that RUNTIME-kind plugins are included in the tool view.""" + + @staticmethod + def test_runtime_in_bucket_by_kind() -> None: + """RUNTIME plugins with content appear in _bucket_by_kind output.""" + plugins = [ + PluginInfo( + name='pim', kind=PluginKind.RUNTIME, version=Version('0.1.0'), installed=True, tool_version=None + ), + ] + packages_map = { + 'pim': [PackageEntry(name='3.14-64', version='3.14.0')], + } + buckets = ToolsView._bucket_by_kind(plugins, packages_map) + assert PluginKind.RUNTIME in buckets + assert buckets[PluginKind.RUNTIME][0].name == 'pim' + + @staticmethod + def test_runtime_excluded_when_empty() -> None: + """RUNTIME plugins without packages or tool_version are excluded.""" + plugins = [ + PluginInfo( + name='pim', kind=PluginKind.RUNTIME, version=Version('0.1.0'), installed=True, tool_version=None + ), + ] + packages_map: dict[str, list[PackageEntry]] = {'pim': []} + buckets = ToolsView._bucket_by_kind(plugins, packages_map) + assert PluginKind.RUNTIME not in buckets + + @staticmethod + def test_runtime_skips_per_directory_queries() -> None: + """RUNTIME plugins only get a global query, not per-directory queries.""" + porringer = _make_porringer() + + call_paths: list[Path | None] = [] + + async def _mock_list(plugin_name: str, project_path: Path | None = None, **kwargs) -> list[Package]: + call_paths.append(project_path) + if project_path is None: + return [Package(name='3.14-64', version='3.14.0')] + return [Package(name='3.14-64', version='3.14.0')] + + porringer.plugin.list_packages = AsyncMock(side_effect=_mock_list) + porringer.plugin.list = AsyncMock( + return_value=[ + PluginInfo( + name='pim', kind=PluginKind.RUNTIME, version=Version('0.1.0'), installed=True, tool_version=None + ), + ], + ) + porringer.cache.list_directories.return_value = [ + MagicMock(directory=ManifestDirectory(path=Path('/fake/project'))), + ] + + view = ToolsView(porringer, _make_config()) + # Use _gather_packages directly with an empty directory list + # (as _gather_refresh_data would do for RUNTIME plugins) + result = asyncio.run(view._gather_packages('pim', [])) + + assert len(call_paths) == 1, 'only the global query should be issued' + assert call_paths[0] is None, 'the single call should have no project_path' + assert len(result) == 1 + assert result[0].name == '3.14-64' + + @staticmethod + def test_runtime_global_packages_are_global() -> None: + """Runtime packages from the global query are marked as global in display model.""" + entries = [PackageEntry(name='3.14-64', version='3.14.0')] + result = ToolsView._build_display_packages(entries, set()) + + assert len(result) == 1 + pkg = result[0] + assert pkg.name == '3.14-64' + assert pkg.is_global is True + assert pkg.global_version == '3.14.0' + assert pkg.project_instances == [] + + +# --------------------------------------------------------------------------- +# Per-runtime package display +# --------------------------------------------------------------------------- + +# Expected widget counts (avoids PLR2004) +_EXPECTED_RUNTIME_PROVIDERS = 2 +_EXPECTED_RUNTIME_PROVIDERS_WITH_VENV = 3 +_EXPECTED_DEFAULT_RT_PACKAGES = 2 +_EXPECTED_NON_DEFAULT_RT_PACKAGES = 1 + + +class TestPerRuntimeDisplay: + """Verify per-runtime provider headers, package rows, and default detection.""" + + @staticmethod + def _pip_plugin() -> PluginInfo: + return PluginInfo( + name='pip', + kind=PluginKind.PACKAGE, + version=Version('0.1.0'), + installed=True, + tool_version=Version('24.0'), + ) + + @staticmethod + def _make_runtime_results(default_exe: Path) -> list[RuntimePackageResult]: + """Two runtimes: 3.14 (default) and 3.13.""" + return [ + RuntimePackageResult( + provider='pim', + tag='3.13', + executable=Path('C:/Python313/python.exe'), + packages=[ + Package(name='django', version='5.0'), + ], + ), + RuntimePackageResult( + provider='pim', + tag='3.14', + executable=default_exe, + packages=[ + Package(name='requests', version='2.31.0'), + Package(name='numpy', version='1.26.0'), + ], + ), + ] + + def test_runtime_sections_create_separate_provider_headers(self) -> None: + """Each RuntimePackageResult produces its own PluginProviderHeader.""" + view = ToolsView(_make_porringer(), _make_config()) + default_exe = Path('C:/Python314/python.exe') + plugin = self._pip_plugin() + rt_results = self._make_runtime_results(default_exe) + + data = _RefreshData( + plugins=[plugin], + packages_map={}, + manifest_packages={}, + runtime_packages={'pip': rt_results}, + default_runtime_executable=default_exe, + ) + + view._build_runtime_sections(plugin, data, {}) + + providers = [w for w in view._section_widgets if isinstance(w, PluginProviderHeader)] + assert len(providers) == _EXPECTED_RUNTIME_PROVIDERS + + def test_default_runtime_comes_first(self) -> None: + """The default runtime's provider header is the first one.""" + view = ToolsView(_make_porringer(), _make_config()) + default_exe = Path('C:/Python314/python.exe') + plugin = self._pip_plugin() + rt_results = self._make_runtime_results(default_exe) + + data = _RefreshData( + plugins=[plugin], + packages_map={}, + manifest_packages={}, + runtime_packages={'pip': rt_results}, + default_runtime_executable=default_exe, + ) + + view._build_runtime_sections(plugin, data, {}) + + providers = [w for w in view._section_widgets if isinstance(w, PluginProviderHeader)] + # First provider should have the default label + first_labels = [w for w in providers[0].findChildren(QLabel) if '(default)' in w.text()] + assert len(first_labels) == 1, 'First provider should have the default tag' + + def test_default_runtime_packages_displayed(self) -> None: + """Package rows for the default runtime appear after its header.""" + view = ToolsView(_make_porringer(), _make_config()) + default_exe = Path('C:/Python314/python.exe') + plugin = self._pip_plugin() + rt_results = self._make_runtime_results(default_exe) + + data = _RefreshData( + plugins=[plugin], + packages_map={}, + manifest_packages={}, + runtime_packages={'pip': rt_results}, + default_runtime_executable=default_exe, + ) + + view._build_runtime_sections(plugin, data, {}) + + rows = [w for w in view._section_widgets if isinstance(w, PluginRow)] + # Default runtime has 2 packages, non-default has 1 + default_rows = rows[:_EXPECTED_DEFAULT_RT_PACKAGES] + names = {r._package_name for r in default_rows} + assert names == {'requests', 'numpy'} + + def test_non_default_runtime_packages_displayed(self) -> None: + """Package rows for the non-default runtime appear after its header.""" + view = ToolsView(_make_porringer(), _make_config()) + default_exe = Path('C:/Python314/python.exe') + plugin = self._pip_plugin() + rt_results = self._make_runtime_results(default_exe) + + data = _RefreshData( + plugins=[plugin], + packages_map={}, + manifest_packages={}, + runtime_packages={'pip': rt_results}, + default_runtime_executable=default_exe, + ) + + view._build_runtime_sections(plugin, data, {}) + + rows = [w for w in view._section_widgets if isinstance(w, PluginRow)] + non_default_rows = rows[_EXPECTED_DEFAULT_RT_PACKAGES:] + assert len(non_default_rows) == _EXPECTED_NON_DEFAULT_RT_PACKAGES + assert non_default_rows[0]._package_name == 'django' + + def test_venv_packages_separate_from_runtime(self) -> None: + """Venv packages appear in a separate section without runtime tag.""" + view = ToolsView(_make_porringer(), _make_config()) + default_exe = Path('C:/Python314/python.exe') + plugin = self._pip_plugin() + rt_results = self._make_runtime_results(default_exe) + + data = _RefreshData( + plugins=[plugin], + packages_map={ + 'pip': [ + PackageEntry( + name='mylib', + version='1.0.0', + project_label='myproject', + project_path='/projects/myproject', + ), + ], + }, + manifest_packages={}, + runtime_packages={'pip': rt_results}, + default_runtime_executable=default_exe, + ) + + auto_update_map: dict[str, bool | dict[str, bool]] = {} + # Build the full widget tree + view._build_widget_tree(data) + + providers = [w for w in view._section_widgets if isinstance(w, PluginProviderHeader)] + # 2 runtime providers + 1 venv provider = 3 + assert len(providers) == _EXPECTED_RUNTIME_PROVIDERS_WITH_VENV + + # The last provider should NOT have a runtime tag + last_provider = providers[-1] + runtime_tags = [w for w in last_provider.findChildren(QLabel) if 'Python' in w.text()] + assert len(runtime_tags) == 0, 'Venv provider should not have a runtime tag' + + def test_runtime_tag_uses_default_style(self) -> None: + """The default runtime tag uses the green highlight style.""" + from synodic_client.application.theme import PLUGIN_PROVIDER_RUNTIME_TAG_DEFAULT_STYLE + + view = ToolsView(_make_porringer(), _make_config()) + default_exe = Path('C:/Python314/python.exe') + plugin = self._pip_plugin() + rt_results = self._make_runtime_results(default_exe) + + data = _RefreshData( + plugins=[plugin], + packages_map={}, + manifest_packages={}, + runtime_packages={'pip': rt_results}, + default_runtime_executable=default_exe, + ) + + view._build_runtime_sections(plugin, data, {}) + + providers = [w for w in view._section_widgets if isinstance(w, PluginProviderHeader)] + first = providers[0] + default_tags = [w for w in first.findChildren(QLabel) if '(default)' in w.text()] + assert len(default_tags) == 1 + assert default_tags[0].styleSheet() == PLUGIN_PROVIDER_RUNTIME_TAG_DEFAULT_STYLE + + def test_runtime_tag_uses_normal_style_for_non_default(self) -> None: + """Non-default runtime tags use the blue style.""" + from synodic_client.application.theme import PLUGIN_PROVIDER_RUNTIME_TAG_STYLE + + view = ToolsView(_make_porringer(), _make_config()) + default_exe = Path('C:/Python314/python.exe') + plugin = self._pip_plugin() + rt_results = self._make_runtime_results(default_exe) + + data = _RefreshData( + plugins=[plugin], + packages_map={}, + manifest_packages={}, + runtime_packages={'pip': rt_results}, + default_runtime_executable=default_exe, + ) + + view._build_runtime_sections(plugin, data, {}) + + providers = [w for w in view._section_widgets if isinstance(w, PluginProviderHeader)] + # Second provider is non-default + second = providers[1] + runtime_tags = [w for w in second.findChildren(QLabel) if 'Python' in w.text() and '(default)' not in w.text()] + assert len(runtime_tags) == 1 + assert runtime_tags[0].styleSheet() == PLUGIN_PROVIDER_RUNTIME_TAG_STYLE + + def test_filter_chips_work_with_runtime_providers(self) -> None: + """Filter chips are built from runtime provider headers and filter works.""" + view = ToolsView(_make_porringer(), _make_config()) + default_exe = Path('C:/Python314/python.exe') + plugin = self._pip_plugin() + rt_results = self._make_runtime_results(default_exe) + + data = _RefreshData( + plugins=[plugin], + packages_map={}, + manifest_packages={}, + runtime_packages={'pip': rt_results}, + default_runtime_executable=default_exe, + ) + + view._build_widget_tree(data) + + # Should have a 'pip' chip + assert 'pip' in view._filter_chips + + # Deselecting 'pip' should hide all runtime rows + view._filter_chips['pip'].setChecked(False) + visible_rows = [w for w in view._section_widgets if isinstance(w, PluginRow) and not w.isHidden()] + assert len(visible_rows) == 0 + + def test_gather_runtime_packages_returns_none_for_non_consumer(self) -> None: + """_gather_runtime_packages returns None when plugin is not a RuntimeConsumer.""" + from porringer.utility.exception import PluginError + + porringer = _make_porringer() + porringer.plugin.list_packages_by_runtime = AsyncMock( + side_effect=PluginError('not a RuntimeConsumer'), + ) + + view = ToolsView(porringer, _make_config()) + result = asyncio.run(view._gather_runtime_packages('pipx', MagicMock())) + assert result is None + + def test_gather_runtime_packages_returns_results(self) -> None: + """_gather_runtime_packages returns list on success.""" + porringer = _make_porringer() + expected = [ + RuntimePackageResult( + provider='pim', + tag='3.14', + executable=Path('C:/Python314/python.exe'), + packages=[Package(name='requests', version='2.31.0')], + ), + ] + porringer.plugin.list_packages_by_runtime = AsyncMock(return_value=expected) + + view = ToolsView(porringer, _make_config()) + result = asyncio.run(view._gather_runtime_packages('pip', MagicMock())) + assert result == expected + + def test_skip_global_flag_skips_global_query(self) -> None: + """_gather_packages with skip_global=True only runs per-directory queries.""" + porringer = _make_porringer() + call_paths: list[Path | None] = [] + + async def _mock_list(plugin_name: str, project_path: Path | None = None, **kwargs) -> list[Package]: + call_paths.append(project_path) + if project_path is None: + return [Package(name='global-pkg', version='1.0')] + return [Package(name='venv-pkg', version='2.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('pip', [directory], skip_global=True)) + + assert all(p is not None for p in call_paths), 'global query should not be issued' + names = {e.name for e in result} + assert 'venv-pkg' in names + assert 'global-pkg' not in names + + def test_bucket_by_kind_includes_runtime_packages(self) -> None: + """_bucket_by_kind considers runtime_packages for content check.""" + plugins = [ + PluginInfo( + name='pip', + kind=PluginKind.PACKAGE, + version=Version('0.1.0'), + installed=True, + tool_version=Version('24.0'), + ), + ] + # packages_map is empty, but runtime_packages has content + buckets = ToolsView._bucket_by_kind( + plugins, + {}, + runtime_packages={'pip': [MagicMock()]}, + ) + assert PluginKind.PACKAGE in buckets From fbcbdd3fcde0593934ca085468c6822ff4b4942a Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Thu, 5 Mar 2026 20:51:04 -0800 Subject: [PATCH 5/6] Update Chore + Upstream Refactor --- pdm.lock | 8 +- pyproject.toml | 2 +- synodic_client/application/data.py | 22 +++- .../application/screen/plugin_row.py | 15 ++- synodic_client/application/screen/schema.py | 3 + synodic_client/application/screen/screen.py | 49 +++++--- .../screen/tool_update_controller.py | 110 ++++++++++++++++-- synodic_client/application/workers.py | 49 +++++++- synodic_client/resolution.py | 4 + tests/unit/qt/test_gather_packages.py | 28 ++--- tests/unit/qt/test_update_feedback.py | 107 +++++++++++++++++ tests/unit/test_resolution.py | 18 +++ tests/unit/test_workers.py | 107 ++++++++++++++++- 13 files changed, 471 insertions(+), 51 deletions(-) diff --git a/pdm.lock b/pdm.lock index e8387be..9c64821 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:8f6349b298fe09fa365c1c799eba84f7e77bb71e1a082d8c7d15e057ed5d6b57" +content_hash = "sha256:f567743c6be6eef49f781b1386b32e50eee0b2dab4cb0ba0efc1ec29f5d1ac78" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -336,7 +336,7 @@ files = [ [[package]] name = "porringer" -version = "0.2.1.dev75" +version = "0.2.1.dev77" requires_python = ">=3.14" summary = "" groups = ["default"] @@ -349,8 +349,8 @@ dependencies = [ "userpath>=1.9.2", ] files = [ - {file = "porringer-0.2.1.dev75-py3-none-any.whl", hash = "sha256:3934b4d332fe2f4473d015bd13c377154be6b39d5dbbb99c5c25705943d10c1a"}, - {file = "porringer-0.2.1.dev75.tar.gz", hash = "sha256:a2fc29a06ce81bee7080d99f58fe51de7cc18509651f4ca1b1fabf786d0f6bb1"}, + {file = "porringer-0.2.1.dev77-py3-none-any.whl", hash = "sha256:0ef3501d381b05cae54e83b37cdaded22672602bd69d1e2a715837a068ba405a"}, + {file = "porringer-0.2.1.dev77.tar.gz", hash = "sha256:3bfabd5cf2c467c7a792e271c36c05d993e18e01da024f143beadd7a23337f32"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index c1a65eb..b184aba 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.dev75", + "porringer>=0.2.1.dev77", "qasync>=0.28.0", "velopack>=0.0.1444.dev49733", "typer>=0.24.1", diff --git a/synodic_client/application/data.py b/synodic_client/application/data.py index 97fd7e2..aa7247f 100644 --- a/synodic_client/application/data.py +++ b/synodic_client/application/data.py @@ -22,6 +22,7 @@ CheckParameters, CheckResult, ) +from porringer.schema.check import RuntimeCheckResult from synodic_client.application.schema import Snapshot @@ -107,7 +108,26 @@ async def check_updates( A list of :class:`CheckResult` per plugin. """ params = CheckParameters(plugins=plugins) - return await self._porringer.sync.check_updates( + return await self._porringer.package.check_updates( + params, + plugins=self._snapshot.discovered, + ) + + async def check_updates_by_runtime( + self, + plugins: list[str] | None = None, + ) -> list[RuntimeCheckResult]: + """Run per-runtime update detection using cached ``DiscoveredPlugins``. + + Args: + plugins: Optional include-set of plugin names. ``None`` + means all plugins. + + Returns: + A list of :class:`RuntimeCheckResult` per runtime. + """ + params = CheckParameters(plugins=plugins) + return await self._porringer.package.check_updates_by_runtime( params, plugins=self._snapshot.discovered, ) diff --git a/synodic_client/application/screen/plugin_row.py b/synodic_client/application/screen/plugin_row.py index 2c5aea6..4251b64 100644 --- a/synodic_client/application/screen/plugin_row.py +++ b/synodic_client/application/screen/plugin_row.py @@ -136,6 +136,7 @@ def __init__( show_controls: bool = False, has_updates: bool = False, runtime_label: str = '', + runtime_tag: str = '', parent: QWidget | None = None, ) -> None: """Initialize the provider header with plugin info and optional controls.""" @@ -143,6 +144,8 @@ def __init__( self.setObjectName('pluginProvider') self.setStyleSheet(PLUGIN_PROVIDER_STYLE) self._plugin_name = plugin.name + self._runtime_tag = runtime_tag + self._signal_key = f'{plugin.name}:{runtime_tag}' if runtime_tag else plugin.name self._update_btn: QPushButton | None = None self._checking_spinner: _RowSpinner | None = None @@ -200,7 +203,7 @@ def __init__( 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), + lambda checked: self.auto_update_toggled.emit(self._signal_key, checked), ) layout.addWidget(toggle_btn) @@ -211,7 +214,7 @@ def __init__( 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), + lambda: self.update_requested.emit(self._signal_key), ) update_btn.setVisible(has_updates) self._update_btn = update_btn @@ -303,6 +306,8 @@ def __init__( self.setStyleSheet(PLUGIN_ROW_STYLE) self._plugin_name = data.plugin_name self._package_name = data.name + self._runtime_tag = data.runtime_tag + self._signal_key = f'{data.plugin_name}:{data.runtime_tag}' if data.runtime_tag else data.plugin_name self._update_btn: QPushButton | None = None self._remove_btn: QPushButton | None = None self._checking_spinner: _RowSpinner | None = None @@ -403,7 +408,7 @@ def _build_toggle(self, layout: QHBoxLayout, data: PluginRowData) -> None: toggle_btn.setToolTip('Auto-update this package') toggle_btn.clicked.connect( lambda checked: self.auto_update_toggled.emit( - self._plugin_name, + self._signal_key, self._package_name, checked, ), @@ -420,7 +425,7 @@ def _build_update_button(self, layout: QHBoxLayout, data: PluginRowData) -> None update_btn.setFixedWidth(PLUGIN_ROW_UPDATE_WIDTH) update_btn.setToolTip(f'Update {data.name}') update_btn.clicked.connect( - lambda: self.update_requested.emit(self._plugin_name, self._package_name), + lambda: self.update_requested.emit(self._signal_key, self._package_name), ) update_btn.setVisible(data.has_update) self._update_btn = update_btn @@ -447,7 +452,7 @@ def _build_remove_button(self, layout: QHBoxLayout, data: PluginRowData) -> None 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), + lambda: self.remove_requested.emit(self._signal_key, self._package_name), ) else: remove_btn.setEnabled(False) diff --git a/synodic_client/application/screen/schema.py b/synodic_client/application/screen/schema.py index acba46a..b7be819 100644 --- a/synodic_client/application/screen/schema.py +++ b/synodic_client/application/screen/schema.py @@ -140,6 +140,9 @@ class PluginRowData: host_tool: str = '' """Host-tool name for injected packages.""" + runtime_tag: str = '' + """Runtime tag for per-runtime packages (e.g. ``\"3.12\"``).""" + project_paths: list[str] = field(default_factory=list) """Filesystem paths for project-scoped packages.""" diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index ba8362c..76b1b81 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -465,6 +465,7 @@ def _sort_key(rt: RuntimePackageResult) -> tuple[int, str]: show_controls=True, has_updates=bool(plugin_updates), runtime_label=tag_text, + runtime_tag=rt.tag, parent=self._container, ) provider.auto_update_toggled.connect(self._on_auto_update_toggled) @@ -496,6 +497,7 @@ def _sort_key(rt: RuntimePackageResult) -> tuple[int, str]: has_update=pkg.name in plugin_updates, is_global=True, host_tool=pkg.host_tool, + runtime_tag=rt.tag, last_updated=tool_timestamps.get(ts_key, ''), ), ) @@ -867,7 +869,7 @@ async def _gather_packages( async def _list_global() -> None: try: - pkgs = await self._porringer.plugin.list_packages( + pkgs = await self._porringer.package.list( plugin_name, plugins=discovered, ) @@ -888,7 +890,7 @@ async def _list_global() -> None: async def _list_one(directory: ManifestDirectory) -> None: try: - pkgs = await self._porringer.plugin.list_packages( + pkgs = await self._porringer.package.list( plugin_name, Path(directory.path), plugins=discovered, @@ -925,7 +927,7 @@ async def _gather_runtime_packages(self, plugin_name: str, discovered) -> list | or ``None`` when the plugin is not a ``RuntimeConsumer``. """ try: - return await self._porringer.plugin.list_packages_by_runtime( + return await self._porringer.package.list_by_runtime( plugin_name, plugins=discovered, ) @@ -1162,16 +1164,37 @@ async def _check_one(directory: ManifestDirectory) -> None: return available async def _check_updates_via_coordinator(self) -> dict[str, dict[str, str]]: - """Use the coordinator's ``check_updates`` for efficient detection.""" + """Use the coordinator's ``check_updates`` for efficient detection. + + Fetches both flat (global) and per-runtime update results. + Per-runtime entries use composite keys ``"plugin:tag"`` so that + :meth:`_apply_update_badges` can match runtime-specific headers. + """ assert self._coordinator is not None - results = await self._coordinator.check_updates() + results, runtime_results = await asyncio.gather( + self._coordinator.check_updates(), + self._coordinator.check_updates_by_runtime(), + ) available: dict[str, dict[str, str]] = {} + + # Flat (global) results keyed by bare plugin name for cr in results: if cr.success: for pi in cr.packages: if pi.update_available: - latest = str(pi.latest_version) if hasattr(pi, 'latest_version') and pi.latest_version else '' + latest = str(pi.latest_version) if pi.latest_version else '' available.setdefault(cr.plugin, {})[pi.name] = latest + + # Per-runtime results keyed by composite "plugin:tag" + for rcr in runtime_results: + for cr in rcr.results: + if cr.success: + for pi in cr.packages: + if pi.update_available: + composite = f'{cr.plugin}:{rcr.tag}' + latest = str(pi.latest_version) if pi.latest_version else '' + available.setdefault(composite, {})[pi.name] = latest + return available async def _check_directory_updates( @@ -1252,13 +1275,13 @@ def _apply_update_badges(self) -> None: current_plugin: str = '' for widget in self._section_widgets: if isinstance(widget, PluginProviderHeader): - current_plugin = widget._plugin_name + current_plugin = widget._signal_key plugin_updates = self._updates_available.get(current_plugin, {}) 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, {}) + plugin_updates = self._updates_available.get(widget._signal_key, {}) latest_version = plugin_updates.get(widget._package_name) has_update = latest_version is not None @@ -1287,7 +1310,7 @@ def _refresh_timestamps(self) -> None: 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: + if isinstance(widget, PluginProviderHeader) and widget._signal_key == plugin_name: widget.set_updating(updating) break @@ -1301,7 +1324,7 @@ def set_package_updating( for widget in self._section_widgets: if ( isinstance(widget, PluginRow) - and widget._plugin_name == plugin_name + and widget._signal_key == plugin_name and widget._package_name == package_name ): widget.set_updating(updating) @@ -1317,7 +1340,7 @@ def set_package_removing( for widget in self._section_widgets: if ( isinstance(widget, PluginRow) - and widget._plugin_name == plugin_name + and widget._signal_key == plugin_name and widget._package_name == package_name ): widget.set_removing(removing) @@ -1333,7 +1356,7 @@ def set_package_error( for widget in self._section_widgets: if ( isinstance(widget, PluginRow) - and widget._plugin_name == plugin_name + and widget._signal_key == plugin_name and widget._package_name == package_name ): widget.set_error(message) @@ -1342,7 +1365,7 @@ def set_package_error( 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: + if isinstance(widget, PluginProviderHeader) and widget._signal_key == plugin_name: widget.set_error(message) break diff --git a/synodic_client/application/screen/tool_update_controller.py b/synodic_client/application/screen/tool_update_controller.py index d9599ae..1f0e3c3 100644 --- a/synodic_client/application/screen/tool_update_controller.py +++ b/synodic_client/application/screen/tool_update_controller.py @@ -15,6 +15,7 @@ from datetime import UTC, datetime from porringer.api import API +from porringer.core.schema import PackageRef from porringer.schema.execution import SetupActionResult from PySide6.QtCore import QTimer from PySide6.QtWidgets import QSystemTrayIcon @@ -22,7 +23,7 @@ from synodic_client.application.schema import ToolUpdateResult from synodic_client.application.screen.screen import MainWindow, ToolsView from synodic_client.application.workers import ( - run_package_remove, + run_runtime_package_updates, run_tool_updates, ) from synodic_client.config import load_user_config @@ -181,7 +182,11 @@ async def _do_tool_update(self, porringer: API) -> None: # -- Single plugin update -- def on_single_plugin_update(self, plugin_name: str) -> None: - """Upgrade a single plugin across all cached projects.""" + """Upgrade a single plugin across all cached projects. + + Composite keys ``"plugin:tag"`` trigger a per-runtime update + scoped to the given runtime tag. + """ porringer = self._window.porringer if porringer is None: logger.warning('Single plugin update skipped: porringer not available') @@ -191,9 +196,54 @@ def on_single_plugin_update(self, plugin_name: str) -> None: 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), - ) + + if ':' in plugin_name: + bare_plugin, runtime_tag = plugin_name.split(':', 1) + self._tool_task = asyncio.create_task( + self._async_runtime_plugin_update(porringer, plugin_name, bare_plugin, runtime_tag), + ) + else: + self._tool_task = asyncio.create_task( + self._async_single_plugin_update(porringer, plugin_name), + ) + + async def _async_runtime_plugin_update( + self, + porringer: API, + signal_key: str, + plugin_name: str, + runtime_tag: str, + ) -> None: + """Run a runtime-scoped plugin update and route results.""" + config = self._resolve_config() + mapping = config.plugin_auto_update or {} + pkg_entry = mapping.get(signal_key) or mapping.get(plugin_name) + coordinator = self._window.coordinator + discovered = coordinator.discovered_plugins if coordinator is not None else None + + include_packages: set[str] | None = None + if isinstance(pkg_entry, dict): + enabled_pkgs = {name for name, enabled in pkg_entry.items() if enabled} + if enabled_pkgs: + include_packages = enabled_pkgs + + try: + result = await run_runtime_package_updates( + porringer, + plugin_name, + runtime_tag, + include_packages=include_packages, + discovered_plugins=discovered, + ) + if coordinator is not None: + coordinator.invalidate() + self._on_tool_update_finished(result, updating_plugin=signal_key, manual=True) + except Exception as exc: + logger.exception('Runtime tool update failed') + tools_view = self._window.tools_view + if tools_view is not None: + tools_view.set_plugin_updating(signal_key, False) + tools_view.set_plugin_error(signal_key, f'Update failed: {exc}') async def _async_single_plugin_update(self, porringer: API, plugin_name: str) -> None: """Run a single-plugin tool update and route results.""" @@ -250,9 +300,40 @@ async def _async_single_package_update( plugin_name: str, package_name: str, ) -> None: - """Run a single-package tool update and route results.""" + """Run a single-package tool update and route results. + + When *plugin_name* is a composite ``"plugin:tag"`` key the + upgrade is scoped to that runtime tag via + ``porringer.package.upgrade(runtime_tag=...)``. + """ coordinator = self._window.coordinator discovered = coordinator.discovered_plugins if coordinator is not None else None + + if ':' in plugin_name: + bare_plugin, runtime_tag = plugin_name.split(':', 1) + try: + result = await run_runtime_package_updates( + porringer, + bare_plugin, + runtime_tag, + include_packages={package_name}, + discovered_plugins=discovered, + ) + if coordinator is not None: + coordinator.invalidate() + self._on_tool_update_finished( + result, + updating_package=(plugin_name, package_name), + manual=True, + ) + except Exception as exc: + logger.exception('Runtime package update failed') + 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}') + return + try: result = await run_tool_updates( porringer, @@ -358,12 +439,19 @@ async def _async_single_package_remove( """Run a single-package removal and route results.""" coordinator = self._window.coordinator discovered = coordinator.discovered_plugins if coordinator is not None else None + + bare_plugin = plugin_name + runtime_tag: str | None = None + if ':' in plugin_name: + bare_plugin, runtime_tag = plugin_name.split(':', 1) + try: - result = await run_package_remove( - porringer, - plugin_name, - package_name, - discovered_plugins=discovered, + package_ref = PackageRef(name=package_name) + result = await porringer.package.uninstall( + bare_plugin, + package_ref, + runtime_tag=runtime_tag, + plugins=discovered, ) logger.info( 'Removal result for %s/%s: success=%s, skipped=%s, skip_reason=%s, message=%s', diff --git a/synodic_client/application/workers.py b/synodic_client/application/workers.py index e53613f..6295c50 100644 --- a/synodic_client/application/workers.py +++ b/synodic_client/application/workers.py @@ -149,4 +149,51 @@ async def run_package_remove( A :class:`SetupActionResult` describing the outcome. """ package_ref = PackageRef(name=package_name) - return await porringer.uninstall(plugin_name, package_ref, plugins=discovered_plugins) + return await porringer.package.uninstall(plugin_name, package_ref, plugins=discovered_plugins) + + +async def run_runtime_package_updates( + porringer: API, + plugin_name: str, + runtime_tag: str, + include_packages: set[str] | None = None, + *, + discovered_plugins: DiscoveredPlugins | None = None, +) -> ToolUpdateResult: + """Upgrade packages for a single plugin scoped to a specific runtime tag. + + Args: + porringer: The porringer API instance. + plugin_name: The installer plugin name (e.g. ``"pipx"``). + runtime_tag: The runtime version tag (e.g. ``"3.12"``). + include_packages: Optional include-set of package names. + discovered_plugins: Pre-discovered plugins to pass through. + + Returns: + A :class:`ToolUpdateResult` summarising the run. + """ + result = ToolUpdateResult() + packages = await porringer.package.list_by_runtime(plugin_name, plugins=discovered_plugins) + for rt in packages: + if rt.tag != runtime_tag: + continue + for pkg in rt.packages: + pkg_name = str(pkg.name) + if include_packages is not None and pkg_name not in include_packages: + continue + package_ref = PackageRef(name=pkg_name) + action_result = await porringer.package.upgrade( + plugin_name, + package_ref, + runtime_tag=runtime_tag, + plugins=discovered_plugins, + ) + if action_result.skipped: + result.already_latest += 1 + elif action_result.success: + result.updated += 1 + result.updated_packages.add(pkg_name) + else: + result.failed += 1 + break + return result diff --git a/synodic_client/resolution.py b/synodic_client/resolution.py index f366d1d..0316122 100644 --- a/synodic_client/resolution.py +++ b/synodic_client/resolution.py @@ -218,6 +218,10 @@ def resolve_auto_update_scope( if mapping: for name, value in mapping.items(): + # Skip runtime-scoped composite keys ("plugin:tag"); they + # are only relevant for on-demand runtime updates. + if ':' in name: + continue if value is False: disabled_plugins.add(name) elif isinstance(value, dict): diff --git a/tests/unit/qt/test_gather_packages.py b/tests/unit/qt/test_gather_packages.py index d31c188..3e2c322 100644 --- a/tests/unit/qt/test_gather_packages.py +++ b/tests/unit/qt/test_gather_packages.py @@ -51,7 +51,7 @@ def _make_porringer() -> MagicMock: """Build a MagicMock standing in for the porringer API.""" mock = MagicMock() mock.plugin.list = AsyncMock(return_value=[]) - mock.plugin.list_packages = AsyncMock(return_value=[]) + mock.package.list = AsyncMock(return_value=[]) mock.cache.list_directories.return_value = [] return mock @@ -68,7 +68,7 @@ class TestGatherPackages: 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( + porringer.package.list = AsyncMock( return_value=[ Package(name='pdm', version='2.22.4'), Package(name='cppython', version='0.5.0'), @@ -84,7 +84,7 @@ def test_global_query_returns_packages_with_no_directories() -> None: 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( + porringer.package.list = AsyncMock( return_value=[ Package(name='pdm', version='2.22.4'), ], @@ -101,13 +101,13 @@ def test_global_query_returns_packages_with_empty_project_path() -> None: 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=[]) + porringer.package.list = 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 + calls = porringer.package.list.call_args_list plugin_name_only = 1 min_args_with_path = 2 global_calls = [ @@ -127,7 +127,7 @@ async def _mock_list(plugin_name: str, project_path: Path | None = None, **kwarg 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) + porringer.package.list = AsyncMock(side_effect=_mock_list) directory = ManifestDirectory(path=Path('/fake/project')) view = ToolsView(porringer, _make_config()) @@ -147,7 +147,7 @@ async def _mock_list(plugin_name: str, project_path: Path | None = None, **kwarg return [] return [Package(name='mylib', version='1.0.0')] - porringer.plugin.list_packages = AsyncMock(side_effect=_mock_list) + porringer.package.list = AsyncMock(side_effect=_mock_list) directory = ManifestDirectory(path=Path('/fake/project')) view = ToolsView(porringer, _make_config()) @@ -161,7 +161,7 @@ async def _mock_list(plugin_name: str, project_path: Path | None = None, **kwarg 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( + porringer.package.list = AsyncMock( return_value=[Package(name='cppython', version='0.5.0')], ) @@ -186,7 +186,7 @@ async def _mock_list(plugin_name: str, project_path: Path | None = None, **kwarg raise RuntimeError('global query failed') return [Package(name='django', version='5.0')] - porringer.plugin.list_packages = AsyncMock(side_effect=_mock_list) + porringer.package.list = AsyncMock(side_effect=_mock_list) directory = ManifestDirectory(path=Path('/fake/project')) view = ToolsView(porringer, _make_config()) @@ -201,7 +201,7 @@ async def _mock_list(plugin_name: str, project_path: Path | None = None, **kwarg 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( + porringer.package.list = AsyncMock( return_value=[ Package( name='cppython', @@ -881,7 +881,7 @@ async def _mock_list(plugin_name: str, project_path: Path | None = None, **kwarg return [Package(name='3.14-64', version='3.14.0')] return [Package(name='3.14-64', version='3.14.0')] - porringer.plugin.list_packages = AsyncMock(side_effect=_mock_list) + porringer.package.list = AsyncMock(side_effect=_mock_list) porringer.plugin.list = AsyncMock( return_value=[ PluginInfo( @@ -1169,7 +1169,7 @@ def test_gather_runtime_packages_returns_none_for_non_consumer(self) -> None: from porringer.utility.exception import PluginError porringer = _make_porringer() - porringer.plugin.list_packages_by_runtime = AsyncMock( + porringer.package.list_by_runtime = AsyncMock( side_effect=PluginError('not a RuntimeConsumer'), ) @@ -1188,7 +1188,7 @@ def test_gather_runtime_packages_returns_results(self) -> None: packages=[Package(name='requests', version='2.31.0')], ), ] - porringer.plugin.list_packages_by_runtime = AsyncMock(return_value=expected) + porringer.package.list_by_runtime = AsyncMock(return_value=expected) view = ToolsView(porringer, _make_config()) result = asyncio.run(view._gather_runtime_packages('pip', MagicMock())) @@ -1205,7 +1205,7 @@ async def _mock_list(plugin_name: str, project_path: Path | None = None, **kwarg return [Package(name='global-pkg', version='1.0')] return [Package(name='venv-pkg', version='2.0')] - porringer.plugin.list_packages = AsyncMock(side_effect=_mock_list) + porringer.package.list = AsyncMock(side_effect=_mock_list) directory = ManifestDirectory(path=Path('/fake/project')) view = ToolsView(porringer, _make_config()) diff --git a/tests/unit/qt/test_update_feedback.py b/tests/unit/qt/test_update_feedback.py index d2b019e..7aa765c 100644 --- a/tests/unit/qt/test_update_feedback.py +++ b/tests/unit/qt/test_update_feedback.py @@ -393,3 +393,110 @@ def test_project_paths_stored() -> None: ) ) assert row._project_paths == ['/fake/project'] + + +# --------------------------------------------------------------------------- +# Composite signal keys (runtime-scoped widgets) +# --------------------------------------------------------------------------- + + +class TestCompositeSignalKeys: + """Tests for ``_signal_key`` on runtime-tagged widgets.""" + + @staticmethod + def test_header_signal_key_bare() -> None: + """Without runtime_tag the signal key equals the plugin name.""" + header = PluginProviderHeader(_make_plugin(name='pipx'), show_controls=True) + assert header._signal_key == 'pipx' + + @staticmethod + def test_header_signal_key_composite() -> None: + """With runtime_tag the signal key is 'plugin:tag'.""" + header = PluginProviderHeader( + _make_plugin(name='pipx'), + show_controls=True, + runtime_tag='3.12', + ) + assert header._signal_key == 'pipx:3.12' + + @staticmethod + def test_header_update_requested_emits_composite() -> None: + """Update button emits the composite signal key.""" + header = PluginProviderHeader( + _make_plugin(name='pipx'), + show_controls=True, + has_updates=True, + runtime_tag='3.12', + ) + spy = MagicMock() + header.update_requested.connect(spy) + assert header._update_btn is not None + header._update_btn.click() + spy.assert_called_once_with('pipx:3.12') + + @staticmethod + def test_header_auto_update_toggled_emits_composite() -> None: + """Auto toggle emits the composite signal key.""" + header = PluginProviderHeader( + _make_plugin(name='pipx'), + show_controls=True, + runtime_tag='3.11', + ) + spy = MagicMock() + header.auto_update_toggled.connect(spy) + # Find the Auto button and click it + from PySide6.QtWidgets import QPushButton + + for child in header.findChildren(QPushButton): + if child.text() == 'Auto': + child.click() + break + spy.assert_called_once() + assert spy.call_args[0][0] == 'pipx:3.11' + + @staticmethod + def test_row_signal_key_bare() -> None: + """PluginRow without runtime_tag has bare signal key.""" + row = PluginRow(PluginRowData(name='pdm', plugin_name='pipx')) + assert row._signal_key == 'pipx' + + @staticmethod + def test_row_signal_key_composite() -> None: + """PluginRow with runtime_tag has composite signal key.""" + row = PluginRow(PluginRowData(name='pdm', plugin_name='pipx', runtime_tag='3.12')) + assert row._signal_key == 'pipx:3.12' + + @staticmethod + def test_row_update_requested_emits_composite() -> None: + """PluginRow update button emits composite key as plugin_name.""" + row = PluginRow( + PluginRowData( + name='pdm', + plugin_name='pipx', + show_toggle=True, + has_update=True, + runtime_tag='3.12', + ) + ) + spy = MagicMock() + row.update_requested.connect(spy) + assert row._update_btn is not None + row._update_btn.click() + spy.assert_called_once_with('pipx:3.12', 'pdm') + + @staticmethod + def test_row_remove_requested_emits_composite() -> None: + """PluginRow remove button emits composite key.""" + row = PluginRow( + PluginRowData( + name='pdm', + plugin_name='pipx', + is_global=True, + runtime_tag='3.12', + ) + ) + spy = MagicMock() + row.remove_requested.connect(spy) + assert row._remove_btn is not None + row._remove_btn.click() + spy.assert_called_once_with('pipx:3.12', 'pdm') diff --git a/tests/unit/test_resolution.py b/tests/unit/test_resolution.py index 031152a..c59707e 100644 --- a/tests/unit/test_resolution.py +++ b/tests/unit/test_resolution.py @@ -296,6 +296,24 @@ def test_mixed_entries() -> None: assert packages is not None assert 'ruff' in packages + @staticmethod + def test_composite_keys_ignored() -> None: + """Runtime-scoped composite keys must not affect global auto-update.""" + config = _make_resolved( + plugin_auto_update={ + 'pip:3.12': False, + 'uv:3.11': {'ruff': False}, + }, + ) + plugins, packages = resolve_auto_update_scope( + config, + ['pip', 'uv'], + ) + # Neither bare plugin should be disabled + assert plugins is None + # No per-package filtering from composite keys + assert packages is None + # --------------------------------------------------------------------------- # resolve_update_config diff --git a/tests/unit/test_workers.py b/tests/unit/test_workers.py index bf586a7..676ca47 100644 --- a/tests/unit/test_workers.py +++ b/tests/unit/test_workers.py @@ -1,8 +1,16 @@ -"""Tests for ToolUpdateResult dataclass in workers module.""" +"""Tests for ToolUpdateResult dataclass and runtime package workers.""" from __future__ import annotations +import asyncio +from unittest.mock import AsyncMock, MagicMock + +from porringer.core.schema import Package +from porringer.schema.execution import SetupAction, SetupActionResult +from porringer.schema.plugin import RuntimePackageResult + from synodic_client.application.schema import ToolUpdateResult +from synodic_client.application.workers import run_runtime_package_updates class TestToolUpdateResult: @@ -53,3 +61,100 @@ def test_independent_set_per_instance() -> None: b = ToolUpdateResult() a.updated_packages.add('foo') assert 'foo' not in b.updated_packages + + +class TestRunRuntimePackageUpdates: + """Tests for the run_runtime_package_updates worker.""" + + @staticmethod + def test_upgrades_packages_for_matching_tag() -> None: + """Packages from the matching runtime tag are upgraded.""" + porringer = MagicMock() + porringer.package.list_by_runtime = AsyncMock( + return_value=[ + RuntimePackageResult( + provider='pim', + tag='3.12', + executable='/usr/bin/python3.12', + packages=[ + Package(name='pdm', version='2.22.0'), + Package(name='ruff', version='0.1.0'), + ], + ), + RuntimePackageResult( + provider='pim', + tag='3.11', + executable='/usr/bin/python3.11', + packages=[Package(name='black', version='24.0')], + ), + ], + ) + porringer.package.upgrade = AsyncMock( + return_value=SetupActionResult( + action=SetupAction(description='upgrade'), + success=True, + ), + ) + result = asyncio.run( + run_runtime_package_updates(porringer, 'pipx', '3.12'), + ) + assert result.updated == 2 + assert result.updated_packages == {'pdm', 'ruff'} + # Only the matching runtime's packages should be upgraded + assert porringer.package.upgrade.call_count == 2 + + @staticmethod + def test_skips_non_matching_tag() -> None: + """Packages from non-matching runtimes are not touched.""" + porringer = MagicMock() + porringer.package.list_by_runtime = AsyncMock( + return_value=[ + RuntimePackageResult( + provider='pim', + tag='3.11', + executable='/usr/bin/python3.11', + packages=[Package(name='pdm', version='2.22.0')], + ), + ], + ) + porringer.package.upgrade = AsyncMock() + result = asyncio.run( + run_runtime_package_updates(porringer, 'pipx', '3.12'), + ) + assert result.updated == 0 + porringer.package.upgrade.assert_not_called() + + @staticmethod + def test_include_packages_filters() -> None: + """Only packages in include_packages are upgraded.""" + porringer = MagicMock() + porringer.package.list_by_runtime = AsyncMock( + return_value=[ + RuntimePackageResult( + provider='pim', + tag='3.12', + executable='/usr/bin/python3.12', + packages=[ + Package(name='pdm', version='2.22.0'), + Package(name='ruff', version='0.1.0'), + ], + ), + ], + ) + porringer.package.upgrade = AsyncMock( + return_value=SetupActionResult( + action=SetupAction(description='upgrade'), + success=True, + ), + ) + result = asyncio.run( + run_runtime_package_updates( + porringer, + 'pipx', + '3.12', + include_packages={'ruff'}, + ), + ) + assert result.updated == 1 + assert result.updated_packages == {'ruff'} + porringer.package.upgrade.assert_called_once() From 5ba9b6fb26caa9d38120408252459ce0ae3098fc Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Thu, 5 Mar 2026 22:50:41 -0800 Subject: [PATCH 6/6] lint --- docs/updates.md | 8 +- .../application/screen/plugin_row.py | 109 ++++++++++-------- synodic_client/application/screen/schema.py | 2 +- synodic_client/application/screen/screen.py | 101 ++++++++-------- tests/unit/qt/test_gather_packages.py | 54 ++++----- tests/unit/qt/test_update_feedback.py | 9 +- tests/unit/test_workers.py | 15 ++- 7 files changed, 156 insertions(+), 142 deletions(-) diff --git a/docs/updates.md b/docs/updates.md index 832f3da..59ab078 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -44,12 +44,12 @@ client.initialize_updater(config) # Check for updates info = client.check_for_update() if info and info.available: - print(f"Update available: {info.current_version} -> {info.latest_version}") - + print(f'Update available: {info.current_version} -> {info.latest_version}') + # Download with progress def on_progress(percent: int) -> None: - print(f"Downloading: {percent}%") - + print(f'Downloading: {percent}%') + if client.download_update(on_progress): # Apply and restart client.apply_update_on_exit(restart=True) diff --git a/synodic_client/application/screen/plugin_row.py b/synodic_client/application/screen/plugin_row.py index 4251b64..b8d354e 100644 --- a/synodic_client/application/screen/plugin_row.py +++ b/synodic_client/application/screen/plugin_row.py @@ -135,8 +135,6 @@ def __init__( *, show_controls: bool = False, has_updates: bool = False, - runtime_label: str = '', - runtime_tag: str = '', parent: QWidget | None = None, ) -> None: """Initialize the provider header with plugin info and optional controls.""" @@ -144,28 +142,19 @@ def __init__( self.setObjectName('pluginProvider') self.setStyleSheet(PLUGIN_PROVIDER_STYLE) self._plugin_name = plugin.name - self._runtime_tag = runtime_tag - self._signal_key = f'{plugin.name}:{runtime_tag}' if runtime_tag else plugin.name + self._runtime_tag = '' + self._signal_key = plugin.name self._update_btn: QPushButton | None = None self._checking_spinner: _RowSpinner | None = None - layout = QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(6) + self._layout = QHBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(6) # Plugin name name_label = QLabel(plugin.name) name_label.setStyleSheet(PLUGIN_PROVIDER_NAME_STYLE) - layout.addWidget(name_label) - - # Runtime tag pill (when per-runtime) - if runtime_label: - is_default = '(default)' in runtime_label - tag = QLabel(runtime_label) - tag.setStyleSheet( - PLUGIN_PROVIDER_RUNTIME_TAG_DEFAULT_STYLE if is_default else PLUGIN_PROVIDER_RUNTIME_TAG_STYLE - ) - layout.addWidget(tag) + self._layout.addWidget(name_label) # Version version_text = ( @@ -177,7 +166,7 @@ def __init__( ) version_label = QLabel(version_text) version_label.setStyleSheet(PLUGIN_PROVIDER_VERSION_STYLE) - layout.addWidget(version_label) + self._layout.addWidget(version_label) # Installed indicator status_label = QLabel('\u25cf' if plugin.installed else '\u25cb') @@ -185,47 +174,73 @@ def __init__( 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) + self._layout.addWidget(status_label) - layout.addStretch() + self._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) + self._layout.addWidget(self._status_label) # Auto / Update controls (only for updatable kinds) if show_controls: - 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._signal_key, checked), - ) - layout.addWidget(toggle_btn) + self._build_controls(self._layout, plugin, auto_update, has_updates) - self._checking_spinner = _RowSpinner(self) - layout.addWidget(self._checking_spinner) + def set_runtime(self, tag: str, label: str = '') -> None: + """Set runtime identity and optionally insert a runtime tag pill. - 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._signal_key), + Must be called before the widget is added to a visible layout. + """ + self._runtime_tag = tag + self._signal_key = f'{self._plugin_name}:{tag}' if tag else self._plugin_name + if label: + is_default = '(default)' in label + pill = QLabel(label) + pill.setStyleSheet( + PLUGIN_PROVIDER_RUNTIME_TAG_DEFAULT_STYLE if is_default else PLUGIN_PROVIDER_RUNTIME_TAG_STYLE ) - update_btn.setVisible(has_updates) - self._update_btn = update_btn - layout.addWidget(update_btn) - - 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') + # Insert after the name label (index 1) + self._layout.insertWidget(1, pill) + + def _build_controls( + self, + layout: QHBoxLayout, + plugin: PluginInfo, + auto_update: bool, + has_updates: bool, + ) -> None: + """Build Auto/Update control buttons.""" + 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._signal_key, checked), + ) + 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._signal_key), + ) + update_btn.setVisible(has_updates) + self._update_btn = update_btn + layout.addWidget(update_btn) + + 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') def set_updating(self, updating: bool) -> None: """Toggle the button between *Updating…* and *Update* states.""" diff --git a/synodic_client/application/screen/schema.py b/synodic_client/application/screen/schema.py index b7be819..1321c02 100644 --- a/synodic_client/application/screen/schema.py +++ b/synodic_client/application/screen/schema.py @@ -154,7 +154,7 @@ class PluginRowData: @dataclass(slots=True) -class _RefreshData: +class RefreshData: """Internal data bundle returned by ``ToolsView._gather_refresh_data``.""" plugins: list[PluginInfo] diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 76b1b81..0bd13c9 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -19,10 +19,10 @@ SkipReason, SyncStrategy, ) -from porringer.schema.plugin import PluginKind +from porringer.schema.plugin import PluginKind, RuntimePackageResult from porringer.utility.exception import PluginError -from PySide6.QtCore import QEasingCurve, QPropertyAnimation, Qt, QTimer, Signal -from PySide6.QtGui import QKeySequence, QShortcut, QShowEvent +from PySide6.QtCore import QEasingCurve, QEvent, QObject, QPropertyAnimation, Qt, QTimer, Signal +from PySide6.QtGui import QKeyEvent, QKeySequence, QShortcut, QShowEvent from PySide6.QtWidgets import ( QHBoxLayout, QLineEdit, @@ -48,7 +48,7 @@ PackageEntry, PluginRowData, ProjectInstance, - _RefreshData, + RefreshData, ) from synodic_client.application.screen.spinner import SpinnerWidget from synodic_client.application.screen.update_banner import UpdateBanner @@ -146,29 +146,7 @@ def _init_ui(self) -> None: outer = QVBoxLayout(self) outer.setContentsMargins(*COMPACT_MARGINS) - # Toolbar — filter toggle left, action buttons right - toolbar = QHBoxLayout() - - self._filter_btn = QPushButton('\U0001f50d') - self._filter_btn.setToolTip('Filter packages (Ctrl+F)') - self._filter_btn.setFlat(True) - self._filter_btn.setStyleSheet(FILTER_TOGGLE_STYLE) - self._filter_btn.clicked.connect(self._toggle_filter_panel) - toolbar.addWidget(self._filter_btn) - - 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) - toolbar.addWidget(update_all_btn) - outer.addLayout(toolbar) + outer.addLayout(self._build_toolbar()) # Collapsible filter panel — search input + chip row self._filter_panel = QWidget() @@ -227,6 +205,32 @@ def _init_ui(self) -> None: self._timestamp_timer.timeout.connect(self._refresh_timestamps) self._timestamp_timer.start() + def _build_toolbar(self) -> QHBoxLayout: + """Build the toolbar with filter toggle and action buttons.""" + toolbar = QHBoxLayout() + + self._filter_btn = QPushButton('\U0001f50d') + self._filter_btn.setToolTip('Filter packages (Ctrl+F)') + self._filter_btn.setFlat(True) + self._filter_btn.setStyleSheet(FILTER_TOGGLE_STYLE) + self._filter_btn.clicked.connect(self._toggle_filter_panel) + toolbar.addWidget(self._filter_btn) + + 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) + toolbar.addWidget(update_all_btn) + + return toolbar + # --- Public API --- def refresh(self) -> None: @@ -267,7 +271,7 @@ async def _async_refresh(self) -> None: # _async_refresh helper methods # ------------------------------------------------------------------ - async def _gather_refresh_data(self) -> _RefreshData: + async def _gather_refresh_data(self) -> RefreshData: """Fetch plugins, packages, and manifest requirements in parallel. For PACKAGE-kind plugins that are ``RuntimeConsumer`` instances, @@ -279,7 +283,7 @@ async def _gather_refresh_data(self) -> _RefreshData: ``skip_global=True``. Returns: - A :class:`_RefreshData` bundle containing all data needed + A :class:`RefreshData` bundle containing all data needed to build the widget tree. """ plugins, directories = await self._fetch_data() @@ -330,8 +334,7 @@ async def _gather_refresh_data(self) -> _RefreshData: # 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 host_tool, sub_packages in tool_plugins_task.result().items(): for env_packages in packages_map.values(): if any(entry.name == host_tool for entry in env_packages): env_packages.extend(sub_packages) @@ -344,7 +347,7 @@ async def _gather_refresh_data(self) -> _RefreshData: if discovered is not None and discovered.runtime_context is not None: default_runtime_executable = discovered.runtime_context.get('python') - return _RefreshData( + return RefreshData( plugins=plugins, packages_map=packages_map, manifest_packages=manifest_packages, @@ -366,7 +369,7 @@ def _collect_manifest_packages( ) return manifest_packages - def _build_widget_tree(self, data: _RefreshData) -> None: + def _build_widget_tree(self, data: RefreshData) -> None: """Clear existing widgets and rebuild the tool/package tree.""" self._clear_section_widgets() @@ -426,7 +429,7 @@ def _bucket_by_kind( def _build_runtime_sections( self, plugin: PluginInfo, - data: _RefreshData, + data: RefreshData, auto_update_map: dict[str, bool | dict[str, bool]], ) -> None: """Build per-runtime provider headers and package rows. @@ -435,8 +438,6 @@ def _build_runtime_sections( :class:`PluginProviderHeader` with a runtime tag pill. The default runtime (matched by executable) is placed first. """ - from porringer.schema.plugin import RuntimePackageResult - runtime_results: list[RuntimePackageResult] = data.runtime_packages[plugin.name] if not runtime_results: return @@ -464,10 +465,9 @@ def _sort_key(rt: RuntimePackageResult) -> tuple[int, str]: auto_val is not False, show_controls=True, has_updates=bool(plugin_updates), - runtime_label=tag_text, - runtime_tag=rt.tag, parent=self._container, ) + provider.set_runtime(rt.tag, label=tag_text) provider.auto_update_toggled.connect(self._on_auto_update_toggled) provider.update_requested.connect(self.plugin_update_requested.emit) self._insert_section_widget(provider) @@ -506,7 +506,7 @@ def _sort_key(rt: RuntimePackageResult) -> tuple[int, str]: def _build_plugin_section( self, plugin: PluginInfo, - data: _RefreshData, + data: RefreshData, auto_update_map: dict[str, bool | dict[str, bool]], ) -> None: """Build the provider header and package rows for a single plugin. @@ -728,18 +728,19 @@ def _clear_active_filters(self) -> None: if chip is not None: chip.setChecked(True) - def eventFilter(self, obj: object, event: object) -> bool: + def eventFilter(self, obj: QObject, event: QEvent) -> bool: """Handle Escape in the search input to clear filters / close panel.""" - from PySide6.QtCore import QEvent - from PySide6.QtGui import QKeyEvent - - if obj is self._search_input and isinstance(event, QKeyEvent): - if event.type() == QEvent.Type.KeyPress and event.key() == Qt.Key.Key_Escape: - if self._has_active_filter: - self._clear_active_filters() - else: - self._close_filter_panel() - return True + if ( + obj is self._search_input + and isinstance(event, QKeyEvent) + and event.type() == QEvent.Type.KeyPress + and event.key() == Qt.Key.Key_Escape + ): + if self._has_active_filter: + self._clear_active_filters() + else: + self._close_filter_panel() + return True return super().eventFilter(obj, event) def _active_chip_plugins(self) -> set[str] | None: diff --git a/tests/unit/qt/test_gather_packages.py b/tests/unit/qt/test_gather_packages.py index 3e2c322..32061da 100644 --- a/tests/unit/qt/test_gather_packages.py +++ b/tests/unit/qt/test_gather_packages.py @@ -10,6 +10,7 @@ from porringer.core.schema import Package, PackageRelation, PackageRelationKind from porringer.schema import ManifestDirectory from porringer.schema.plugin import PluginInfo, PluginKind, RuntimePackageResult +from porringer.utility.exception import PluginError from PySide6.QtWidgets import QLabel, QPushButton from synodic_client.application.screen.plugin_row import ( @@ -19,8 +20,14 @@ PluginRow, ProjectChildRow, ) -from synodic_client.application.screen.schema import PackageEntry, PluginRowData, ProjectInstance, _RefreshData +from synodic_client.application.screen.schema import PackageEntry, PluginRowData, ProjectInstance, RefreshData from synodic_client.application.screen.screen import ToolsView +from synodic_client.application.theme import ( + FILTER_TOGGLE_ACTIVE_STYLE, + FILTER_TOGGLE_STYLE, + PLUGIN_PROVIDER_RUNTIME_TAG_DEFAULT_STYLE, + PLUGIN_PROVIDER_RUNTIME_TAG_STYLE, +) from synodic_client.resolution import ResolvedConfig # Named constants for expected counts (avoids PLR2004) @@ -775,23 +782,17 @@ def test_toggle_closes_open_panel(self) -> None: def test_badge_inactive_by_default(self) -> None: """The filter badge is inactive when no filters are set.""" - from synodic_client.application.theme import FILTER_TOGGLE_STYLE - view = self._make_view() assert view._filter_btn.styleSheet() == FILTER_TOGGLE_STYLE def test_badge_active_with_search_text(self) -> None: """The filter badge activates when search text is entered.""" - from synodic_client.application.theme import FILTER_TOGGLE_ACTIVE_STYLE - view = self._make_view() view._search_input.setText('ruff') assert view._filter_btn.styleSheet() == FILTER_TOGGLE_ACTIVE_STYLE def test_badge_active_with_deselected_chip(self) -> None: """The filter badge activates when a chip is deselected.""" - from synodic_client.application.theme import FILTER_TOGGLE_ACTIVE_STYLE - view = self._make_view() # Manually inject a deselected plugin to test badge without full tree view._deselected_plugins.add('test-plugin') @@ -800,8 +801,6 @@ def test_badge_active_with_deselected_chip(self) -> None: def test_badge_clears_when_filters_removed(self) -> None: """The filter badge deactivates when all filters are cleared.""" - from synodic_client.application.theme import FILTER_TOGGLE_STYLE - view = self._make_view() view._search_input.setText('ruff') view._search_input.clear() @@ -813,7 +812,7 @@ def test_clear_active_filters_resets_search(self) -> None: view._search_input.setText('some query') view._deselected_plugins.add('fake') view._clear_active_filters() - assert view._search_input.text() == '' + assert not view._search_input.text() def test_has_active_filter_false_by_default(self) -> None: """_has_active_filter is False when no filters are set.""" @@ -971,7 +970,7 @@ def test_runtime_sections_create_separate_provider_headers(self) -> None: plugin = self._pip_plugin() rt_results = self._make_runtime_results(default_exe) - data = _RefreshData( + data = RefreshData( plugins=[plugin], packages_map={}, manifest_packages={}, @@ -991,7 +990,7 @@ def test_default_runtime_comes_first(self) -> None: plugin = self._pip_plugin() rt_results = self._make_runtime_results(default_exe) - data = _RefreshData( + data = RefreshData( plugins=[plugin], packages_map={}, manifest_packages={}, @@ -1013,7 +1012,7 @@ def test_default_runtime_packages_displayed(self) -> None: plugin = self._pip_plugin() rt_results = self._make_runtime_results(default_exe) - data = _RefreshData( + data = RefreshData( plugins=[plugin], packages_map={}, manifest_packages={}, @@ -1036,7 +1035,7 @@ def test_non_default_runtime_packages_displayed(self) -> None: plugin = self._pip_plugin() rt_results = self._make_runtime_results(default_exe) - data = _RefreshData( + data = RefreshData( plugins=[plugin], packages_map={}, manifest_packages={}, @@ -1058,7 +1057,7 @@ def test_venv_packages_separate_from_runtime(self) -> None: plugin = self._pip_plugin() rt_results = self._make_runtime_results(default_exe) - data = _RefreshData( + data = RefreshData( plugins=[plugin], packages_map={ 'pip': [ @@ -1075,7 +1074,6 @@ def test_venv_packages_separate_from_runtime(self) -> None: default_runtime_executable=default_exe, ) - auto_update_map: dict[str, bool | dict[str, bool]] = {} # Build the full widget tree view._build_widget_tree(data) @@ -1090,14 +1088,12 @@ def test_venv_packages_separate_from_runtime(self) -> None: def test_runtime_tag_uses_default_style(self) -> None: """The default runtime tag uses the green highlight style.""" - from synodic_client.application.theme import PLUGIN_PROVIDER_RUNTIME_TAG_DEFAULT_STYLE - view = ToolsView(_make_porringer(), _make_config()) default_exe = Path('C:/Python314/python.exe') plugin = self._pip_plugin() rt_results = self._make_runtime_results(default_exe) - data = _RefreshData( + data = RefreshData( plugins=[plugin], packages_map={}, manifest_packages={}, @@ -1115,14 +1111,12 @@ def test_runtime_tag_uses_default_style(self) -> None: def test_runtime_tag_uses_normal_style_for_non_default(self) -> None: """Non-default runtime tags use the blue style.""" - from synodic_client.application.theme import PLUGIN_PROVIDER_RUNTIME_TAG_STYLE - view = ToolsView(_make_porringer(), _make_config()) default_exe = Path('C:/Python314/python.exe') plugin = self._pip_plugin() rt_results = self._make_runtime_results(default_exe) - data = _RefreshData( + data = RefreshData( plugins=[plugin], packages_map={}, manifest_packages={}, @@ -1146,7 +1140,7 @@ def test_filter_chips_work_with_runtime_providers(self) -> None: plugin = self._pip_plugin() rt_results = self._make_runtime_results(default_exe) - data = _RefreshData( + data = RefreshData( plugins=[plugin], packages_map={}, manifest_packages={}, @@ -1164,10 +1158,9 @@ def test_filter_chips_work_with_runtime_providers(self) -> None: visible_rows = [w for w in view._section_widgets if isinstance(w, PluginRow) and not w.isHidden()] assert len(visible_rows) == 0 - def test_gather_runtime_packages_returns_none_for_non_consumer(self) -> None: + @staticmethod + def test_gather_runtime_packages_returns_none_for_non_consumer() -> None: """_gather_runtime_packages returns None when plugin is not a RuntimeConsumer.""" - from porringer.utility.exception import PluginError - porringer = _make_porringer() porringer.package.list_by_runtime = AsyncMock( side_effect=PluginError('not a RuntimeConsumer'), @@ -1177,7 +1170,8 @@ def test_gather_runtime_packages_returns_none_for_non_consumer(self) -> None: result = asyncio.run(view._gather_runtime_packages('pipx', MagicMock())) assert result is None - def test_gather_runtime_packages_returns_results(self) -> None: + @staticmethod + def test_gather_runtime_packages_returns_results() -> None: """_gather_runtime_packages returns list on success.""" porringer = _make_porringer() expected = [ @@ -1194,7 +1188,8 @@ def test_gather_runtime_packages_returns_results(self) -> None: result = asyncio.run(view._gather_runtime_packages('pip', MagicMock())) assert result == expected - def test_skip_global_flag_skips_global_query(self) -> None: + @staticmethod + def test_skip_global_flag_skips_global_query() -> None: """_gather_packages with skip_global=True only runs per-directory queries.""" porringer = _make_porringer() call_paths: list[Path | None] = [] @@ -1216,7 +1211,8 @@ async def _mock_list(plugin_name: str, project_path: Path | None = None, **kwarg assert 'venv-pkg' in names assert 'global-pkg' not in names - def test_bucket_by_kind_includes_runtime_packages(self) -> None: + @staticmethod + def test_bucket_by_kind_includes_runtime_packages() -> None: """_bucket_by_kind considers runtime_packages for content check.""" plugins = [ PluginInfo( diff --git a/tests/unit/qt/test_update_feedback.py b/tests/unit/qt/test_update_feedback.py index 7aa765c..44cee0e 100644 --- a/tests/unit/qt/test_update_feedback.py +++ b/tests/unit/qt/test_update_feedback.py @@ -7,6 +7,7 @@ from packaging.version import Version from porringer.schema import PluginInfo from porringer.schema.plugin import PluginKind +from PySide6.QtWidgets import QPushButton from synodic_client.application.screen.plugin_row import PluginProviderHeader, PluginRow from synodic_client.application.screen.schema import PluginRowData @@ -415,8 +416,8 @@ def test_header_signal_key_composite() -> None: header = PluginProviderHeader( _make_plugin(name='pipx'), show_controls=True, - runtime_tag='3.12', ) + header.set_runtime('3.12') assert header._signal_key == 'pipx:3.12' @staticmethod @@ -426,8 +427,8 @@ def test_header_update_requested_emits_composite() -> None: _make_plugin(name='pipx'), show_controls=True, has_updates=True, - runtime_tag='3.12', ) + header.set_runtime('3.12') spy = MagicMock() header.update_requested.connect(spy) assert header._update_btn is not None @@ -440,13 +441,11 @@ def test_header_auto_update_toggled_emits_composite() -> None: header = PluginProviderHeader( _make_plugin(name='pipx'), show_controls=True, - runtime_tag='3.11', ) + header.set_runtime('3.11') spy = MagicMock() header.auto_update_toggled.connect(spy) # Find the Auto button and click it - from PySide6.QtWidgets import QPushButton - for child in header.findChildren(QPushButton): if child.text() == 'Auto': child.click() diff --git a/tests/unit/test_workers.py b/tests/unit/test_workers.py index 676ca47..d664ee0 100644 --- a/tests/unit/test_workers.py +++ b/tests/unit/test_workers.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from pathlib import Path from unittest.mock import AsyncMock, MagicMock from porringer.core.schema import Package @@ -12,6 +13,8 @@ from synodic_client.application.schema import ToolUpdateResult from synodic_client.application.workers import run_runtime_package_updates +_EXPECTED_RUNTIME_UPGRADES = 2 + class TestToolUpdateResult: """Tests for the ToolUpdateResult dataclass.""" @@ -75,7 +78,7 @@ def test_upgrades_packages_for_matching_tag() -> None: RuntimePackageResult( provider='pim', tag='3.12', - executable='/usr/bin/python3.12', + executable=Path('/usr/bin/python3.12'), packages=[ Package(name='pdm', version='2.22.0'), Package(name='ruff', version='0.1.0'), @@ -84,7 +87,7 @@ def test_upgrades_packages_for_matching_tag() -> None: RuntimePackageResult( provider='pim', tag='3.11', - executable='/usr/bin/python3.11', + executable=Path('/usr/bin/python3.11'), packages=[Package(name='black', version='24.0')], ), ], @@ -98,10 +101,10 @@ def test_upgrades_packages_for_matching_tag() -> None: result = asyncio.run( run_runtime_package_updates(porringer, 'pipx', '3.12'), ) - assert result.updated == 2 + assert result.updated == _EXPECTED_RUNTIME_UPGRADES assert result.updated_packages == {'pdm', 'ruff'} # Only the matching runtime's packages should be upgraded - assert porringer.package.upgrade.call_count == 2 + assert porringer.package.upgrade.call_count == _EXPECTED_RUNTIME_UPGRADES @staticmethod def test_skips_non_matching_tag() -> None: @@ -112,7 +115,7 @@ def test_skips_non_matching_tag() -> None: RuntimePackageResult( provider='pim', tag='3.11', - executable='/usr/bin/python3.11', + executable=Path('/usr/bin/python3.11'), packages=[Package(name='pdm', version='2.22.0')], ), ], @@ -133,7 +136,7 @@ def test_include_packages_filters() -> None: RuntimePackageResult( provider='pim', tag='3.12', - executable='/usr/bin/python3.12', + executable=Path('/usr/bin/python3.12'), packages=[ Package(name='pdm', version='2.22.0'), Package(name='ruff', version='0.1.0'),