diff --git a/synodic_client/application/bootstrap.py b/synodic_client/application/bootstrap.py index 8e91d51..d155bcc 100644 --- a/synodic_client/application/bootstrap.py +++ b/synodic_client/application/bootstrap.py @@ -18,7 +18,7 @@ from synodic_client.config import set_dev_mode from synodic_client.logging import configure_logging from synodic_client.protocol import register_protocol -from synodic_client.resolution import resolve_auto_start, resolve_config +from synodic_client.resolution import resolve_config, seed_user_config_from_build from synodic_client.startup import register_startup, remove_startup from synodic_client.updater import initialize_velopack @@ -32,10 +32,13 @@ initialize_velopack() if not _dev_mode: + # Seed user config from the build config (one-time propagation). + seed_user_config_from_build() + register_protocol(sys.executable) _config = resolve_config() - if resolve_auto_start(_config): + if _config.auto_start: register_startup(sys.executable) else: remove_startup() diff --git a/synodic_client/application/qt.py b/synodic_client/application/qt.py index 4f08c57..bc1e2ff 100644 --- a/synodic_client/application/qt.py +++ b/synodic_client/application/qt.py @@ -21,15 +21,21 @@ from synodic_client.application.screen.tray import TrayScreen from synodic_client.application.uri import parse_uri from synodic_client.client import Client -from synodic_client.config import GlobalConfiguration, set_dev_mode +from synodic_client.config import set_dev_mode from synodic_client.logging import configure_logging from synodic_client.protocol import register_protocol -from synodic_client.resolution import resolve_auto_start, resolve_config, resolve_update_config +from synodic_client.resolution import ( + ResolvedConfig, + resolve_config, + resolve_update_config, + resolve_version, + seed_user_config_from_build, +) from synodic_client.startup import register_startup, remove_startup from synodic_client.updater import initialize_velopack -def _init_services(logger: logging.Logger) -> tuple[Client, API, GlobalConfiguration]: +def _init_services(logger: logging.Logger) -> tuple[Client, API, ResolvedConfig]: """Create and configure core services. Returns: @@ -47,11 +53,10 @@ def _init_services(logger: logging.Logger) -> tuple[Client, API, GlobalConfigura cached_dirs = porringer.cache.list_directories() logger.info( - 'Synodic Client v%s started (channel: %s, source: %s, config_fields_set: %s, cached_projects: %d)', - client.version, + 'Synodic Client v%s started (channel: %s, source: %s, cached_projects: %d)', + resolve_version(client), update_config.channel.name, update_config.repo_url, - sorted(config.model_fields_set), len(cached_dirs), ) @@ -140,8 +145,11 @@ def application(*, uri: str | None = None, dev_mode: bool = False) -> None: initialize_velopack() register_protocol(sys.executable) + # Seed user config from build config (one-time propagation). + seed_user_config_from_build() + startup_config = resolve_config() - if resolve_auto_start(startup_config): + if startup_config.auto_start: register_startup(sys.executable) else: remove_startup() diff --git a/synodic_client/application/screen/install.py b/synodic_client/application/screen/install.py index e38e744..138c25e 100644 --- a/synodic_client/application/screen/install.py +++ b/synodic_client/application/screen/install.py @@ -72,7 +72,7 @@ MUTED_STYLE, NO_MARGINS, ) -from synodic_client.config import GlobalConfiguration, save_config +from synodic_client.resolution import ResolvedConfig, update_user_config logger = logging.getLogger(__name__) @@ -332,7 +332,7 @@ def __init__( parent: QWidget | None = None, *, show_close: bool = True, - config: GlobalConfiguration | None = None, + config: ResolvedConfig | None = None, ) -> None: """Initialize the preview widget. @@ -579,14 +579,14 @@ def _flush_prerelease_overrides(self) -> None: if self._config is None or self._manifest_key is None: return - pkgs = self._config.prerelease_packages or {} + pkgs = dict(self._config.prerelease_packages or {}) if self._prerelease_overrides: pkgs[self._manifest_key] = sorted(self._prerelease_overrides) else: pkgs.pop(self._manifest_key, None) - self._config.prerelease_packages = pkgs if pkgs else None - save_config(self._config) + new_value = pkgs if pkgs else None + self._config = update_user_config(prerelease_packages=new_value) logger.info('Pre-release overrides for %s: %s', self._manifest_key, self._prerelease_overrides) if not self._installing: @@ -918,7 +918,7 @@ def __init__( manifest_url: str, parent: QWidget | None = None, *, - config: GlobalConfiguration | None = None, + config: ResolvedConfig | None = None, ) -> None: """Initialize the install preview window. @@ -926,13 +926,13 @@ def __init__( porringer: The porringer API instance. manifest_url: The URL of the manifest to install. parent: Optional parent widget. - config: Resolved global configuration for per-manifest pre-release + config: Resolved configuration for per-manifest pre-release state and update detection flags. """ super().__init__(parent) self._porringer = porringer self._manifest_url = manifest_url - self._config = config or GlobalConfiguration() + self._config = config self._temp_dir_path: str | None = None self._runner: QThread | None = None @@ -1038,13 +1038,16 @@ def start(self) -> None: self._preview_widget.set_manifest_key(self._manifest_url) manifest_key = normalize_manifest_key(self._manifest_url) - overrides = set((self._config.prerelease_packages or {}).get(manifest_key, [])) + config = self._config + if config is None: + return + overrides = set((config.prerelease_packages or {}).get(manifest_key, [])) preview_worker = PreviewWorker( self._porringer, self._manifest_url, project_directory=self._project_directory, - detect_updates=self._config.detect_updates, + detect_updates=config.detect_updates, prerelease_packages=overrides or None, ) diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 29bd476..a574abd 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -53,7 +53,7 @@ PLUGIN_UPDATE_STYLE, SETTINGS_GEAR_STYLE, ) -from synodic_client.config import GlobalConfiguration, save_config +from synodic_client.resolution import ResolvedConfig, update_user_config logger = logging.getLogger(__name__) @@ -306,14 +306,14 @@ class PluginsView(QWidget): def __init__( self, porringer: API, - config: GlobalConfiguration, + config: ResolvedConfig, parent: QWidget | None = None, ) -> None: """Initialize the plugins view. Args: porringer: The porringer API instance. - config: Resolved global configuration (for auto-update toggles). + config: Resolved configuration (for auto-update toggles). parent: Optional parent widget. """ super().__init__(parent) @@ -484,10 +484,7 @@ def _gather_packages( def _on_auto_update_toggled(self, plugin_name: str, enabled: bool) -> None: """Persist the auto-update toggle change to config.""" - mapping = self._config.plugin_auto_update - if mapping is None: - mapping = {} - self._config.plugin_auto_update = mapping + mapping = dict(self._config.plugin_auto_update or {}) if enabled: mapping.pop(plugin_name, None) @@ -495,10 +492,8 @@ def _on_auto_update_toggled(self, plugin_name: str, enabled: bool) -> None: mapping[plugin_name] = False # Clean up the dict if all plugins are enabled - if not mapping: - self._config.plugin_auto_update = None - - save_config(self._config) + new_value = mapping if mapping else None + self._config = update_user_config(plugin_auto_update=new_value) logger.info('Auto-update for %s set to %s', plugin_name, enabled) @@ -510,12 +505,12 @@ class ProjectsView(QWidget): install execution. """ - def __init__(self, porringer: API, config: GlobalConfiguration, parent: QWidget | None = None) -> None: + def __init__(self, porringer: API, config: ResolvedConfig, parent: QWidget | None = None) -> None: """Initialize the projects view. Args: porringer: The porringer API instance. - config: Resolved global configuration. + config: Resolved configuration. parent: Optional parent widget. """ super().__init__(parent) @@ -805,17 +800,17 @@ class MainWindow(QMainWindow): def __init__( self, porringer: API | None = None, - config: GlobalConfiguration | None = None, + config: ResolvedConfig | None = None, ) -> None: """Initialize the main window. Args: porringer: Optional porringer API instance for manifest display. - config: Resolved global configuration for plugin auto-update state. + config: Resolved configuration for plugin auto-update state. """ super().__init__() self._porringer = porringer - self._config = config or GlobalConfiguration() + self._config = config self.setWindowTitle('Synodic Client') self.setMinimumSize(*MAIN_WINDOW_MIN_SIZE) self.setWindowIcon(app_icon()) @@ -832,7 +827,7 @@ def plugins_view(self) -> PluginsView | None: def show(self) -> None: """Show the window, initializing UI lazily on first show.""" - if self._tabs is None and self._porringer is not None: + if self._tabs is None and self._porringer is not None and self._config is not None: self._tabs = QTabWidget(self) self._projects_view = ProjectsView(self._porringer, self._config, self) @@ -867,13 +862,13 @@ class Screen: def __init__( self, porringer: API | None = None, - config: GlobalConfiguration | None = None, + config: ResolvedConfig | None = None, ) -> None: """Initialize the screen. Args: porringer: Optional porringer API instance. - config: Resolved global configuration. + config: Resolved configuration. """ self._porringer = porringer self._config = config diff --git a/synodic_client/application/screen/settings.py b/synodic_client/application/screen/settings.py index 5414642..78042ba 100644 --- a/synodic_client/application/screen/settings.py +++ b/synodic_client/application/screen/settings.py @@ -10,7 +10,7 @@ from collections.abc import Iterator from contextlib import contextmanager -from PySide6.QtCore import QUrl, Signal +from PySide6.QtCore import Qt, QUrl, Signal from PySide6.QtGui import QDesktopServices from PySide6.QtWidgets import ( QCheckBox, @@ -30,12 +30,10 @@ 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 -from synodic_client.config import GlobalConfiguration, save_config from synodic_client.logging import log_path +from synodic_client.resolution import ResolvedConfig, update_user_config from synodic_client.startup import is_startup_registered, register_startup, remove_startup from synodic_client.updater import ( - DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES, - DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES, GITHUB_REPO_URL, ) @@ -45,25 +43,26 @@ class SettingsWindow(QMainWindow): """Application settings window with grouped card sections. - All controls persist changes immediately via :func:`save_config` and - emit :attr:`settings_changed` so that the tray and updater can react. + All controls persist changes immediately via :func:`update_user_config` + and emit :attr:`settings_changed` so that the tray and updater can + react. The signal carries the new :class:`ResolvedConfig`. """ - settings_changed = Signal() - """Emitted whenever a setting is changed and persisted.""" + settings_changed = Signal(object) + """Emitted with the new ``ResolvedConfig`` whenever a setting is changed and persisted.""" check_updates_requested = Signal() """Emitted when the user clicks the *Check for Updates* button.""" def __init__( self, - config: GlobalConfiguration, + config: ResolvedConfig, parent: QWidget | None = None, ) -> None: """Initialise the settings window. Args: - config: The shared global configuration object. + config: The current resolved configuration snapshot. parent: Optional parent widget. """ super().__init__(parent) @@ -127,6 +126,12 @@ def _build_updates_section(self) -> CardFrame: row.addWidget(browse_btn) content.addLayout(row) + self._add_update_controls(content) + + return card + + def _add_update_controls(self, content: QVBoxLayout) -> None: + """Add interval spinners, detect-updates checkbox, and update button.""" # Auto-update interval row = QHBoxLayout() label = QLabel('App update interval (min)') @@ -164,12 +169,11 @@ def _build_updates_section(self) -> CardFrame: self._check_updates_btn.clicked.connect(self._on_check_updates_clicked) row.addWidget(self._check_updates_btn) self._update_status_label = QLabel('') + self._update_status_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) row.addWidget(self._update_status_label) row.addStretch() content.addLayout(row) - return card - def _build_startup_section(self) -> CardFrame: """Construct the *Startup* settings card.""" card = CardFrame('Startup') @@ -208,15 +212,9 @@ def sync_from_config(self) -> None: # Update source self._source_edit.setText(config.update_source or '') - # Intervals - auto_interval = config.auto_update_interval_minutes - self._auto_update_spin.setValue( - auto_interval if auto_interval is not None else DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES, - ) - tool_interval = config.tool_update_interval_minutes - self._tool_update_spin.setValue( - tool_interval if tool_interval is not None else DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES, - ) + # Intervals (already resolved to concrete ints) + self._auto_update_spin.setValue(config.auto_update_interval_minutes) + self._tool_update_spin.setValue(config.tool_update_interval_minutes) # Checkboxes self._detect_updates_check.setChecked(config.detect_updates) @@ -241,10 +239,14 @@ def show(self) -> None: # Callbacks # ------------------------------------------------------------------ - def _persist(self) -> None: - """Save config and notify listeners.""" - save_config(self._config) - self.settings_changed.emit() + def _persist(self, **changes: object) -> None: + """Save config changes and notify listeners. + + Args: + **changes: Field-name / value pairs to persist. + """ + self._config = update_user_config(**changes) + self.settings_changed.emit(self._config) @contextmanager def _block_signals(self) -> Iterator[None]: @@ -273,13 +275,11 @@ def _on_check_updates_clicked(self) -> None: self.check_updates_requested.emit() def _on_channel_changed(self, index: int) -> None: - self._config.update_channel = 'dev' if index == 1 else 'stable' - self._persist() + self._persist(update_channel='dev' if index == 1 else 'stable') def _on_source_changed(self) -> None: text = self._source_edit.text().strip() - self._config.update_source = text or None - self._persist() + self._persist(update_source=text or None) def _on_browse_source(self) -> None: path = QFileDialog.getExistingDirectory(self, 'Select Releases Directory') @@ -288,25 +288,21 @@ def _on_browse_source(self) -> None: self._on_source_changed() def _on_auto_update_interval_changed(self, value: int) -> None: - self._config.auto_update_interval_minutes = value - self._persist() + self._persist(auto_update_interval_minutes=value) def _on_tool_update_interval_changed(self, value: int) -> None: - self._config.tool_update_interval_minutes = value - self._persist() + self._persist(tool_update_interval_minutes=value) def _on_detect_updates_changed(self, checked: bool) -> None: - self._config.detect_updates = checked - self._persist() + self._persist(detect_updates=checked) def _on_auto_start_changed(self, checked: bool) -> None: - self._config.auto_start = checked - save_config(self._config) + self._config = update_user_config(auto_start=checked) if checked: register_startup(sys.executable) else: remove_startup() - self.settings_changed.emit() + self.settings_changed.emit(self._config) @staticmethod def _open_log() -> None: diff --git a/synodic_client/application/screen/tray.py b/synodic_client/application/screen/tray.py index c77c8bd..5c7bf23 100644 --- a/synodic_client/application/screen/tray.py +++ b/synodic_client/application/screen/tray.py @@ -20,12 +20,11 @@ from synodic_client.application.screen.settings import SettingsWindow from synodic_client.application.workers import ToolUpdateWorker, UpdateCheckWorker, UpdateDownloadWorker from synodic_client.client import Client -from synodic_client.config import GlobalConfiguration from synodic_client.resolution import ( + ResolvedConfig, resolve_config, resolve_enabled_plugins, resolve_update_config, - update_and_resolve, ) from synodic_client.updater import UpdateInfo @@ -40,7 +39,7 @@ def __init__( app: QApplication, client: Client, window: MainWindow, - config: GlobalConfiguration | None = None, + config: ResolvedConfig | None = None, ) -> None: """Initialize the tray icon. @@ -123,14 +122,14 @@ def _build_menu(self, app: QApplication, window: MainWindow) -> None: # -- Config helpers -- - def _resolve_config(self) -> GlobalConfiguration: + def _resolve_config(self) -> ResolvedConfig: """Return the injected config or resolve from disk.""" if self._config is not None: return self._config return resolve_config() + @staticmethod def _restart_timer( - self, current: QTimer | None, interval_minutes: int, slot: Callable[[], None], @@ -192,14 +191,21 @@ def _show_settings(self) -> None: """Show the settings window.""" self._settings_window.show() - def _on_settings_changed(self) -> None: + def _on_settings_changed(self, config: ResolvedConfig) -> None: """React to a change made in the settings window.""" - config = self._resolve_config() + self._config = config self._reinitialize_updater(config) - def _reinitialize_updater(self, config: GlobalConfiguration) -> None: - """Re-derive update settings and restart the updater and timers.""" - update_cfg = update_and_resolve(config) + def _reinitialize_updater(self, config: ResolvedConfig) -> None: + """Re-derive update settings and restart the updater and timers. + + The new ``Updater`` starts with the ``importlib.metadata`` + version which may be stale after a Velopack update. The + authoritative Velopack version is recovered automatically on + the first ``_get_velopack_manager()`` call (i.e. the next + update check), so no special handling is required here. + """ + update_cfg = resolve_update_config(config) self._client.initialize_updater(update_cfg) self._restart_auto_update_timer() self._restart_tool_update_timer() diff --git a/synodic_client/config.py b/synodic_client/config.py index de9c646..09f1387 100644 --- a/synodic_client/config.py +++ b/synodic_client/config.py @@ -2,16 +2,16 @@ Two configuration layers are supported: -- **LocalConfiguration** — a portable ``config.json`` next to the executable +- **BuildConfig** — a read-only ``config.json`` next to the executable (frozen builds only). Written by the packaging script for dev builds. - Fields set here override the global configuration. + Contains only ``update_source`` and ``update_channel``. -- **GlobalConfiguration** — a user-scoped ``config.json`` in the OS application +- **UserConfig** — a user-scoped ``config.json`` in the OS application data directory. On Windows this is ``%LOCALAPPDATA%/Synodic/config.json``. - Persisted by the Settings UI. + Persisted by the Settings UI. Always contains every field. -Merging and resolution of these layers is handled by -:mod:`synodic_client.resolution`. +Resolution of these layers into an immutable ``ResolvedConfig`` is handled +by :mod:`synodic_client.resolution`. """ import json @@ -52,8 +52,38 @@ def is_dev_mode() -> bool: return _dev_mode -class _ConfigBase(BaseModel): - """Shared fields for both configuration layers.""" +# --------------------------------------------------------------------------- +# BuildConfig — read-only, lives next to the executable +# --------------------------------------------------------------------------- + + +class BuildConfig(BaseModel): + """Read-only configuration embedded next to the executable. + + Written by the packaging script (e.g. ``pdm run package -- --local-source``). + Only contains the two fields the build system needs to seed. + """ + + # URL or local file path for Velopack releases. + update_source: str | None = None + + # Update channel: "stable" or "dev". + update_channel: str | None = None + + +# --------------------------------------------------------------------------- +# UserConfig — read-write, lives in the OS data directory +# --------------------------------------------------------------------------- + + +class UserConfig(BaseModel): + """User-scoped configuration persisted in the OS application data directory. + + On Windows: ``%LOCALAPPDATA%/Synodic/config.json``. + + Every field is always saved. There are no sparse/unset semantics — + the on-disk file is a complete snapshot of the user's preferences. + """ # URL or local file path for Velopack releases. # None means use the default GitHub release source. @@ -95,56 +125,35 @@ class _ConfigBase(BaseModel): auto_start: bool | None = None -class LocalConfiguration(_ConfigBase): - """Portable configuration embedded next to the executable. - - Written by the packaging script (e.g. ``pdm run package -- --local-source``). - Fields set here override the corresponding ``GlobalConfiguration`` values. - """ - - -class GlobalConfiguration(_ConfigBase): - """User-scoped configuration persisted in the OS application data directory. - - On Windows: ``%LOCALAPPDATA%/Synodic/config.json``. - """ +# --------------------------------------------------------------------------- +# File I/O +# --------------------------------------------------------------------------- -def _portable_config_path() -> Path | None: - """Return the path to a portable config file next to the executable, if it exists. +def load_build_config() -> BuildConfig | None: + """Load the portable build configuration next to the executable. - Only checked when running as a frozen (PyInstaller) build. + Only applicable when running as a frozen (PyInstaller) build and a + ``config.json`` file exists next to the executable. Returns: - Path to the portable config file, or None if not applicable. + The loaded build config, or ``None`` when not in a frozen build + or no portable config exists. """ if not getattr(sys, 'frozen', False): return None - exe_dir = Path(sys.executable).resolve().parent - candidate = exe_dir / _CONFIG_FILENAME - if candidate.exists(): - return candidate - return None - - -def _load_local_config() -> LocalConfiguration | None: - """Load the portable local configuration, if present. - - Returns: - The loaded local config, or None. - """ - portable = _portable_config_path() - if portable is None: + path = Path(sys.executable).resolve().parent / _CONFIG_FILENAME + if not path.exists(): return None try: - data = json.loads(portable.read_text(encoding='utf-8')) - config = LocalConfiguration.model_validate(data) - logger.debug('Loaded local config from %s', portable) + data = json.loads(path.read_text(encoding='utf-8')) + config = BuildConfig.model_validate(data) + logger.debug('Loaded build config from %s', path) return config except Exception: - logger.exception('Failed to load local config from %s', portable) + logger.exception('Failed to load build config from %s', path) return None @@ -170,35 +179,33 @@ def config_dir() -> Path: return Path.home() / f'.{app_name.lower()}' -def _load_global_config() -> GlobalConfiguration: - """Load the global configuration from the OS data directory. +def load_user_config() -> UserConfig: + """Load the user configuration from the OS data directory. Returns: - The loaded or default global configuration. + The loaded or default user configuration. """ path = config_dir() / _CONFIG_FILENAME if not path.exists(): - logger.debug('No global config at %s, using defaults', path) - return GlobalConfiguration() + logger.debug('No user config at %s, using defaults', path) + return UserConfig() try: data = json.loads(path.read_text(encoding='utf-8')) - config = GlobalConfiguration.model_validate(data) - logger.debug('Loaded global config from %s', path) + config = UserConfig.model_validate(data) + logger.debug('Loaded user config from %s', path) return config except Exception: - logger.exception('Failed to load global config from %s, using defaults', path) - return GlobalConfiguration() + logger.exception('Failed to load user config from %s, using defaults', path) + return UserConfig() -def save_config(config: GlobalConfiguration) -> None: +def save_user_config(config: UserConfig) -> None: """Save configuration to the global (system) config directory. - Only fields that have been explicitly set (either loaded from the - existing config file or changed at runtime) are written. This - sparse serialisation ensures that build-time local-config values - do not leak into the user's global config and that future defaults - can take effect for fields the user has not customised. + All fields are always written. The on-disk file is a complete + snapshot of the user's preferences so that no implicit state is + lost when builds change or new defaults are introduced. Args: config: The configuration to persist. @@ -209,7 +216,7 @@ def save_config(config: GlobalConfiguration) -> None: try: path.write_text( - config.model_dump_json(indent=2, exclude_unset=True), + config.model_dump_json(indent=2), encoding='utf-8', ) logger.info('Saved config to %s', path) diff --git a/synodic_client/resolution.py b/synodic_client/resolution.py index 215d25f..f04f1cb 100644 --- a/synodic_client/resolution.py +++ b/synodic_client/resolution.py @@ -1,19 +1,30 @@ """Configuration resolution for the Synodic Client. -Merges ``LocalConfiguration`` (portable, next to exe) and ``GlobalConfiguration`` -(user-scoped, ``%LOCALAPPDATA%``) into a single resolved configuration, then -derives runtime objects like ``UpdateConfig``. +Combines ``BuildConfig`` (read-only, next to exe) and ``UserConfig`` +(read-write, ``%LOCALAPPDATA%``) into an immutable ``ResolvedConfig`` +dataclass, then derives runtime objects like ``UpdateConfig``. + +Key design rules: +- ``ResolvedConfig`` is frozen — no mutation after construction. +- ``UserConfig`` always saves *all* fields (no sparse writes). +- ``BuildConfig`` seeds user config once, then stays out of the way. +- Settings UI calls ``update_user_config()`` to persist changes and + receive a new ``ResolvedConfig``. """ +from __future__ import annotations + import logging import sys +from dataclasses import dataclass + +from packaging.version import Version from synodic_client.config import ( - GlobalConfiguration, - LocalConfiguration, - _load_global_config, - _load_local_config, - save_config, + UserConfig, + load_build_config, + load_user_config, + save_user_config, ) from synodic_client.updater import ( DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES, @@ -27,87 +38,207 @@ logger = logging.getLogger(__name__) -def merge_config( - global_config: GlobalConfiguration, - local_config: LocalConfiguration | None, -) -> GlobalConfiguration: - """Merge local overrides into a global configuration. +# --------------------------------------------------------------------------- +# ResolvedConfig — immutable runtime snapshot +# --------------------------------------------------------------------------- - Fields that the user has explicitly saved (present in the global - config file) take priority over local overrides. Local config - fields only fill in values the user has **not** set. - The returned object preserves the global config's - ``model_fields_set`` so that :func:`save_config` can use - ``exclude_unset=True`` to write only user-changed fields. +@dataclass(frozen=True) +class ResolvedConfig: + """Immutable runtime configuration snapshot. + + Constructed by :func:`resolve_config` from the merged + ``BuildConfig`` + ``UserConfig`` layers. Every field has a + concrete, non-``None`` value (except ``update_source`` and + ``prerelease_packages`` where ``None`` is a valid semantic value + meaning "use default" / "no overrides"). + """ + + update_source: str | None + update_channel: str + auto_update_interval_minutes: int + tool_update_interval_minutes: int + plugin_auto_update: dict[str, bool] | None + detect_updates: bool + prerelease_packages: dict[str, list[str]] | None + auto_start: bool + + +# --------------------------------------------------------------------------- +# Seed — one-time build → user config propagation +# --------------------------------------------------------------------------- + + +def seed_user_config_from_build() -> None: + """Copy ``BuildConfig`` fields into ``UserConfig`` when they are still at defaults. + + Called once during bootstrap (before the UI) so that the build's + channel/source are persisted into the user config. If the user + has already customised a field, the build value is ignored. + """ + build = load_build_config() + if build is None: + return + + user = load_user_config() + changed = False + + if build.update_source is not None and user.update_source is None: + user.update_source = build.update_source + changed = True + + if build.update_channel is not None and user.update_channel is None: + user.update_channel = build.update_channel + changed = True + + if changed: + save_user_config(user) + logger.info( + 'Seeded user config from build config: source=%s, channel=%s', + user.update_source, + user.update_channel, + ) + + +# --------------------------------------------------------------------------- +# Resolution +# --------------------------------------------------------------------------- - Args: - global_config: The user-scoped global configuration. - local_config: The portable local configuration, or None. + +def _default_channel() -> str: + """Return the default channel based on whether we're running frozen.""" + return 'stable' if getattr(sys, 'frozen', False) else 'dev' + + +def resolve_config() -> ResolvedConfig: + """Load user config and return an immutable :class:`ResolvedConfig`. + + Build config is *not* consulted here — it should already have been + seeded via :func:`seed_user_config_from_build` at startup. Returns: - A ``GlobalConfiguration`` with merged values. + A fully resolved, immutable configuration snapshot. """ - if local_config is None: - return global_config + user = load_user_config() + return _resolve_from_user(user) + + +def _resolve_from_user(user: UserConfig) -> ResolvedConfig: + """Derive a ``ResolvedConfig`` from a ``UserConfig``. + + Resolves every ``None`` field to its concrete default. + """ + channel = user.update_channel or _default_channel() + + # Note: intervals use explicit None-checks because 0 is a valid + # value meaning "disabled" and `or` would incorrectly skip it. + auto_interval = user.auto_update_interval_minutes + if auto_interval is None: + auto_interval = DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES - user_set = global_config.model_fields_set - merged = global_config.model_dump() - for field_name, value in local_config.model_dump().items(): - if value is not None and field_name not in user_set: - merged[field_name] = value + tool_interval = user.tool_update_interval_minutes + if tool_interval is None: + tool_interval = DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES + + auto_start = user.auto_start if user.auto_start is not None else True - return GlobalConfiguration.model_construct(_fields_set=set(user_set), **merged) + return ResolvedConfig( + update_source=user.update_source, + update_channel=channel, + auto_update_interval_minutes=auto_interval, + tool_update_interval_minutes=tool_interval, + plugin_auto_update=user.plugin_auto_update, + detect_updates=user.detect_updates, + prerelease_packages=user.prerelease_packages, + auto_start=auto_start, + ) -def resolve_config() -> GlobalConfiguration: - """Load and merge both configuration layers. +# --------------------------------------------------------------------------- +# Mutation — save and re-resolve +# --------------------------------------------------------------------------- + + +def update_user_config(**changes: object) -> ResolvedConfig: + """Load user config, apply *changes*, save, and return a new ``ResolvedConfig``. + + This is the primary write path for the Settings UI. Each keyword + argument corresponds to a :class:`UserConfig` field name. + + Args: + **changes: Field-name / value pairs to apply to the user config. Returns: - A fully resolved ``GlobalConfiguration``. + A fresh :class:`ResolvedConfig` reflecting the saved state. """ - return merge_config(_load_global_config(), _load_local_config()) + user = load_user_config() + for field_name, value in changes.items(): + setattr(user, field_name, value) + save_user_config(user) + return _resolve_from_user(user) + + +# --------------------------------------------------------------------------- +# Derived helpers +# --------------------------------------------------------------------------- -def resolve_update_config(config: GlobalConfiguration) -> UpdateConfig: +def resolve_update_config(config: ResolvedConfig) -> UpdateConfig: """Derive an ``UpdateConfig`` from resolved configuration values. Args: - config: A resolved global configuration. + config: A resolved configuration snapshot. Returns: An ``UpdateConfig`` ready to initialise the updater. """ - is_dev = not getattr(sys, 'frozen', False) - - if config.update_channel is not None: - channel = UpdateChannel.DEVELOPMENT if config.update_channel == 'dev' else UpdateChannel.STABLE - else: - channel = UpdateChannel.DEVELOPMENT if is_dev else UpdateChannel.STABLE + channel = UpdateChannel.DEVELOPMENT if config.update_channel == 'dev' else UpdateChannel.STABLE repo_url = github_release_asset_url( config.update_source or GITHUB_REPO_URL, channel, ) - interval = config.auto_update_interval_minutes - if interval is None: - interval = DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES - - tool_interval = config.tool_update_interval_minutes - if tool_interval is None: - tool_interval = DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES - return UpdateConfig( channel=channel, repo_url=repo_url, - auto_update_interval_minutes=interval, - tool_update_interval_minutes=tool_interval, + auto_update_interval_minutes=config.auto_update_interval_minutes, + tool_update_interval_minutes=config.tool_update_interval_minutes, ) +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: # noqa: BLE001 + 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: GlobalConfiguration, + config: ResolvedConfig, all_plugin_names: list[str], ) -> list[str] | None: """Derive the include-list of plugins that should auto-update. @@ -117,7 +248,7 @@ def resolve_enabled_plugins( indicate "no filtering". Args: - config: A resolved global configuration. + config: A resolved configuration snapshot. all_plugin_names: Every known plugin name. Returns: @@ -132,35 +263,3 @@ def resolve_enabled_plugins( return None return [n for n in all_plugin_names if n not in disabled] - - -def resolve_auto_start(config: GlobalConfiguration) -> bool: - """Determine whether auto-startup should be enabled. - - ``None`` (the default) is treated as enabled. - - Args: - config: A resolved global configuration. - - Returns: - ``True`` when the application should register for auto-startup. - """ - if config.auto_start is None: - return True - return config.auto_start - - -def update_and_resolve(config: GlobalConfiguration) -> UpdateConfig: - """Save a modified global config and resolve it into an UpdateConfig. - - Convenience function for the Settings UI: persists the change, then - returns the derived ``UpdateConfig``. - - Args: - config: The modified global configuration to save. - - Returns: - An ``UpdateConfig`` derived from the saved configuration. - """ - save_config(config) - return resolve_update_config(config) diff --git a/synodic_client/updater.py b/synodic_client/updater.py index 310e7e5..5e73fc0 100644 --- a/synodic_client/updater.py +++ b/synodic_client/updater.py @@ -171,6 +171,15 @@ def __init__(self, current_version: Version, config: UpdateConfig | None = None) self._config.repo_url, ) + @property + def current_version(self) -> Version: + """Best-known application version. + + Returns the Velopack-installed version when available, otherwise + the version from Python package metadata passed at construction. + """ + return self._current_version + @property def state(self) -> UpdateState: """Current state of the update process.""" @@ -405,10 +414,17 @@ def _get_velopack_manager(self) -> Any: self._config.repo_url, options, ) + + # The Velopack-installed version is authoritative; Python + # package metadata may be stale after an in-place update. + self._current_version = Version( + self._velopack_manager.get_current_version(), + ) + logger.debug( 'Velopack manager created: app_id=%s, version=%s, portable=%s', self._velopack_manager.get_app_id(), - self._velopack_manager.get_current_version(), + self._current_version, self._velopack_manager.get_is_portable(), ) return self._velopack_manager diff --git a/tests/unit/qt/test_settings.py b/tests/unit/qt/test_settings.py index 3c8f766..39c105d 100644 --- a/tests/unit/qt/test_settings.py +++ b/tests/unit/qt/test_settings.py @@ -9,7 +9,7 @@ from synodic_client.application.screen.settings import SettingsWindow from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE -from synodic_client.config import GlobalConfiguration +from synodic_client.resolution import ResolvedConfig from synodic_client.updater import DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES, DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES _app = QApplication.instance() or QApplication(sys.argv) @@ -20,12 +20,23 @@ # --------------------------------------------------------------------------- -def _make_config(**overrides: object) -> GlobalConfiguration: - """Create a ``GlobalConfiguration`` with optional field overrides.""" - return GlobalConfiguration(**overrides) # type: ignore[arg-type] +def _make_config(**overrides: object) -> ResolvedConfig: + """Create a ``ResolvedConfig`` with sensible defaults and optional overrides.""" + defaults: dict[str, object] = { + 'update_source': None, + 'update_channel': 'stable', + 'auto_update_interval_minutes': DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES, + 'tool_update_interval_minutes': DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES, + 'plugin_auto_update': None, + 'detect_updates': True, + 'prerelease_packages': None, + 'auto_start': True, + } + defaults.update(overrides) + return ResolvedConfig(**defaults) # type: ignore[arg-type] -def _make_window(config: GlobalConfiguration | None = None) -> SettingsWindow: +def _make_window(config: ResolvedConfig | None = None) -> SettingsWindow: """Create a ``SettingsWindow`` without showing it.""" cfg = config or _make_config() window = SettingsWindow(cfg) @@ -151,33 +162,35 @@ def test_auto_start_reflects_registry() -> None: class TestSettingsCallbacks: - """Verify that control changes mutate config and emit the signal.""" + """Verify that control changes persist via update_user_config and emit the signal.""" @staticmethod def test_channel_change_to_dev() -> None: - """Switching to dev mutates config and emits settings_changed.""" + """Switching to dev calls update_user_config and emits settings_changed.""" config = _make_config() window = _make_window(config) signal_spy = MagicMock() window.settings_changed.connect(signal_spy) - with patch('synodic_client.application.screen.settings.save_config'): + new_config = _make_config(update_channel='dev') + with patch('synodic_client.application.screen.settings.update_user_config', return_value=new_config): window._channel_combo.setCurrentIndex(1) - assert config.update_channel == 'dev' - signal_spy.assert_called_once() + signal_spy.assert_called_once_with(new_config) @staticmethod def test_channel_change_to_stable() -> None: - """Switching from dev to stable writes 'stable'.""" + """Switching from dev to stable persists 'stable'.""" config = _make_config(update_channel='dev') window = _make_window(config) window.sync_from_config() - with patch('synodic_client.application.screen.settings.save_config'): + new_config = _make_config(update_channel='stable') + target = 'synodic_client.application.screen.settings.update_user_config' + with patch(target, return_value=new_config) as mock_update: window._channel_combo.setCurrentIndex(0) - assert config.update_channel == 'stable' + mock_update.assert_called_with(update_channel='stable') @staticmethod def test_source_change() -> None: @@ -187,12 +200,12 @@ def test_source_change() -> None: signal_spy = MagicMock() window.settings_changed.connect(signal_spy) - with patch('synodic_client.application.screen.settings.save_config'): + new_config = _make_config(update_source='https://custom.example.com') + with patch('synodic_client.application.screen.settings.update_user_config', return_value=new_config): window._source_edit.setText('https://custom.example.com') window._on_source_changed() - assert config.update_source == 'https://custom.example.com' - signal_spy.assert_called_once() + signal_spy.assert_called_once_with(new_config) @staticmethod def test_source_blank_sets_none() -> None: @@ -200,11 +213,13 @@ def test_source_blank_sets_none() -> None: config = _make_config(update_source='https://old.example.com') window = _make_window(config) - with patch('synodic_client.application.screen.settings.save_config'): + new_config = _make_config(update_source=None) + target = 'synodic_client.application.screen.settings.update_user_config' + with patch(target, return_value=new_config) as mock_update: window._source_edit.setText('') window._on_source_changed() - assert config.update_source is None + mock_update.assert_called_with(update_source=None) @staticmethod def test_auto_update_interval_change() -> None: @@ -215,11 +230,11 @@ def test_auto_update_interval_change() -> None: signal_spy = MagicMock() window.settings_changed.connect(signal_spy) - with patch('synodic_client.application.screen.settings.save_config'): + new_config = _make_config(auto_update_interval_minutes=new_interval) + with patch('synodic_client.application.screen.settings.update_user_config', return_value=new_config): window._auto_update_spin.setValue(new_interval) - assert config.auto_update_interval_minutes == new_interval - signal_spy.assert_called_once() + signal_spy.assert_called_once_with(new_config) @staticmethod def test_tool_update_interval_change() -> None: @@ -230,11 +245,11 @@ def test_tool_update_interval_change() -> None: signal_spy = MagicMock() window.settings_changed.connect(signal_spy) - with patch('synodic_client.application.screen.settings.save_config'): + new_config = _make_config(tool_update_interval_minutes=new_interval) + with patch('synodic_client.application.screen.settings.update_user_config', return_value=new_config): window._tool_update_spin.setValue(new_interval) - assert config.tool_update_interval_minutes == new_interval - signal_spy.assert_called_once() + signal_spy.assert_called_once_with(new_config) @staticmethod def test_detect_updates_change() -> None: @@ -245,11 +260,11 @@ def test_detect_updates_change() -> None: signal_spy = MagicMock() window.settings_changed.connect(signal_spy) - with patch('synodic_client.application.screen.settings.save_config'): + new_config = _make_config(detect_updates=False) + with patch('synodic_client.application.screen.settings.update_user_config', return_value=new_config): window._detect_updates_check.setChecked(False) - assert config.detect_updates is False - signal_spy.assert_called_once() + signal_spy.assert_called_once_with(new_config) @staticmethod def test_auto_start_registers_startup() -> None: @@ -257,14 +272,14 @@ def test_auto_start_registers_startup() -> None: config = _make_config() window = _make_window(config) + new_config = _make_config(auto_start=True) with ( - patch('synodic_client.application.screen.settings.save_config'), + patch('synodic_client.application.screen.settings.update_user_config', return_value=new_config), patch('synodic_client.application.screen.settings.register_startup') as mock_register, patch('synodic_client.application.screen.settings.is_startup_registered', return_value=False), ): window._auto_start_check.setChecked(True) - assert config.auto_start is True mock_register.assert_called_once() @staticmethod @@ -277,13 +292,13 @@ def test_auto_start_removes_startup() -> None: window._auto_start_check.setChecked(True) window._auto_start_check.blockSignals(False) + new_config = _make_config(auto_start=False) with ( - patch('synodic_client.application.screen.settings.save_config'), + patch('synodic_client.application.screen.settings.update_user_config', return_value=new_config), patch('synodic_client.application.screen.settings.remove_startup') as mock_remove, ): window._auto_start_check.setChecked(False) - assert config.auto_start is False mock_remove.assert_called_once() @@ -323,7 +338,7 @@ def test_button_and_label_exist() -> None: assert hasattr(window, '_check_updates_btn') assert hasattr(window, '_update_status_label') assert window._check_updates_btn.text() == 'Check for Updates\u2026' - assert window._update_status_label.text() == '' + assert not window._update_status_label.text() @staticmethod def test_click_emits_signal_and_disables() -> None: diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 5b2d628..1684c22 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -7,45 +7,39 @@ import pytest from synodic_client.config import ( - GlobalConfiguration, - LocalConfiguration, + BuildConfig, + UserConfig, config_dir, - save_config, + save_user_config, set_dev_mode, ) -class TestLocalConfiguration: - """Tests for the LocalConfiguration model.""" +class TestBuildConfig: + """Tests for the BuildConfig model.""" @staticmethod def test_defaults() -> None: """Verify default values for a fresh config.""" - config = LocalConfiguration() + config = BuildConfig() assert config.update_source is None assert config.update_channel is None - assert config.auto_update_interval_minutes is None - assert config.tool_update_interval_minutes is None - assert config.plugin_auto_update is None - assert config.detect_updates is True - assert config.prerelease_packages is None - assert config.auto_start is None @staticmethod def test_with_values() -> None: """Verify config accepts explicit values.""" - config = LocalConfiguration(update_source='/path/to/releases', update_channel='dev') + config = BuildConfig(update_source='/path/to/releases', update_channel='dev') assert config.update_source == '/path/to/releases' assert config.update_channel == 'dev' -class TestGlobalConfiguration: - """Tests for the GlobalConfiguration model.""" +class TestUserConfig: + """Tests for the UserConfig model.""" @staticmethod def test_defaults() -> None: """Verify default values for a fresh config.""" - config = GlobalConfiguration() + config = UserConfig() assert config.update_source is None assert config.update_channel is None assert config.auto_update_interval_minutes is None @@ -58,7 +52,7 @@ def test_defaults() -> None: @staticmethod def test_with_values() -> None: """Verify config accepts explicit values.""" - config = GlobalConfiguration(update_source='/path/to/releases', update_channel='dev') + config = UserConfig(update_source='/path/to/releases', update_channel='dev') assert config.update_source == '/path/to/releases' assert config.update_channel == 'dev' @@ -66,43 +60,43 @@ def test_with_values() -> None: def test_prerelease_packages_round_trip() -> None: """Verify prerelease_packages survives JSON round-trip.""" packages = {'/some/path': ['alpha', 'beta'], 'https://example.com/manifest.json': ['gamma']} - original = GlobalConfiguration(prerelease_packages=packages) + original = UserConfig(prerelease_packages=packages) data = json.loads(original.model_dump_json()) - restored = GlobalConfiguration.model_validate(data) + restored = UserConfig.model_validate(data) assert restored.prerelease_packages == packages @staticmethod def test_plugin_auto_update_round_trip() -> None: """Verify plugin_auto_update survives JSON round-trip.""" mapping = {'pipx': False, 'pip': True} - original = GlobalConfiguration(plugin_auto_update=mapping) + original = UserConfig(plugin_auto_update=mapping) data = json.loads(original.model_dump_json()) - restored = GlobalConfiguration.model_validate(data) + restored = UserConfig.model_validate(data) assert restored.plugin_auto_update == mapping @staticmethod def test_auto_start_round_trip() -> None: """Verify auto_start survives JSON round-trip.""" for value in (True, False, None): - original = GlobalConfiguration(auto_start=value) + original = UserConfig(auto_start=value) data = json.loads(original.model_dump_json()) - restored = GlobalConfiguration.model_validate(data) + restored = UserConfig.model_validate(data) assert restored.auto_start is value @staticmethod def test_json_round_trip() -> None: """Verify config can round-trip through JSON.""" - original = GlobalConfiguration(update_source='https://example.com', update_channel='stable') + original = UserConfig(update_source='https://example.com', update_channel='stable') data = json.loads(original.model_dump_json()) - restored = GlobalConfiguration.model_validate(data) + restored = UserConfig.model_validate(data) assert restored == original @staticmethod def test_json_round_trip_defaults() -> None: """Verify default config round-trips cleanly.""" - original = GlobalConfiguration() + original = UserConfig() data = json.loads(original.model_dump_json()) - restored = GlobalConfiguration.model_validate(data) + restored = UserConfig.model_validate(data) assert restored.update_source is None assert restored.update_channel is None @@ -110,7 +104,7 @@ def test_json_round_trip_defaults() -> None: def test_extra_fields_ignored() -> None: """Verify unrecognized fields do not cause errors.""" data = {'update_source': None, 'update_channel': None, 'unknown_field': 42} - config = GlobalConfiguration.model_validate(data) + config = UserConfig.model_validate(data) assert config.update_source is None @@ -147,16 +141,16 @@ def test_dev_mode_uses_separate_dir() -> None: set_dev_mode(False) -class TestSaveConfig: - """Tests for save_config.""" +class TestSaveUserConfig: + """Tests for save_user_config.""" @staticmethod def test_creates_file(tmp_path: Path) -> None: """Verify config is saved to disk.""" - config = GlobalConfiguration(update_source='/my/releases', update_channel='stable') + config = UserConfig(update_source='/my/releases', update_channel='stable') with patch('synodic_client.config.config_dir', return_value=tmp_path): - save_config(config) + save_user_config(config) saved_path = tmp_path / 'config.json' assert saved_path.exists() @@ -166,56 +160,55 @@ def test_creates_file(tmp_path: Path) -> None: assert data['update_channel'] == 'stable' @staticmethod - def test_sparse_serialization(tmp_path: Path) -> None: - """Verify save_config only writes user-set fields (exclude_unset).""" - config = GlobalConfiguration(update_channel='dev') + def test_saves_all_fields(tmp_path: Path) -> None: + """Verify save_user_config writes all fields (no sparse serialization).""" + config = UserConfig(update_channel='dev') with patch('synodic_client.config.config_dir', return_value=tmp_path): - save_config(config) + save_user_config(config) data = json.loads((tmp_path / 'config.json').read_text(encoding='utf-8')) - # Only 'update_channel' should be in the file - assert data == {'update_channel': 'dev'} - assert 'update_source' not in data - assert 'auto_update_interval_minutes' not in data + # All fields should be present, not just user-set ones + assert data['update_channel'] == 'dev' + assert 'update_source' in data + assert 'auto_update_interval_minutes' in data + assert 'detect_updates' in data @staticmethod def test_creates_directory(tmp_path: Path) -> None: - """Verify save_config creates the directory if missing.""" + """Verify save_user_config creates the directory if missing.""" nested = tmp_path / 'nested' / 'dir' - config = GlobalConfiguration() + config = UserConfig() with patch('synodic_client.config.config_dir', return_value=nested): - save_config(config) + save_user_config(config) assert (nested / 'config.json').exists() @staticmethod def test_overwrites_existing(tmp_path: Path) -> None: - """Verify save_config overwrites an existing file.""" + """Verify save_user_config overwrites an existing file.""" config_path = tmp_path / 'config.json' config_path.write_text('{}', encoding='utf-8') - config = GlobalConfiguration(update_source='http://new-source') + config = UserConfig(update_source='http://new-source') with patch('synodic_client.config.config_dir', return_value=tmp_path): - save_config(config) + save_user_config(config) data = json.loads(config_path.read_text(encoding='utf-8')) assert data['update_source'] == 'http://new-source' @staticmethod def test_save_load_round_trip(tmp_path: Path) -> None: - """Verify saved config can be loaded back with correct fields_set.""" - original = GlobalConfiguration(update_channel='dev', auto_start=False) + """Verify saved config can be loaded back identically.""" + original = UserConfig(update_channel='dev', auto_start=False) with patch('synodic_client.config.config_dir', return_value=tmp_path): - save_config(original) + save_user_config(original) data = json.loads((tmp_path / 'config.json').read_text(encoding='utf-8')) - loaded = GlobalConfiguration.model_validate(data) + loaded = UserConfig.model_validate(data) assert loaded.update_channel == 'dev' assert loaded.auto_start is False - # Only saved fields should be in model_fields_set - assert loaded.model_fields_set == {'update_channel', 'auto_start'} assert loaded.update_source is None diff --git a/tests/unit/test_resolution.py b/tests/unit/test_resolution.py index ad306ad..c1b0384 100644 --- a/tests/unit/test_resolution.py +++ b/tests/unit/test_resolution.py @@ -1,17 +1,21 @@ """Tests for the configuration resolution module.""" -import json +import dataclasses from pathlib import Path -from unittest.mock import patch +from unittest.mock import MagicMock, PropertyMock, patch -from synodic_client.config import GlobalConfiguration, LocalConfiguration +import pytest +from packaging.version import Version + +from synodic_client.config import BuildConfig, UserConfig from synodic_client.resolution import ( - merge_config, - resolve_auto_start, + ResolvedConfig, resolve_config, resolve_enabled_plugins, resolve_update_config, - update_and_resolve, + resolve_version, + seed_user_config_from_build, + update_user_config, ) from synodic_client.updater import ( DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES, @@ -20,290 +24,255 @@ UpdateChannel, ) +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- -class TestMergeConfig: - """Tests for merge_config.""" - - @staticmethod - def test_returns_global_when_no_local() -> None: - """Verify global config is returned unchanged when local is None.""" - global_cfg = GlobalConfiguration(update_source='/system', update_channel='stable') - result = merge_config(global_cfg, None) - assert result.update_source == '/system' - assert result.update_channel == 'stable' - - @staticmethod - def test_local_overrides_global() -> None: - """Verify local fields fill in values the user hasn't set.""" - global_cfg = GlobalConfiguration(update_channel='stable') - local_cfg = LocalConfiguration(update_source='/local') - result = merge_config(global_cfg, local_cfg) - assert result.update_source == '/local' - assert result.update_channel == 'stable' - @staticmethod - def test_local_none_fields_do_not_override() -> None: - """Verify local None fields preserve global values.""" - global_cfg = GlobalConfiguration(update_source='/system', update_channel='stable') - local_cfg = LocalConfiguration() - result = merge_config(global_cfg, local_cfg) - assert result.update_source == '/system' - assert result.update_channel == 'stable' +def _make_resolved(**overrides: object) -> ResolvedConfig: + """Create a ``ResolvedConfig`` with sensible defaults and optional overrides.""" + defaults: dict[str, object] = { + 'update_source': None, + 'update_channel': 'stable', + 'auto_update_interval_minutes': DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES, + 'tool_update_interval_minutes': DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES, + 'plugin_auto_update': None, + 'detect_updates': True, + 'prerelease_packages': None, + 'auto_start': True, + } + defaults.update(overrides) + return ResolvedConfig(**defaults) # type: ignore[arg-type] - @staticmethod - def test_full_override() -> None: - """Verify local fields are ignored when the user has saved both.""" - global_cfg = GlobalConfiguration(update_source='/system', update_channel='stable') - local_cfg = LocalConfiguration(update_source='/local', update_channel='dev') - result = merge_config(global_cfg, local_cfg) - assert result.update_source == '/system' - assert result.update_channel == 'stable' - @staticmethod - def test_user_saved_wins_over_local() -> None: - """Verify a user-saved field takes priority over local config.""" - global_cfg = GlobalConfiguration(update_channel='dev') - local_cfg = LocalConfiguration(update_channel='stable') - result = merge_config(global_cfg, local_cfg) - assert result.update_channel == 'dev' +# --------------------------------------------------------------------------- +# seed_user_config_from_build +# --------------------------------------------------------------------------- - @staticmethod - def test_local_fills_unsaved_fields() -> None: - """Verify local config fills in fields the user hasn't saved.""" - global_cfg = GlobalConfiguration(update_channel='dev') - local_cfg = LocalConfiguration(update_source='/local/releases') - result = merge_config(global_cfg, local_cfg) - assert result.update_channel == 'dev' - assert result.update_source == '/local/releases' - @staticmethod - def test_preserves_model_fields_set() -> None: - """Verify merge preserves the global config's model_fields_set.""" - global_cfg = GlobalConfiguration(update_channel='dev') - local_cfg = LocalConfiguration(update_source='/local') - result = merge_config(global_cfg, local_cfg) - # Only 'update_channel' was in the user's config file - assert result.model_fields_set == {'update_channel'} - # But the runtime value from local config is available - assert result.update_source == '/local' +class TestSeedUserConfigFromBuild: + """Tests for seed_user_config_from_build.""" @staticmethod - def test_local_overrides_plugin_auto_update() -> None: - """Verify local plugin_auto_update fills in when user hasn't set it.""" - global_cfg = GlobalConfiguration() - local_cfg = LocalConfiguration(plugin_auto_update={'pip': True, 'pipx': False}) - result = merge_config(global_cfg, local_cfg) - assert result.plugin_auto_update == {'pip': True, 'pipx': False} + def test_no_build_config_is_noop(tmp_path: Path) -> None: + """Verify nothing happens when there is no build config.""" + with ( + patch('synodic_client.resolution.load_build_config', return_value=None), + patch('synodic_client.resolution.save_user_config') as mock_save, + ): + seed_user_config_from_build() + mock_save.assert_not_called() @staticmethod - def test_user_plugin_auto_update_wins() -> None: - """Verify user-saved plugin_auto_update wins over local.""" - global_cfg = GlobalConfiguration(plugin_auto_update={'pip': False}) - local_cfg = LocalConfiguration(plugin_auto_update={'pip': True, 'pipx': False}) - result = merge_config(global_cfg, local_cfg) - assert result.plugin_auto_update == {'pip': False} - - -class TestResolveAutoStart: - """Tests for resolve_auto_start.""" + def test_seeds_both_fields(tmp_path: Path) -> None: + """Verify build config fields are seeded into user config.""" + build = BuildConfig(update_source='/local', update_channel='dev') + user = UserConfig() # all defaults - @staticmethod - def test_none_defaults_to_true() -> None: - """Verify None (default) resolves to True.""" - config = GlobalConfiguration() - assert resolve_auto_start(config) is True + with ( + patch('synodic_client.resolution.load_build_config', return_value=build), + patch('synodic_client.resolution.load_user_config', return_value=user), + patch('synodic_client.resolution.save_user_config') as mock_save, + ): + seed_user_config_from_build() - @staticmethod - def test_explicit_true() -> None: - """Verify explicit True is returned.""" - config = GlobalConfiguration(auto_start=True) - assert resolve_auto_start(config) is True + mock_save.assert_called_once() + saved = mock_save.call_args.args[0] + assert saved.update_source == '/local' + assert saved.update_channel == 'dev' @staticmethod - def test_explicit_false() -> None: - """Verify explicit False is returned.""" - config = GlobalConfiguration(auto_start=False) - assert resolve_auto_start(config) is False + def test_does_not_overwrite_user_values() -> None: + """Verify user-customised values are not overwritten by build.""" + build = BuildConfig(update_source='/local', update_channel='dev') + user = UserConfig(update_source='/user-source', update_channel='stable') + with ( + patch('synodic_client.resolution.load_build_config', return_value=build), + patch('synodic_client.resolution.load_user_config', return_value=user), + patch('synodic_client.resolution.save_user_config') as mock_save, + ): + seed_user_config_from_build() -class TestResolveEnabledPlugins: - """Tests for resolve_enabled_plugins.""" + # User values should be preserved — nothing to save + mock_save.assert_not_called() @staticmethod - def test_none_when_no_mapping() -> None: - """Verify None is returned when plugin_auto_update is unset.""" - config = GlobalConfiguration() - result = resolve_enabled_plugins(config, ['pip', 'pipx', 'git']) - assert result is None + def test_partial_seed() -> None: + """Verify only missing fields are seeded.""" + build = BuildConfig(update_source='/local', update_channel='dev') + user = UserConfig(update_channel='stable') # channel set, source not - @staticmethod - def test_none_when_all_enabled() -> None: - """Verify None when all entries are True.""" - config = GlobalConfiguration(plugin_auto_update={'pip': True, 'pipx': True}) - result = resolve_enabled_plugins(config, ['pip', 'pipx', 'git']) - assert result is None + with ( + patch('synodic_client.resolution.load_build_config', return_value=build), + patch('synodic_client.resolution.load_user_config', return_value=user), + patch('synodic_client.resolution.save_user_config') as mock_save, + ): + seed_user_config_from_build() - @staticmethod - def test_filters_disabled_plugins() -> None: - """Verify disabled plugins are excluded from the list.""" - config = GlobalConfiguration(plugin_auto_update={'pipx': False}) - result = resolve_enabled_plugins(config, ['pip', 'pipx', 'git']) - assert result is not None - assert 'pipx' not in result - assert 'pip' in result - assert 'git' in result + mock_save.assert_called_once() + saved = mock_save.call_args.args[0] + assert saved.update_source == '/local' + assert saved.update_channel == 'stable' # user's value preserved - @staticmethod - def test_empty_mapping_returns_none() -> None: - """Verify an empty dict behaves like None.""" - config = GlobalConfiguration(plugin_auto_update={}) - result = resolve_enabled_plugins(config, ['pip']) - assert result is None + +# --------------------------------------------------------------------------- +# resolve_config +# --------------------------------------------------------------------------- class TestResolveConfig: - """Tests for resolve_config (loads and merges both layers).""" + """Tests for resolve_config (returns ResolvedConfig).""" @staticmethod def test_returns_defaults_when_no_files(tmp_path: Path) -> None: """Verify defaults when no config files exist.""" - with ( - patch('synodic_client.config._portable_config_path', return_value=None), - patch('synodic_client.config.config_dir', return_value=tmp_path), - ): + with patch('synodic_client.resolution.load_user_config', return_value=UserConfig()): config = resolve_config() + + assert isinstance(config, ResolvedConfig) assert config.update_source is None - assert config.update_channel is None + assert config.auto_update_interval_minutes == DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES @staticmethod - def test_loads_global_file(tmp_path: Path) -> None: - """Verify loading a valid global config file.""" - data = {'update_source': 'https://example.com/releases', 'update_channel': 'dev'} - (tmp_path / 'config.json').write_text(json.dumps(data), encoding='utf-8') + def test_loads_user_values() -> None: + """Verify user config values are reflected in resolved config.""" + user = UserConfig(update_source='https://example.com/releases', update_channel='dev') - with ( - patch('synodic_client.config._portable_config_path', return_value=None), - patch('synodic_client.config.config_dir', return_value=tmp_path), - ): + with patch('synodic_client.resolution.load_user_config', return_value=user): config = resolve_config() assert config.update_source == 'https://example.com/releases' assert config.update_channel == 'dev' @staticmethod - def test_returns_defaults_on_corrupt_json(tmp_path: Path) -> None: - """Verify defaults when config file contains invalid JSON.""" - (tmp_path / 'config.json').write_text('not json', encoding='utf-8') + def test_none_channel_resolves_to_default() -> None: + """Verify None channel resolves based on frozen state.""" + user = UserConfig() # update_channel is None with ( - patch('synodic_client.config._portable_config_path', return_value=None), - patch('synodic_client.config.config_dir', return_value=tmp_path), + patch('synodic_client.resolution.load_user_config', return_value=user), + patch('synodic_client.resolution.sys') as mock_sys, ): + del mock_sys.frozen # Not frozen → dev config = resolve_config() - assert config == GlobalConfiguration() + assert config.update_channel == 'dev' @staticmethod - def test_local_overrides_global_per_field(tmp_path: Path) -> None: - """Verify local config fills in fields the user hasn't saved.""" - local_data = {'update_source': '/local/releases'} - local_path = tmp_path / 'local' / 'config.json' - local_path.parent.mkdir() - local_path.write_text(json.dumps(local_data), encoding='utf-8') - - system_dir = tmp_path / 'system' - system_dir.mkdir() - # User has only saved update_channel, not update_source - system_data = {'update_channel': 'stable'} - (system_dir / 'config.json').write_text(json.dumps(system_data), encoding='utf-8') + def test_none_intervals_resolve_to_defaults() -> None: + """Verify None intervals resolve to module defaults.""" + user = UserConfig() - with ( - patch('synodic_client.config._portable_config_path', return_value=local_path), - patch('synodic_client.config.config_dir', return_value=system_dir), - ): + with patch('synodic_client.resolution.load_user_config', return_value=user): config = resolve_config() - # Local fills in update_source since user didn't set it - assert config.update_source == '/local/releases' - # User's saved update_channel is preserved - assert config.update_channel == 'stable' + assert config.auto_update_interval_minutes == DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES + assert config.tool_update_interval_minutes == DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES @staticmethod - def test_falls_back_to_global_when_no_portable(tmp_path: Path) -> None: - """Verify global config is used when no portable config exists.""" - system_data = {'update_source': '/system/releases', 'update_channel': 'stable'} - (tmp_path / 'config.json').write_text(json.dumps(system_data), encoding='utf-8') + def test_none_auto_start_resolves_to_true() -> None: + """Verify None auto_start resolves to True.""" + user = UserConfig() - with ( - patch('synodic_client.config._portable_config_path', return_value=None), - patch('synodic_client.config.config_dir', return_value=tmp_path), - ): + with patch('synodic_client.resolution.load_user_config', return_value=user): config = resolve_config() - assert config.update_source == '/system/releases' - assert config.update_channel == 'stable' + assert config.auto_start is True @staticmethod - def test_falls_back_to_global_on_corrupt_portable(tmp_path: Path) -> None: - """Verify global config is used when portable config is corrupt.""" - portable_path = tmp_path / 'portable' / 'config.json' - portable_path.parent.mkdir() - portable_path.write_text('not valid json', encoding='utf-8') + def test_config_is_frozen() -> None: + """Verify ResolvedConfig is immutable.""" + with patch('synodic_client.resolution.load_user_config', return_value=UserConfig()): + config = resolve_config() + + assert dataclasses.is_dataclass(config) - system_dir = tmp_path / 'system' - system_dir.mkdir() - system_data = {'update_source': '/system/releases'} - (system_dir / 'config.json').write_text(json.dumps(system_data), encoding='utf-8') + with pytest.raises(dataclasses.FrozenInstanceError): + config.update_channel = 'dev' # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# update_user_config +# --------------------------------------------------------------------------- + + +class TestUpdateUserConfig: + """Tests for update_user_config.""" + + @staticmethod + def test_returns_resolved_config(tmp_path: Path) -> None: + """Verify update_user_config returns a ResolvedConfig.""" + user = UserConfig(update_channel='stable') with ( - patch('synodic_client.config._portable_config_path', return_value=portable_path), - patch('synodic_client.config.config_dir', return_value=system_dir), + patch('synodic_client.resolution.load_user_config', return_value=user), + patch('synodic_client.resolution.save_user_config') as mock_save, ): - config = resolve_config() + result = update_user_config(update_channel='dev') - assert config.update_source == '/system/releases' + assert isinstance(result, ResolvedConfig) + assert result.update_channel == 'dev' + mock_save.assert_called_once() @staticmethod - def test_portable_takes_precedence(tmp_path: Path) -> None: - """Verify portable config fills in fields user hasn't saved.""" - portable_data = {'update_source': '/portable/releases', 'update_channel': 'dev'} - portable_path = tmp_path / 'config.json' - portable_path.write_text(json.dumps(portable_data), encoding='utf-8') - - system_dir = tmp_path / 'system' - system_dir.mkdir() - # User has NOT saved any config (empty file or missing) - (system_dir / 'config.json').write_text('{}', encoding='utf-8') + def test_saves_changed_field() -> None: + """Verify the changed field is persisted.""" + user = UserConfig(update_channel='stable') with ( - patch('synodic_client.config._portable_config_path', return_value=portable_path), - patch('synodic_client.config.config_dir', return_value=system_dir), + patch('synodic_client.resolution.load_user_config', return_value=user), + patch('synodic_client.resolution.save_user_config') as mock_save, ): - config = resolve_config() + update_user_config(update_channel='dev') + + saved = mock_save.call_args.args[0] + assert saved.update_channel == 'dev' - assert config.update_source == '/portable/releases' - assert config.update_channel == 'dev' + +# --------------------------------------------------------------------------- +# resolve_enabled_plugins +# --------------------------------------------------------------------------- + + +class TestResolveEnabledPlugins: + """Tests for resolve_enabled_plugins.""" @staticmethod - def test_user_saved_wins_over_portable(tmp_path: Path) -> None: - """Verify user-saved values in global config win over portable.""" - portable_data = {'update_source': '/portable/releases', 'update_channel': 'dev'} - portable_path = tmp_path / 'config.json' - portable_path.write_text(json.dumps(portable_data), encoding='utf-8') + def test_none_when_no_mapping() -> None: + """Verify None is returned when plugin_auto_update is unset.""" + config = _make_resolved() + result = resolve_enabled_plugins(config, ['pip', 'pipx', 'git']) + assert result is None - system_dir = tmp_path / 'system' - system_dir.mkdir() - system_data = {'update_source': '/system/releases', 'update_channel': 'stable'} - (system_dir / 'config.json').write_text(json.dumps(system_data), encoding='utf-8') + @staticmethod + def test_none_when_all_enabled() -> None: + """Verify None when all entries are True.""" + config = _make_resolved(plugin_auto_update={'pip': True, 'pipx': True}) + result = resolve_enabled_plugins(config, ['pip', 'pipx', 'git']) + assert result is None - with ( - patch('synodic_client.config._portable_config_path', return_value=portable_path), - patch('synodic_client.config.config_dir', return_value=system_dir), - ): - config = resolve_config() + @staticmethod + def test_filters_disabled_plugins() -> None: + """Verify disabled plugins are excluded from the list.""" + config = _make_resolved(plugin_auto_update={'pipx': False}) + result = resolve_enabled_plugins(config, ['pip', 'pipx', 'git']) + assert result is not None + assert 'pipx' not in result + assert 'pip' in result + assert 'git' in result + + @staticmethod + def test_empty_mapping_returns_none() -> None: + """Verify an empty dict behaves like None.""" + config = _make_resolved(plugin_auto_update={}) + result = resolve_enabled_plugins(config, ['pip']) + assert result is None - assert config.update_source == '/system/releases' - assert config.update_channel == 'stable' + +# --------------------------------------------------------------------------- +# resolve_update_config +# --------------------------------------------------------------------------- class TestResolveUpdateConfig: @@ -312,51 +281,42 @@ class TestResolveUpdateConfig: @staticmethod def test_dev_channel_from_config() -> None: """Verify dev channel is set from config.""" - config = GlobalConfiguration(update_channel='dev') + config = _make_resolved(update_channel='dev') result = resolve_update_config(config) assert result.channel == UpdateChannel.DEVELOPMENT @staticmethod def test_stable_channel_from_config() -> None: """Verify stable channel is set from config.""" - config = GlobalConfiguration(update_channel='stable') + config = _make_resolved(update_channel='stable') result = resolve_update_config(config) assert result.channel == UpdateChannel.STABLE - @staticmethod - def test_default_channel_unfrozen() -> None: - """Verify default channel is DEVELOPMENT when not frozen.""" - config = GlobalConfiguration() - with patch('synodic_client.resolution.sys') as mock_sys: - del mock_sys.frozen # Ensure frozen is not set - result = resolve_update_config(config) - assert result.channel == UpdateChannel.DEVELOPMENT - @staticmethod def test_custom_source_non_github() -> None: """Verify non-GitHub custom source passes through unchanged.""" - config = GlobalConfiguration(update_source='https://custom.example.com') + config = _make_resolved(update_source='https://custom.example.com') result = resolve_update_config(config) assert result.repo_url == 'https://custom.example.com' @staticmethod def test_default_source_dev() -> None: """Verify default dev source uses GitHub download path with dev tag.""" - config = GlobalConfiguration(update_channel='dev') + config = _make_resolved(update_channel='dev') result = resolve_update_config(config) assert result.repo_url == f'{GITHUB_REPO_URL}/releases/download/dev' @staticmethod def test_default_source_stable() -> None: """Verify default stable source uses GitHub latest download path.""" - config = GlobalConfiguration(update_channel='stable') + config = _make_resolved(update_channel='stable') result = resolve_update_config(config) assert result.repo_url == f'{GITHUB_REPO_URL}/releases/latest/download' @staticmethod def test_default_auto_update_interval() -> None: """Verify default auto-update interval in minutes.""" - config = GlobalConfiguration() + config = _make_resolved() result = resolve_update_config(config) assert result.auto_update_interval_minutes == DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES @@ -364,14 +324,14 @@ def test_default_auto_update_interval() -> None: def test_custom_auto_update_interval() -> None: """Verify custom auto-update interval is passed through.""" custom = DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES * 2 - config = GlobalConfiguration(auto_update_interval_minutes=custom) + config = _make_resolved(auto_update_interval_minutes=custom) result = resolve_update_config(config) assert result.auto_update_interval_minutes == custom @staticmethod def test_default_tool_update_interval() -> None: """Verify default tool update interval in minutes.""" - config = GlobalConfiguration() + config = _make_resolved() result = resolve_update_config(config) assert result.tool_update_interval_minutes == DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES @@ -379,36 +339,69 @@ def test_default_tool_update_interval() -> None: def test_custom_tool_update_interval() -> None: """Verify custom tool update interval is passed through.""" custom = DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES * 2 - config = GlobalConfiguration(tool_update_interval_minutes=custom) + config = _make_resolved(tool_update_interval_minutes=custom) result = resolve_update_config(config) assert result.tool_update_interval_minutes == custom @staticmethod def test_disabled_intervals() -> None: """Verify zero disables both intervals.""" - config = GlobalConfiguration(auto_update_interval_minutes=0, tool_update_interval_minutes=0) + config = _make_resolved(auto_update_interval_minutes=0, tool_update_interval_minutes=0) result = resolve_update_config(config) assert result.auto_update_interval_minutes == 0 assert result.tool_update_interval_minutes == 0 -class TestUpdateAndResolve: - """Tests for update_and_resolve.""" +# --------------------------------------------------------------------------- +# resolve_version +# --------------------------------------------------------------------------- + + +class TestResolveVersion: + """Tests for resolve_version.""" @staticmethod - def test_saves_and_resolves(tmp_path: Path) -> None: - """Verify config is saved and an UpdateConfig is returned.""" - config = GlobalConfiguration(update_source='/my/source', update_channel='dev') + 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') - with patch('synodic_client.config.config_dir', return_value=tmp_path): - result = update_and_resolve(config) + mock_client = MagicMock() + mock_client.updater = mock_updater + mock_client.version = Version('1.0.0.dev1') - assert result.channel == UpdateChannel.DEVELOPMENT - assert result.repo_url == '/my/source' # non-GitHub path unchanged - - # Verify file was saved (sparse — only user-set fields) - saved = json.loads((tmp_path / 'config.json').read_text(encoding='utf-8')) - assert saved['update_source'] == '/my/source' - assert saved['update_channel'] == 'dev' - # Unset fields should not appear in the sparse output - assert 'auto_update_interval_minutes' not in saved + 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 f00f14b..e8d4ea7 100644 --- a/tests/unit/test_updater.py +++ b/tests/unit/test_updater.py @@ -94,6 +94,11 @@ def test_initial_state(updater: Updater) -> None: """Verify updater starts in NO_UPDATE state.""" assert updater.state == UpdateState.NO_UPDATE + @staticmethod + def test_current_version_returns_init_value(updater: Updater) -> None: + """Verify current_version starts as the value passed to __init__.""" + assert updater.current_version == Version('1.0.0') + @staticmethod def test_initial_update_info_is_none(updater: Updater) -> None: """Verify initial update info is None.""" @@ -473,6 +478,7 @@ def test_non_runtime_error_propagates(updater: Updater) -> None: def test_success_caches_manager(updater: Updater) -> None: """Verify successful manager creation is cached.""" mock_manager = MagicMock() + mock_manager.get_current_version.return_value = '9.8.7' with ( TestGetVelopackManager._PATCH_OPTIONS, patch('synodic_client.updater.velopack.UpdateManager', return_value=mock_manager) as mock_cls, @@ -482,3 +488,17 @@ def test_success_caches_manager(updater: Updater) -> None: assert result1 is mock_manager assert result2 is mock_manager mock_cls.assert_called_once() + + @staticmethod + def test_success_promotes_version(updater: Updater) -> None: + """Verify _current_version is overwritten with the Velopack version.""" + mock_manager = MagicMock() + mock_manager.get_current_version.return_value = '9.8.7' + with ( + TestGetVelopackManager._PATCH_OPTIONS, + patch('synodic_client.updater.velopack.UpdateManager', return_value=mock_manager), + ): + updater._get_velopack_manager() + + assert updater._current_version == Version('9.8.7') + assert updater.current_version == Version('9.8.7')