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.

21 changes: 17 additions & 4 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.dev52",
"porringer>=0.2.1.dev53",
"qasync>=0.28.0",
"velopack>=0.0.1442.dev64255",
"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.2", "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.2",
"pyrefly>=0.54.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 @@ -57,6 +66,10 @@ select = [
"PT", # flake8-pytest-style
]

[tool.ruff.lint.per-file-ignores]
"synodic_client/application/bootstrap.py" = ["E402"]
"synodic_client/cli.py" = ["PLC0415"]

[tool.ruff.lint.pydocstyle]
convention = "google"

Expand Down
2 changes: 1 addition & 1 deletion synodic_client/application/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
remove_startup()

# Heavy imports happen here — PySide6, porringer, etc.
from synodic_client.application.qt import application # noqa: E402
from synodic_client.application.qt import application

_uri = next((a for a in sys.argv[1:] if a.lower().startswith(f'{_PROTOCOL_SCHEME}://')), None)
application(uri=_uri, dev_mode=_dev_mode)
12 changes: 5 additions & 7 deletions synodic_client/application/icon.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
so every caller shares the same instance.
"""

import functools

from PySide6.QtGui import QIcon, QPixmap

from synodic_client.client import Client

_cached_icon: QIcon | None = None


@functools.cache
def app_icon() -> QIcon:
"""Return the shared application ``QIcon``, loading it on first call.

Expand All @@ -20,8 +21,5 @@ def app_icon() -> QIcon:
Returns:
A ``QIcon`` backed by the application logo.
"""
global _cached_icon # noqa: PLW0603
if _cached_icon is None:
with Client.resource(Client.icon) as icon_path:
_cached_icon = QIcon(QPixmap(str(icon_path)))
return _cached_icon
with Client.resource(Client.icon) as icon_path:
return QIcon(QPixmap(str(icon_path)))
16 changes: 15 additions & 1 deletion synodic_client/application/screen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from __future__ import annotations

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

ACTION_KIND_LABELS: dict[PluginKind | None, str] = {
Expand Down Expand Up @@ -54,3 +54,17 @@ def skip_reason_label(reason: SkipReason | None) -> str:
if reason is None:
return 'Skipped'
return SKIP_REASON_LABELS.get(reason, reason.name.replace('_', ' ').capitalize())


def format_cli_command(action: SetupAction) -> 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
finally returns the action description as a last resort.
"""
if parts := (action.cli_command or action.command):
return ' '.join(parts)
if action.kind == PluginKind.PACKAGE and action.package:
return f'{action.installer or "pip"} install {action.package}'
return action.description
97 changes: 67 additions & 30 deletions synodic_client/application/screen/action_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
QWidget,
)

