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
8 changes: 4 additions & 4 deletions pdm.lock

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

2 changes: 1 addition & 1 deletion 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.dev77",
"porringer>=0.2.1.dev78",
"qasync>=0.28.0",
"velopack>=0.0.1444.dev49733",
"typer>=0.24.1",
Expand Down
2 changes: 1 addition & 1 deletion synodic_client/application/config_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class ConfigStore(QObject):

store = ConfigStore(initial_config)
store.changed.connect(some_consumer.on_config_changed)
store.update(auto_apply=False) # persists + emits
store.update(auto_apply=False) # persists + emits
"""

changed = Signal(object)
Expand Down
125 changes: 125 additions & 0 deletions synodic_client/application/package_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""Shared package update state registry.

Provides :class:`PackageStateStore`, a centralised record of
per-package update status used by both ToolsView and ProjectsView
so that version/update information discovered in one view is
immediately available to the other.
"""

from __future__ import annotations

from dataclasses import dataclass

from PySide6.QtCore import QObject, Signal


@dataclass(slots=True)
class PackageState:
"""Canonical update state for a single package.

Keyed by ``(signal_key, name)`` inside :class:`PackageStateStore`.
"""

name: str
"""Package name (e.g. ``"ruff"``)."""

installed_version: str = ''
"""Currently installed version, or empty if unknown."""

available_version: str = ''
"""Latest available version, or empty if unknown."""

has_update: bool = False
"""Whether the package has a newer version available."""


class PackageStateStore(QObject):
"""Shared registry of package update states across views.

Both ToolsView and ProjectsView write discovered update information
into the store; each view can then read the canonical state
regardless of which view made the discovery.

:attr:`state_changed` is emitted whenever new data arrives so
listeners can refresh their badges/labels without polling.
"""

state_changed = Signal()
"""Emitted whenever any package state is created or modified."""

def __init__(self, parent: QObject | None = None) -> None:
"""Initialise with an empty registry."""
super().__init__(parent)
self._data: dict[str, dict[str, PackageState]] = {}

# -- Bulk write (ToolsView check_updates) ----------------------------

def set_check_results(self, available: dict[str, dict[str, str]]) -> None:
"""Populate from ``check_updates`` results.

*available* maps ``{signal_key: {package_name: latest_version}}``
— the same shape previously stored in
``ToolsView._updates_available``.
"""
self._data.clear()
for key, packages in available.items():
for pkg_name, latest in packages.items():
self._data.setdefault(key, {})[pkg_name] = PackageState(
name=pkg_name,
available_version=latest,
has_update=True,
)
self.state_changed.emit()

# -- Single-action write (ProjectsView dry-run) ----------------------

def record_action_result(
self,
signal_key: str,
pkg_name: str,
*,
installed_version: str = '',
available_version: str = '',
has_update: bool = False,
) -> None:
"""Record a single dry-run result.

Merges with any existing state so that data discovered by
different views accumulates rather than overwrites.
"""
existing = self._data.get(signal_key, {}).get(pkg_name)
state = PackageState(
name=pkg_name,
installed_version=installed_version or (existing.installed_version if existing else ''),
available_version=available_version or (existing.available_version if existing else ''),
has_update=has_update or (existing.has_update if existing else False),
)
self._data.setdefault(signal_key, {})[pkg_name] = state
self.state_changed.emit()

# -- Read API --------------------------------------------------------

def get_updates(self, signal_key: str) -> dict[str, str]:
"""Return ``{package_name: latest_version}`` for packages with updates.

Drop-in replacement for ``_updates_available.get(key, {})``.
"""
bucket = self._data.get(signal_key, {})
return {name: s.available_version for name, s in bucket.items() if s.has_update}

def has_updates_for(self, signal_key: str) -> bool:
"""Return whether any package under *signal_key* has an update."""
return any(s.has_update for s in self._data.get(signal_key, {}).values())

def get(self, signal_key: str, pkg_name: str) -> PackageState | None:
"""Return the state for a specific package, or ``None``."""
return self._data.get(signal_key, {}).get(pkg_name)

@property
def has_data(self) -> bool:
"""Return whether any update data has been recorded."""
return bool(self._data)

def clear(self) -> None:
"""Remove all recorded state."""
self._data.clear()
21 changes: 14 additions & 7 deletions synodic_client/application/screen/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
QWidget,
)

from synodic_client.application.package_state import PackageStateStore
from synodic_client.application.screen import skip_reason_label
from synodic_client.application.screen.action_card import ActionCardList, action_key
from synodic_client.application.screen.card import CardFrame
Expand Down Expand Up @@ -113,6 +114,7 @@ def __init__(
*,
show_close: bool = True,
config: ResolvedConfig | None = None,
package_store: PackageStateStore | None = None,
) -> None:
"""Initialize the preview widget.

Expand All @@ -122,11 +124,13 @@ def __init__(
show_close: Whether to show the Close button. Set ``False``
when embedding inside a persistent view (e.g. a tab).
config: Global configuration for per-manifest pre-release state.
package_store: Shared package update state registry.
"""
super().__init__(parent)
self._porringer = porringer
self._show_close = show_close
self._config = config
self._package_store = package_store
self._discovered_plugins: DiscoveredPlugins | None = None

