mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-09 15:24:01 +01:00
fix(config): validate prediction providers before the prediction exists
The provider settings (weather, PV forecast, load, electricity price, feed-in tariff, electricity fee) validate their provider id against the providers of the prediction instance. While the configuration is created, that instance does not exist yet, and the validation falls back to fixed lists. These were incomplete: weather knew only 'WeatherImport', load lacked 'LoadAkkudoktorAdjusted'. A configuration file with e.g. weather provider 'OpenMeteo' then fails to load: Provider 'OpenMeteo' is not a valid weather provider: ['WeatherImport'] This is hidden today because ConfigEOS.documentation_mode() is always true (_force_documentation_mode is a pydantic private attribute, whose class attribute is a truthy descriptor), and get_prediction() then creates the prediction instance on access. Once documentation mode is enabled only explicitly, the configuration does not load. Derive the provider ids from the provider classes instead (prediction_provider_ids). They no longer depend on whether the prediction instance exists, so the validation is the same before and after initialization. Additional changes - Add the test fixture uncreated_config, which lets get_config(init=True) create a new configuration from the config file and restores the old one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4475774c06
commit
a0e57a18b7
@@ -3,7 +3,6 @@ from typing import Optional
|
|||||||
from pydantic import Field, computed_field, field_validator
|
from pydantic import Field, computed_field, field_validator
|
||||||
|
|
||||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||||
from akkudoktoreos.core.coreabc import get_prediction
|
|
||||||
from akkudoktoreos.prediction.elecfeeabc import ElecFeeProvider
|
from akkudoktoreos.prediction.elecfeeabc import ElecFeeProvider
|
||||||
from akkudoktoreos.prediction.elecfeefixed import ElecFeeFixedCommonSettings
|
from akkudoktoreos.prediction.elecfeefixed import ElecFeeFixedCommonSettings
|
||||||
from akkudoktoreos.prediction.elecfeeimport import ElecFeeImportCommonSettings
|
from akkudoktoreos.prediction.elecfeeimport import ElecFeeImportCommonSettings
|
||||||
@@ -11,20 +10,11 @@ from akkudoktoreos.prediction.elecfeeimport import ElecFeeImportCommonSettings
|
|||||||
|
|
||||||
def elecfee_provider_ids() -> list[str]:
|
def elecfee_provider_ids() -> list[str]:
|
||||||
"""Valid elecfee provider ids."""
|
"""Valid elecfee provider ids."""
|
||||||
try:
|
# From the provider classes: valid also while the configuration is created,
|
||||||
prediction_eos = get_prediction()
|
# before the prediction instance exists.
|
||||||
except Exception:
|
from akkudoktoreos.prediction.prediction import prediction_provider_ids
|
||||||
# Prediction may not be initialized. Return static built-in provider ids.
|
|
||||||
return [
|
|
||||||
"ElecFeeFixed",
|
|
||||||
"ElecFeeImport",
|
|
||||||
]
|
|
||||||
|
|
||||||
return [
|
return prediction_provider_ids(ElecFeeProvider)
|
||||||
provider.provider_id()
|
|
||||||
for provider in prediction_eos.providers
|
|
||||||
if isinstance(provider, ElecFeeProvider)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class ElecFeeCommonSettings(SettingsBaseModel):
|
class ElecFeeCommonSettings(SettingsBaseModel):
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from typing import Optional
|
|||||||
from pydantic import Field, computed_field, field_validator
|
from pydantic import Field, computed_field, field_validator
|
||||||
|
|
||||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||||
from akkudoktoreos.core.coreabc import get_prediction
|
|
||||||
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
|
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
|
||||||
from akkudoktoreos.prediction.elecpriceakkudoktor import (
|
from akkudoktoreos.prediction.elecpriceakkudoktor import (
|
||||||
ElecPriceAkkudoktorCommonSettings,
|
ElecPriceAkkudoktorCommonSettings,
|
||||||
@@ -19,24 +18,11 @@ from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibberCommonSettin
|
|||||||
|
|
||||||
def elecprice_provider_ids() -> list[str]:
|
def elecprice_provider_ids() -> list[str]:
|
||||||
"""Valid elecprice provider ids."""
|
"""Valid elecprice provider ids."""
|
||||||
try:
|
# From the provider classes: valid also while the configuration is created,
|
||||||
prediction_eos = get_prediction()
|
# before the prediction instance exists.
|
||||||
except Exception:
|
from akkudoktoreos.prediction.prediction import prediction_provider_ids
|
||||||
# Prediction may not be initialized. Return static built-in provider ids.
|
|
||||||
return [
|
|
||||||
"ElecPriceAkkudoktor",
|
|
||||||
"ElecPriceEnergyCharts",
|
|
||||||
"ElecPriceFixed",
|
|
||||||
"ElecPriceSMARD",
|
|
||||||
"ElecPriceImport",
|
|
||||||
"ElecPriceTibber",
|
|
||||||
]
|
|
||||||
|
|
||||||
return [
|
return prediction_provider_ids(ElecPriceProvider)
|
||||||
provider.provider_id()
|
|
||||||
for provider in prediction_eos.providers
|
|
||||||
if isinstance(provider, ElecPriceProvider)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class ElecPriceCommonSettings(SettingsBaseModel):
|
class ElecPriceCommonSettings(SettingsBaseModel):
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from typing import Optional
|
|||||||
from pydantic import Field, computed_field, field_validator
|
from pydantic import Field, computed_field, field_validator
|
||||||
|
|
||||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||||
from akkudoktoreos.core.coreabc import get_prediction
|
|
||||||
from akkudoktoreos.prediction.feedintariffabc import FeedInTariffProvider
|
from akkudoktoreos.prediction.feedintariffabc import FeedInTariffProvider
|
||||||
from akkudoktoreos.prediction.feedintariffdvhubonline import (
|
from akkudoktoreos.prediction.feedintariffdvhubonline import (
|
||||||
FeedInTariffDvhubOnlineCommonSettings,
|
FeedInTariffDvhubOnlineCommonSettings,
|
||||||
@@ -18,25 +17,11 @@ from akkudoktoreos.prediction.feedintariffsmard import FeedInTariffSMARDCommonSe
|
|||||||
|
|
||||||
def feedintariff_provider_ids() -> list[str]:
|
def feedintariff_provider_ids() -> list[str]:
|
||||||
"""Valid feedintariff provider ids."""
|
"""Valid feedintariff provider ids."""
|
||||||
try:
|
# From the provider classes: valid also while the configuration is created,
|
||||||
prediction_eos = get_prediction()
|
# before the prediction instance exists.
|
||||||
except Exception:
|
from akkudoktoreos.prediction.prediction import prediction_provider_ids
|
||||||
# Prediction may not be initialized. Return static built-in provider ids.
|
|
||||||
return [
|
|
||||||
"FeedInTariffAkkudoktor",
|
|
||||||
"FeedInTariffDvhubOnline",
|
|
||||||
"FeedInTariffEnergyCharts",
|
|
||||||
"FeedInTariffFixed",
|
|
||||||
"FeedInTariffImport",
|
|
||||||
"FeedInTariffSMARD",
|
|
||||||
"FeedInTariffTibber",
|
|
||||||
]
|
|
||||||
|
|
||||||
return [
|
return prediction_provider_ids(FeedInTariffProvider)
|
||||||
provider.provider_id()
|
|
||||||
for provider in prediction_eos.providers
|
|
||||||
if isinstance(provider, FeedInTariffProvider)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class FeedInTariffCommonSettings(SettingsBaseModel):
|
class FeedInTariffCommonSettings(SettingsBaseModel):
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from typing import Optional
|
|||||||
from pydantic import Field, computed_field, field_validator
|
from pydantic import Field, computed_field, field_validator
|
||||||
|
|
||||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||||
from akkudoktoreos.core.coreabc import get_prediction
|
|
||||||
from akkudoktoreos.prediction.loadabc import LoadProvider
|
from akkudoktoreos.prediction.loadabc import LoadProvider
|
||||||
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
|
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
|
||||||
from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings
|
from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings
|
||||||
@@ -14,18 +13,11 @@ from akkudoktoreos.prediction.loadvrm import LoadVrmCommonSettings
|
|||||||
|
|
||||||
def load_providers() -> list[str]:
|
def load_providers() -> list[str]:
|
||||||
"""Valid load provider ids."""
|
"""Valid load provider ids."""
|
||||||
try:
|
# From the provider classes: valid also while the configuration is created,
|
||||||
prediction_eos = get_prediction()
|
# before the prediction instance exists.
|
||||||
except Exception:
|
from akkudoktoreos.prediction.prediction import prediction_provider_ids
|
||||||
# Prediction may not be initialized
|
|
||||||
# Return at least provider used in example
|
|
||||||
return ["LoadAkkudoktor", "LoadVrm", "LoadImport"]
|
|
||||||
|
|
||||||
return [
|
return prediction_provider_ids(LoadProvider)
|
||||||
provider.provider_id()
|
|
||||||
for provider in prediction_eos.providers
|
|
||||||
if isinstance(provider, LoadProvider)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class LoadCommonSettings(SettingsBaseModel):
|
class LoadCommonSettings(SettingsBaseModel):
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Attributes:
|
|||||||
weather_clearoutside (WeatherClearOutside): Weather forecast provider using ClearOutside.
|
weather_clearoutside (WeatherClearOutside): Weather forecast provider using ClearOutside.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union, get_args
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
@@ -159,6 +159,26 @@ PredictionProviderType = Union[
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def prediction_provider_ids(provider_type: type) -> list[str]:
|
||||||
|
"""Return the ids of all prediction providers of a kind.
|
||||||
|
|
||||||
|
Derived from the provider classes, so it does not need the prediction
|
||||||
|
instance. The configuration validates its provider ids with it while it is
|
||||||
|
created, before the prediction instance exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider_type: Provider base class of the kind, e.g. ``WeatherProvider``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The provider ids of all provider classes derived from ``provider_type``.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
provider_class.provider_id()
|
||||||
|
for provider_class in get_args(PredictionProviderType)
|
||||||
|
if issubclass(provider_class, provider_type)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def prediction_providers() -> list[PredictionProviderType]:
|
def prediction_providers() -> list[PredictionProviderType]:
|
||||||
"""Return list of prediction providers.
|
"""Return list of prediction providers.
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from typing import Any, List, Optional, Self
|
|||||||
from pydantic import Field, computed_field, field_validator, model_validator
|
from pydantic import Field, computed_field, field_validator, model_validator
|
||||||
|
|
||||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||||
from akkudoktoreos.core.coreabc import get_prediction
|
|
||||||
from akkudoktoreos.prediction.pvakkuadapt import (
|
from akkudoktoreos.prediction.pvakkuadapt import (
|
||||||
PVForecastAdaptiveAkkudoktorCommonSettings,
|
PVForecastAdaptiveAkkudoktorCommonSettings,
|
||||||
)
|
)
|
||||||
@@ -29,28 +28,11 @@ from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrmCommonSettings
|
|||||||
|
|
||||||
def pvforecast_provider_ids() -> list[str]:
|
def pvforecast_provider_ids() -> list[str]:
|
||||||
"""Valid PV forecast providers."""
|
"""Valid PV forecast providers."""
|
||||||
try:
|
# From the provider classes: valid also while the configuration is created,
|
||||||
prediction_eos = get_prediction()
|
# before the prediction instance exists.
|
||||||
except Exception:
|
from akkudoktoreos.prediction.prediction import prediction_provider_ids
|
||||||
# Prediction may not be initialized
|
|
||||||
# Return at least provider used in example
|
|
||||||
return [
|
|
||||||
"PVForecastAdaptiveAkkudoktor",
|
|
||||||
"PVForecastAkkudoktor",
|
|
||||||
"PVForecastForecastSolar",
|
|
||||||
"PVForecastHomeAssistant",
|
|
||||||
"PVForecastImport",
|
|
||||||
"PVForecastPVLib",
|
|
||||||
"PVForecastPVNode",
|
|
||||||
"PVForecastSolcast",
|
|
||||||
"PVForecastVrm",
|
|
||||||
]
|
|
||||||
|
|
||||||
return [
|
return prediction_provider_ids(PVForecastProvider)
|
||||||
provider.provider_id()
|
|
||||||
for provider in prediction_eos.providers
|
|
||||||
if isinstance(provider, PVForecastProvider)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class PVForecastPlaneSetting(SettingsBaseModel):
|
class PVForecastPlaneSetting(SettingsBaseModel):
|
||||||
|
|||||||
@@ -5,25 +5,17 @@ from typing import Optional
|
|||||||
from pydantic import Field, computed_field, field_validator
|
from pydantic import Field, computed_field, field_validator
|
||||||
|
|
||||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||||
from akkudoktoreos.core.coreabc import get_prediction
|
|
||||||
from akkudoktoreos.prediction.weatherabc import WeatherProvider
|
from akkudoktoreos.prediction.weatherabc import WeatherProvider
|
||||||
from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings
|
from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings
|
||||||
|
|
||||||
|
|
||||||
def weather_provider_ids() -> list[str]:
|
def weather_provider_ids() -> list[str]:
|
||||||
"""Valid weather provider ids."""
|
"""Valid weather provider ids."""
|
||||||
try:
|
# From the provider classes: valid also while the configuration is created,
|
||||||
prediction_eos = get_prediction()
|
# before the prediction instance exists.
|
||||||
except Exception:
|
from akkudoktoreos.prediction.prediction import prediction_provider_ids
|
||||||
# Prediction may not be initialized
|
|
||||||
# Return at least provider used in example
|
|
||||||
return ["WeatherImport"]
|
|
||||||
|
|
||||||
return [
|
return prediction_provider_ids(WeatherProvider)
|
||||||
provider.provider_id()
|
|
||||||
for provider in prediction_eos.providers
|
|
||||||
if isinstance(provider, WeatherProvider)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class WeatherCommonSettings(SettingsBaseModel):
|
class WeatherCommonSettings(SettingsBaseModel):
|
||||||
|
|||||||
@@ -462,6 +462,31 @@ def config_eos(config_eos_factory) -> ConfigEOS:
|
|||||||
return config_eos
|
return config_eos
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def uncreated_config(config_eos: ConfigEOS, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||||
|
"""Drop the created configuration, so get_config(init=True) creates a new one from the file.
|
||||||
|
|
||||||
|
Write the configuration to the returned config file path, then call
|
||||||
|
``get_config(init=True)``. The new configuration reads the config file, as
|
||||||
|
the server does. monkeypatch puts the fixture's configuration back into
|
||||||
|
both ``coreabc._config_eos`` and the ``ConfigEOS`` singleton registry, so
|
||||||
|
the two stay in sync after the test.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path of the config file the new configuration reads.
|
||||||
|
"""
|
||||||
|
from akkudoktoreos.core import coreabc
|
||||||
|
|
||||||
|
config_file_path = config_eos.general.config_file_path
|
||||||
|
if config_file_path is None:
|
||||||
|
raise RuntimeError("The test configuration has no config file path.")
|
||||||
|
singleton_instances: dict[type, object] = ConfigEOS._instances
|
||||||
|
monkeypatch.setattr(coreabc, "_config_eos", None)
|
||||||
|
monkeypatch.setattr(ConfigEOS, "_init_config_eos", {"with_file_settings": True})
|
||||||
|
monkeypatch.delitem(singleton_instances, ConfigEOS, raising=False)
|
||||||
|
return config_file_path
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------
|
# ------------------------------------
|
||||||
# Provide pytest EOS server management
|
# Provide pytest EOS server management
|
||||||
# ------------------------------------
|
# ------------------------------------
|
||||||
|
|||||||
@@ -854,3 +854,34 @@ class TestDeviceSettings:
|
|||||||
def test_device_map_rejects_conflicting_identity(self):
|
def test_device_map_rejects_conflicting_identity(self):
|
||||||
with pytest.raises(ValidationError, match="device_id"):
|
with pytest.raises(ValidationError, match="device_id"):
|
||||||
DevicesCommonSettings.model_validate({"batteries": {"house": {"device_id": "other"}}})
|
DevicesCommonSettings.model_validate({"batteries": {"house": {"device_id": "other"}}})
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigProviderValidation:
|
||||||
|
"""Provider ids are validated while the configuration is created."""
|
||||||
|
|
||||||
|
def test_config_with_providers_is_created_before_the_prediction(
|
||||||
|
self, uncreated_config, monkeypatch
|
||||||
|
):
|
||||||
|
"""No prediction instance exists yet while the configuration is created."""
|
||||||
|
from akkudoktoreos.core import coreabc
|
||||||
|
from akkudoktoreos.core.version import __version__
|
||||||
|
|
||||||
|
providers = {
|
||||||
|
"weather": {"provider": "OpenMeteo"},
|
||||||
|
"load": {"provider": "LoadAkkudoktorAdjusted"},
|
||||||
|
"pvforecast": {"provider": "PVForecastPVLib"},
|
||||||
|
"elecprice": {"provider": "ElecPriceEnergyCharts"},
|
||||||
|
"feedintariff": {"provider": "FeedInTariffFixed"},
|
||||||
|
"elecfee": {"provider": "ElecFeeFixed"},
|
||||||
|
}
|
||||||
|
# A current version: the file is validated as is, without migration.
|
||||||
|
uncreated_config.write_text(json.dumps({"general": {"version": __version__}, **providers}))
|
||||||
|
monkeypatch.setattr(coreabc, "_prediction_eos", None)
|
||||||
|
# Outside of documentation mode get_prediction() does not create the
|
||||||
|
# prediction instance on access.
|
||||||
|
monkeypatch.setattr(ConfigEOS, "documentation_mode", classmethod(lambda cls: False))
|
||||||
|
|
||||||
|
config = coreabc.get_config(init=True)
|
||||||
|
|
||||||
|
for section, value in providers.items():
|
||||||
|
assert getattr(config, section).provider == value["provider"]
|
||||||
|
|||||||
@@ -209,3 +209,34 @@ async def test_empty_providers(prediction, forecast_providers):
|
|||||||
|
|
||||||
# Cleanup after Test
|
# Cleanup after Test
|
||||||
prediction.providers = providers_bkup
|
prediction.providers = providers_bkup
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"provider_type",
|
||||||
|
[
|
||||||
|
"WeatherProvider",
|
||||||
|
"PVForecastProvider",
|
||||||
|
"LoadProvider",
|
||||||
|
"ElecPriceProvider",
|
||||||
|
"FeedInTariffProvider",
|
||||||
|
"ElecFeeProvider",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_provider_ids_without_prediction_instance(prediction, provider_type):
|
||||||
|
"""The ids from the provider classes match the providers of the prediction."""
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
from akkudoktoreos.prediction.prediction import prediction_provider_ids
|
||||||
|
|
||||||
|
module_name = {
|
||||||
|
"WeatherProvider": "weatherabc",
|
||||||
|
"PVForecastProvider": "pvforecastabc",
|
||||||
|
"LoadProvider": "loadabc",
|
||||||
|
"ElecPriceProvider": "elecpriceabc",
|
||||||
|
"FeedInTariffProvider": "feedintariffabc",
|
||||||
|
"ElecFeeProvider": "elecfeeabc",
|
||||||
|
}[provider_type]
|
||||||
|
base = getattr(importlib.import_module(f"akkudoktoreos.prediction.{module_name}"), provider_type)
|
||||||
|
expected = {p.provider_id() for p in prediction.providers if isinstance(p, base)}
|
||||||
|
assert expected
|
||||||
|
assert set(prediction_provider_ids(base)) == expected
|
||||||
|
|||||||
Reference in New Issue
Block a user