merge: preserve runtime configuration updates from PR #1305

This commit is contained in:
Andreas
2026-09-16 12:07:08 +02:00
6 changed files with 281 additions and 37 deletions
+8
View File
@@ -14,6 +14,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `PVForecastForecastSolar` — forecasts from the free Forecast.Solar API.
- `PVForecastSolcast` — forecasts from the Solcast rooftop-site API.
### Fixed
- Configuration updates made at runtime, e.g. by `PUT /v1/config`, are no longer discarded when the
same configuration key is set in the EOS configuration file or in the environment
([#1303](https://github.com/Akkudoktor-EOS/EOS/issues/1303)).
- Environment variables are applied to the configuration on server startup instead of taking effect
only after the first configuration change.
## 0.3.0 (2026-03-17)
Akkudoktor-EOS can now be run as Home Assistant add-on and standalone.
+9 -4
View File
@@ -40,10 +40,15 @@ Use endpoint `POST /v1/config/reset` to reset the configuration to the values in
The configuration sources and their priorities are as follows:
1. `Settings`: Provided during runtime by the REST interface
2. `Environment Variables`: Defined at startup of the REST server and during runtime
3. `EOS Configuration File`: Read at startup of the REST server and on request
4. `Default Values`
1. `Command Line Arguments`: Provided at startup of the REST server
2. `Settings`: Provided during runtime by the REST interface
3. `Environment Variables`: Defined at startup of the REST server and during runtime
4. `EOS Configuration File`: Read at startup of the REST server and on request
5. `Default Values`
Runtime settings are kept until they are reset by `POST /v1/config/reset`. All other sources are
re-evaluated on every configuration change, which keeps environment variable changes effective for
all configuration keys that were not set during runtime.
### Runtime Config Updates
+71 -14
View File
@@ -13,6 +13,7 @@ import json
import os
import sys
import tempfile
from copy import deepcopy
from pathlib import Path
from typing import Any, Callable, ClassVar, Optional, Type, Union
@@ -38,7 +39,7 @@ from akkudoktoreos.core.emsettings import (
)
from akkudoktoreos.core.logabc import LOGGING_LEVELS
from akkudoktoreos.core.logsettings import LoggingCommonSettings
from akkudoktoreos.core.pydantic import PydanticModelNestedValueMixin, merge_models
from akkudoktoreos.core.pydantic import PydanticModelNestedValueMixin, deep_merge
from akkudoktoreos.core.version import __version__
from akkudoktoreos.devices.devices import DevicesCommonSettings
from akkudoktoreos.measurement.measurement import MeasurementCommonSettings
@@ -396,6 +397,8 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
}
_config_file_path: ClassVar[Optional[Path]] = None
_config_autosave: ClassVar[str] = ""
# Settings provided at runtime, e.g. by the REST interface. Highest priority after CLI.
_runtime_settings: ClassVar[dict[str, Any]] = {}
_force_documentation_mode = False
def __hash__(self) -> int:
@@ -519,6 +522,14 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
return settings
def lazy_runtime_settings() -> dict:
"""Runtime settings.
Settings provided during runtime, e.g. by the REST interface. They supersede any
setting from the environment or the configuration file until they are reset.
"""
return deepcopy(cls._runtime_settings)
def lazy_config_file_settings() -> dict:
"""Config file settings.
@@ -668,6 +679,7 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
# runtime configuration.
setting_sources = [
lazy_config_cli_settings, # Prio high
lazy_runtime_settings, # settings provided during runtime
lazy_env_settings,
lazy_dotenv_settings,
lazy_config_file_settings, # resolves/creates config file path
@@ -705,7 +717,9 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
logger.debug("Config init called again with parameters {} {}", args, kwargs)
return
logger.debug("Config init with parameters {} {}", args, kwargs)
self._setup(self, *args, **kwargs)
# Do not pass self - the first positional argument of pydantic_settings.BaseSettings
# is _case_sensitive, which would make environment variable lookup case sensitive.
self._setup(*args, **kwargs)
def _setup(self, *args: Any, **kwargs: Any) -> None:
"""Re-initialize global settings."""
@@ -741,11 +755,10 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
)
def merge_settings_from_dict(self, data: dict) -> None:
"""Merges the provided dictionary data into the current instance.
"""Merges the provided dictionary data into the runtime settings.
Creates a new settings instance, then applies the dictionary data through validation,
and finally merges the validated settings into the current instance. None values
are not merged.
The data is added to the runtime settings, which have priority over the environment and
the EOS configuration file. All configuration sources are re-evaluated afterwards.
Args:
data (dict): Dictionary containing field values to merge into the
@@ -762,18 +775,58 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
config.merge_settings_from_dict(new_data)
"""
merged = merge_models(
self,
data,
)
previous_settings = ConfigEOS._runtime_settings
ConfigEOS._runtime_settings = deep_merge(previous_settings, deepcopy(data))
try:
self._setup()
except Exception:
# Keep the runtime settings in sync with the actual configuration
ConfigEOS._runtime_settings = previous_settings
raise
self._setup(**merged)
def set_nested_value(self, path: str, value: Any) -> None:
"""Set a nested configuration value and remember it as runtime setting.
Args:
path (str): A '/'-separated path to the nested attribute (e.g. "server/port").
value (Any): The new value to set.
"""
super().set_nested_value(path, value)
# Remember as runtime setting to survive re-evaluation of the configuration sources.
# List indices can not be expressed by the settings dictionary - remember the whole list.
keys = []
for key in path.strip("/").split("/"):
if key.isdigit():
break
keys.append(key)
setting = self.get_nested_value("/".join(keys))
if isinstance(setting, SettingsBaseModel):
setting = setting.model_dump(
exclude_none=True, exclude_unset=True, exclude_computed_fields=True
)
elif isinstance(setting, list):
setting = [
item.model_dump(
exclude_none=True, exclude_unset=True, exclude_computed_fields=True
)
if isinstance(item, SettingsBaseModel)
else item
for item in setting
]
runtime_setting: dict[str, Any] = {}
node = runtime_setting
for key in keys[:-1]:
node = node.setdefault(key, {})
node[keys[-1]] = setting
ConfigEOS._runtime_settings = deep_merge(ConfigEOS._runtime_settings, runtime_setting)
def reset_settings(self) -> None:
"""Reset all changed settings to environment/config file defaults.
This functions basically deletes the settings provided before.
"""
ConfigEOS._runtime_settings = {}
self._setup()
def revert_settings(self, backup_id: str) -> None:
@@ -812,7 +865,11 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
backup_data: dict[str, Any] = json.load(f)
backup_settings = migrate_config_data(backup_data)
self._setup(**backup_settings.model_dump(exclude_none=True, exclude_unset=True))
# Backup settings are runtime settings - they supersede environment and config file.
ConfigEOS._runtime_settings = backup_settings.model_dump(
exclude_none=True, exclude_defaults=True, exclude_computed_fields=True
)
self._setup()
def list_backups(self) -> dict[str, dict[str, Any]]:
"""List available configuration backup files and extract metadata.
@@ -1094,11 +1151,11 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
"""Updates all configuration fields.
This method updates all configuration fields using the following order for value retrieval:
1. Current settings.
1. Runtime settings.
2. Environment variables.
3. EOS configuration file.
4. Field default constants.
The first non None value in priority order is taken.
"""
self._setup(**self.model_dump())
self._setup()
+31 -17
View File
@@ -65,6 +65,37 @@ from akkudoktoreos.utils.datetimeutil import (
_model_private_state: "weakref.WeakKeyDictionary[Union[PydanticBaseModel, PydanticModelNestedValueMixin], Dict[str, Any]]" = weakref.WeakKeyDictionary()
def deep_merge(source_data: Any, update_data: Any) -> Any:
"""Merge two data structures recursively.
Values in update_data (including None) override source values.
Nested dictionaries are merged recursively.
Lists in update_data replace source lists entirely.
Args:
source_data (Any): Data to merge into.
update_data (Any): Data to merge from.
Returns:
Any: The merged data.
"""
if isinstance(source_data, dict) and isinstance(update_data, dict):
merged = dict(source_data)
for key, update_value in update_data.items():
if key in merged:
merged[key] = deep_merge(merged[key], update_value)
else:
merged[key] = update_value
return merged
# If both are lists, replace source list with update list
if isinstance(source_data, list) and isinstance(update_data, list):
return update_data
# For other types or if update_data is None, override source_data
return update_data
def merge_models(source: BaseModel, update_dict: dict[str, Any]) -> dict[str, Any]:
"""Merge a Pydantic model instance with an update dictionary.
@@ -83,23 +114,6 @@ def merge_models(source: BaseModel, update_dict: dict[str, Any]) -> dict[str, An
dict[str, Any]: Merged dictionary representing combined model data.
"""
def deep_merge(source_data: Any, update_data: Any) -> Any:
if isinstance(source_data, dict) and isinstance(update_data, dict):
merged = dict(source_data)
for key, update_value in update_data.items():
if key in merged:
merged[key] = deep_merge(merged[key], update_value)
else:
merged[key] = update_value
return merged
# If both are lists, replace source list with update list
if isinstance(source_data, list) and isinstance(update_data, list):
return update_data
# For other types or if update_data is None, override source_data
return update_data
source_dict = source.model_dump(
exclude_unset=True,
exclude_computed_fields=True,
+2 -2
View File
@@ -370,8 +370,8 @@ def config_eos_factory(
assert not config_file_cwd.exists()
config_eos = get_config(init=init)
# Ensure newly created configurations are respected
# Note: Workaround for pydantic_settings and pytest
# Ensure newly created configurations are respected and runtime settings of
# previous tests are dropped
config_eos.reset_settings()
# Check user data directory pathes (config_default_dirs[-1] == data_default_dir_user)
+160
View File
@@ -1,3 +1,4 @@
import json
import tempfile
from pathlib import Path
from typing import Any, Optional, Union
@@ -594,3 +595,162 @@ 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,
{
"general": {"latitude": 48.0},
"optimization": {"genetic": {"individuals": 200}},
"pvforecast": {
"planes": [
{"surface_tilt": 30.0, "surface_azimuth": azimuth, "peakpower": 5.0}
for azimuth in (0.0, 90.0)
]
},
},
)
config_eos_file.set_nested_value("optimization/genetic/individuals", 400)
# A list index can not be expressed by the settings dictionary
config_eos_file.set_nested_value("pvforecast/planes/1/peakpower", 9.9)
# Clearing a value must not be reverted by the config file either
config_eos_file.set_nested_value("general/latitude", None)
config_eos_file.merge_settings_from_dict({"server": {"port": 8600}})
assert config_eos_file.server.port == 8600
assert config_eos_file.optimization.genetic.individuals == 400
assert config_eos_file.pvforecast.planes[1].peakpower == 9.9
assert config_eos_file.general.latitude is None
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
def test_config_from_env_on_first_init(config_eos, config_default_dirs, monkeypatch):
"""Environment variables are applied on the first configuration build."""
config_eos.reset_instance()
monkeypatch.setenv("EOS_CONFIG_DIR", str(config_default_dirs[0]))
monkeypatch.setenv("EOS_SERVER__PORT", "8553")
assert ConfigEOS().server.port == 8553