diff --git a/pyproject.toml b/pyproject.toml index e5a58bc..54e7ddb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,9 @@ quote-style = "single" [tool.coverage.report] skip_empty = true +[tool.pdm.version] +source = "scm" + [tool.pdm.scripts] analyze = "ruff check synodic_client tests" format = "ruff format" diff --git a/synodic_client/application/qt.py b/synodic_client/application/qt.py index 00cf73f..566a0f5 100644 --- a/synodic_client/application/qt.py +++ b/synodic_client/application/qt.py @@ -37,7 +37,7 @@ def application() -> None: logger.info('Synodic Client v%s started (channel: %s)', client.version, update_channel.name) list_params = ListPluginsParameters() - list_results = porringer.plugin.list(list_params) + porringer.plugin.list(list_params) app = QApplication([]) app.setQuitOnLastWindowClosed(False) diff --git a/synodic_client/client.py b/synodic_client/client.py index c92c31a..20b5869 100644 --- a/synodic_client/client.py +++ b/synodic_client/client.py @@ -4,6 +4,7 @@ import importlib.metadata import logging +from collections.abc import Callable from contextlib import AbstractContextManager from importlib.resources import as_file, files from pathlib import Path @@ -90,7 +91,7 @@ def check_for_update(self) -> UpdateInfo | None: return self._updater.check_for_update() - def download_update(self, progress_callback: callable | None = None) -> Path | None: + def download_update(self, progress_callback: Callable | None = None) -> Path | None: """Download an available update. Args: diff --git a/synodic_client/schema.py b/synodic_client/schema.py index 8cd3e48..08d93e1 100644 --- a/synodic_client/schema.py +++ b/synodic_client/schema.py @@ -1,6 +1,6 @@ """Schema for the client""" -from enum import Enum +from enum import StrEnum from pydantic import BaseModel @@ -11,14 +11,14 @@ class VersionInformation(BaseModel): version: str -class UpdateChannel(str, Enum): +class UpdateChannel(StrEnum): """Update channel for selecting release types.""" STABLE = 'stable' DEVELOPMENT = 'development' -class UpdateStatus(str, Enum): +class UpdateStatus(StrEnum): """Status of an update check or operation.""" NO_UPDATE = 'no_update' diff --git a/synodic_client/updater.py b/synodic_client/updater.py index bc596bc..e5bb809 100644 --- a/synodic_client/updater.py +++ b/synodic_client/updater.py @@ -4,6 +4,7 @@ import shutil import subprocess import sys +from collections.abc import Callable from dataclasses import dataclass, field from enum import Enum, auto from pathlib import Path @@ -160,7 +161,7 @@ def check_for_update(self) -> UpdateInfo: error=str(e), ) - def download_update(self, progress_callback: callable | None = None) -> Path | None: + def download_update(self, progress_callback: Callable | None = None) -> Path | None: """Download the update artifact using TUF for verification. Args: @@ -373,7 +374,7 @@ def _get_backup_path(self) -> Path: exe_name = self.executable_path.name return self._config.backup_dir / f'{exe_name}.backup' - def _download_direct(self, download_path: Path, progress_callback: callable | None = None) -> None: + def _download_direct(self, download_path: Path, progress_callback: Callable | None = None) -> None: """Download update directly via porringer (fallback for dev mode). Args: diff --git a/tests/unit/test_client_updater.py b/tests/unit/test_client_updater.py index b85bf30..c7d487a 100644 --- a/tests/unit/test_client_updater.py +++ b/tests/unit/test_client_updater.py @@ -9,34 +9,38 @@ from synodic_client.updater import UpdateConfig -class TestClientUpdater: - """Tests for Client update methods.""" +@pytest.fixture +def mock_porringer_api() -> MagicMock: + """Create a mock porringer API.""" + api = MagicMock() + api.update = MagicMock() + return api + + +@pytest.fixture +def client_with_updater(mock_porringer_api: MagicMock, tmp_path: Path) -> Client: + """Create a Client with initialized updater.""" + client = Client() + config = UpdateConfig( + metadata_dir=tmp_path / 'metadata', + download_dir=tmp_path / 'downloads', + backup_dir=tmp_path / 'backup', + ) + client.initialize_updater(mock_porringer_api, config) + return client - @pytest.fixture - def mock_porringer_api(self) -> MagicMock: - """Create a mock porringer API.""" - api = MagicMock() - api.update = MagicMock() - return api - @pytest.fixture - def client_with_updater(self, mock_porringer_api: MagicMock, tmp_path: Path) -> Client: - """Create a Client with initialized updater.""" - client = Client() - config = UpdateConfig( - metadata_dir=tmp_path / 'metadata', - download_dir=tmp_path / 'downloads', - backup_dir=tmp_path / 'backup', - ) - client.initialize_updater(mock_porringer_api, config) - return client +class TestClientUpdater: + """Tests for Client update methods.""" - def test_updater_not_initialized(self) -> None: + @staticmethod + def test_updater_not_initialized() -> None: """Verify updater is None before initialization.""" client = Client() assert client.updater is None - def test_initialize_updater(self, mock_porringer_api: MagicMock, tmp_path: Path) -> None: + @staticmethod + def test_initialize_updater(mock_porringer_api: MagicMock, tmp_path: Path) -> None: """Verify updater can be initialized.""" client = Client() config = UpdateConfig( @@ -50,13 +54,15 @@ def test_initialize_updater(self, mock_porringer_api: MagicMock, tmp_path: Path) assert client.updater is not None assert updater is client.updater - def test_check_for_update_without_init(self) -> None: + @staticmethod + def test_check_for_update_without_init() -> None: """Verify check_for_update returns None when updater not initialized.""" client = Client() result = client.check_for_update() assert result is None - def test_check_for_update_with_init(self, client_with_updater: Client, mock_porringer_api: MagicMock) -> None: + @staticmethod + def test_check_for_update_with_init(client_with_updater: Client, mock_porringer_api: MagicMock) -> None: """Verify check_for_update delegates to updater.""" mock_result = MagicMock() mock_result.available = False @@ -68,19 +74,22 @@ def test_check_for_update_with_init(self, client_with_updater: Client, mock_porr assert result is not None assert result.available is False - def test_download_update_without_init(self) -> None: + @staticmethod + def test_download_update_without_init() -> None: """Verify download_update returns None when updater not initialized.""" client = Client() result = client.download_update() assert result is None - def test_apply_update_without_init(self) -> None: + @staticmethod + def test_apply_update_without_init() -> None: """Verify apply_update returns False when updater not initialized.""" client = Client() result = client.apply_update() assert result is False - def test_restart_for_update_without_init(self) -> None: + @staticmethod + def test_restart_for_update_without_init() -> None: """Verify restart_for_update does nothing when updater not initialized.""" client = Client() # Should not raise diff --git a/tests/unit/test_updater.py b/tests/unit/test_updater.py index c8c8b85..3907a3e 100644 --- a/tests/unit/test_updater.py +++ b/tests/unit/test_updater.py @@ -21,12 +21,12 @@ class TestUpdateChannel: @staticmethod def test_stable_channel_exists() -> None: """Verify STABLE channel is defined.""" - assert UpdateChannel.STABLE is not None + assert hasattr(UpdateChannel, 'STABLE') @staticmethod def test_development_channel_exists() -> None: """Verify DEVELOPMENT channel is defined.""" - assert UpdateChannel.DEVELOPMENT is not None + assert hasattr(UpdateChannel, 'DEVELOPMENT') class TestUpdateState: @@ -125,51 +125,58 @@ def test_default_paths(tmp_path: Path) -> None: assert '.synodic' in str(config.backup_dir) -class TestUpdater: - """Tests for Updater class.""" +@pytest.fixture +def mock_porringer_api() -> MagicMock: + """Create a mock porringer API.""" + api = MagicMock() + api.update = MagicMock() + return api - @pytest.fixture - def mock_porringer_api(self) -> MagicMock: - """Create a mock porringer API.""" - api = MagicMock() - api.update = MagicMock() - return api - @pytest.fixture - def updater(self, mock_porringer_api: MagicMock, tmp_path: Path) -> Updater: - """Create an Updater instance with temporary directories.""" - config = UpdateConfig( - metadata_dir=tmp_path / 'metadata', - download_dir=tmp_path / 'downloads', - backup_dir=tmp_path / 'backup', - ) - return Updater( - current_version=Version('1.0.0'), - porringer_api=mock_porringer_api, - config=config, - ) +@pytest.fixture +def updater(mock_porringer_api: MagicMock, tmp_path: Path) -> Updater: + """Create an Updater instance with temporary directories.""" + config = UpdateConfig( + metadata_dir=tmp_path / 'metadata', + download_dir=tmp_path / 'downloads', + backup_dir=tmp_path / 'backup', + ) + return Updater( + current_version=Version('1.0.0'), + porringer_api=mock_porringer_api, + config=config, + ) + - def test_initial_state(self, updater: Updater) -> None: +class TestUpdater: + """Tests for Updater class.""" + + @staticmethod + def test_initial_state(updater: Updater) -> None: """Verify updater starts in NO_UPDATE state.""" assert updater.state == UpdateState.NO_UPDATE - def test_directories_created(self, updater: Updater) -> None: + @staticmethod + def test_directories_created(updater: Updater) -> None: """Verify configuration directories are created on init.""" assert updater._config.metadata_dir.exists() assert updater._config.download_dir.exists() assert updater._config.backup_dir.exists() - def test_is_frozen_property(self, updater: Updater) -> None: + @staticmethod + def test_is_frozen_property(updater: Updater) -> None: """Verify is_frozen returns False in test environment.""" # Tests run in non-frozen environment assert updater.is_frozen is False - def test_executable_path_not_frozen(self, updater: Updater) -> None: + @staticmethod + def test_executable_path_not_frozen(updater: Updater) -> None: """Verify executable_path returns a Path in non-frozen mode.""" path = updater.executable_path assert isinstance(path, Path) - def test_check_for_update_no_update(self, updater: Updater, mock_porringer_api: MagicMock) -> None: + @staticmethod + def test_check_for_update_no_update(updater: Updater, mock_porringer_api: MagicMock) -> None: """Verify check_for_update handles no update available.""" mock_result = MagicMock() mock_result.available = False @@ -182,7 +189,8 @@ def test_check_for_update_no_update(self, updater: Updater, mock_porringer_api: assert info.current_version == Version('1.0.0') assert updater.state == UpdateState.NO_UPDATE - def test_check_for_update_available(self, updater: Updater, mock_porringer_api: MagicMock) -> None: + @staticmethod + def test_check_for_update_available(updater: Updater, mock_porringer_api: MagicMock) -> None: """Verify check_for_update handles update available.""" mock_result = MagicMock() mock_result.available = True @@ -196,7 +204,8 @@ def test_check_for_update_available(self, updater: Updater, mock_porringer_api: assert info.latest_version == Version('2.0.0') assert updater.state == UpdateState.UPDATE_AVAILABLE - def test_check_for_update_error(self, updater: Updater, mock_porringer_api: MagicMock) -> None: + @staticmethod + def test_check_for_update_error(updater: Updater, mock_porringer_api: MagicMock) -> None: """Verify check_for_update handles errors gracefully.""" mock_porringer_api.update.check.side_effect = Exception('Network error') @@ -206,27 +215,32 @@ def test_check_for_update_error(self, updater: Updater, mock_porringer_api: Magi assert info.error == 'Network error' assert updater.state == UpdateState.FAILED - def test_download_update_no_update_available(self, updater: Updater) -> None: + @staticmethod + def test_download_update_no_update_available(updater: Updater) -> None: """Verify download_update fails when no update is available.""" result = updater.download_update() assert result is None - def test_apply_update_no_download(self, updater: Updater) -> None: + @staticmethod + def test_apply_update_no_download(updater: Updater) -> None: """Verify apply_update fails when no update is downloaded.""" result = updater.apply_update() assert result is False - def test_rollback_no_backup(self, updater: Updater) -> None: + @staticmethod + def test_rollback_no_backup(updater: Updater) -> None: """Verify rollback fails when no backup exists.""" result = updater.rollback() assert result is False - def test_cleanup_backup_no_backup(self, updater: Updater) -> None: + @staticmethod + def test_cleanup_backup_no_backup(updater: Updater) -> None: """Verify cleanup_backup handles missing backup gracefully.""" # Should not raise updater.cleanup_backup() - def test_cleanup_backup_with_backup(self, updater: Updater) -> None: + @staticmethod + def test_cleanup_backup_with_backup(updater: Updater) -> None: """Verify cleanup_backup removes existing backup.""" backup_path = updater._get_backup_path() backup_path.parent.mkdir(parents=True, exist_ok=True) @@ -236,7 +250,8 @@ def test_cleanup_backup_with_backup(self, updater: Updater) -> None: assert not backup_path.exists() - def test_get_target_name_windows(self, updater: Updater, mock_porringer_api: MagicMock) -> None: + @staticmethod + def test_get_target_name_windows(updater: Updater, mock_porringer_api: MagicMock) -> None: """Verify target name generation for Windows.""" # Set up update info mock_result = MagicMock() @@ -250,7 +265,8 @@ def test_get_target_name_windows(self, updater: Updater, mock_porringer_api: Mag target_name = updater._get_target_name() assert target_name == 'synodic-2.0.0-windows-x64.exe' - def test_get_target_name_linux(self, updater: Updater, mock_porringer_api: MagicMock) -> None: + @staticmethod + def test_get_target_name_linux(updater: Updater, mock_porringer_api: MagicMock) -> None: """Verify target name generation for Linux.""" mock_result = MagicMock() mock_result.available = True @@ -263,7 +279,8 @@ def test_get_target_name_linux(self, updater: Updater, mock_porringer_api: Magic target_name = updater._get_target_name() assert target_name == 'synodic-2.0.0-linux-x64' - def test_get_target_name_macos(self, updater: Updater, mock_porringer_api: MagicMock) -> None: + @staticmethod + def test_get_target_name_macos(updater: Updater, mock_porringer_api: MagicMock) -> None: """Verify target name generation for macOS.""" mock_result = MagicMock() mock_result.available = True @@ -280,14 +297,8 @@ def test_get_target_name_macos(self, updater: Updater, mock_porringer_api: Magic class TestUpdaterIntegration: """Integration tests for the full update workflow.""" - @pytest.fixture - def mock_porringer_api(self) -> MagicMock: - """Create a mock porringer API.""" - api = MagicMock() - api.update = MagicMock() - return api - - def test_full_update_check_workflow(self, mock_porringer_api: MagicMock, tmp_path: Path) -> None: + @staticmethod + def test_full_update_check_workflow(mock_porringer_api: MagicMock, tmp_path: Path) -> None: """Test the complete update check workflow.""" config = UpdateConfig( metadata_dir=tmp_path / 'metadata',