Skip to content

Commit 0c52c48

Browse files
committed
add tests and docs
1 parent 1a329d4 commit 0c52c48

File tree

5 files changed

+251
-8
lines changed

5 files changed

+251
-8
lines changed

docs/index.md

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

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

pydantic_settings/sources/base.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,11 +198,17 @@ def _read_files(self, files: PathType | None, deep_merge: bool = False) -> dict[
198198
if isinstance(files, (str, os.PathLike)):
199199
files = [files]
200200
vars: dict[str, Any] = {}
201-
update = deep_update if deep_merge else dict.update
201+
update = deep_update if deep_merge else self._shallow_update
202202
for file in files:
203203
file_path = Path(file).expanduser()
204204
if file_path.is_file():
205-
update(vars, self._read_file(file_path))
205+
vars = update(vars, self._read_file(file_path))
206+
return vars
207+
208+
def _shallow_update(self, vars: dict[str, Any], updating_vars: dict[str, Any]) -> dict[str, Any]:
209+
# this mimics the semantics of pydantic._internal._utils.deep_update
210+
vars = vars.copy()
211+
vars.update(updating_vars)
206212
return vars
207213

208214
@abstractmethod

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)