self._model = PreviewModel()
Expand Down Expand Up @@ -293,7 +297,6 @@ def load(
path_or_url: str,
*,
project_directory: Path | None = None,
detect_updates: bool = True,
) -> None:
"""Load a manifest preview, or skip if the same manifest is already showing results.

Expand All @@ -305,7 +308,6 @@ def load(
Args:
path_or_url: Manifest path or URL.
project_directory: Working directory for project sync actions.
detect_updates: Query package indices for newer versions.
"""
key = normalize_manifest_key(path_or_url)

Expand Down Expand Up @@ -345,7 +347,6 @@ def load(
self._run_preview_task(
path_or_url,
project_directory=self._model.project_directory,
detect_updates=detect_updates,
prerelease_packages=overrides,
),
)
Expand Down Expand Up @@ -474,7 +475,6 @@ async def _run_preview_task(
path_or_url: str,
*,
project_directory: Path | None = None,
detect_updates: bool = True,
prerelease_packages: set[str] | None = None,
) -> None:
"""Run the preview coroutine and route completion/errors."""
Expand All @@ -484,7 +484,6 @@ async def _run_preview_task(
path_or_url,
config=PreviewConfig(
project_directory=project_directory,
detect_updates=detect_updates,
prerelease_packages=prerelease_packages,
),
callbacks=PreviewCallbacks(
Expand Down Expand Up @@ -640,6 +639,16 @@ def _on_action_checked(self, row: int, result: SetupActionResult) -> None:
if card is not None:
card.set_check_result(result)

# Record in shared store so ToolsView can reflect the update
if self._package_store is not None and action.installer and action.package:
self._package_store.record_action_result(
action.installer,
str(action.package.name),
installed_version=result.installed_version or '',
available_version=result.available_version or '',
has_update=result.skip_reason == SkipReason.UPDATE_AVAILABLE,
)

# Update phase text
m.checked_count += 1
total = len(m.action_states)
Expand Down Expand Up @@ -987,11 +996,9 @@ def start(self) -> None:
logger.info('Starting install preview for: %s', self._manifest_url)
self._url_label.setText(f'<b>Manifest:</b> {self._manifest_url}')

detect = self._config.detect_updates if self._config else True
self._preview_widget.load(
self._manifest_url,
project_directory=self._project_directory,
detect_updates=detect,
)

# --- Callbacks ---
Expand Down
1 change: 0 additions & 1 deletion synodic_client/application/screen/install_workers.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,6 @@ async def run_preview(
paths=[manifest_path],
dry_run=True,
project_directory=cfg.project_directory,
detect_updates=cfg.detect_updates,
prerelease_packages=cfg.prerelease_packages,
)
state = _DispatchState()
Expand Down
6 changes: 5 additions & 1 deletion synodic_client/application/screen/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
)

from synodic_client.application.data import DataCoordinator
from synodic_client.application.package_state import PackageStateStore
from synodic_client.application.screen.install import SetupPreviewWidget
from synodic_client.application.screen.schema import PreviewPhase
from synodic_client.application.screen.sidebar import ManifestSidebar
Expand Down Expand Up @@ -48,6 +49,7 @@ def __init__(
parent: QWidget | None = None,
*,
coordinator: DataCoordinator | None = None,
package_store: PackageStateStore | None = None,
) -> None:
"""Initialize the projects view.

Expand All @@ -57,11 +59,13 @@ def __init__(
parent: Optional parent widget.
coordinator: Shared data coordinator for validated directory
data.
package_store: Shared package update state registry.
"""
super().__init__(parent)
self._porringer = porringer
self._store = store
self._coordinator = coordinator
self._package_store = package_store
self._refresh_in_progress = False
self._pending_select: Path | None = None
self._widgets: dict[Path, SetupPreviewWidget] = {}
Expand Down Expand Up @@ -166,7 +170,6 @@ async def _async_refresh(self) -> None:
widget.load(
str(path),
project_directory=path if path.is_dir() else path.parent,
detect_updates=self._store.config.detect_updates,
)

except Exception:
Expand Down Expand Up @@ -200,6 +203,7 @@ def _create_directory_widgets(
self,
show_close=False,
config=self._store.config,
package_store=self._package_store,
)
widget._discovered_plugins = discovered
widget.install_finished.connect(self._on_install_finished)
Expand Down
1 change: 0 additions & 1 deletion synodic_client/application/screen/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,6 @@ class PreviewConfig:
"""Optional execution parameters for :func:`run_preview`."""

project_directory: Path | None = None
detect_updates: bool = True
prerelease_packages: set[str] | None = None


Expand Down
Loading
Loading