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