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.dev78",
"porringer>=0.2.1.dev79",
"qasync>=0.28.0",
"velopack>=0.0.1444.dev49733",
"typer>=0.24.1",
Expand Down
20 changes: 14 additions & 6 deletions synodic_client/application/screen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from datetime import UTC, datetime

from porringer.schema import SetupAction, SkipReason
from porringer.schema import SetupAction, SetupActionResult, SkipReason
from porringer.schema.plugin import PluginKind

_SECONDS_PER_MINUTE = 60
Expand Down Expand Up @@ -63,18 +63,26 @@ def skip_reason_label(reason: SkipReason | None) -> str:
return SKIP_REASON_LABELS.get(reason, reason.name.replace('_', ' ').capitalize())


def format_cli_command(action: SetupAction, *, suppress_description: bool = False) -> str:
def format_cli_command(
action: SetupAction,
*,
result: SetupActionResult | None = None,
suppress_description: bool = False,
) -> str:
"""Return a human-readable CLI command string for *action*.

Prefers ``cli_command``, falls back to ``command``, then synthesises
an ``installer install <package>`` string for package actions, and
Prefers ``result.cli_command`` (populated after dry-run), falls
back to ``action.command``, then synthesises an
``installer install <package>`` string for package actions, and
finally returns the action description as a last resort.

When *suppress_description* is ``True`` the final description
fallback returns an empty string instead.
"""
if parts := (action.cli_command or action.command):
return ' '.join(parts)
if result is not None and result.cli_command:
return ' '.join(result.cli_command)
if action.command:
return ' '.join(action.command)
if action.kind == PluginKind.PACKAGE and action.package:
return f'{action.installer or "pip"} install {action.package}'
return '' if suppress_description else action.description
Expand Down
61 changes: 19 additions & 42 deletions synodic_client/application/screen/action_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,19 +73,6 @@
_KIND_ORDER: dict[PluginKind | None, int] = {kind: i for i, kind in enumerate(PHASE_ORDER)}


def action_key(action: SetupAction) -> tuple[object, ...]:
"""Return a stable identity key for *action*.

Uses content-based fields (kind, installer, package name, command)
so the same logical action from different ``execute_stream`` runs
resolves to the same key.
"""
pkg_name = str(action.package.name) if action.package else None
pt_name = str(action.plugin_target.name) if action.plugin_target else None
cmd = tuple(action.command) if action.command else None
return (action.kind, action.installer, pkg_name, pt_name, cmd)


def action_sort_key(action: SetupAction) -> int:
"""Return a sort key that groups cards by execution phase.

Expand Down Expand Up @@ -396,24 +383,6 @@ def populate(
else:
self._prerelease_cb.hide()

def update_command(self, action: SetupAction) -> None:
"""Update the CLI command label after the resolved preview arrives.

Called from the two-phase display flow once ``MANIFEST_LOADED``
provides actions with their ``cli_command`` populated.

Args:
action: The setup action with resolved CLI command.
"""
if self._is_skeleton:
return
cmd_text = format_cli_command(action, suppress_description=True)
if cmd_text:
self._command_label.setText(cmd_text)
self._command_row.show()
else:
self._command_row.hide()

def _populate_status(
self,
action: SetupAction,
Expand Down Expand Up @@ -494,7 +463,7 @@ def set_check_result(self, result: SetupActionResult) -> None:
self._status_label.setText(label)
self._status_label.setStyleSheet(ACTION_CARD_STATUS_UPDATE)
elif result.skipped:
label = skip_reason_label(result.skip_reason)
label = '\u2713 ' + skip_reason_label(result.skip_reason)
self._status_label.setText(label)
self._status_label.setStyleSheet(ACTION_CARD_STATUS_SATISFIED)
elif not result.success:
Expand All @@ -517,13 +486,23 @@ def set_check_result(self, result: SetupActionResult) -> None:
else:
self._status_label.setToolTip('')

# CLI command — update with resolved cli_command from result
assert self._action is not None
cmd_text = format_cli_command(self._action, result=result, suppress_description=True)
if cmd_text:
self._command_label.setText(cmd_text)
self._command_row.show()

# Version column
self._check_available_version = result.available_version
if result.installed_version and result.available_version:
self._version_label.setText(f'{result.installed_version} \u2192 {result.available_version}')
self._version_label.setStyleSheet(ACTION_CARD_VERSION_STYLE + ' color: #d7ba7d;')
elif result.installed_version:
self._version_label.setText(result.installed_version)
elif result.available_version:
self._version_label.setText(f'\u2192 {result.available_version}')
self._version_label.setStyleSheet(ACTION_CARD_VERSION_STYLE + ' color: grey;')

def finalize_checking(self) -> None:
"""Resolve a still-pending 'Checking\u2026' status to 'Needed'.
Expand Down Expand Up @@ -611,9 +590,8 @@ def is_update_available(self) -> bool:
class ActionCardList(QWidget):
"""Container of :class:`ActionCard` widgets.

Cards are keyed by :func:`action_key` (content-based) so that
look-ups work across different ``execute_stream`` runs where the
``SetupAction`` objects are different Python instances.
Cards are keyed by ``SetupAction`` directly (frozen dataclass) so
that look-ups work across different ``execute_stream`` runs.
"""

