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
26 changes: 17 additions & 9 deletions .github/workflows/release-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,22 +44,30 @@ jobs:
fetch-depth: 0
filter: blob:none

- name: Semantic Version
- name: Install PDM
if: github.event_name == 'workflow_run'
id: semver
uses: PaulHatch/semantic-version@v6.0.1
uses: pdm-project/setup-pdm@v4
with:
tag_prefix: "v"
version_format: "${major}.${minor}.${patch}"
python-version: "3.14"

- name: Get version
id: version
run: |
if [ "${{ github.event_name }}" == "workflow_run" ]; then
base_version="${{ steps.semver.outputs.version }}"
increment="${{ steps.semver.outputs.increment }}"
echo "version=${base_version}.dev${increment}" >> $GITHUB_OUTPUT
echo "installer-version=${base_version}-dev.${increment}" >> $GITHUB_OUTPUT
# PDM SCM is the single source of truth for version numbers.
# It reads git tags and produces a PEP 440 version string.
pep440_version=$(pdm show --version)

# Convert PEP 440 → SemVer for Velopack (e.g. 0.1.0.dev47 → 0.1.0-dev.47)
installer_version=$(python3 -c "
from packaging.version import Version
v = Version('${pep440_version}')
base = f'{v.major}.{v.minor}.{v.micro}'
print(f'{base}-dev.{v.dev}' if v.dev is not None else base)
")

echo "version=${pep440_version}" >> $GITHUB_OUTPUT
echo "installer-version=${installer_version}" >> $GITHUB_OUTPUT
echo "channel=dev" >> $GITHUB_OUTPUT
echo "tag=dev" >> $GITHUB_OUTPUT
echo "is-dev=true" >> $GITHUB_OUTPUT
Expand Down
17 changes: 3 additions & 14 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,9 @@ 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 Expand Up @@ -86,8 +77,6 @@ replace-imports-with-any = ["velopack", "winreg"]

[tool.pdm.version]
source = "scm"
write_to = "synodic_client/_version.py"
write_template = "__version__ = '{}'\n"

[tool.pdm.resolution]
allow-prereleases = true
Expand Down
3 changes: 1 addition & 2 deletions synodic_client/application/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
ResolvedConfig,
resolve_config,
resolve_update_config,
resolve_version,
)
from synodic_client.updater import initialize_velopack

Expand All @@ -52,7 +51,7 @@ def _init_services(logger: logging.Logger) -> tuple[Client, API, ResolvedConfig]

logger.info(
'Synodic Client v%s started (channel: %s, source: %s, cached_projects: %d)',
resolve_version(client),
client.version,
update_config.channel.name,
update_config.repo_url,
len(cached_dirs),
Expand Down
6 changes: 4 additions & 2 deletions synodic_client/application/screen/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
QWidget,
)

from synodic_client import __version__
from synodic_client.application.icon import app_icon
from synodic_client.application.screen.card import CardFrame
from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE, UPDATE_STATUS_CHECKING_STYLE
Expand Down Expand Up @@ -58,16 +57,19 @@ class SettingsWindow(QMainWindow):
def __init__(
self,
config: ResolvedConfig,
version: str = '',
parent: QWidget | None = None,
) -> None:
"""Initialise the settings window.

Args:
config: The current resolved configuration snapshot.
version: The application version string to display.
parent: Optional parent widget.
"""
super().__init__(parent)
self._config = config
self._version = version
self.setWindowTitle('Synodic Settings')
self.setMinimumSize(*SETTINGS_WINDOW_MIN_SIZE)
self.setWindowIcon(app_icon())
Expand All @@ -93,7 +95,7 @@ def _init_ui(self) -> None:
layout.addWidget(self._build_advanced_section())
layout.addStretch()

