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
33 changes: 0 additions & 33 deletions synodic_client/application/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,36 +43,3 @@ class Snapshot:

plugin_capabilities: dict[str, frozenset[PluginCapability]] = field(default_factory=dict)
"""Protocol capabilities reported for each discovered plugin."""


@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."""


@dataclass(frozen=True, slots=True)
class UpdateTarget:
"""Identifies the scope of a manual tool update.

Passed to the shared completion handler so it can clear the correct
updating state and derive timestamp keys. ``None`` (the default in
the handler) means the update was periodic / automatic.

When *package* is empty the update targeted an entire plugin;
otherwise it targeted one specific package within the plugin.
*plugin* always carries the signal key (possibly composite
``"plugin:tag"``).
"""

plugin: str
"""Signal key for the plugin (may be composite ``"name:tag"``)."""

package: str = ''
"""Package name, or empty when the whole plugin was updated."""
18 changes: 9 additions & 9 deletions synodic_client/application/screen/action_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@
#: display order always matches the order actions actually execute.
_KIND_ORDER: dict[PluginKind | None, int] = {kind: i for i, kind in enumerate(PHASE_ORDER)}

#: Mapping of resolved status label → stylesheet for dry-run badge styling.
_STATUS_STYLES: dict[str, str] = {
'Update available': ACTION_CARD_STATUS_UPDATE,
'Failed': ACTION_CARD_STATUS_FAILED,
'Pending': ACTION_CARD_STATUS_PENDING,
'Ready': ACTION_CARD_STATUS_SATISFIED,
'Needed': ACTION_CARD_STATUS_NEEDED,
}


def action_sort_key(action: SetupAction) -> int:
"""Return a sort key that groups cards by execution phase.
Expand Down Expand Up @@ -465,15 +474,6 @@ def set_check_result(self, result: SetupActionResult, status: str) -> None:

self._stop_spinner()

# Status-to-style mapping
_STATUS_STYLES: dict[str, str] = {
'Update available': ACTION_CARD_STATUS_UPDATE,
'Failed': ACTION_CARD_STATUS_FAILED,
'Pending': ACTION_CARD_STATUS_PENDING,
'Ready': ACTION_CARD_STATUS_SATISFIED,
'Needed': ACTION_CARD_STATUS_NEEDED,
}

style = _STATUS_STYLES.get(status, ACTION_CARD_STATUS_SATISFIED)
display = status

