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.

17 changes: 13 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.dev73",
"porringer>=0.2.1.dev74",
"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.55.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
4 changes: 2 additions & 2 deletions synodic_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
import importlib.metadata

from synodic_client.client import Client
from synodic_client.updater import (
from synodic_client.schema import (
UpdateChannel,
UpdateConfig,
UpdateInfo,
Updater,
UpdateState,
)
from synodic_client.updater import Updater

try:
__version__ = importlib.metadata.version('synodic_client')
Expand Down
6 changes: 2 additions & 4 deletions synodic_client/application/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,9 @@

from synodic_client.config import set_dev_mode
from synodic_client.logging import configure_logging
from synodic_client.protocol import extract_uri_from_args
from synodic_client.updater import initialize_velopack

_PROTOCOL_SCHEME = 'synodic'

# Parse --dev flag early so logging uses the right filename.
_dev_mode = '--dev' in sys.argv[1:]
set_dev_mode(_dev_mode)
Expand All @@ -36,5 +35,4 @@
# Heavy imports happen here — PySide6, porringer, etc.
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)
application(uri=extract_uri_from_args(), dev_mode=_dev_mode)
30 changes: 2 additions & 28 deletions synodic_client/application/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,44 +14,18 @@

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.
"""
from synodic_client.application.schema import Snapshot

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."""
logger = logging.getLogger(__name__)


class DataCoordinator:
Expand Down
11 changes: 7 additions & 4 deletions synodic_client/application/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@

logger = logging.getLogger(__name__)

_preamble_done = False

class _PreambleState:
"""Module-level mutable state (avoids ``global`` statements)."""

done: bool = False


def run_startup_preamble(exe_path: str | None = None) -> None:
Expand All @@ -39,10 +43,9 @@ def run_startup_preamble(exe_path: str | None = None) -> None:
exe_path: Absolute path to the application executable. Defaults
to ``sys.executable`` when not supplied.
"""
global _preamble_done # noqa: PLW0603
if _preamble_done:
if _PreambleState.done:
return
_preamble_done = True
_PreambleState.done = True

if exe_path is None:
exe_path = sys.executable
Expand Down
6 changes: 2 additions & 4 deletions synodic_client/application/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from synodic_client.client import Client
from synodic_client.config import set_dev_mode
from synodic_client.logging import configure_logging
from synodic_client.protocol import extract_uri_from_args
from synodic_client.resolution import (
ResolvedConfig,
resolve_config,
Expand Down Expand Up @@ -187,8 +188,5 @@ def _handle_install_uri(manifest_url: str) -> None:
loop.run_forever()


_PROTOCOL_SCHEME = 'synodic'

if __name__ == '__main__':
_uri = next((a for a in sys.argv[1:] if a.lower().startswith(f'{_PROTOCOL_SCHEME}://')), None)
application(uri=_uri)
application(uri=extract_uri_from_args())
53 changes: 53 additions & 0 deletions synodic_client/application/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Application-layer data models.

Contains data structures shared across application modules — the
``DataCoordinator`` snapshot and the tool-update result summary.
"""

from __future__ import annotations

from dataclasses import dataclass, field

from porringer.backend.command.core.discovery import DiscoveredPlugins
from porringer.core.plugin_schema.plugin_manager import PluginManager
from porringer.schema import (
DirectoryValidationResult,
ManifestDirectory,
PluginInfo,
)


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


@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."""
40 changes: 38 additions & 2 deletions synodic_client/application/screen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@

from __future__ import annotations

from datetime import UTC, datetime

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

_SECONDS_PER_MINUTE = 60
_MINUTES_PER_HOUR = 60
_HOURS_PER_DAY = 24

ACTION_KIND_LABELS: dict[PluginKind | None, str] = {
PluginKind.PACKAGE: 'Package',
PluginKind.TOOL: 'Tool',
Expand Down Expand Up @@ -57,15 +63,45 @@ def skip_reason_label(reason: SkipReason | None) -> str:
return SKIP_REASON_LABELS.get(reason, reason.name.replace('_', ' ').capitalize())


def format_cli_command(action: SetupAction) -> str:
def format_cli_command(action: SetupAction, *, 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
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 action.kind == PluginKind.PACKAGE and action.package:
return f'{action.installer or "pip"} install {action.package}'
return action.description
return '' if suppress_description else action.description


def _format_relative_time(iso_timestamp: str) -> str:
"""Format an ISO 8601 timestamp as a human-readable relative time.

Returns strings like ``'just now'``, ``'5m ago'``, ``'2h ago'``,
``'3d ago'``. Returns an empty string if the timestamp cannot be
parsed.
"""
try:
dt = datetime.fromisoformat(iso_timestamp)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
delta = datetime.now(UTC) - dt
seconds = max(int(delta.total_seconds()), 0)
if seconds < _SECONDS_PER_MINUTE:
return 'just now'
minutes = seconds // _SECONDS_PER_MINUTE
if minutes < _MINUTES_PER_HOUR:
return f'{minutes}m ago'
hours = minutes // _MINUTES_PER_HOUR
if hours < _HOURS_PER_DAY:
return f'{hours}h ago'
days = hours // _HOURS_PER_DAY
return f'{days}d ago'
except ValueError, TypeError:
return ''
Loading
Loading