diff --git a/src/akkudoktoreos/prediction/elecfee.py b/src/akkudoktoreos/prediction/elecfee.py index 475e95e1..1374acbe 100644 --- a/src/akkudoktoreos/prediction/elecfee.py +++ b/src/akkudoktoreos/prediction/elecfee.py @@ -3,7 +3,6 @@ from typing import Optional from pydantic import Field, computed_field, field_validator from akkudoktoreos.config.configabc import SettingsBaseModel -from akkudoktoreos.core.coreabc import get_prediction from akkudoktoreos.prediction.elecfeeabc import ElecFeeProvider from akkudoktoreos.prediction.elecfeefixed import ElecFeeFixedCommonSettings from akkudoktoreos.prediction.elecfeeimport import ElecFeeImportCommonSettings @@ -11,20 +10,11 @@ from akkudoktoreos.prediction.elecfeeimport import ElecFeeImportCommonSettings def elecfee_provider_ids() -> list[str]: """Valid elecfee provider ids.""" - try: - prediction_eos = get_prediction() - except Exception: - # Prediction may not be initialized. Return static built-in provider ids. - return [ - "ElecFeeFixed", - "ElecFeeImport", - ] + # From the provider classes: valid also while the configuration is created, + # before the prediction instance exists. + from akkudoktoreos.prediction.prediction import prediction_provider_ids - return [ - provider.provider_id() - for provider in prediction_eos.providers - if isinstance(provider, ElecFeeProvider) - ] + return prediction_provider_ids(ElecFeeProvider) class ElecFeeCommonSettings(SettingsBaseModel): diff --git a/src/akkudoktoreos/prediction/elecprice.py b/src/akkudoktoreos/prediction/elecprice.py index b681201b..3dea6b2a 100644 --- a/src/akkudoktoreos/prediction/elecprice.py +++ b/src/akkudoktoreos/prediction/elecprice.py @@ -3,7 +3,6 @@ from typing import Optional from pydantic import Field, computed_field, field_validator from akkudoktoreos.config.configabc import SettingsBaseModel -from akkudoktoreos.core.coreabc import get_prediction from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider from akkudoktoreos.prediction.elecpriceakkudoktor import ( ElecPriceAkkudoktorCommonSettings, @@ -19,24 +18,11 @@ from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibberCommonSettin def elecprice_provider_ids() -> list[str]: """Valid elecprice provider ids.""" - try: - prediction_eos = get_prediction() - except Exception: - # Prediction may not be initialized. Return static built-in provider ids. - return [ - "ElecPriceAkkudoktor", - "ElecPriceEnergyCharts", - "ElecPriceFixed", - "ElecPriceSMARD", - "ElecPriceImport", - "ElecPriceTibber", - ] + # From the provider classes: valid also while the configuration is created, + # before the prediction instance exists. + from akkudoktoreos.prediction.prediction import prediction_provider_ids - return [ - provider.provider_id() - for provider in prediction_eos.providers - if isinstance(provider, ElecPriceProvider) - ] + return prediction_provider_ids(ElecPriceProvider) class ElecPriceCommonSettings(SettingsBaseModel): diff --git a/src/akkudoktoreos/prediction/feedintariff.py b/src/akkudoktoreos/prediction/feedintariff.py index 6f9cd5e2..39d25698 100644 --- a/src/akkudoktoreos/prediction/feedintariff.py +++ b/src/akkudoktoreos/prediction/feedintariff.py @@ -3,7 +3,6 @@ from typing import Optional from pydantic import Field, computed_field, field_validator from akkudoktoreos.config.configabc import SettingsBaseModel -from akkudoktoreos.core.coreabc import get_prediction from akkudoktoreos.prediction.feedintariffabc import FeedInTariffProvider from akkudoktoreos.prediction.feedintariffdvhubonline import ( FeedInTariffDvhubOnlineCommonSettings, @@ -18,25 +17,11 @@ from akkudoktoreos.prediction.feedintariffsmard import FeedInTariffSMARDCommonSe def feedintariff_provider_ids() -> list[str]: """Valid feedintariff provider ids.""" - try: - prediction_eos = get_prediction() - except Exception: - # Prediction may not be initialized. Return static built-in provider ids. - return [ - "FeedInTariffAkkudoktor", - "FeedInTariffDvhubOnline", - "FeedInTariffEnergyCharts", - "FeedInTariffFixed", - "FeedInTariffImport", - "FeedInTariffSMARD", - "FeedInTariffTibber", - ] + # From the provider classes: valid also while the configuration is created, + # before the prediction instance exists. + from akkudoktoreos.prediction.prediction import prediction_provider_ids - return [ - provider.provider_id() - for provider in prediction_eos.providers - if isinstance(provider, FeedInTariffProvider) - ] + return prediction_provider_ids(FeedInTariffProvider) class FeedInTariffCommonSettings(SettingsBaseModel): diff --git a/src/akkudoktoreos/prediction/load.py b/src/akkudoktoreos/prediction/load.py index 42295265..5a7130ac 100644 --- a/src/akkudoktoreos/prediction/load.py +++ b/src/akkudoktoreos/prediction/load.py @@ -5,7 +5,6 @@ from typing import Optional from pydantic import Field, computed_field, field_validator from akkudoktoreos.config.configabc import SettingsBaseModel -from akkudoktoreos.core.coreabc import get_prediction from akkudoktoreos.prediction.loadabc import LoadProvider from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings @@ -14,18 +13,11 @@ from akkudoktoreos.prediction.loadvrm import LoadVrmCommonSettings def load_providers() -> list[str]: """Valid load provider ids.""" - try: - prediction_eos = get_prediction() - except Exception: - # Prediction may not be initialized - # Return at least provider used in example - return ["LoadAkkudoktor", "LoadVrm", "LoadImport"] + # From the provider classes: valid also while the configuration is created, + # before the prediction instance exists. + from akkudoktoreos.prediction.prediction import prediction_provider_ids - return [ - provider.provider_id() - for provider in prediction_eos.providers - if isinstance(provider, LoadProvider) - ] + return prediction_provider_ids(LoadProvider) class LoadCommonSettings(SettingsBaseModel): diff --git a/src/akkudoktoreos/prediction/prediction.py b/src/akkudoktoreos/prediction/prediction.py index e4b1802e..e584c67d 100644 --- a/src/akkudoktoreos/prediction/prediction.py +++ b/src/akkudoktoreos/prediction/prediction.py @@ -26,7 +26,7 @@ Attributes: weather_clearoutside (WeatherClearOutside): Weather forecast provider using ClearOutside. """ -from typing import Optional, Union +from typing import Optional, Union, get_args 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]: """Return list of prediction providers. diff --git a/src/akkudoktoreos/prediction/pvforecast.py b/src/akkudoktoreos/prediction/pvforecast.py index e59fb01b..80d11991 100644 --- a/src/akkudoktoreos/prediction/pvforecast.py +++ b/src/akkudoktoreos/prediction/pvforecast.py @@ -5,7 +5,6 @@ from typing import Any, List, Optional, Self from pydantic import Field, computed_field, field_validator, model_validator from akkudoktoreos.config.configabc import SettingsBaseModel -from akkudoktoreos.core.coreabc import get_prediction from akkudoktoreos.prediction.pvakkuadapt import ( PVForecastAdaptiveAkkudoktorCommonSettings, ) @@ -29,28 +28,11 @@ from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrmCommonSettings def pvforecast_provider_ids() -> list[str]: """Valid PV forecast providers.""" - try: - prediction_eos = get_prediction() - except Exception: - # Prediction may not be initialized - # Return at least provider used in example - return [ - "PVForecastAdaptiveAkkudoktor", - "PVForecastAkkudoktor", - "PVForecastForecastSolar", - "PVForecastHomeAssistant", - "PVForecastImport", - "PVForecastPVLib", - "PVForecastPVNode", - "PVForecastSolcast", - "PVForecastVrm", - ] + # From the provider classes: valid also while the configuration is created, + # before the prediction instance exists. + from akkudoktoreos.prediction.prediction import prediction_provider_ids - return [ - provider.provider_id() - for provider in prediction_eos.providers - if isinstance(provider, PVForecastProvider) - ] + return prediction_provider_ids(PVForecastProvider) class PVForecastPlaneSetting(SettingsBaseModel): diff --git a/src/akkudoktoreos/prediction/weather.py b/src/akkudoktoreos/prediction/weather.py index c90d63d0..307ab7fb 100644 --- a/src/akkudoktoreos/prediction/weather.py +++ b/src/akkudoktoreos/prediction/weather.py @@ -5,25 +5,17 @@ from typing import Optional from pydantic import Field, computed_field, field_validator from akkudoktoreos.config.configabc import SettingsBaseModel -from akkudoktoreos.core.coreabc import get_prediction from akkudoktoreos.prediction.weatherabc import WeatherProvider from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings def weather_provider_ids() -> list[str]: """Valid weather provider ids.""" - try: - prediction_eos = get_prediction() - except Exception: - # Prediction may not be initialized - # Return at least provider used in example - return ["WeatherImport"] + # From the provider classes: valid also while the configuration is created, + # before the prediction instance exists. + from akkudoktoreos.prediction.prediction import prediction_provider_ids - return [ - provider.provider_id() - for provider in prediction_eos.providers - if isinstance(provider, WeatherProvider) - ] + return prediction_provider_ids(WeatherProvider) class WeatherCommonSettings(SettingsBaseModel): diff --git a/tests/conftest.py b/tests/conftest.py index 9832d061..897e812e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -462,6 +462,31 @@ def config_eos(config_eos_factory) -> ConfigEOS: 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 # ------------------------------------ diff --git a/tests/test_config.py b/tests/test_config.py index 4975fab0..38ec462a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -854,3 +854,34 @@ class TestDeviceSettings: def test_device_map_rejects_conflicting_identity(self): with pytest.raises(ValidationError, match="device_id"): 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"] diff --git a/tests/test_prediction.py b/tests/test_prediction.py index f6fffd33..594449e5 100644 --- a/tests/test_prediction.py +++ b/tests/test_prediction.py @@ -209,3 +209,34 @@ async def test_empty_providers(prediction, forecast_providers): # Cleanup after Test 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