diff --git a/pyproject.toml b/pyproject.toml index a6bd48f..3e294fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,18 +25,9 @@ homepage = "https://github.com/synodic/synodic-client" repository = "https://github.com/synodic/synodic-client" [dependency-groups] -build = [ - "pyinstaller>=6.19.0", -] -lint = [ - "ruff>=0.15.4", - "pyrefly>=0.55.0", -] -test = [ - "pytest>=9.0.2", - "pytest-cov>=7.0.0", - "pytest-mock>=3.15.1", -] +build = ["pyinstaller>=6.19.0"] +lint = ["ruff>=0.15.4", "pyrefly>=0.55.0"] +test = ["pytest>=9.0.2", "pytest-cov>=7.0.0", "pytest-mock>=3.15.1"] [project.scripts] synodic-c = "synodic_client.cli:app" diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 400d919..0be9ae0 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -8,6 +8,7 @@ from porringer.api import API from porringer.backend.builder import Builder +from porringer.backend.command.core.discovery import DiscoveredPlugins from porringer.core.plugin_schema.plugin_manager import PluginManager from porringer.core.plugin_schema.project_environment import ProjectEnvironment from porringer.schema import ( @@ -1158,26 +1159,29 @@ def _active_chip_plugins(self) -> set[str] | None: return None return {name for name, chip in self._filter_chips.items() if chip.isChecked()} - def _apply_filter(self, _text: str | None = None) -> None: - """Show/hide section widgets based on search text and active chips. + @staticmethod + def _is_plugin_active(plugin_name: str, active: set[str] | None) -> bool: + """Return whether *plugin_name* passes the chip filter.""" + return active is None or plugin_name in active - A single pass walks ``_section_widgets`` tracking the current - plugin and kind. Visibility rules: + @staticmethod + def _finalise_provider( + provider: PluginProviderHeader | None, + has_visible: bool, + ) -> bool: + """Set provider visibility and return whether it had visible children.""" + if provider is not None: + provider.setVisible(has_visible) + return has_visible - * **PluginProviderHeader** — visible when its plugin is in the - active chip set **and** at least one child row matches the - search text. - * **PluginRow** — visible when its plugin is active **and** its - package name or plugin name contains the search text. - * **ProjectChildRow** — follows its parent :class:`PluginRow`. - * **PluginKindHeader** — visible when at least one child - provider in its kind group is visible. + def _apply_filter(self, _text: str | None = None) -> None: + """Show/hide section widgets based on search text and active chips. - After the pass, kind headers with no visible children are hidden. + Delegates to :meth:`_is_plugin_active` for chip matching and + :meth:`_finalise_provider` for provider visibility bookkeeping. """ query = self._search_input.text().strip().lower() active = self._active_chip_plugins() - all_active = active is None # None → no chips yet, show all current_kind_header: PluginKindHeader | None = None kind_has_visible = False @@ -1187,13 +1191,8 @@ def _apply_filter(self, _text: str | None = None) -> None: for widget in self._section_widgets: if isinstance(widget, PluginKindHeader): - # Finalise previous kind + kind_has_visible |= self._finalise_provider(current_provider, provider_has_visible_child) if current_kind_header is not None: - # Finalise last provider of previous kind - if current_provider is not None: - current_provider.setVisible(provider_has_visible_child) - if provider_has_visible_child: - kind_has_visible = True current_kind_header.setVisible(kind_has_visible) current_kind_header = widget @@ -1202,31 +1201,20 @@ def _apply_filter(self, _text: str | None = None) -> None: provider_has_visible_child = False elif isinstance(widget, PluginProviderHeader): - # Finalise previous provider - if current_provider is not None: - current_provider.setVisible(provider_has_visible_child) - if provider_has_visible_child: - kind_has_visible = True + kind_has_visible |= self._finalise_provider(current_provider, provider_has_visible_child) current_provider = widget provider_has_visible_child = False - plugin_name = widget._plugin_name - plugin_active = all_active or (active is not None and plugin_name in active) - - if not plugin_active: - # Entire provider hidden + if not self._is_plugin_active(widget._plugin_name, active): widget.setVisible(False) - provider_has_visible_child = False elif isinstance(widget, PluginRow): - plugin_name = widget._plugin_name - plugin_active = all_active or (active is not None and plugin_name in active) - if not plugin_active: + if not self._is_plugin_active(widget._plugin_name, active): widget.setVisible(False) parent_row_visible = False continue - name_match = not query or query in widget._package_name.lower() or query in plugin_name.lower() + name_match = not query or query in widget._package_name.lower() or query in widget._plugin_name.lower() widget.setVisible(name_match) parent_row_visible = name_match if name_match: @@ -1236,10 +1224,7 @@ def _apply_filter(self, _text: str | None = None) -> None: widget.setVisible(parent_row_visible) # Finalise last provider and kind - if current_provider is not None: - current_provider.setVisible(provider_has_visible_child) - if provider_has_visible_child: - kind_has_visible = True + kind_has_visible |= self._finalise_provider(current_provider, provider_has_visible_child) if current_kind_header is not None: current_kind_header.setVisible(kind_has_visible) @@ -1834,6 +1819,7 @@ async def _async_refresh(self) -> None: if self._coordinator is not None: snapshot = await self._coordinator.refresh() results = snapshot.validated_directories + discovered = snapshot.discovered else: loop = asyncio.get_running_loop() results = await loop.run_in_executor( @@ -1843,6 +1829,7 @@ async def _async_refresh(self) -> None: check_manifest=True, ), ) + discovered = None directories: list[tuple[Path, str, bool]] = [] current_paths: set[Path] = set() @@ -1854,32 +1841,12 @@ async def _async_refresh(self) -> None: current_paths.add(path) # Remove widgets for directories no longer in cache - for path in list(self._widgets): - if path not in current_paths: - widget = self._widgets.pop(path) - self._stack.removeWidget(widget) - widget.reset() - widget.deleteLater() + self._remove_stale_widgets(current_paths) # Grab pre-discovered plugins so each widget can skip redundant discovery - discovered = snapshot.discovered if self._coordinator is not None else None # Create new widgets for new directories - for path, _name, valid in directories: - if path not in self._widgets and valid: - widget = SetupPreviewWidget( - self._porringer, - self, - show_close=False, - config=self._config, - ) - widget._discovered_plugins = discovered - widget.install_finished.connect(self._on_install_finished) - widget.phase_changed.connect( - lambda phase, p=path: self._on_widget_phase_changed(p, phase), - ) - self._widgets[path] = widget - self._stack.addWidget(widget) + self._create_directory_widgets(directories, discovered) # Rebuild sidebar self._sidebar.set_directories(directories) @@ -1909,6 +1876,37 @@ async def _async_refresh(self) -> None: # --- Event handlers --- + def _remove_stale_widgets(self, current_paths: set[Path]) -> None: + """Remove stacked widgets for directories no longer in the cache.""" + for path in list(self._widgets): + if path not in current_paths: + widget = self._widgets.pop(path) + self._stack.removeWidget(widget) + widget.reset() + widget.deleteLater() + + def _create_directory_widgets( + self, + directories: list[tuple[Path, str, bool]], + discovered: DiscoveredPlugins | None, + ) -> None: + """Create :class:`SetupPreviewWidget` instances for new valid directories.""" + for path, _name, valid in directories: + if path not in self._widgets and valid: + widget = SetupPreviewWidget( + self._porringer, + self, + show_close=False, + config=self._config, + ) + widget._discovered_plugins = discovered + widget.install_finished.connect(self._on_install_finished) + widget.phase_changed.connect( + lambda phase, p=path: self._on_widget_phase_changed(p, phase), + ) + self._widgets[path] = widget + self._stack.addWidget(widget) + def _on_selection_changed(self, path: Path) -> None: """Handle sidebar selection — switch the stacked widget.""" widget = self._widgets.get(path) diff --git a/tests/unit/qt/test_gather_packages.py b/tests/unit/qt/test_gather_packages.py index ad4ea11..1d18f58 100644 --- a/tests/unit/qt/test_gather_packages.py +++ b/tests/unit/qt/test_gather_packages.py @@ -6,12 +6,30 @@ from pathlib import Path from unittest.mock import AsyncMock, MagicMock +from packaging.version import Version from porringer.core.schema import Package, PackageRelation, PackageRelationKind from porringer.schema import ManifestDirectory - -from synodic_client.application.screen.screen import PackageEntry, ProjectChildRow, ToolsView +from porringer.schema.plugin import PluginInfo, PluginKind +from PySide6.QtWidgets import QLabel, QPushButton + +from synodic_client.application.screen.screen import ( + FilterChip, + PackageEntry, + PluginKindHeader, + PluginProviderHeader, + PluginRow, + PluginRowData, + ProjectChildRow, + ProjectInstance, + ToolsView, +) from synodic_client.resolution import ResolvedConfig +# Named constants for expected counts (avoids PLR2004) +_EXPECTED_PROJECT_INSTANCES = 2 +_EXPECTED_VISIBLE_ROWS_ALL = 3 +_EXPECTED_VISIBLE_ROWS_PIPX = 2 + def _make_config() -> ResolvedConfig: """Build a minimal ResolvedConfig for tests.""" @@ -429,7 +447,7 @@ def test_multiple_projects_same_package() -> None: assert len(result) == 1 pkg = result[0] - assert len(pkg.project_instances) == 2 + assert len(pkg.project_instances) == _EXPECTED_PROJECT_INSTANCES labels = {pi.project_label for pi in pkg.project_instances} assert labels == {'project-a', 'project-b'} @@ -479,8 +497,6 @@ class TestProjectChildRow: @staticmethod def _make_instance(*, transitive: bool = False) -> ProjectChildRow: - from synodic_client.application.screen.screen import ProjectInstance - return ProjectChildRow( ProjectInstance( project_label='periapsis', @@ -498,8 +514,6 @@ def test_navigate_signal_emitted(self) -> None: row.navigate_to_project.connect(spy) # Find the navigate button (→) - from PySide6.QtWidgets import QPushButton - nav_btns = [w for w in row.findChildren(QPushButton) if w.text() == '\u2192'] assert len(nav_btns) == 1 nav_btns[0].click() @@ -518,8 +532,6 @@ def test_transitive_label_hidden_when_not_transitive(self) -> None: assert len(labels) == 0 -from PySide6.QtWidgets import QLabel - # --------------------------------------------------------------------------- # FilterChip # --------------------------------------------------------------------------- @@ -531,8 +543,6 @@ class TestFilterChip: @staticmethod def test_chip_starts_checked() -> None: """Filter chips start in the checked (active) state.""" - from synodic_client.application.screen.screen import FilterChip - chip = FilterChip('pipx') assert chip.isChecked() assert chip.text() == 'pipx' @@ -540,8 +550,6 @@ def test_chip_starts_checked() -> None: @staticmethod def test_toggling_emits_signal() -> None: """Toggling a chip emits the plugin name and new state.""" - from synodic_client.application.screen.screen import FilterChip - chip = FilterChip('uv') spy = MagicMock() chip.toggled_with_name.connect(spy) @@ -552,8 +560,6 @@ def test_toggling_emits_signal() -> None: @staticmethod def test_recheck_emits_true() -> None: """Re-checking a chip emits True.""" - from synodic_client.application.screen.screen import FilterChip - chip = FilterChip('pip') spy = MagicMock() chip.setChecked(False) @@ -589,16 +595,6 @@ def _populate_section_widgets(view: ToolsView) -> None: ProviderHeader(uv) PluginRow(mypy, plugin=uv) """ - from packaging.version import Version - from porringer.schema.plugin import PluginInfo, PluginKind - - from synodic_client.application.screen.screen import ( - PluginKindHeader, - PluginProviderHeader, - PluginRow, - PluginRowData, - ) - kind_hdr = PluginKindHeader(PluginKind.TOOL) view._section_widgets.append(kind_hdr) view._container_layout.insertWidget(0, kind_hdr) @@ -639,8 +635,6 @@ def test_search_hides_non_matching_rows(self) -> None: view._search_input.setText('ruff') - from synodic_client.application.screen.screen import PluginRow - visible_rows = [w for w in view._section_widgets if isinstance(w, PluginRow) and not w.isHidden()] assert len(visible_rows) == 1 assert visible_rows[0]._package_name == 'ruff' @@ -653,10 +647,8 @@ def test_empty_search_shows_all(self) -> None: view._search_input.setText('ruff') view._search_input.setText('') - from synodic_client.application.screen.screen import PluginRow - visible_rows = [w for w in view._section_widgets if isinstance(w, PluginRow) and not w.isHidden()] - assert len(visible_rows) == 3 + assert len(visible_rows) == _EXPECTED_VISIBLE_ROWS_ALL def test_chip_deselection_hides_plugin(self) -> None: """Deselecting a chip hides all rows from that plugin.""" @@ -665,8 +657,6 @@ def test_chip_deselection_hides_plugin(self) -> None: view._filter_chips['pipx'].setChecked(False) - from synodic_client.application.screen.screen import PluginRow - visible_rows = [w for w in view._section_widgets if isinstance(w, PluginRow) and not w.isHidden()] assert len(visible_rows) == 1 assert visible_rows[0]._package_name == 'mypy' @@ -679,10 +669,8 @@ def test_chip_reselection_restores(self) -> None: view._filter_chips['pipx'].setChecked(False) view._filter_chips['pipx'].setChecked(True) - from synodic_client.application.screen.screen import PluginRow - visible_rows = [w for w in view._section_widgets if isinstance(w, PluginRow) and not w.isHidden()] - assert len(visible_rows) == 3 + assert len(visible_rows) == _EXPECTED_VISIBLE_ROWS_ALL def test_search_plus_chip_filter(self) -> None: """Search and chip filtering compose — only matching rows in active plugins survive.""" @@ -692,8 +680,6 @@ def test_search_plus_chip_filter(self) -> None: view._filter_chips['uv'].setChecked(False) view._search_input.setText('pdm') - from synodic_client.application.screen.screen import PluginRow - visible_rows = [w for w in view._section_widgets if isinstance(w, PluginRow) and not w.isHidden()] assert len(visible_rows) == 1 assert visible_rows[0]._package_name == 'pdm' @@ -707,8 +693,6 @@ def test_kind_header_hidden_when_no_children_visible(self) -> None: view._filter_chips['pipx'].setChecked(False) view._filter_chips['uv'].setChecked(False) - from synodic_client.application.screen.screen import PluginKindHeader - hidden_kinds = [w for w in view._section_widgets if isinstance(w, PluginKindHeader) and w.isHidden()] assert len(hidden_kinds) == 1 @@ -719,8 +703,6 @@ def test_provider_hidden_when_search_matches_nothing(self) -> None: view._search_input.setText('mypy') - from synodic_client.application.screen.screen import PluginProviderHeader - visible_providers = [ w for w in view._section_widgets if isinstance(w, PluginProviderHeader) and not w.isHidden() ] @@ -734,10 +716,8 @@ def test_search_matches_plugin_name(self) -> None: view._search_input.setText('pipx') - from synodic_client.application.screen.screen import PluginRow - visible_rows = [w for w in view._section_widgets if isinstance(w, PluginRow) and not w.isHidden()] - assert len(visible_rows) == 2 + assert len(visible_rows) == _EXPECTED_VISIBLE_ROWS_PIPX names = {w._package_name for w in visible_rows} assert names == {'ruff', 'pdm'} diff --git a/uninstall_debug.txt b/uninstall_debug.txt deleted file mode 100644 index 404cabd..0000000 --- a/uninstall_debug.txt +++ /dev/null @@ -1,74 +0,0 @@ -.venv\Scripts\python.exe : DEBUG:asyncio:Using proactor: IocpProactor -At line:1 char:1 -+ .venv\Scripts\python.exe -c " -+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - + CategoryInfo : NotSpecified: (DEBUG:asyncio:U...r: IocpProactor - :String) [], RemoteException - + FullyQualifiedErrorId : NativeCommandError - -DEBUG:porringer.backend.builder:Entry points for porringer.environment: -['apt', 'brew', 'bun', 'deno', 'npm', 'pim', 'pip', 'pipx', 'pnpm', 'pyenv', -'uv', 'winget'] -DEBUG:porringer.backend.builder:environment plugin found: apt -DEBUG:porringer.backend.builder:environment plugin found: brew -DEBUG:porringer.backend.builder:environment plugin found: bun -DEBUG:porringer.backend.builder:environment plugin found: deno -DEBUG:porringer.backend.builder:environment plugin found: npm -DEBUG:porringer.backend.builder:environment plugin found: pim -DEBUG:porringer.backend.builder:environment plugin found: pip -DEBUG:porringer.backend.builder:environment plugin found: pipx -DEBUG:porringer.backend.builder:environment plugin found: pnpm -DEBUG:porringer.backend.builder:environment plugin found: pyenv -DEBUG:porringer.backend.builder:environment plugin found: uv -DEBUG:porringer.backend.builder:environment plugin found: winget -DEBUG:porringer.backend.builder:Plugin 'pim' dependency on 'winget' satisfied -DEBUG:porringer.backend.builder:Entry points for -porringer.project_environment: ['bun-project', 'deno-project', 'npm-project', -'pdm', 'pnpm-project', 'poetry', 'uv-project', 'yarn-project'] -DEBUG:porringer.backend.builder:project_environment plugin found: bun-project -DEBUG:porringer.backend.builder:project_environment plugin found: deno-project -DEBUG:porringer.backend.builder:project_environment plugin found: npm-project -DEBUG:porringer.backend.builder:project_environment plugin found: pdm -DEBUG:porringer.backend.builder:project_environment plugin found: pnpm-project -DEBUG:porringer.backend.builder:project_environment plugin found: poetry -DEBUG:porringer.backend.builder:project_environment plugin found: uv-project -DEBUG:porringer.backend.builder:project_environment plugin found: yarn-project -DEBUG:porringer.backend.builder:Entry points for porringer.scm: ['git'] -DEBUG:porringer.backend.builder:scm plugin found: git -INFO:porringer.backend.command.core.discovery:Plugin discovery: 12 -environments, 8 project, 1 scm -DEBUG:porringer.backend.command.core.discovery:Discovered plugins ù -environments: ['apt', 'brew', 'bun', 'deno', 'npm', 'pim', 'pip', 'pipx', -'pnpm', 'pyenv', 'uv', 'winget'], project: ['bun-project', 'deno-project', -'npm-project', 'pdm', 'pnpm-project', 'poetry', 'uv-project', 'yarn-project'], -scm: ['git'] -DEBUG:porringer.core.path:System PATH already in sync -DEBUG:porringer.backend.builder:RuntimeProvider 'pim' reported 1 tag(s): -['3.14-64'] -DEBUG:porringer.backend.builder:Resolved runtime 'python' via provider 'pim': -tag=3.14-64 -path=C:\Users\asher\AppData\Local\Python\pythoncore-3.14-64\python.exe -DEBUG:porringer.backend.builder:RuntimeProvider 'pyenv' is not available; -skipping -DEBUG:porringer.backend.builder:resolve_runtime_context complete: {'python': -'C:\\Users\\asher\\AppData\\Local\\Python\\pythoncore-3.14-64\\python.exe'} -DEBUG:porringer.api:discover_plugins: runtime_context={'python': -'C:\\Users\\asher\\AppData\\Local\\Python\\pythoncore-3.14-64\\python.exe'} -DEBUG:porringer.api:uninstall requested: plugin=pip package=cppython -dry_run=True -DEBUG:porringer.python_environment:python_command: using runtime override -C:\Users\asher\AppData\Local\Python\pythoncore-3.14-64\python.exe for -kind=python -DEBUG:porringer.pip.packages:listing packages via: -C:\Users\asher\AppData\Local\Python\pythoncore-3.14-64\python.exe -DEBUG:porringer.backend.command.core.resolution:packages query for pip -returned 43 entries -DEBUG:porringer.backend.command.core.resolution:is_package_installed('cppython' -): found=False matched=None -DEBUG:porringer.backend.command.core.resolution:resolved to skip: -reason=SkipReason.NOT_INSTALLED message='cppython' is not installed -=== Runtime Context === -executables: {'python': 'C:\\Users\\asher\\AppData\\Local\\Python\\pythoncore-3.14-64\\python.exe'} -=== Uninstall Result === -success=True skipped=True skip_reason=SkipReason.NOT_INSTALLED -message='cppython' is not installed