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
29 changes: 23 additions & 6 deletions src/ndev_settings/_settings.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
88 changes: 88 additions & 0 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading