Skip to content

Commit b534e7c

Browse files
author
Johannes Rueschel
committed
Apply source order: init > env > dotenv > secrets > defaults and preserve earlier values
- Deep merge for config files using deep_update in ConfigFileSourceMixin._read_files - Deterministic alias selection for init kwargs (prefer first alias in validation_alias; field name allowed with populate_by_name=True).
1 parent 3e66430 commit b534e7c

File tree

3 files changed

+358
-6
lines changed

3 files changed

+358
-6
lines changed

pydantic_settings/sources/base.py

Lines changed: 23 additions & 6 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_extensions import get_args
1919
from typing_inspection import typing_objects
@@ -202,7 +202,9 @@ def _read_files(self, files: PathType | None) -> dict[str, Any]:
202202
for file in files:
203203
file_path = Path(file).expanduser()
204204
if file_path.is_file():
205-
vars.update(self._read_file(file_path))
205+
file_data = self._read_file(file_path)
206+
# Deep merge so later files override earlier nested keys instead of replacing whole objects
207+
vars = deep_update(vars, file_data)
206208
return vars
207209

208210
@abstractmethod
@@ -265,12 +267,27 @@ def __init__(
265267
init_kwarg_names = set(init_kwargs.keys())
266268
for field_name, field_info in settings_cls.model_fields.items():
267269
alias_names, *_ = _get_alias_names(field_name, field_info)
268-
init_kwarg_name = init_kwarg_names & set(alias_names)
270+
# When populate_by_name is True, allow using the field name as an input key,
271+
# but normalize to the preferred alias to keep keys consistent across sources.
272+
matchable_names = set(alias_names)
273+
include_name = settings_cls.model_config.get('populate_by_name', False)
274+
if include_name:
275+
matchable_names.add(field_name)
276+
init_kwarg_name = init_kwarg_names & matchable_names
269277
if init_kwarg_name:
270-
preferred_alias = alias_names[0]
271-
preferred_set_alias = next(alias for alias in alias_names if alias in init_kwarg_name)
278+
preferred_alias = alias_names[0] if alias_names else field_name
279+
# Choose provided key deterministically: prefer the first alias in alias_names order;
280+
# fall back to field_name if allowed and provided.
281+
provided_key = next((alias for alias in alias_names if alias in init_kwarg_names), None)
282+
if provided_key is None and include_name and field_name in init_kwarg_names:
283+
provided_key = field_name
284+
# provided_key should not be None here because init_kwarg_name is non-empty
285+
assert provided_key is not None
272286
init_kwarg_names -= init_kwarg_name
273-
self.init_kwargs[preferred_alias] = init_kwargs[preferred_set_alias]
287+
self.init_kwargs[preferred_alias] = init_kwargs[provided_key]
288+
# Include any remaining init kwargs (e.g., extras) unchanged
289+
# Note: If populate_by_name is True and the provided key is the field name, but
290+
# no alias exists, we keep it as-is so it can be processed as extra if allowed.
274291
self.init_kwargs.update({key: val for key, val in init_kwargs.items() if key in init_kwarg_names})
275292

276293
super().__init__(settings_cls)
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
from __future__ import annotations as _annotations
2+
3+
import json
4+
import sys
5+
from pathlib import Path
6+
from typing import Optional
7+
8+
import pytest
9+
from pydantic import AnyHttpUrl, Field
10+
11+
try:
12+
import yaml # type: ignore
13+
except Exception:
14+
yaml = None
15+
16+
try:
17+
import tomli # type: ignore
18+
except Exception:
19+
tomli = None
20+
21+
from pydantic_settings import (
22+
BaseSettings,
23+
JsonConfigSettingsSource,
24+
PydanticBaseSettingsSource,
25+
SettingsConfigDict,
26+
TomlConfigSettingsSource,
27+
YamlConfigSettingsSource,
28+
)
29+
30+
31+
def test_init_kwargs_override_env_for_alias_with_populate_by_name(monkeypatch):
32+
class Settings(BaseSettings):
33+
abc: AnyHttpUrl = Field(validation_alias='my_abc')
34+
model_config = SettingsConfigDict(populate_by_name=True, extra='allow')
35+
36+
monkeypatch.setenv('MY_ABC', 'http://localhost.com/')
37+
38+
# Passing by field name should be accepted (populate_by_name=True) and should
39+
# override env-derived value. Also ensures init > env precedence with validation_alias.
40+
assert str(Settings(abc='http://prod.localhost.com/').abc) == 'http://prod.localhost.com/'
41+
42+
43+
def test_deep_merge_multiple_file_json(tmp_path: Path):
44+
p1 = tmp_path / 'a.json'
45+
p2 = tmp_path / 'b.json'
46+
47+
with open(p1, 'w') as f1:
48+
json.dump({'a': 1, 'nested': {'x': 1, 'y': 1}}, f1)
49+
with open(p2, 'w') as f2:
50+
json.dump({'b': 2, 'nested': {'y': 2, 'z': 3}}, f2)
51+
52+
class Settings(BaseSettings):
53+
a: Optional[int] = None
54+
b: Optional[int] = None
55+
nested: dict[str, int]
56+
57+
@classmethod
58+
def settings_customise_sources(
59+
cls,
60+
settings_cls: type[BaseSettings],
61+
init_settings: PydanticBaseSettingsSource,
62+
env_settings: PydanticBaseSettingsSource,
63+
dotenv_settings: PydanticBaseSettingsSource,
64+
file_secret_settings: PydanticBaseSettingsSource,
65+
) -> tuple[PydanticBaseSettingsSource, ...]:
66+
return (JsonConfigSettingsSource(settings_cls, json_file=[p1, p2]),)
67+
68+
s = Settings()
69+
assert s.a == 1
70+
assert s.b == 2
71+
assert s.nested == {'x': 1, 'y': 2, 'z': 3}
72+
73+
74+
@pytest.mark.skipif(yaml is None, reason='pyYAML is not installed')
75+
def test_deep_merge_multiple_file_yaml(tmp_path: Path):
76+
p1 = tmp_path / 'a.yaml'
77+
p2 = tmp_path / 'b.yaml'
78+
79+
p1.write_text(
80+
"""
81+
a: 1
82+
nested:
83+
x: 1
84+
y: 1
85+
"""
86+
)
87+
p2.write_text(
88+
"""
89+
b: 2
90+
nested:
91+
y: 2
92+
z: 3
93+
"""
94+
)
95+
96+
class Settings(BaseSettings):
97+
a: Optional[int] = None
98+
b: Optional[int] = None
99+
nested: dict[str, int]
100+
101+
@classmethod
102+
def settings_customise_sources(
103+
cls,
104+
settings_cls: type[BaseSettings],
105+
init_settings: PydanticBaseSettingsSource,
106+
env_settings: PydanticBaseSettingsSource,
107+
dotenv_settings: PydanticBaseSettingsSource,
108+
file_secret_settings: PydanticBaseSettingsSource,
109+
) -> tuple[PydanticBaseSettingsSource, ...]:
110+
return (YamlConfigSettingsSource(settings_cls, yaml_file=[p1, p2]),)
111+
112+
s = Settings()
113+
assert s.a == 1
114+
assert s.b == 2
115+
assert s.nested == {'x': 1, 'y': 2, 'z': 3}
116+
117+
118+
@pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, reason='tomli/tomllib is not installed')
119+
def test_deep_merge_multiple_file_toml(tmp_path: Path):
120+
p1 = tmp_path / 'a.toml'
121+
p2 = tmp_path / 'b.toml'
122+
123+
p1.write_text(
124+
"""
125+
a=1
126+
127+
[nested]
128+
x=1
129+
y=1
130+
"""
131+
)
132+
p2.write_text(
133+
"""
134+
b=2
135+
136+
[nested]
137+
y=2
138+
z=3
139+
"""
140+
)
141+
142+
class Settings(BaseSettings):
143+
a: Optional[int] = None
144+
b: Optional[int] = None
145+
nested: dict[str, int]
146+
147+
@classmethod
148+
def settings_customise_sources(
149+
cls,
150+
settings_cls: type[BaseSettings],
151+
init_settings: PydanticBaseSettingsSource,
152+
env_settings: PydanticBaseSettingsSource,
153+
dotenv_settings: PydanticBaseSettingsSource,
154+
file_secret_settings: PydanticBaseSettingsSource,
155+
) -> tuple[PydanticBaseSettingsSource, ...]:
156+
return (TomlConfigSettingsSource(settings_cls, toml_file=[p1, p2]),)
157+
158+
s = Settings()
159+
assert s.a == 1
160+
assert s.b == 2
161+
assert s.nested == {'x': 1, 'y': 2, 'z': 3}
162+
163+
164+
@pytest.mark.skipif(yaml is None, reason='pyYAML is not installed')
165+
def test_yaml_config_section_after_deep_merge(tmp_path: Path):
166+
# Ensure that config section is picked from the deep-merged data
167+
p1 = tmp_path / 'a.yaml'
168+
p2 = tmp_path / 'b.yaml'
169+
p1.write_text(
170+
"""
171+
nested:
172+
x: 1
173+
y: 1
174+
"""
175+
)
176+
p2.write_text(
177+
"""
178+
nested:
179+
y: 2
180+
z: 3
181+
other: true
182+
"""
183+
)
184+
185+
class S2(BaseSettings):
186+
x: int
187+
y: int
188+
z: int
189+
model_config = SettingsConfigDict(yaml_config_section='nested')
190+
191+
@classmethod
192+
def settings_customise_sources(
193+
cls,
194+
settings_cls: type[BaseSettings],
195+
init_settings: PydanticBaseSettingsSource,
196+
env_settings: PydanticBaseSettingsSource,
197+
dotenv_settings: PydanticBaseSettingsSource,
198+
file_secret_settings: PydanticBaseSettingsSource,
199+
) -> tuple[PydanticBaseSettingsSource, ...]:
200+
return (YamlConfigSettingsSource(settings_cls, yaml_file=[p1, p2]),)
201+
202+
s2 = S2()
203+
assert s2.model_dump() == {'x': 1, 'y': 2, 'z': 3}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
from __future__ import annotations as _annotations
2+
3+
from pathlib import Path
4+
5+
import pytest
6+
from pydantic import AnyHttpUrl, Field
7+
8+
from pydantic_settings import (
9+
BaseSettings,
10+
PydanticBaseSettingsSource,
11+
SettingsConfigDict,
12+
)
13+
14+
15+
@pytest.fixture(autouse=True)
16+
def clear_env(monkeypatch):
17+
monkeypatch.delenv('FOO', raising=False)
18+
monkeypatch.delenv('BAR', raising=False)
19+
monkeypatch.delenv('NESTED', raising=False)
20+
monkeypatch.delenv('NESTED__X', raising=False)
21+
monkeypatch.delenv('NESTED__Y', raising=False)
22+
23+
24+
def test_init_kwargs_override_env_for_alias_with_populate_by_name(monkeypatch):
25+
class Settings(BaseSettings):
26+
abc: AnyHttpUrl = Field(validation_alias='my_abc')
27+
model_config = SettingsConfigDict(populate_by_name=True, extra='allow')
28+
29+
monkeypatch.setenv('MY_ABC', 'http://localhost.com/')
30+
31+
# Passing by field name should be accepted (populate_by_name=True) and should
32+
# override env-derived value. Also ensures init > env precedence with validation_alias.
33+
assert str(Settings(abc='http://prod.localhost.com/').abc) == 'http://prod.localhost.com/'
34+
35+
36+
def test_precedence_init_over_env(tmp_path: Path, monkeypatch):
37+
class Settings(BaseSettings):
38+
foo: str
39+
40+
monkeypatch.setenv('FOO', 'from-env')
41+
42+
# init should win over env
43+
s = Settings(foo='from-init')
44+
assert s.foo == 'from-init'
45+
46+
47+
def test_precedence_env_over_dotenv(tmp_path: Path, monkeypatch):
48+
env_file = tmp_path / '.env'
49+
env_file.write_text('FOO=from-dotenv\n')
50+
51+
class Settings(BaseSettings):
52+
foo: str
53+
54+
model_config = SettingsConfigDict(env_file=env_file)
55+
56+
# env set should override dotenv
57+
monkeypatch.setenv('FOO', 'from-env')
58+
s = Settings()
59+
assert s.foo == 'from-env'
60+
61+
62+
def test_precedence_dotenv_over_secrets(tmp_path: Path, monkeypatch):
63+
# create dotenv
64+
env_file = tmp_path / '.env'
65+
env_file.write_text('FOO=from-dotenv\n')
66+
67+
# create secrets directory with same key
68+
secrets_dir = tmp_path / 'secrets'
69+
secrets_dir.mkdir()
70+
(secrets_dir / 'FOO').write_text('from-secrets\n')
71+
72+
class Settings(BaseSettings):
73+
foo: str
74+
75+
model_config = SettingsConfigDict(env_file=env_file, secrets_dir=secrets_dir)
76+
77+
# No env set, dotenv should override secrets
78+
s = Settings()
79+
assert s.foo == 'from-dotenv'
80+
81+
82+
def test_precedence_secrets_over_defaults(tmp_path: Path):
83+
secrets_dir = tmp_path / 'secrets'
84+
secrets_dir.mkdir()
85+
(secrets_dir / 'FOO').write_text('from-secrets\n')
86+
87+
class Settings(BaseSettings):
88+
foo: str = 'from-default'
89+
90+
model_config = SettingsConfigDict(secrets_dir=secrets_dir)
91+
92+
s = Settings()
93+
assert s.foo == 'from-secrets'
94+
95+
96+
def test_merging_preserves_earlier_values(tmp_path: Path, monkeypatch):
97+
# Prove that merging preserves earlier source values: init -> env -> dotenv -> secrets -> defaults
98+
# We'll populate nested from dotenv and env parts, then set a default for a, and init for b
99+
env_file = tmp_path / '.env'
100+
env_file.write_text('NESTED={"x":1}\n')
101+
102+
secrets_dir = tmp_path / 'secrets'
103+
secrets_dir.mkdir()
104+
(secrets_dir / 'NESTED').write_text('{"y": 2}')
105+
106+
class Settings(BaseSettings):
107+
a: int = 10
108+
b: int = 0
109+
nested: dict
110+
111+
model_config = SettingsConfigDict(env_file=env_file, secrets_dir=secrets_dir, env_nested_delimiter='__')
112+
113+
@classmethod
114+
def settings_customise_sources(
115+
cls,
116+
settings_cls: type[BaseSettings],
117+
init_settings: PydanticBaseSettingsSource,
118+
env_settings: PydanticBaseSettingsSource,
119+
dotenv_settings: PydanticBaseSettingsSource,
120+
file_secret_settings: PydanticBaseSettingsSource,
121+
):
122+
# normal order; we want to assert deep merging
123+
return init_settings, env_settings, dotenv_settings, file_secret_settings
124+
125+
# env contributes nested.y and overrides dotenv nested.x=1 if set; we'll set only y to prove merge
126+
monkeypatch.setenv('NESTED__y', '3')
127+
# init contributes b, defaults contribute a
128+
s = Settings(b=20)
129+
assert s.a == 10 # defaults preserved
130+
assert s.b == 20 # init wins
131+
# nested: dotenv provides x=1; env provides y=3; deep merged => {x:1, y:3}
132+
assert s.nested == {'x': 1, 'y': 3}

0 commit comments

Comments
 (0)