version_label = QLabel(f'Version {__version__}')
version_label = QLabel(f'Version {self._version}')
version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
version_label.setStyleSheet('color: rgba(255, 255, 255, 0.4); font-size: 11px;')
layout.addWidget(version_label)
Expand Down
5 changes: 4 additions & 1 deletion synodic_client/application/screen/tray.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ def __init__(
self._build_menu(app, window)

# Settings window (created once, shown/hidden on demand)
self._settings_window = SettingsWindow(self._resolve_config())
self._settings_window = SettingsWindow(
self._resolve_config(),
version=str(self._client.version),
)
self._settings_window.settings_changed.connect(self._on_settings_changed)

# MainWindow gear button → open settings
Expand Down
14 changes: 12 additions & 2 deletions synodic_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,21 @@ class Client:

@property
def version(self) -> Version:
"""Extracts the version from the installed client.
"""Return the best-known application version.

When a Velopack-installed updater is available the authoritative
version comes from the native binary manifest. Otherwise, the
Python package metadata version (``importlib.metadata``) is used.

Returns:
The version data
The resolved version.
"""
if self._updater is not None:
try:
if self._updater.is_installed:
return self._updater.current_version
except Exception:
logger.debug('Failed to query Velopack version, falling back', exc_info=True)
try:
return Version(importlib.metadata.version(self.distribution))
except importlib.metadata.PackageNotFoundError:
Expand Down
32 changes: 0 additions & 32 deletions synodic_client/resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@
import sys
from dataclasses import dataclass

from packaging.version import Version

from synodic_client.config import (
UserConfig,
load_build_config,
Expand Down Expand Up @@ -210,36 +208,6 @@ def resolve_update_config(config: ResolvedConfig) -> UpdateConfig:
)


def resolve_version(client: object) -> Version:
"""Return the best-known application version.

When a Velopack-installed ``Updater`` is available the authoritative
version comes from the native binary manifest. Otherwise, the
Python package metadata version (``importlib.metadata``) is used.

Accepts ``Client`` (or any object with ``.updater`` and ``.version``
attributes) so that :mod:`resolution` does not need a hard import
of :class:`~synodic_client.client.Client` — avoiding a tighter
coupling than necessary.

Args:
client: The application service facade (typically a
:class:`~synodic_client.client.Client` instance).

Returns:
The resolved :class:`~packaging.version.Version`.
"""
updater = getattr(client, 'updater', None)
if updater is not None:
try:
if updater.is_installed:
return updater.current_version
except Exception:
logger.debug('Failed to query Velopack version, falling back', exc_info=True)

return getattr(client, 'version', Version('0.0.0'))


def resolve_enabled_plugins(
config: ResolvedConfig,
all_plugin_names: list[str],
Expand Down
34 changes: 34 additions & 0 deletions synodic_client/updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,40 @@
_DEV_RELEASE_TAG = 'dev'


def pep440_to_semver(version_string: str) -> str:
"""Convert a PEP 440 version string to a SemVer string for Velopack.

Velopack requires strict SemVer (``MAJOR.MINOR.PATCH[-pre.N]``) while
Python tooling produces PEP 440 (e.g. ``0.1.dev47+g799543c``). This
function bridges the two:

* Normalises the base to three components (``0.1`` → ``0.1.0``).
* Converts ``.devN`` to ``-dev.N``.
* Strips local segments (``+g…``).
* Stable versions pass through unchanged (``1.0.0`` → ``1.0.0``).

Examples::

>>> pep440_to_semver('0.1.dev47+g799543c')
'0.1.0-dev.47'
>>> pep440_to_semver('0.1.1.dev3')
'0.1.1-dev.3'
>>> pep440_to_semver('1.0.0')
'1.0.0'

Args:
version_string: A PEP 440 version string.

Returns:
A SemVer-compatible version string.
"""
v = Version(version_string)
base = f'{v.major}.{v.minor}.{v.micro}'
if v.dev is not None:
return f'{base}-dev.{v.dev}'
return base


def github_release_asset_url(repo_url: str, channel: UpdateChannel) -> str:
"""Convert a GitHub repository URL into a release-asset download URL.

Expand Down
10 changes: 8 additions & 2 deletions tests/unit/qt/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ def _make_config(**overrides: Any) -> ResolvedConfig:
return ResolvedConfig(**defaults)


def _make_window(config: ResolvedConfig | None = None) -> SettingsWindow:
def _make_window(config: ResolvedConfig | None = None, version: str = '0.0.0.test') -> SettingsWindow:
"""Create a ``SettingsWindow`` without showing it."""
cfg = config or _make_config()
window = SettingsWindow(cfg)
window = SettingsWindow(cfg, version=version)
return window


Expand All @@ -60,6 +60,12 @@ def test_minimum_size() -> None:
assert window.minimumWidth() == SETTINGS_WINDOW_MIN_SIZE[0]
assert window.minimumHeight() == SETTINGS_WINDOW_MIN_SIZE[1]

@staticmethod
def test_version_label_displays_passed_version() -> None:
"""Version label shows the version string passed to the constructor."""
window = _make_window(version='1.2.3.dev42')
assert window._version == '1.2.3.dev42'


# ---------------------------------------------------------------------------
# sync_from_config
Expand Down
56 changes: 55 additions & 1 deletion tests/unit/test_client_version.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Tests for Client.version property behavior."""

import importlib.metadata
from unittest.mock import patch
from unittest.mock import MagicMock, PropertyMock, patch

from packaging.version import Version

Expand Down Expand Up @@ -42,3 +42,57 @@ def test_version_dev_format() -> None:
assert version == expected
assert version.dev == expected.dev
assert version.local == expected.local


class TestClientVersionResolution:
"""Verify Client.version prefers Velopack when available."""

@staticmethod
def test_returns_velopack_version_when_installed() -> None:
"""Velopack version is preferred when an installed updater exists."""
mock_updater = MagicMock()
mock_updater.is_installed = True
mock_updater.current_version = Version('5.6.7')

client = Client()
client._updater = mock_updater

assert client.version == Version('5.6.7')

@staticmethod
def test_falls_back_when_not_installed() -> None:
"""Metadata version is used when the updater is not Velopack-installed."""
mock_updater = MagicMock()
mock_updater.is_installed = False

client = Client()
client._updater = mock_updater

with patch.object(importlib.metadata, 'version', return_value='1.0.0.dev1'):
version = client.version

assert version == Version('1.0.0.dev1')

@staticmethod
def test_falls_back_when_no_updater() -> None:
"""Metadata version is used when the updater has not been initialized."""
client = Client()

with patch.object(importlib.metadata, 'version', return_value='2.3.4'):
version = client.version

assert version == Version('2.3.4')

@staticmethod
def test_falls_back_on_exception() -> None:
"""Graceful fallback when querying the updater raises an exception."""
mock_updater = MagicMock()
type(mock_updater).is_installed = PropertyMock(side_effect=RuntimeError('boom'))

client = Client()
client._updater = mock_updater

with patch.object(importlib.metadata, 'version', return_value='3.0.0'):
version = client.version

assert version == Version('3.0.0')
Loading