Expand Down
47 changes: 29 additions & 18 deletions synodic_client/application/screen/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@
ActionState,
InstallCallbacks,
InstallConfig,
PreviewCallbacks,
PreviewConfig,
PreviewModel,
PreviewPhase,
Expand Down Expand Up @@ -499,12 +498,7 @@ async def _run_preview_task(
project_directory=project_directory,
prerelease_packages=prerelease_packages,
),
callbacks=PreviewCallbacks(
on_manifest_parsed=self._on_manifest_parsed,
on_plugins_queried=self._on_plugins_queried,
on_preview_ready=self._on_preview_resolved,
on_action_checked=self._on_action_checked,
),
on_event=self._on_preview_event,
plugins=self._discovered_plugins,
)
self._on_preview_finished()
Expand Down Expand Up @@ -540,6 +534,26 @@ async def _run_install_task(
logger.exception('Install execution failed')
self._on_install_error(str(exc))

# --- Preview event dispatcher ---

def _on_preview_event(self, event: object) -> None:
"""Route a :data:`PreviewEvent` to the appropriate handler."""
from synodic_client.operations.schema import (
PreviewActionChecked,
PreviewManifestParsed,
PreviewPluginsQueried,
PreviewReady,
)

if isinstance(event, PreviewManifestParsed):
self._on_manifest_parsed(event.manifest, event.manifest_path, event.temp_dir)
elif isinstance(event, PreviewPluginsQueried):
self._on_plugins_queried(event.availability, event.capabilities)
elif isinstance(event, PreviewReady):
self._on_preview_resolved(event.manifest, event.manifest_path, event.temp_dir)
elif isinstance(event, PreviewActionChecked):
self._on_action_checked(event.index, event.result, event.status)

# --- Preview callbacks (wired by load()) ---

def _on_manifest_parsed(self, preview: SetupResults, manifest_path: str, temp_dir_path: str) -> None:
Expand Down Expand Up @@ -577,9 +591,6 @@ def _on_preview_ready(self, preview: SetupResults, manifest_path: str, temp_dir_

self._show_metadata(preview)

if preview.metadata:
self.metadata_ready.emit(preview)

if not preview.actions:
self._card_list.clear()
self._status_label.setText('No actions to perform — the manifest is empty.')
Expand Down Expand Up @@ -618,16 +629,13 @@ def _on_preview_resolved(self, preview: SetupResults, manifest_path: str, temp_d

Called after ``MANIFEST_LOADED`` — cards are already visible
from the earlier ``_on_manifest_parsed`` handler. This updates
the temp-dir reference and emits metadata.
the temp-dir reference.
"""
if self._model.preview is None:
return

self._model.temp_dir = temp_dir_path

if preview.metadata:
self.metadata_ready.emit(preview)

def _on_action_checked(self, row: int, result: SetupActionResult, status: str) -> None:
"""Update the model and action card with a dry-run result.

Expand Down Expand Up @@ -976,9 +984,7 @@ def _on_post_sync_finished(self, results: SetupResults) -> None:
pre_skipped = len(m.install_plan.satisfied_indices) if m.install_plan else 0

# If we have stashed install results (auto-run path), use combined summary
install_results = (
list(self._install_results.results) if hasattr(self, '_install_results') and self._install_results else None
)
install_results = list(self._install_results.results) if self._install_results else None
summary = format_install_summary(
install_results=install_results,
post_sync_results=m.post_sync_results,
Expand All @@ -988,7 +994,12 @@ def _on_post_sync_finished(self, results: SetupResults) -> None:
self._install_btn.setEnabled(False)
self._run_commands_btn.setEnabled(False)
self._close_btn.setEnabled(True)
self.install_finished.emit(results)

# Only emit when coming from the auto-run path (install_finished
# was not yet emitted). On the manual "Run Commands" path the
# signal was already emitted by _on_install_finished.
if self._install_results is not None:
self.install_finished.emit(results)


# ---------------------------------------------------------------------------
Expand Down
108 changes: 31 additions & 77 deletions synodic_client/application/screen/install_workers.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,33 +10,28 @@

import asyncio
import logging
from collections.abc import Callable
from pathlib import Path

from porringer.api import API
from porringer.backend.command.core.discovery import DiscoveredPlugins
from porringer.schema import (
ActionCompletedEvent,
ActionStartedEvent,
ManifestLoadedEvent,
SetupAction,
SetupActionResult,
SetupResults,
SubActionProgressEvent,
)

from synodic_client.application.screen.schema import (
InstallCallbacks,
InstallConfig,
PreviewCallbacks,
PreviewConfig,
)
from synodic_client.application.uri import safe_rmtree
from synodic_client.operations.install import execute_install, execute_post_sync, preview_manifest_stream
from synodic_client.operations.install import collect_install, collect_post_sync, preview_manifest_stream
from synodic_client.operations.schema import (
PreviewActionChecked,
PreviewEvent,
PreviewManifestParsed,
PreviewPluginsQueried,
PreviewReady,
)

logger = logging.getLogger(__name__)
Expand All @@ -58,44 +53,29 @@ async def run_install(
) -> SetupResults:
"""Execute setup actions via the operations layer and stream progress.

Delegates to :func:`~synodic_client.operations.install.execute_install`
and routes the tagged ``(stage, event)`` stream to GUI callbacks.
Delegates to :func:`~synodic_client.operations.install.collect_install`
and routes progress events to GUI callbacks.
"""
cfg = config or InstallConfig()
cb = callbacks or InstallCallbacks()
actions: list[SetupAction] = []
collected: list[SetupActionResult] = []
manifest_result: SetupResults | None = None

async for stage, event in execute_install(
def _on_progress(stage: str, event: object) -> None:
if stage == 'action_started' and isinstance(event, ActionStartedEvent) and cb.on_action_started is not None:
cb.on_action_started(event.action)
elif stage == 'sub_progress' and isinstance(event, SubActionProgressEvent) and cb.on_sub_progress is not None:
cb.on_sub_progress(event.action, event.sub_action)
elif stage == 'action_completed' and isinstance(event, ActionCompletedEvent) and cb.on_progress is not None:
cb.on_progress(event.action, event.result)

return await collect_install(
porringer,
manifest_path,
project_directory=cfg.project_directory,
strategy=cfg.strategy,
prerelease_packages=cfg.prerelease_packages,
discovered=plugins,
exclude_post_sync=exclude_post_sync,
):
if stage == 'manifest_loaded' and isinstance(event, ManifestLoadedEvent):
manifest_result = event.manifest
actions = list(event.manifest.actions)

elif stage == 'action_started' and isinstance(event, ActionStartedEvent) and cb.on_action_started is not None:
cb.on_action_started(event.action)

elif stage == 'sub_progress' and isinstance(event, SubActionProgressEvent) and cb.on_sub_progress is not None:
cb.on_sub_progress(event.action, event.sub_action)

elif stage == 'action_completed' and isinstance(event, ActionCompletedEvent):
collected.append(event.result)
if cb.on_progress is not None:
cb.on_progress(event.action, event.result)

return SetupResults(
actions=actions,
results=collected,
manifest_path=manifest_result.manifest_path if manifest_result else None,
metadata=manifest_result.metadata if manifest_result else None,
on_progress=_on_progress,
)


Expand All @@ -114,40 +94,25 @@ async def run_post_sync(
) -> SetupResults:
"""Execute only the post-sync commands from a manifest.

Delegates to :func:`~synodic_client.operations.install.execute_post_sync`
and routes events to GUI callbacks.
Delegates to :func:`~synodic_client.operations.install.collect_post_sync`
and routes progress events to GUI callbacks.
"""
cb = callbacks or InstallCallbacks()
actions: list[SetupAction] = []
collected: list[SetupActionResult] = []
manifest_result: SetupResults | None = None

async for stage, event in execute_post_sync(
porringer,
manifest_path,
project_directory=project_directory,
discovered=plugins,
):
if stage == 'manifest_loaded' and isinstance(event, ManifestLoadedEvent):
manifest_result = event.manifest
actions = list(event.manifest.actions)

elif stage == 'action_started' and isinstance(event, ActionStartedEvent) and cb.on_action_started is not None:
def _on_progress(stage: str, event: object) -> None:
if stage == 'action_started' and isinstance(event, ActionStartedEvent) and cb.on_action_started is not None:
cb.on_action_started(event.action)

elif stage == 'sub_progress' and isinstance(event, SubActionProgressEvent) and cb.on_sub_progress is not None:
cb.on_sub_progress(event.action, event.sub_action)
elif stage == 'action_completed' and isinstance(event, ActionCompletedEvent) and cb.on_progress is not None:
cb.on_progress(event.action, event.result)

elif stage == 'action_completed' and isinstance(event, ActionCompletedEvent):
collected.append(event.result)
if cb.on_progress is not None:
cb.on_progress(event.action, event.result)

return SetupResults(
actions=actions,
results=collected,
manifest_path=manifest_result.manifest_path if manifest_result else None,
metadata=manifest_result.metadata if manifest_result else None,
return await collect_post_sync(
porringer,
manifest_path,
project_directory=project_directory,
discovered=plugins,
on_progress=_on_progress,
)


Expand All @@ -161,17 +126,15 @@ async def run_preview(
url: str,
*,
config: PreviewConfig | None = None,
callbacks: PreviewCallbacks | None = None,
on_event: Callable[[PreviewEvent], object] | None = None,
plugins: DiscoveredPlugins | None = None,
) -> None:
"""Download a manifest and perform a dry-run preview.

Delegates to :func:`preview_manifest_stream` in the operations
layer, then routes each :data:`PreviewEvent` to the appropriate
callback.
layer, then yields each :data:`PreviewEvent` to *on_event*.
"""
logger.info('run_preview starting for: %s', url)
cb = callbacks or PreviewCallbacks()
cfg = config or PreviewConfig()
temp_dir: str | None = None
try:
Expand All @@ -184,18 +147,9 @@ async def run_preview(
):
if isinstance(event, PreviewManifestParsed):
temp_dir = event.temp_dir or None
if cb.on_manifest_parsed is not None:
cb.on_manifest_parsed(event.manifest, event.manifest_path, event.temp_dir)

elif isinstance(event, PreviewPluginsQueried) and cb.on_plugins_queried is not None:
cb.on_plugins_queried(event.availability, event.capabilities)

elif isinstance(event, PreviewReady):
if cb.on_preview_ready is not None:
cb.on_preview_ready(event.manifest, event.manifest_path, event.temp_dir)

elif isinstance(event, PreviewActionChecked) and cb.on_action_checked is not None:
cb.on_action_checked(event.index, event.result, event.status)
if on_event is not None:
on_event(event)

except asyncio.CancelledError:
if temp_dir:
Expand Down
Loading
Loading