diff --git a/service_oriented/application/config/base_config.py b/service_oriented/application/config/base_config.py index 9f9af0c..eb317e0 100644 --- a/service_oriented/application/config/base_config.py +++ b/service_oriented/application/config/base_config.py @@ -1,7 +1,14 @@ -from typing import Any, Optional +from typing import Any, Optional, Tuple, Type -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import ( + BaseSettings, + PydanticBaseSettingsSource, + SettingsConfigDict, +) +from service_oriented.application.config.yaml_config_settings_source import ( + YamlConfigSettingsSource, +) from service_oriented.deployment_environment import DeploymentEnvironment @@ -9,6 +16,7 @@ class BaseConfig(BaseSettings): model_config = SettingsConfigDict() deployment_environment: DeploymentEnvironment + yaml_config_path: Optional[str] = None def __init__(self, *args: Any, **kwargs: Any): if not self.model_config.get("env_nested_delimiter"): @@ -18,3 +26,30 @@ def __init__(self, *args: Any, **kwargs: Any): raise RuntimeError("env_prefix model config is required") super().__init__(*args, **kwargs) + + @classmethod + def settings_customise_sources( + cls, + settings_cls: Type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> Tuple[PydanticBaseSettingsSource, ...]: + yaml_config_settings = YamlConfigSettingsSource.from_sources( + settings_cls=settings_cls, + sources=[ + init_settings, + env_settings, + file_secret_settings, + dotenv_settings, + ], + ) + + return ( + init_settings, + env_settings, + file_secret_settings, + yaml_config_settings, + dotenv_settings, + ) diff --git a/service_oriented_test/application/config/base_config_test.py b/service_oriented_test/application/config/base_config_test.py index e4ec493..1fafca5 100644 --- a/service_oriented_test/application/config/base_config_test.py +++ b/service_oriented_test/application/config/base_config_test.py @@ -1,3 +1,6 @@ +import os +from tempfile import NamedTemporaryFile, TemporaryDirectory + import pytest from pydantic import ValidationError from pydantic_settings import SettingsConfigDict @@ -14,21 +17,10 @@ ) -class ConfigWithoutEnvPrefix(BaseConfig): - model_config = SettingsConfigDict(env_nested_delimiter="__") - - class ConfigWithoutEnvNestedDelimiter(BaseConfig): model_config = SettingsConfigDict(env_prefix="prefix_") -class ConfigWithAllTheThings(BaseConfig): - model_config = SettingsConfigDict( - env_nested_delimiter="__", - env_prefix="test_", - ) - - def test_env_nested_delimiter_is_required() -> None: with pytest.raises(RuntimeError) as exinfo: ConfigWithoutEnvNestedDelimiter( @@ -39,6 +31,10 @@ def test_env_nested_delimiter_is_required() -> None: assert "env_nested_delimiter model config is required" == exception_message +class ConfigWithoutEnvPrefix(BaseConfig): + model_config = SettingsConfigDict(env_nested_delimiter="__") + + def test_env_prefix_is_required() -> None: with pytest.raises(RuntimeError) as exinfo: ConfigWithoutEnvPrefix(deployment_environment=TEST_DEPLOYMENT_ENVIRONMENT) @@ -47,6 +43,13 @@ def test_env_prefix_is_required() -> None: assert "env_prefix model config is required" == exception_message +class ConfigWithAllTheThings(BaseConfig): + model_config = SettingsConfigDict( + env_nested_delimiter="__", + env_prefix="base_config_test_", + ) + + def test_deployment_environment_is_accessible() -> None: config = ConfigWithAllTheThings( deployment_environment=TEST_DEPLOYMENT_ENVIRONMENT, @@ -63,3 +66,71 @@ def test_deployment_environment_is_required() -> None: assert "1 validation error for ConfigWithAllTheThings" in exception_message assert "deployment_environment" in exception_message assert "Field required" in exception_message + + +def test_yaml_config_path_is_accessible() -> None: + yaml_config_path = "foo" + config = ConfigWithAllTheThings( + deployment_environment=TEST_DEPLOYMENT_ENVIRONMENT, + yaml_config_path=yaml_config_path, + ) + assert yaml_config_path == config.yaml_config_path + + +def test_yaml_config_path_is_optional() -> None: + config = ConfigWithAllTheThings( + deployment_environment=TEST_DEPLOYMENT_ENVIRONMENT, + ) + assert config.yaml_config_path is None + + +class ConfigForTestingSourcePriorities(ConfigWithAllTheThings): + layer_one: str + layer_two: str + layer_three: str + layer_four: str + layer_five: str + + +def test_setting_source_priorities() -> None: + os.environ["BASE_CONFIG_TEST_LAYER_ONE"] = "env" + os.environ["BASE_CONFIG_TEST_LAYER_TWO"] = "env" + with TemporaryDirectory() as secrets_dir: + for config_name in ["layer_one", "layer_two", "layer_three"]: + with open( + f"{secrets_dir}/base_config_test_{config_name}", "w+" + ) as secret_file: + secret_file.write("secret") + with NamedTemporaryFile(mode="w+") as yaml_file: + yaml_file.write( + """ + layer_one: yaml + layer_two: yaml + layer_three: yaml + layer_four: yaml + """ + ) + yaml_file.flush() + with NamedTemporaryFile(mode="w+") as dotenv_file: + dotenv_file.write( + """ + BASE_CONFIG_TEST_LAYER_ONE=dotenv + BASE_CONFIG_TEST_LAYER_TWO=dotenv + BASE_CONFIG_TEST_LAYER_THREE=dotenv + BASE_CONFIG_TEST_LAYER_FOUR=dotenv + BASE_CONFIG_TEST_LAYER_FIVE=dotenv + """ + ) + dotenv_file.flush() + config = ConfigForTestingSourcePriorities( + _env_file=dotenv_file.name, + _secrets_dir=secrets_dir, + layer_one="init", + deployment_environment=TEST_DEPLOYMENT_ENVIRONMENT, + yaml_config_path=yaml_file.name, + ) + assert "init" == config.layer_one + assert "env" == config.layer_two + assert "secret" == config.layer_three + assert "yaml" == config.layer_four + assert "dotenv" == config.layer_five