Skip to content

Commit 831ff64

Browse files
committed
Add YamlConfigSettingsSource for loading app config from yaml file
1 parent 8e558c5 commit 831ff64

File tree

6 files changed

+163
-6
lines changed

6 files changed

+163
-6
lines changed

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ SecretStorage==3.3.3
7272
toml==0.10.2
7373
tomli==2.0.1
7474
twine==4.0.2
75+
types-PyYAML==6.0.12.12
7576
typing_extensions==4.9.0
7677
urllib3==2.1.0
7778
virtualenv==20.25.0

service_oriented/application/config/base_config.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
1-
from typing import Any, Tuple, Type
1+
from typing import Any, Optional
22

3-
from pydantic_settings import (
4-
BaseSettings,
5-
PydanticBaseSettingsSource,
6-
SettingsConfigDict,
7-
)
3+
from pydantic_settings import BaseSettings, SettingsConfigDict
84

95
from service_oriented.deployment_environment import DeploymentEnvironment
106

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import os
2+
from typing import Any, Dict, List, Optional, Tuple, Type
3+
4+
import yaml
5+
from pydantic.fields import FieldInfo
6+
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource
7+
8+
9+
class YamlConfigSettingsSource(PydanticBaseSettingsSource):
10+
@classmethod
11+
def get_yaml_config_path_from_sources(
12+
cls,
13+
sources: List[PydanticBaseSettingsSource],
14+
) -> Optional[str]:
15+
for source in sources:
16+
config_from_source = source()
17+
yaml_config_path = config_from_source.get("yaml_config_path")
18+
if yaml_config_path:
19+
return str(yaml_config_path)
20+
return None
21+
22+
@classmethod
23+
def from_sources(
24+
cls,
25+
settings_cls: Type[BaseSettings],
26+
sources: List[PydanticBaseSettingsSource],
27+
) -> "YamlConfigSettingsSource":
28+
yaml_config_path = YamlConfigSettingsSource.get_yaml_config_path_from_sources(
29+
sources=sources,
30+
)
31+
32+
return YamlConfigSettingsSource(
33+
settings_cls=settings_cls,
34+
yaml_config_path=yaml_config_path,
35+
)
36+
37+
def __init__(
38+
self,
39+
settings_cls: Type[BaseSettings],
40+
yaml_config_path: Optional[str] = None,
41+
):
42+
super().__init__(settings_cls)
43+
44+
self._yaml_config: Dict[str, Any] = self._load_yaml_config(yaml_config_path)
45+
46+
def _load_yaml_config(self, yaml_config_path: Optional[str]) -> Dict[str, Any]:
47+
if not yaml_config_path or not os.path.exists(yaml_config_path):
48+
return {}
49+
50+
with open(yaml_config_path, mode="r", encoding="utf-8") as stream:
51+
yaml_config = yaml.safe_load(stream)
52+
53+
return yaml_config if isinstance(yaml_config, dict) else {}
54+
55+
def get_field_value(
56+
self,
57+
field: FieldInfo,
58+
field_name: str,
59+
) -> Tuple[Any, str, bool]:
60+
# We're not required to do anything here. Implement return to make mypy happy.
61+
return None, "", False
62+
63+
def __call__(self) -> Dict[str, Any]:
64+
return self._yaml_config
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""
2+
Tests for YamlConfigSettingsSource integration with pydantic.
3+
"""
4+
5+
import os
6+
import textwrap
7+
from tempfile import NamedTemporaryFile
8+
from typing import Any, Dict, Optional, Tuple, Type
9+
10+
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource
11+
12+
from service_oriented.application.config.yaml_config_settings_source import (
13+
YamlConfigSettingsSource,
14+
)
15+
16+
17+
YAML_TEMPLATE = """
18+
a_str: '{a_str}'
19+
an_int: {an_int}
20+
a_dict:
21+
a_str: '{a_str}'
22+
an_int: {an_int}
23+
"""
24+
25+
26+
class YamlConfigTestConfig(BaseSettings):
27+
"""
28+
Test configuration class that is configured to load config from yaml.
29+
"""
30+
31+
a_str: str
32+
an_int: int
33+
a_dict: Dict[str, Any]
34+
yaml_config_path: Optional[str] = None
35+
36+
@classmethod
37+
def settings_customise_sources(
38+
cls,
39+
settings_cls: Type[BaseSettings],
40+
init_settings: PydanticBaseSettingsSource,
41+
env_settings: PydanticBaseSettingsSource,
42+
dotenv_settings: PydanticBaseSettingsSource,
43+
file_secret_settings: PydanticBaseSettingsSource,
44+
) -> Tuple[PydanticBaseSettingsSource, ...]:
45+
yaml_config_settings = YamlConfigSettingsSource.from_sources(
46+
settings_cls=settings_cls,
47+
sources=[
48+
init_settings,
49+
env_settings,
50+
],
51+
)
52+
return (
53+
# Include a built-in settings source for extra validation
54+
init_settings,
55+
yaml_config_settings,
56+
)
57+
58+
59+
def test_yaml_config_settings_source_loads_init_yaml_config_as_expected() -> None:
60+
"""
61+
Test the the yaml config path can be taken from the init settings and that
62+
the config is loaded from yaml as expected.
63+
"""
64+
a_str: str = "A man a plan, a canal, panama"
65+
an_int: int = 5
66+
yaml = YAML_TEMPLATE.format(a_str=a_str, an_int=an_int)
67+
with NamedTemporaryFile(mode="w+") as temp_file:
68+
temp_file.write(textwrap.dedent(yaml))
69+
temp_file.flush()
70+
config = YamlConfigTestConfig(yaml_config_path=temp_file.name)
71+
assert a_str == config.a_str
72+
assert an_int == config.an_int
73+
assert a_str == config.a_dict["a_str"]
74+
assert an_int == config.a_dict["an_int"]
75+
76+
77+
def test_yaml_config_settings_source_loads_config_yaml_config_as_expected() -> None:
78+
"""
79+
Test that the yaml config path can come from other settings sources if no
80+
yaml config path is given as an argument to init and the config is loaded
81+
as expected.
82+
"""
83+
a_str: str = "taco cat"
84+
an_int: int = 42
85+
yaml = YAML_TEMPLATE.format(a_str=a_str, an_int=an_int)
86+
with NamedTemporaryFile(mode="w+") as temp_file:
87+
temp_file.write(textwrap.dedent(yaml))
88+
temp_file.flush()
89+
os.environ["YAML_CONFIG_PATH"] = temp_file.name
90+
config = YamlConfigTestConfig()
91+
assert a_str == config.a_str
92+
assert an_int == config.an_int
93+
assert a_str == config.a_dict["a_str"]
94+
assert an_int == config.a_dict["an_int"]

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
VERSION = f.read().strip()
1111

1212
_dependencies = [
13+
"PyYaml~=6.0.1",
1314
"pydantic~=2.5.3",
1415
"pydantic-settings~=2.1.0",
1516
]

whitelist.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
dirname
2+
dotenv
23
exinfo
34
pydantic

0 commit comments

Comments
 (0)