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
42 changes: 21 additions & 21 deletions pdm.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ requires-python = ">=3.14, <3.15"
dependencies = [
"pyside6>=6.10.2",
"packaging>=26.0",
"porringer>=0.2.1.dev48",
"porringer>=0.2.1.dev49",
"qasync>=0.28.0",
"velopack>=0.0.1369.dev7516",
"velopack>=0.0.1442.dev64255",
"typer>=0.24.1",
]

Expand Down
7 changes: 6 additions & 1 deletion synodic_client/application/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,16 @@ def _init_services(logger: logging.Logger) -> tuple[Client, API, GlobalConfigura
update_config = resolve_update_config(config)
client.initialize_updater(update_config)

cached_dirs = porringer.cache.list_directories()

logger.info(
'Synodic Client v%s started (channel: %s, source: %s)',
'Synodic Client v%s started (channel: %s, source: %s, '
'config_fields_set: %s, cached_projects: %d)',
client.version,
update_config.channel.name,
update_config.repo_url,
sorted(config.model_fields_set),
len(cached_dirs),
)

return client, porringer, config
Expand Down
11 changes: 10 additions & 1 deletion synodic_client/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,12 @@ def _load_global_config() -> GlobalConfiguration:
def save_config(config: GlobalConfiguration) -> 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.

Args:
config: The configuration to persist.
"""
Expand All @@ -202,7 +208,10 @@ def save_config(config: GlobalConfiguration) -> None:
path = directory / _CONFIG_FILENAME

try:
path.write_text(config.model_dump_json(indent=2), encoding='utf-8')
path.write_text(
config.model_dump_json(indent=2, exclude_unset=True),
encoding='utf-8',
)
logger.info('Saved config to %s', path)
except Exception:
logger.exception('Failed to save config to %s', path)
14 changes: 10 additions & 4 deletions synodic_client/resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,13 @@ def merge_config(
) -> GlobalConfiguration:
"""Merge local overrides into a global configuration.

Fields explicitly set (not None) in the local config override the
corresponding global values.
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.

Args:
global_config: The user-scoped global configuration.
Expand All @@ -45,12 +50,13 @@ def merge_config(
if local_config is None:
return global_config

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:
if value is not None and field_name not in user_set:
merged[field_name] = value

return GlobalConfiguration.model_validate(merged)
return GlobalConfiguration.model_construct(_fields_set=set(user_set), **merged)


def resolve_config() -> GlobalConfiguration:
Expand Down
30 changes: 30 additions & 0 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,20 @@ def test_creates_file(tmp_path: Path) -> None:
assert data['update_source'] == '/my/releases'
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')

with patch('synodic_client.config.config_dir', return_value=tmp_path):
save_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

@staticmethod
def test_creates_directory(tmp_path: Path) -> None:
"""Verify save_config creates the directory if missing."""
Expand All @@ -189,3 +203,19 @@ def test_overwrites_existing(tmp_path: Path) -> None:

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)

with patch('synodic_client.config.config_dir', return_value=tmp_path):
save_config(original)

data = json.loads((tmp_path / 'config.json').read_text(encoding='utf-8'))
loaded = GlobalConfiguration.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
86 changes: 74 additions & 12 deletions tests/unit/test_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ def test_returns_global_when_no_local() -> None:

@staticmethod
def test_local_overrides_global() -> None:
"""Verify local fields override global values."""
global_cfg = GlobalConfiguration(update_source='/system', update_channel='stable')
"""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'
Expand All @@ -52,21 +52,57 @@ def test_local_none_fields_do_not_override() -> None:

@staticmethod
def test_full_override() -> None:
"""Verify all local fields override when set."""
"""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 == '/local'
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'

@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'

@staticmethod
def test_local_overrides_plugin_auto_update() -> None:
"""Verify local plugin_auto_update overrides global."""
global_cfg = GlobalConfiguration(plugin_auto_update={'pip': False})
"""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}

@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."""
Expand Down Expand Up @@ -169,15 +205,16 @@ def test_returns_defaults_on_corrupt_json(tmp_path: Path) -> None:

@staticmethod
def test_local_overrides_global_per_field(tmp_path: Path) -> None:
"""Verify local config overrides global on a per-field basis."""
"""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()
system_data = {'update_source': '/system/releases', 'update_channel': 'stable'}
# 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')

with (
Expand All @@ -186,7 +223,9 @@ def test_local_overrides_global_per_field(tmp_path: Path) -> None:
):
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'

@staticmethod
Expand Down Expand Up @@ -226,15 +265,15 @@ def test_falls_back_to_global_on_corrupt_portable(tmp_path: Path) -> None:

@staticmethod
def test_portable_takes_precedence(tmp_path: Path) -> None:
"""Verify portable config values override system config."""
"""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()
system_data = {'update_source': '/system/releases', 'update_channel': 'stable'}
(system_dir / 'config.json').write_text(json.dumps(system_data), encoding='utf-8')
# User has NOT saved any config (empty file or missing)
(system_dir / 'config.json').write_text('{}', encoding='utf-8')

with (
patch('synodic_client.config._portable_config_path', return_value=portable_path),
Expand All @@ -245,6 +284,27 @@ def test_portable_takes_precedence(tmp_path: Path) -> None:
assert config.update_source == '/portable/releases'
assert config.update_channel == 'dev'

@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')

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')

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()

assert config.update_source == '/system/releases'
assert config.update_channel == 'stable'


class TestResolveUpdateConfig:
"""Tests for resolve_update_config."""
Expand Down Expand Up @@ -339,7 +399,9 @@ def test_saves_and_resolves(tmp_path: Path) -> None:
assert result.channel == UpdateChannel.DEVELOPMENT
assert result.repo_url == '/my/source'

# Verify file was saved
# 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
Loading
Loading