diff --git a/src/ndev_settings/_settings.py b/src/ndev_settings/_settings.py index 2087040..798ace9 100644 --- a/src/ndev_settings/_settings.py +++ b/src/ndev_settings/_settings.py @@ -1,6 +1,7 @@ from __future__ import annotations import hashlib +import importlib.metadata as importlib_metadata import logging from importlib.metadata import entry_points from pathlib import Path @@ -33,10 +34,27 @@ def _load_yaml(path: Path) -> dict: def _get_entry_points_hash() -> str: """Generate a hash of installed ndev_settings.manifest entry points. - Used to detect when packages are installed/removed. + Used to detect when contributed settings schemas may have changed. + Package versions are included so upgrading a contributing package + invalidates the cache even when the entry point string itself is stable. """ - eps = entry_points(group="ndev_settings.manifest") - ep_strings = sorted(f"{ep.name}:{ep.value}" for ep in eps) + eps = sorted( + entry_points(group="ndev_settings.manifest"), + key=lambda ep: (ep.name, ep.value), + ) + ep_strings = [] + for ep in eps: + package_name, _, _ = ep.value.partition(":") + try: + dist = importlib_metadata.distribution(package_name) + package_version = getattr(dist, "version", "unknown") + except ( + importlib_metadata.PackageNotFoundError, + ValueError, + OSError, + ): + package_version = "unknown" + ep_strings.append(f"{ep.name}:{ep.value}:{package_version}") return hashlib.sha256("|".join(ep_strings).encode()).hexdigest() @@ -114,9 +132,7 @@ def _load_defaults(self) -> dict: # Use distribution() to find package location WITHOUT importing it # This avoids slow package imports (e.g., ndevio takes 2.5s to import) - from importlib.metadata import distribution - - dist = distribution(package_name) + dist = importlib_metadata.distribution(package_name) yaml_path = None # For regular installs, dist.files contains the file list @@ -184,6 +200,7 @@ def _load_defaults(self) -> dict: if name not in all_settings[group_name]: all_settings[group_name][name] = data except ( + importlib_metadata.PackageNotFoundError, ModuleNotFoundError, FileNotFoundError, ValueError, diff --git a/tests/test_settings.py b/tests/test_settings.py index 756b5d9..6e0d8aa 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -220,6 +220,94 @@ def test_package_change_preserves_user_values( # User's custom value should be preserved assert settings2.Group_A.setting_int == 777 + def test_package_upgrade_backfills_new_settings( + self, test_settings_file, tmp_path, monkeypatch + ): + """Upgrading a contributing package should invalidate the cache. + + This covers the common case where a package keeps the same + ``ndev_settings.manifest`` entry point but ships a new setting in its + YAML schema in a later version. + """ + import yaml + + external_file = tmp_path / "external.yaml" + version_state = {"value": "1.0.0"} + + external_v1 = { + "External_Group": { + "existing_setting": { + "value": 10, + "default": 10, + } + } + } + external_v2 = { + "External_Group": { + "existing_setting": { + "value": 10, + "default": 10, + }, + "new_setting": { + "value": 42, + "default": 42, + }, + } + } + external_file.write_text(yaml.dump(external_v1)) + + class MockEntryPoint: + def __init__(self): + self.name = "mock_external" + self.value = "mock_package:settings.yaml" + + class MockPackagePath: + def __init__(self, actual_path): + self._path = actual_path + self.name = "settings.yaml" + + def __str__(self): + return "mock_package/settings.yaml" + + class MockDistribution: + def __init__(self): + self.version = version_state["value"] + self.files = [MockPackagePath(external_file)] + + def locate_file(self, file): + return file._path + + from importlib.metadata import distribution as orig_distribution + + def mock_entry_points(group=None): + if group == "ndev_settings.manifest": + return [MockEntryPoint()] + return [] + + def mock_distribution(name): + if name == "mock_package": + return MockDistribution() + return orig_distribution(name) + + monkeypatch.setattr( + "ndev_settings._settings.entry_points", mock_entry_points + ) + monkeypatch.setattr( + "importlib.metadata.distribution", mock_distribution + ) + + settings1 = Settings(str(test_settings_file)) + settings1.External_Group.existing_setting = 777 + settings1.save() + + external_file.write_text(yaml.dump(external_v2)) + version_state["value"] = "2.0.0" + + settings2 = Settings(str(test_settings_file)) + + assert settings2.External_Group.existing_setting == 777 + assert settings2.External_Group.new_setting == 42 + def test_clear_settings_handles_missing_file(self): """Test that clear_settings doesn't crash if file doesn't exist.""" from ndev_settings import _settings