From 1a78ffa2862e4622540965e12b8f2903e20fa8e7 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Tue, 3 Mar 2026 15:49:43 -0800 Subject: [PATCH] Simplify Versioning --- .github/workflows/release-build.yml | 26 +++++--- pyproject.toml | 17 +----- synodic_client/application/qt.py | 3 +- synodic_client/application/screen/settings.py | 6 +- synodic_client/application/screen/tray.py | 5 +- synodic_client/client.py | 14 ++++- synodic_client/resolution.py | 32 ---------- synodic_client/updater.py | 34 +++++++++++ tests/unit/qt/test_settings.py | 10 +++- tests/unit/test_client_version.py | 56 ++++++++++++++++- tests/unit/test_resolution.py | 60 +------------------ tests/unit/test_updater.py | 41 +++++++++++++ tool/scripts/common.py | 2 +- tool/scripts/package.py | 7 ++- 14 files changed, 185 insertions(+), 128 deletions(-) diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 85c04df..9920d64 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index ac2144e..f7fa189 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" @@ -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 diff --git a/synodic_client/application/qt.py b/synodic_client/application/qt.py index 31ca467..1e9f209 100644 --- a/synodic_client/application/qt.py +++ b/synodic_client/application/qt.py @@ -28,7 +28,6 @@ ResolvedConfig, resolve_config, resolve_update_config, - resolve_version, ) from synodic_client.updater import initialize_velopack @@ -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), diff --git a/synodic_client/application/screen/settings.py b/synodic_client/application/screen/settings.py index a2eead2..d6fbe19 100644 --- a/synodic_client/application/screen/settings.py +++ b/synodic_client/application/screen/settings.py @@ -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 @@ -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()) @@ -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) diff --git a/synodic_client/application/screen/tray.py b/synodic_client/application/screen/tray.py index 94e4de9..bf4e2c6 100644 --- a/synodic_client/application/screen/tray.py +++ b/synodic_client/application/screen/tray.py @@ -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 diff --git a/synodic_client/client.py b/synodic_client/client.py index c25b68e..6ac056e 100644 --- a/synodic_client/client.py +++ b/synodic_client/client.py @@ -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: diff --git a/synodic_client/resolution.py b/synodic_client/resolution.py index b716f89..f3a11c3 100644 --- a/synodic_client/resolution.py +++ b/synodic_client/resolution.py @@ -18,8 +18,6 @@ import sys from dataclasses import dataclass -from packaging.version import Version - from synodic_client.config import ( UserConfig, load_build_config, @@ -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], diff --git a/synodic_client/updater.py b/synodic_client/updater.py index 4b0b922..9bbc4a3 100644 --- a/synodic_client/updater.py +++ b/synodic_client/updater.py @@ -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. diff --git a/tests/unit/qt/test_settings.py b/tests/unit/qt/test_settings.py index d3a63f4..c7a9832 100644 --- a/tests/unit/qt/test_settings.py +++ b/tests/unit/qt/test_settings.py @@ -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 @@ -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 diff --git a/tests/unit/test_client_version.py b/tests/unit/test_client_version.py index aed2580..7e6eb11 100644 --- a/tests/unit/test_client_version.py +++ b/tests/unit/test_client_version.py @@ -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 @@ -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') diff --git a/tests/unit/test_resolution.py b/tests/unit/test_resolution.py index c954171..3742ec0 100644 --- a/tests/unit/test_resolution.py +++ b/tests/unit/test_resolution.py @@ -2,9 +2,7 @@ from pathlib import Path from typing import Any -from unittest.mock import MagicMock, PropertyMock, patch - -from packaging.version import Version +from unittest.mock import patch from synodic_client.config import BuildConfig, UserConfig from synodic_client.resolution import ( @@ -13,7 +11,6 @@ resolve_config, resolve_enabled_plugins, resolve_update_config, - resolve_version, seed_user_config_from_build, update_user_config, ) @@ -425,58 +422,3 @@ def test_disabled_intervals() -> None: result = resolve_update_config(config) assert result.auto_update_interval_minutes == 0 assert result.tool_update_interval_minutes == 0 - - -# --------------------------------------------------------------------------- -# resolve_version -# --------------------------------------------------------------------------- - - -class TestResolveVersion: - """Tests for resolve_version.""" - - @staticmethod - def test_returns_velopack_version_when_installed() -> None: - """Verify the Velopack version is preferred when a manager is present.""" - mock_updater = MagicMock() - mock_updater.is_installed = True - mock_updater.current_version = Version('5.6.7') - - mock_client = MagicMock() - mock_client.updater = mock_updater - mock_client.version = Version('1.0.0.dev1') - - assert resolve_version(mock_client) == Version('5.6.7') - - @staticmethod - def test_falls_back_when_not_installed() -> None: - """Verify importlib.metadata version is used when not Velopack-installed.""" - mock_updater = MagicMock() - mock_updater.is_installed = False - - mock_client = MagicMock() - mock_client.updater = mock_updater - mock_client.version = Version('1.0.0.dev1') - - assert resolve_version(mock_client) == Version('1.0.0.dev1') - - @staticmethod - def test_falls_back_when_no_updater() -> None: - """Verify importlib.metadata version is used when updater is None.""" - mock_client = MagicMock() - mock_client.updater = None - mock_client.version = Version('2.3.4') - - assert resolve_version(mock_client) == Version('2.3.4') - - @staticmethod - def test_falls_back_on_exception() -> None: - """Verify graceful fallback when querying the updater raises.""" - mock_updater = MagicMock() - type(mock_updater).is_installed = PropertyMock(side_effect=RuntimeError('boom')) - - mock_client = MagicMock() - mock_client.updater = mock_updater - mock_client.version = Version('3.0.0') - - assert resolve_version(mock_client) == Version('3.0.0') diff --git a/tests/unit/test_updater.py b/tests/unit/test_updater.py index 7ec29cf..6110f7d 100644 --- a/tests/unit/test_updater.py +++ b/tests/unit/test_updater.py @@ -16,6 +16,7 @@ UpdateState, github_release_asset_url, initialize_velopack, + pep440_to_semver, platform_suffix, ) @@ -528,3 +529,43 @@ def test_success_promotes_version(updater: Updater) -> None: assert updater._current_version == Version('9.8.7') assert updater.current_version == Version('9.8.7') + + +class TestPep440ToSemver: + """Tests for pep440_to_semver conversion.""" + + @staticmethod + def test_dev_version_two_part_base() -> None: + """PDM SCM 2-part base normalises to 3-part SemVer.""" + assert pep440_to_semver('0.1.dev47+g799543c') == '0.1.0-dev.47' + + @staticmethod + def test_dev_version_three_part_base() -> None: + """Three-part dev version converts correctly.""" + assert pep440_to_semver('0.1.1.dev3') == '0.1.1-dev.3' + + @staticmethod + def test_stable_version() -> None: + """Stable version passes through unchanged.""" + assert pep440_to_semver('1.0.0') == '1.0.0' + + @staticmethod + def test_stable_version_two_part() -> None: + """Two-part stable normalises to three-part.""" + assert pep440_to_semver('1.0') == '1.0.0' + + @staticmethod + def test_dev_zero() -> None: + """Dev release number zero is preserved.""" + assert pep440_to_semver('0.1.0.dev0') == '0.1.0-dev.0' + + @staticmethod + def test_local_segment_stripped() -> None: + """Local segment (+gXXXXXXX) is stripped.""" + assert pep440_to_semver('1.2.3.dev10+gabcdef1') == '1.2.3-dev.10' + + @staticmethod + def test_semver_input_passthrough() -> None: + """SemVer-style pre-release input is normalised via PEP 440.""" + # packaging.version.Version normalises '0.1.0-dev.5' to '0.1.0.dev5' + assert pep440_to_semver('0.1.0-dev.5') == '0.1.0-dev.5' diff --git a/tool/scripts/common.py b/tool/scripts/common.py index 1c025cc..a875040 100644 --- a/tool/scripts/common.py +++ b/tool/scripts/common.py @@ -12,7 +12,7 @@ OUTPUT_DIR = REPO_ROOT / 'Releases' MAIN_EXE = 'synodic.exe' ICON_FILE = REPO_ROOT / 'data' / 'icon.ico' -PACK_ID = 'Synodic.SynodicClient' +PACK_ID = 'synodic' def run(cmd: list[str], *, description: str) -> None: diff --git a/tool/scripts/package.py b/tool/scripts/package.py index f11e550..c28e69f 100644 --- a/tool/scripts/package.py +++ b/tool/scripts/package.py @@ -19,7 +19,7 @@ import typer from synodic_client import __version__ -from synodic_client.updater import platform_suffix +from synodic_client.updater import pep440_to_semver, platform_suffix from tool.scripts.common import ICON_FILE, MAIN_EXE, OUTPUT_DIR, PACK_DIR, PACK_ID, build, kill_running_instances, run app = typer.Typer(help='Package Synodic Client with PyInstaller and Velopack.') @@ -45,7 +45,8 @@ def main( ) -> None: """Entry point for the packaging script.""" velopack_channel = f'{channel.value}-{platform_suffix()}' - print(f'Packaging Synodic Client v{__version__} (channel: {velopack_channel})') + pack_version = pep440_to_semver(__version__) + print(f'Packaging Synodic Client v{__version__} (pack version: {pack_version}, channel: {velopack_channel})') # Step 1: PyInstaller if not skip_pyinstaller: @@ -83,7 +84,7 @@ def main( '--packId', PACK_ID, '--packVersion', - __version__, + pack_version, '--packDir', str(PACK_DIR), '--mainExe',