prerelease_toggled = Signal(str, bool)
Expand All @@ -629,7 +607,7 @@ def __init__(self, parent: QWidget | None = None) -> None:
self._layout.addStretch()

self._cards: list[ActionCard] = []
self._action_map: dict[tuple[object, ...], ActionCard] = {}
self._action_map: dict[SetupAction, ActionCard] = {}

# ------------------------------------------------------------------
# Skeleton loading
Expand Down Expand Up @@ -679,7 +657,7 @@ def populate(
card.prerelease_toggled.connect(self.prerelease_toggled.emit)
self._layout.insertWidget(self._layout.count() - 1, card)
self._cards.append(card)
self._action_map[action_key(act)] = card
self._action_map[act] = card

# ------------------------------------------------------------------
# Card lookup
Expand All @@ -696,19 +674,18 @@ def card_count(self) -> int:
return len(self._cards)

def get_card(self, action: SetupAction) -> ActionCard | None:
"""Look up the card for a given action by stable content key.
"""Look up the card for a given action.

Works across different ``execute_stream`` runs — the preview
and install phases produce different ``SetupAction`` instances
but the same logical action maps to the same card.
``SetupAction`` is a frozen dataclass, so the same logical
action from different ``execute_stream`` runs hashes equally.

Args:
action: The setup action to find.

Returns:
The card widget, or ``None`` if not found.
"""
return self._action_map.get(action_key(action))
return self._action_map.get(action)

# ------------------------------------------------------------------
# Bulk operations
Expand Down
16 changes: 5 additions & 11 deletions synodic_client/application/screen/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@

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.action_card import ActionCardList
from synodic_client.application.screen.card import CardFrame
from synodic_client.application.screen.install_workers import run_install, run_preview
from synodic_client.application.screen.log_panel import ExecutionLogPanel
Expand Down Expand Up @@ -595,11 +595,11 @@ def _on_preview_ready(self, preview: SetupResults, manifest_path: str, temp_dir_
self._install_btn.setEnabled(True)

def _on_preview_resolved(self, preview: SetupResults, manifest_path: str, temp_dir_path: str) -> None:
"""Handle the fully-resolved preview (CLI commands populated).
"""Handle the fully-resolved preview.

Called after ``MANIFEST_LOADED`` — cards are already visible
from the earlier ``_on_manifest_parsed`` handler. This only
updates CLI command text and the temp-dir reference.
from the earlier ``_on_manifest_parsed`` handler. This updates
the temp-dir reference and emits metadata.
"""
if self._model.preview is None:
return
Expand All @@ -609,19 +609,13 @@ def _on_preview_resolved(self, preview: SetupResults, manifest_path: str, temp_d
if preview.metadata:
self.metadata_ready.emit(preview)

for action in preview.actions:
if action.cli_command:
card = self._card_list.get_card(action)
if card is not None:
card.update_command(action)

def _on_action_checked(self, row: int, result: SetupActionResult) -> None:
"""Update the model and action card with a dry-run result."""
m = self._model
if result.skipped and result.skip_reason == SkipReason.UPDATE_AVAILABLE:
label = skip_reason_label(result.skip_reason)
if 0 <= row < len(m.action_states):
m.upgradable_keys.add(action_key(m.action_states[row].action))
m.upgradable_keys.add(m.action_states[row].action)
elif result.skipped:
label = skip_reason_label(result.skip_reason)
elif not result.success:
Expand Down
16 changes: 8 additions & 8 deletions synodic_client/application/screen/install_workers.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,10 +159,9 @@ def _dispatch_preview_event(
) -> None:
"""Route a single preview stream event to the appropriate callback.

Mutates *state* in-place with updated ``action_index`` / ``got_parsed``.
Mutates *state* in-place (``got_parsed`` flag).
"""
if event.kind == ProgressEventKind.MANIFEST_PARSED and event.manifest:
state.action_index = {id(a): i for i, a in enumerate(event.manifest.actions)}
if cb.on_manifest_parsed is not None:
cb.on_manifest_parsed(event.manifest, manifest_path, temp_dir_str)
state.got_parsed = True
Expand All @@ -176,16 +175,17 @@ def _dispatch_preview_event(
return

if event.kind == ProgressEventKind.MANIFEST_LOADED and event.manifest:
if not state.got_parsed:
state.action_index = {id(a): i for i, a in enumerate(event.manifest.actions)}
if cb.on_preview_ready is not None:
cb.on_preview_ready(event.manifest, manifest_path, temp_dir_str)
return

if event.kind == ProgressEventKind.ACTION_COMPLETED and event.result and event.action:
row = state.action_index.get(id(event.action))
if row is not None and cb.on_action_checked is not None:
cb.on_action_checked(row, event.result)
if (
event.kind == ProgressEventKind.ACTION_COMPLETED
and event.result
and event.action_index is not None
and cb.on_action_checked is not None
):
cb.on_action_checked(event.action_index, event.result)


# ---------------------------------------------------------------------------
Expand Down
7 changes: 3 additions & 4 deletions synodic_client/application/screen/log_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
)

from synodic_client.application.screen import ACTION_KIND_LABELS, skip_reason_label
from synodic_client.application.screen.action_card import action_key
from synodic_client.application.screen.card import CHEVRON_DOWN, CHEVRON_RIGHT, ClickableHeader
from synodic_client.application.theme import (
LOG_CHEVRON_STYLE,
Expand Down Expand Up @@ -186,7 +185,7 @@ def __init__(self, parent: QWidget | None = None) -> None:
self._layout.addStretch()

# Map action content-key → section widget for quick lookup
self._sections: dict[tuple[object, ...], ActionLogSection] = {}
self._sections: dict[SetupAction, ActionLogSection] = {}
self._section_count = 0

# --- Public API ---
Expand All @@ -204,7 +203,7 @@ def add_section(self, action: SetupAction) -> ActionLogSection:
section = ActionLogSection(action, self._section_count, self)
# Insert before the stretch
self._layout.insertWidget(self._layout.count() - 1, section)
self._sections[action_key(action)] = section
self._sections[action] = section

return section

Expand All @@ -217,7 +216,7 @@ def get_section(self, action: SetupAction) -> ActionLogSection | None:
Returns:
The section widget, or ``None`` if not found.
"""
return self._sections.get(action_key(action))
return self._sections.get(action)

def on_sub_progress(self, action: SetupAction, progress: SubActionProgress) -> None:
"""Handle a sub-action progress event.
Expand Down
17 changes: 7 additions & 10 deletions synodic_client/application/screen/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
)
from porringer.schema.plugin import RuntimePackageResult

from synodic_client.application.screen.action_card import action_key
from synodic_client.application.uri import normalize_manifest_key

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -237,7 +236,6 @@ class PreviewModel:

def __init__(self) -> None:
"""Initialise a blank preview model."""
self._action_key = action_key
self._normalize = normalize_manifest_key

self.phase: PreviewPhase = PreviewPhase.IDLE
Expand All @@ -248,19 +246,19 @@ def __init__(self) -> None:
self.plugin_installed: dict[str, bool] = {}
self.prerelease_overrides: set[str] = set()
self.action_states: list[ActionState] = []
self._action_state_map: dict[tuple[object, ...], ActionState] = {}
self._action_state_map: dict[SetupAction, ActionState] = {}
self._action_state_map_len: int = 0
self.upgradable_keys: set[tuple[object, ...]] = set()
self.upgradable_keys: set[SetupAction] = set()
self.checked_count: int = 0
self.completed_count: int = 0
self.temp_dir: str | None = None

# -- Computed helpers --------------------------------------------------

def _ensure_action_state_map(self) -> dict[tuple[object, ...], ActionState]:
"""Return the action-key → state lookup, rebuilding if stale."""
def _ensure_action_state_map(self) -> dict[SetupAction, ActionState]:
"""Return the action → state lookup, rebuilding if stale."""
if len(self.action_states) != self._action_state_map_len:
self._action_state_map = {self._action_key(s.action): s for s in self.action_states}
self._action_state_map = {s.action: s for s in self.action_states}
self._action_state_map_len = len(self.action_states)
return self._action_state_map

Expand All @@ -279,8 +277,8 @@ def install_enabled(self) -> bool:
return self.actionable_count > 0 or any(s.action.kind is None for s in self.action_states)

def action_state_for(self, act: SetupAction) -> ActionState | None:
"""Look up :class:`ActionState` by content key (O(1) amortized)."""
return self._ensure_action_state_map().get(self._action_key(act))
"""Look up :class:`ActionState` for *act* (O(1) amortized)."""
return self._ensure_action_state_map().get(act)

def has_same_manifest(self, key: str) -> bool:
"""Return ``True`` if *key* matches the current manifest key."""
Expand Down Expand Up @@ -339,5 +337,4 @@ class PreviewConfig:
class _DispatchState:
"""Mutable accumulator for :func:`_dispatch_preview_event`."""

action_index: dict[int, int] = field(default_factory=dict)
got_parsed: bool = False
4 changes: 2 additions & 2 deletions synodic_client/application/theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -427,8 +427,8 @@
ACTION_CARD_STATUS_NEEDED = 'color: palette(text); font-size: 11px; font-weight: bold;'
"""Status label: Needed."""

ACTION_CARD_STATUS_SATISFIED = 'color: grey; font-size: 11px;'
"""Status label: Already installed."""
ACTION_CARD_STATUS_SATISFIED = 'color: #6a9955; font-size: 11px;'
"""Status label: Already installed (muted green with checkmark)."""

ACTION_CARD_STATUS_UPDATE = 'color: #d7ba7d; font-size: 11px; font-weight: bold;'
"""Status label: Update available (amber)."""
Expand Down
Loading
Loading