fix: runtime config update ignored by config file

Runtime settings were handed back to pydantic-settings as init settings,
which rank below the config file and the environment. Any key already
present in EOS.config.json or in the environment silently discarded the
update, so a bulk PUT /v1/config returned 200 without applying anything,
while the granular PUT /v1/config/{path} endpoint kept working.

Add a dedicated runtime settings source ranked directly below the command
line arguments and record granular updates there as well, so both
endpoints share one store that survives re-evaluation of the settings
sources. Environment variables keep precedence over the config file for
all keys that were not set at runtime.

Also repairs revert_settings() and update(), which passed their data
through the same init settings.

Closes #1303
This commit is contained in:
r0b2g1t
2026-09-13 13:42:21 +02:00
parent 3b9eecc52b
commit f882f62164
4 changed files with 239 additions and 34 deletions
+131
View File
@@ -1,3 +1,4 @@
import json
import tempfile
from pathlib import Path
from typing import Any, Optional, Union
@@ -547,3 +548,133 @@ def test_merge_settings_empty(config_eos):
config_eos.merge_settings_from_dict({}) # No changes
assert config_eos.general.latitude == original_latitude # Should remain unchanged
# ------------------------------------
# Runtime settings priority (issue #1303)
# ------------------------------------
@pytest.fixture
def config_eos_file(config_eos_factory) -> ConfigEOS:
"""ConfigEOS with the EOS configuration file as an active settings source."""
return config_eos_factory(
init={
"with_init_settings": True,
"with_env_settings": True,
"with_dotenv_settings": False,
"with_file_settings": True,
"with_file_secret_settings": False,
}
)
def write_config_file(config_eos: ConfigEOS, settings: dict[str, Any]) -> None:
"""Write settings to the EOS configuration file and load them."""
settings = {"general": {"version": config_eos.general.version}, **settings}
config_file_path = config_eos.general.config_file_path
assert config_file_path is not None
config_file_path.write_text(json.dumps(settings), encoding="utf-8")
config_eos.reset_settings()
def test_merge_settings_overrides_config_file(config_eos_file):
"""Runtime settings take precedence over the EOS configuration file."""
write_config_file(
config_eos_file,
{
"optimization": {"genetic": {"individuals": 200}},
"pvforecast": {
"planes": [
{"surface_tilt": 30.0, "surface_azimuth": azimuth, "peakpower": 5.0}
for azimuth in (0.0, 90.0, 180.0, 270.0)
]
},
},
)
assert config_eos_file.optimization.genetic.individuals == 200
assert len(config_eos_file.pvforecast.planes) == 4
config_eos_file.merge_settings_from_dict(
{
"optimization": {"genetic": {"individuals": 300}},
"pvforecast": {
"planes": [{"surface_tilt": 30.0, "surface_azimuth": 180.0, "peakpower": 5.0}]
},
}
)
assert config_eos_file.optimization.genetic.individuals == 300
assert len(config_eos_file.pvforecast.planes) == 1
def test_merge_settings_overrides_env(config_eos_file, monkeypatch):
"""Runtime settings take precedence over environment variables."""
monkeypatch.setenv("EOS_OPTIMIZATION__GENETIC__INDIVIDUALS", "150")
config_eos_file.reset_settings()
assert config_eos_file.optimization.genetic.individuals == 150
config_eos_file.merge_settings_from_dict({"optimization": {"genetic": {"individuals": 300}}})
assert config_eos_file.optimization.genetic.individuals == 300
def test_env_overrides_config_file_after_merge(config_eos_file, monkeypatch):
"""Environment variables keep precedence over the config file for untouched keys."""
write_config_file(config_eos_file, {"server": {"port": 9000}})
monkeypatch.setenv("EOS_SERVER__PORT", "9500")
config_eos_file.reset_settings()
assert config_eos_file.server.port == 9500
# A runtime update of an unrelated key must not freeze the env value
config_eos_file.merge_settings_from_dict({"general": {"latitude": 51.1657}})
assert config_eos_file.general.latitude == 51.1657
assert config_eos_file.server.port == 9500
monkeypatch.setenv("EOS_SERVER__PORT", "9600")
config_eos_file.reset_settings()
assert config_eos_file.server.port == 9600
def test_reset_settings_drops_runtime_settings(config_eos_file):
"""Reset drops runtime settings and falls back to the config file."""
write_config_file(config_eos_file, {"optimization": {"genetic": {"individuals": 200}}})
config_eos_file.merge_settings_from_dict({"optimization": {"genetic": {"individuals": 300}}})
assert config_eos_file.optimization.genetic.individuals == 300
config_eos_file.reset_settings()
assert config_eos_file.optimization.genetic.individuals == 200
def test_set_nested_value_survives_merge(config_eos_file):
"""Granular updates are not lost by a later bulk update."""
write_config_file(config_eos_file, {"optimization": {"genetic": {"individuals": 200}}})
config_eos_file.set_nested_value("optimization/genetic/individuals", 400)
assert config_eos_file.optimization.genetic.individuals == 400
config_eos_file.merge_settings_from_dict({"general": {"latitude": 51.1657}})
assert config_eos_file.optimization.genetic.individuals == 400
def test_revert_settings_restores_backup(config_eos_file):
"""Revert restores the backup values even if the config file differs."""
write_config_file(config_eos_file, {"optimization": {"genetic": {"individuals": 200}}})
config_file_path = config_eos_file.general.config_file_path
assert config_file_path is not None
backup_path = config_file_path.with_suffix(".backup")
backup_path.write_text(
json.dumps(
{
"general": {"version": config_eos_file.general.version},
"optimization": {"genetic": {"individuals": 500}},
}
),
encoding="utf-8",
)
config_eos_file.revert_settings("backup")
assert config_eos_file.optimization.genetic.individuals == 500