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
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion synodic_client/application/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,14 @@
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)

screen = Screen()

app.tray = TrayScreen(app, client, icon, screen.window)

Check failure on line 47 in synodic_client/application/qt.py

View workflow job for this annotation

GitHub Actions / check / lint

Pyrefly missing-attribute

Object of class `QApplication` has no attribute `tray`

app.exec_()

Expand Down
3 changes: 2 additions & 1 deletion synodic_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions synodic_client/schema.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Schema for the client"""

from enum import Enum
from enum import StrEnum

from pydantic import BaseModel

Expand All @@ -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'
Expand Down
5 changes: 3 additions & 2 deletions synodic_client/updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -132,7 +133,7 @@
result = self._porringer.update.check(params)

if result.available and result.latest_version:
latest = Version(result.latest_version)

Check failure on line 136 in synodic_client/updater.py

View workflow job for this annotation

GitHub Actions / check / lint

Pyrefly bad-argument-type

Argument `Version` is not assignable to parameter `version` with type `str` in function `packaging.version.Version.__init__`
self._update_info = UpdateInfo(
available=True,
current_version=self._current_version,
Expand Down Expand Up @@ -160,7 +161,7 @@
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:
Expand Down Expand Up @@ -238,7 +239,7 @@
except Exception as e:
logger.exception('Failed to apply update')
self._state = UpdateState.ROLLBACK_REQUIRED
self._update_info.error = str(e)

Check failure on line 242 in synodic_client/updater.py

View workflow job for this annotation

GitHub Actions / check / lint

Pyrefly missing-attribute

Object of class `NoneType` has no attribute `error`
return False

def rollback(self) -> bool:
Expand Down Expand Up @@ -373,7 +374,7 @@
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:
Expand Down Expand Up @@ -410,7 +411,7 @@
return self._apply_windows_update(current_exe, new_exe, backup_path)
else:
# Unix: Can replace executable while running
shutil.copy2(new_exe, current_exe)

Check failure on line 414 in synodic_client/updater.py

View workflow job for this annotation

GitHub Actions / check / lint

Pyrefly no-matching-overload

No matching overload found for function `shutil.copy2` called with arguments: (Path | None, Path) Possible overloads: (src: StrPath, dst: _StrPathT, *, follow_symlinks: bool = True) -> str | _StrPathT [closest match] (src: BytesPath, dst: _BytesPathT, *, follow_symlinks: bool = True) -> bytes | _BytesPathT
self._state = UpdateState.APPLIED
logger.info('Update applied successfully')
return True
Expand Down Expand Up @@ -447,9 +448,9 @@
script_path.write_text(script_content)

# Schedule the script to run
subprocess.Popen(
['cmd', '/c', str(script_path)],
creationflags=subprocess.CREATE_NEW_CONSOLE | subprocess.DETACHED_PROCESS,

Check failure on line 453 in synodic_client/updater.py

View workflow job for this annotation

GitHub Actions / check / lint

Pyrefly missing-attribute

No attribute `DETACHED_PROCESS` in module `subprocess`

Check failure on line 453 in synodic_client/updater.py

View workflow job for this annotation

GitHub Actions / check / lint

Pyrefly missing-attribute

No attribute `CREATE_NEW_CONSOLE` in module `subprocess`
)

Check failure on line 454 in synodic_client/updater.py

View workflow job for this annotation

GitHub Actions / check / lint

Pyrefly no-matching-overload

No matching overload found for function `subprocess.Popen.__init__` called with arguments: (list[str], creationflags=type[Any]) Possible overloads: (args: _CMD, bufsize: int = -1, executable: PathLike[bytes] | PathLike[str] | bytes | str | None = None, stdin: IO[Any] | int | None = None, stdout: IO[Any] | int | None = None, stderr: IO[Any] | int | None = None, preexec_fn: (() -> Any) | None = None, close_fds: bool = True, shell: bool = False, cwd: PathLike[bytes] | PathLike[str] | bytes | str | None = None, env: Mapping[bytes, StrOrBytesPath] | Mapping[str, StrOrBytesPath] | None = None, universal_newlines: bool | None = None, startupinfo: Any | None = None, creationflags: int = 0, restore_signals: bool = True, start_new_session: bool = False, pass_fds: Collection[int] = ..., *, text: bool | None = None, encoding: str, errors: str | None = None, user: int | str | None = None, group: int | str | None = None, extra_groups: Iterable[int | str] | None = None, umask: int = -1, pipesize: int = -1, process_group: int | None = None) -> None (args: _CMD, bufsize: int = -1, executable: PathLike[bytes] | PathLike[str] | bytes | str | None = None, stdin: IO[Any] | int | None = None, stdout: IO[Any] | int | None = None, stderr: IO[Any] | int | None = None, preexec_fn: (() -> Any) | None = None, close_fds: bool = True, shell: bool = False, cwd: PathLike[bytes] | PathLike[str] | bytes | str | None = None, env: Mapping[bytes, StrOrBytesPath] | Mapping[str, StrOrBytesPath] | None = None, universal_newlines: bool | None = None, startupinfo: Any | None = None, creationflags: int = 0, restore_signals: bool = True, start_new_session: bool = False, pass_fds: Collection[int] = ..., *, text: bool | None = None, encoding: str | None = None, errors: str, user: int | str | None = None, group: int | str | None = None, extra_groups: Iterable[int | str] | None = None, umask: int = -1, pipesize: int = -1, process_group: int | None = None) -> None (args: _CMD, bufsize: int = -1, executable: PathLike[bytes] | PathLike[str] | bytes | str | None = None, stdin: IO[Any] | int | None = None, stdout: IO[Any] | int | None = None, stderr: IO[Any] | int | None = None, preexec_fn: (() -> Any) | None = None, close_fds: bool = True, shell: bool = False, cwd: PathLike[bytes] | PathLike[str] | bytes | str | None = None, env: Mapping[bytes, StrOrBytesPath] | Mapping[str, StrOrBytesPath] | None = None, *, universal_newlines: Literal[True], startupinfo: Any | None = None, creationflags: int = 0, restore_signals: bool = True, start_new_session: bool = False, pass_fds: Collection[int] = ..., text: bool | None = None, encoding: str | None = None, errors: str | None = None, user: int | str | None = None, group: int | str | None = None, extra_groups: Iterable[int | str] | None = None, umask: int = -1, pipesize: int = -1, process_group: int | None = None) -> None (args: _CMD, bufsize: int = -1, executable: PathLike[bytes] | PathLike[str] | bytes | str | None = None, stdin: IO[Any] | int | None = None, stdout: IO[Any] | int | None = None, stderr: IO[Any] | int | None = None, preexec_fn: (() -> Any) | None = None, close_fds: bool = True, shell: bool = False, cwd: PathLike[bytes] | PathLike[str] | bytes | str | None = None, env: Mapping[bytes, StrOrBytesPath] | Mapping[str, StrOrBytesPath] | None = None, universal_newlines: bool | None = None, startupinfo: Any | None = None, creationflags: int = 0, restore_signals: bool = True, start_new_session: bool = False, pass_fds: Collection[int] = ..., *, text: Literal[True], encoding: str | None = None, errors: str | None = None, user: int | str | None = None, group: int | str | None = None, extra_groups: Iterable[int | str] | None = None, umask: int = -1, pipesize: int = -1, process_group: int | None = None) -> None (args: _CMD, bufsize: int = -1, executable: PathLike[bytes] | PathLike[str] | bytes | str | None = None, stdin: IO[Any] | int | None = None, stdout: IO[Any] | int | None = None, stderr: IO[Any] | int | None = None, preexec_fn: (() -> Any) | None = None, close_fds: bool = True, shell: bool = False, cwd: PathLike[b

self._state = UpdateState.APPLIED
Expand Down
61 changes: 35 additions & 26 deletions tests/unit/test_client_updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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
Expand Down
103 changes: 57 additions & 46 deletions tests/unit/test_updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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')

Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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',
Expand Down
Loading