From 40a571cb942a101c700cf5d603761d1da6b0746e Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Fri, 3 Apr 2026 23:13:49 -0500 Subject: [PATCH 1/2] hash check against package upgrades not just installs --- src/ndev_settings/_settings.py | 22 ++++++--- tests/test_settings.py | 88 ++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 6 deletions(-) diff --git a/src/ndev_settings/_settings.py b/src/ndev_settings/_settings.py index 2087040..495910c 100644 --- a/src/ndev_settings/_settings.py +++ b/src/ndev_settings/_settings.py @@ -2,7 +2,7 @@ import hashlib import logging -from importlib.metadata import entry_points +from importlib.metadata import PackageNotFoundError, distribution, entry_points from pathlib import Path from urllib.parse import unquote, urlparse @@ -33,10 +33,22 @@ 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: + package_version = distribution(package_name).version + except (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,8 +126,6 @@ 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) yaml_path = None 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 From 02556be1704d9676ff7381c7542d638c7346f295 Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Fri, 3 Apr 2026 23:41:05 -0500 Subject: [PATCH 2/2] fix current version checks --- src/ndev_settings/_settings.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/ndev_settings/_settings.py b/src/ndev_settings/_settings.py index 495910c..798ace9 100644 --- a/src/ndev_settings/_settings.py +++ b/src/ndev_settings/_settings.py @@ -1,8 +1,9 @@ from __future__ import annotations import hashlib +import importlib.metadata as importlib_metadata import logging -from importlib.metadata import PackageNotFoundError, distribution, entry_points +from importlib.metadata import entry_points from pathlib import Path from urllib.parse import unquote, urlparse @@ -45,8 +46,13 @@ def _get_entry_points_hash() -> str: for ep in eps: package_name, _, _ = ep.value.partition(":") try: - package_version = distribution(package_name).version - except (PackageNotFoundError, ValueError, OSError): + 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() @@ -126,7 +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) - dist = distribution(package_name) + dist = importlib_metadata.distribution(package_name) yaml_path = None # For regular installs, dist.files contains the file list @@ -194,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,