Skip to content

Commit 61e0b46

Browse files
authored
add deep merge functionality to config file sources (#698)
1 parent fc8a694 commit 61e0b46

File tree

8 files changed

+269
-13
lines changed

8 files changed

+269
-13
lines changed

docs/index.md

Lines changed: 131 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2401,12 +2401,7 @@ Other settings sources are available for common configuration files:
24012401
- `TomlConfigSettingsSource` using `toml_file` argument
24022402
- `YamlConfigSettingsSource` using `yaml_file` and yaml_file_encoding arguments
24032403

2404-
You can also provide multiple files by providing a list of path:
2405-
```py
2406-
toml_file = ['config.default.toml', 'config.custom.toml']
2407-
```
2408-
To use them, you can use the same mechanism described [here](#customise-settings-sources)
2409-
2404+
To use them, you can use the same mechanism described [here](#customise-settings-sources).
24102405

24112406
```py
24122407
from pydantic import BaseModel
@@ -2448,6 +2443,136 @@ foobar = "Hello"
24482443
nested_field = "world!"
24492444
```
24502445

2446+
You can also provide multiple files by providing a list of paths.
2447+
2448+
```py
2449+
from pydantic import BaseModel
2450+
2451+
from pydantic_settings import (
2452+
BaseSettings,
2453+
PydanticBaseSettingsSource,
2454+
SettingsConfigDict,
2455+
TomlConfigSettingsSource,
2456+
)
2457+
2458+
2459+
class Nested(BaseModel):
2460+
foo: int
2461+
bar: int = 0
2462+
2463+
2464+
class Settings(BaseSettings):
2465+
hello: str
2466+
nested: Nested
2467+
model_config = SettingsConfigDict(
2468+
toml_file=['config.default.toml', 'config.custom.toml']
2469+
)
2470+
2471+
@classmethod
2472+
def settings_customise_sources(
2473+
cls,
2474+
settings_cls: type[BaseSettings],
2475+
init_settings: PydanticBaseSettingsSource,
2476+
env_settings: PydanticBaseSettingsSource,
2477+
dotenv_settings: PydanticBaseSettingsSource,
2478+
file_secret_settings: PydanticBaseSettingsSource,
2479+
) -> tuple[PydanticBaseSettingsSource, ...]:
2480+
return (TomlConfigSettingsSource(settings_cls),)
2481+
```
2482+
2483+
The following two configuration files
2484+
2485+
```toml
2486+
# config.default.toml
2487+
hello = "World"
2488+
2489+
[nested]
2490+
foo = 1
2491+
bar = 2
2492+
```
2493+
2494+
```toml
2495+
# config.custom.toml
2496+
[nested]
2497+
foo = 3
2498+
```
2499+
2500+
are equivalent to
2501+
2502+
```toml
2503+
hello = "world"
2504+
2505+
[nested]
2506+
foo = 3
2507+
```
2508+
2509+
The files are merged shallowly in increasing order of priority. To enable deep merging, set `deep_merge=True` on the source directly.
2510+
2511+
!!! warning
2512+
The `deep_merge` option is **not available** through the `SettingsConfigDict`.
2513+
2514+
```py
2515+
from pydantic import BaseModel
2516+
2517+
from pydantic_settings import (
2518+
BaseSettings,
2519+
PydanticBaseSettingsSource,
2520+
SettingsConfigDict,
2521+
TomlConfigSettingsSource,
2522+
)
2523+
2524+
2525+
class Nested(BaseModel):
2526+
foo: int
2527+
bar: int = 0
2528+
2529+
2530+
class Settings(BaseSettings):
2531+
hello: str
2532+
nested: Nested
2533+
model_config = SettingsConfigDict(
2534+
toml_file=['config.default.toml', 'config.custom.toml']
2535+
)
2536+
2537+
@classmethod
2538+
def settings_customise_sources(
2539+
cls,
2540+
settings_cls: type[BaseSettings],
2541+
init_settings: PydanticBaseSettingsSource,
2542+
env_settings: PydanticBaseSettingsSource,
2543+
dotenv_settings: PydanticBaseSettingsSource,
2544+
file_secret_settings: PydanticBaseSettingsSource,
2545+
) -> tuple[PydanticBaseSettingsSource, ...]:
2546+
return (TomlConfigSettingsSource(settings_cls, deep_merge=True),)
2547+
```
2548+
2549+
With deep merge enabled, the following two configuration files
2550+
2551+
```toml
2552+
# config.default.toml
2553+
hello = "World"
2554+
2555+
[nested]
2556+
foo = 1
2557+
bar = 2
2558+
```
2559+
2560+
```toml
2561+
# config.custom.toml
2562+
[nested]
2563+
foo = 3
2564+
```
2565+
2566+
are equivalent to
2567+
2568+
```toml
2569+
hello = "world"
2570+
2571+
[nested]
2572+
foo = 3
2573+
bar = 2
2574+
```
2575+
24512576
### pyproject.toml
24522577

24532578
"pyproject.toml" is a standardized file for providing configuration values in Python projects.

pydantic_settings/sources/base.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from pydantic._internal._typing_extra import ( # type: ignore[attr-defined]
1414
get_origin,
1515
)
16-
from pydantic._internal._utils import is_model_class
16+
from pydantic._internal._utils import deep_update, is_model_class
1717
from pydantic.fields import FieldInfo
1818
from typing_inspection import typing_objects
1919
from typing_inspection.introspection import is_union_origin
@@ -192,16 +192,22 @@ def __call__(self) -> dict[str, Any]:
192192

193193

194194
class ConfigFileSourceMixin(ABC):
195-
def _read_files(self, files: PathType | None) -> dict[str, Any]:
195+
def _read_files(self, files: PathType | None, deep_merge: bool = False) -> dict[str, Any]:
196196
if files is None:
197197
return {}
198198
if isinstance(files, (str, os.PathLike)):
199199
files = [files]
200200
vars: dict[str, Any] = {}
201201
for file in files:
202202
file_path = Path(file).expanduser()
203-
if file_path.is_file():
204-
vars.update(self._read_file(file_path))
203+
if not file_path.is_file():
204+
continue
205+
206+
updating_vars = self._read_file(file_path)
207+
if deep_merge:
208+
vars = deep_update(vars, updating_vars)
209+
else:
210+
vars.update(updating_vars)
205211
return vars
206212

207213
@abstractmethod

pydantic_settings/sources/providers/json.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,15 @@ def __init__(
2626
settings_cls: type[BaseSettings],
2727
json_file: PathType | None = DEFAULT_PATH,
2828
json_file_encoding: str | None = None,
29+
deep_merge: bool = False,
2930
):
3031
self.json_file_path = json_file if json_file != DEFAULT_PATH else settings_cls.model_config.get('json_file')
3132
self.json_file_encoding = (
3233
json_file_encoding
3334
if json_file_encoding is not None
3435
else settings_cls.model_config.get('json_file_encoding')
3536
)
36-
self.json_data = self._read_files(self.json_file_path)
37+
self.json_data = self._read_files(self.json_file_path, deep_merge=deep_merge)
3738
super().__init__(settings_cls, self.json_data)
3839

3940
def _read_file(self, file_path: Path) -> dict[str, Any]:

pydantic_settings/sources/providers/toml.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,10 @@ def __init__(
5050
self,
5151
settings_cls: type[BaseSettings],
5252
toml_file: PathType | None = DEFAULT_PATH,
53+
deep_merge: bool = False,
5354
):
5455
self.toml_file_path = toml_file if toml_file != DEFAULT_PATH else settings_cls.model_config.get('toml_file')
55-
self.toml_data = self._read_files(self.toml_file_path)
56+
self.toml_data = self._read_files(self.toml_file_path, deep_merge=deep_merge)
5657
super().__init__(settings_cls, self.toml_data)
5758

5859
def _read_file(self, file_path: Path) -> dict[str, Any]:

pydantic_settings/sources/providers/yaml.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ def __init__(
4040
yaml_file: PathType | None = DEFAULT_PATH,
4141
yaml_file_encoding: str | None = None,
4242
yaml_config_section: str | None = None,
43+
deep_merge: bool = False,
4344
):
4445
self.yaml_file_path = yaml_file if yaml_file != DEFAULT_PATH else settings_cls.model_config.get('yaml_file')
4546
self.yaml_file_encoding = (
@@ -52,7 +53,7 @@ def __init__(
5253
if yaml_config_section is not None
5354
else settings_cls.model_config.get('yaml_config_section')
5455
)
55-
self.yaml_data = self._read_files(self.yaml_file_path)
56+
self.yaml_data = self._read_files(self.yaml_file_path, deep_merge=deep_merge)
5657

5758
if self.yaml_config_section:
5859
try:

tests/test_source_json.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import json
66
from pathlib import Path
77

8+
import pytest
89
from pydantic import BaseModel
910

1011
from pydantic_settings import (
@@ -98,3 +99,36 @@ def settings_customise_sources(
9899

99100
s = Settings()
100101
assert s.model_dump() == {'json5': 5, 'json6': 6}
102+
103+
104+
@pytest.mark.parametrize('deep_merge', [False, True])
105+
def test_multiple_file_json_merge(tmp_path, deep_merge):
106+
p5 = tmp_path / '.env.json5'
107+
p6 = tmp_path / '.env.json6'
108+
109+
with open(p5, 'w') as f5:
110+
json.dump({'hello': 'world', 'nested': {'foo': 1, 'bar': 2}}, f5)
111+
with open(p6, 'w') as f6:
112+
json.dump({'nested': {'foo': 3}}, f6)
113+
114+
class Nested(BaseModel):
115+
foo: int
116+
bar: int = 0
117+
118+
class Settings(BaseSettings):
119+
hello: str
120+
nested: Nested
121+
122+
@classmethod
123+
def settings_customise_sources(
124+
cls,
125+
settings_cls: type[BaseSettings],
126+
init_settings: PydanticBaseSettingsSource,
127+
env_settings: PydanticBaseSettingsSource,
128+
dotenv_settings: PydanticBaseSettingsSource,
129+
file_secret_settings: PydanticBaseSettingsSource,
130+
) -> tuple[PydanticBaseSettingsSource, ...]:
131+
return (JsonConfigSettingsSource(settings_cls, json_file=[p5, p6], deep_merge=deep_merge),)
132+
133+
s = Settings()
134+
assert s.model_dump() == {'hello': 'world', 'nested': {'foo': 3, 'bar': 2 if deep_merge else 0}}

tests/test_source_toml.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,47 @@ def settings_customise_sources(
114114

115115
s = Settings()
116116
assert s.model_dump() == {'toml1': 1, 'toml2': 2}
117+
118+
119+
@pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, reason='tomli/tomllib is not installed')
120+
@pytest.mark.parametrize('deep_merge', [False, True])
121+
def test_multiple_file_toml_merge(tmp_path, deep_merge):
122+
p1 = tmp_path / '.env.toml1'
123+
p2 = tmp_path / '.env.toml2'
124+
p1.write_text(
125+
"""
126+
hello = "world"
127+
128+
[nested]
129+
foo=1
130+
bar=2
131+
"""
132+
)
133+
p2.write_text(
134+
"""
135+
[nested]
136+
foo=3
137+
"""
138+
)
139+
140+
class Nested(BaseModel):
141+
foo: int
142+
bar: int = 0
143+
144+
class Settings(BaseSettings):
145+
hello: str
146+
nested: Nested
147+
148+
@classmethod
149+
def settings_customise_sources(
150+
cls,
151+
settings_cls: type[BaseSettings],
152+
init_settings: PydanticBaseSettingsSource,
153+
env_settings: PydanticBaseSettingsSource,
154+
dotenv_settings: PydanticBaseSettingsSource,
155+
file_secret_settings: PydanticBaseSettingsSource,
156+
) -> tuple[PydanticBaseSettingsSource, ...]:
157+
return (TomlConfigSettingsSource(settings_cls, toml_file=[p1, p2], deep_merge=deep_merge),)
158+
159+
s = Settings()
160+
assert s.model_dump() == {'hello': 'world', 'nested': {'foo': 3, 'bar': 2 if deep_merge else 0}}

tests/test_source_yaml.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,50 @@ def settings_customise_sources(
167167
assert s.model_dump() == {'yaml3': 3, 'yaml4': 4}
168168

169169

170+
@pytest.mark.skipif(yaml is None, reason='pyYAML is not installed')
171+
@pytest.mark.parametrize('deep_merge', [False, True])
172+
def test_multiple_file_yaml_deep_merge(tmp_path, deep_merge):
173+
p3 = tmp_path / '.env.yaml3'
174+
p4 = tmp_path / '.env.yaml4'
175+
p3.write_text(
176+
"""
177+
hello: world
178+
179+
nested:
180+
foo: 1
181+
bar: 2
182+
"""
183+
)
184+
p4.write_text(
185+
"""
186+
nested:
187+
foo: 3
188+
"""
189+
)
190+
191+
class Nested(BaseModel):
192+
foo: int
193+
bar: int = 0
194+
195+
class Settings(BaseSettings):
196+
hello: str
197+
nested: Nested
198+
199+
@classmethod
200+
def settings_customise_sources(
201+
cls,
202+
settings_cls: type[BaseSettings],
203+
init_settings: PydanticBaseSettingsSource,
204+
env_settings: PydanticBaseSettingsSource,
205+
dotenv_settings: PydanticBaseSettingsSource,
206+
file_secret_settings: PydanticBaseSettingsSource,
207+
) -> tuple[PydanticBaseSettingsSource, ...]:
208+
return (YamlConfigSettingsSource(settings_cls, yaml_file=[p3, p4], deep_merge=deep_merge),)
209+
210+
s = Settings()
211+
assert s.model_dump() == {'hello': 'world', 'nested': {'foo': 3, 'bar': 2 if deep_merge else 0}}
212+
213+
170214
@pytest.mark.skipif(yaml is None, reason='pyYAML is not installed')
171215
def test_yaml_config_section(tmp_path):
172216
p = tmp_path / '.env'

0 commit comments

Comments
 (0)