from synodic_client.application.screen import ACTION_KIND_LABELS, skip_reason_label
from synodic_client.application.screen import ACTION_KIND_LABELS, format_cli_command, skip_reason_label
from synodic_client.application.theme import (
ACTION_CARD_COMMAND_STYLE,
ACTION_CARD_DESC_STYLE,
Expand All @@ -48,6 +48,7 @@
ACTION_CARD_STATUS_DONE,
ACTION_CARD_STATUS_FAILED,
ACTION_CARD_STATUS_NEEDED,
ACTION_CARD_STATUS_PENDING,
ACTION_CARD_STATUS_RUNNING,
ACTION_CARD_STATUS_SATISFIED,
ACTION_CARD_STATUS_SKIPPED,
Expand Down Expand Up @@ -113,12 +114,14 @@ def action_sort_key(action: SetupAction) -> int:


def _format_command(action: SetupAction) -> str:
"""Return a short CLI command string for display."""
if parts := (action.cli_command or action.command):
return ' '.join(parts)
if action.kind == PluginKind.PACKAGE and action.package:
return f'{action.installer or "pip"} install {action.package}'
return ''
"""Return a short CLI command string for display.

Wraps :func:`~synodic_client.application.screen.format_cli_command`
but returns an empty string instead of the description fallback so
cards only show an explicit command line.
"""
text = format_cli_command(action)
return '' if text == action.description else text


# ---------------------------------------------------------------------------
Expand All @@ -138,7 +141,7 @@ def __init__(self, parent: QWidget | None = None) -> None:
self._angle = 0
self.setFixedSize(ACTION_CARD_SPINNER_SIZE, ACTION_CARD_SPINNER_SIZE)

def paintEvent(self, _event: object) -> None: # noqa: N802
def paintEvent(self, _event: object) -> None:
"""Draw the muted track and animated highlight arc."""
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
Expand Down Expand Up @@ -379,7 +382,7 @@ def _build_log_output(self) -> QTextEdit:
# Mouse events (toggle log)
# ------------------------------------------------------------------

def mousePressEvent(self, event: object) -> None: # noqa: N802
def mousePressEvent(self, event: object) -> None:
"""Toggle the inline log body on click."""
if self._is_skeleton or not hasattr(self, '_log_output'):
return
Expand Down Expand Up @@ -471,23 +474,8 @@ def populate(

self._version_label.setText('')

# Status — check plugin presence first
installer_missing = (
action.installer is not None
and action.installer in plugin_installed
and not plugin_installed[action.installer]
)

if installer_missing:
self._status_label.setText('Not installed')
self._status_label.setStyleSheet(ACTION_CARD_STATUS_UNAVAILABLE)
self._status_label.show()
else:
# Show spinner instead of status text while checking
self._status_label.hide()
self._checking = True
self._spinner_canvas.show()
self._spinner_timer.start()
# Status
self._populate_status(action, plugin_installed)

# Pre-release checkbox
if action.package is not None:
Expand Down Expand Up @@ -525,6 +513,40 @@ def update_command(self, action: SetupAction) -> None:
else:
self._command_row.hide()

def _populate_status(
self,
action: SetupAction,
plugin_installed: dict[str, bool],
) -> None:
"""Set the initial status badge during :meth:`populate`.

Bare-command actions (``kind is None``) show a static *Pending*
badge. Plugin-backed actions either flag a missing installer or
start the dry-run spinner.
"""
if action.kind is None:
self._status_label.setText('Pending')
self._status_label.setStyleSheet(ACTION_CARD_STATUS_PENDING)
self._status_label.show()
return

installer_missing = (
action.installer is not None
and action.installer in plugin_installed
and not plugin_installed[action.installer]
)

if installer_missing:
self._status_label.setText('Not installed')
self._status_label.setStyleSheet(ACTION_CARD_STATUS_UNAVAILABLE)
self._status_label.show()
else:
# Show spinner instead of status text while checking
self._status_label.hide()
self._checking = True
self._spinner_canvas.show()
self._spinner_timer.start()

def initial_status(self) -> str:
"""Return the initial status text set during :meth:`populate`."""
if self._is_skeleton or not hasattr(self, '_status_label'):
Expand All @@ -549,6 +571,15 @@ def _stop_spinner(self) -> None:
def set_check_result(self, result: SetupActionResult) -> None:
"""Update the card with a dry-run check result.

Handles four cases:

* **Skipped (update available)** — amber "Update available" badge.
* **Skipped (other)** — muted satisfied badge.
* **Failed** — red "Failed" badge with diagnostic tooltip.
This covers backend failures surfaced during the dry-run
(e.g. missing SCM plugin, unresolvable deferred action).
* **Needed** — default blue badge.

Args:
result: The action check result from the preview worker.
"""
Expand All @@ -565,6 +596,15 @@ def set_check_result(self, result: SetupActionResult) -> None:
label = skip_reason_label(result.skip_reason)
self._status_label.setText(label)
self._status_label.setStyleSheet(ACTION_CARD_STATUS_SATISFIED)
elif not result.success:
label = 'Failed'
self._status_label.setText(label)
self._status_label.setStyleSheet(ACTION_CARD_STATUS_FAILED)
logger.warning(
'Dry-run check failed for %s: %s',
self._action.description if self._action else '(unknown)',
result.message or 'unknown error',
)
else:
label = 'Needed'
self._status_label.setText(label)
Expand Down Expand Up @@ -768,10 +808,7 @@ def populate(
prerelease_overrides: Package names with user pre-release overrides.
"""
self.clear()
sorted_actions = sorted(
(a for a in actions if a.kind is not None),
key=action_sort_key,
)
sorted_actions = sorted(actions, key=action_sort_key)
for act in sorted_actions:
card = ActionCard(self._container)
card.populate(
Expand Down
2 changes: 1 addition & 1 deletion synodic_client/application/screen/card.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def header_layout(self) -> QHBoxLayout:

# --- Event handling ---------------------------------------------------

def mousePressEvent(self, _event: object) -> None: # noqa: N802
def mousePressEvent(self, _event: object) -> None:
"""Emit :attr:`clicked` on any mouse press."""
self.clicked.emit()

Expand Down
Loading
Loading