Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 14 additions & 14 deletions pdm.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 14 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down Expand Up @@ -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"] }
Expand Down
192 changes: 192 additions & 0 deletions synodic_client/application/data.py
Original file line number Diff line number Diff line change
@@ -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,
)
1 change: 1 addition & 0 deletions synodic_client/application/screen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading