Skip to content

Commit 7a6e96e

Browse files
chbndrhnnsJohannes Rueschelhramezani
authored
Apply source order: init > env > dotenv > secrets > defaults and pres… (#688)
Co-authored-by: Johannes Rueschel <[email protected]> Co-authored-by: Hasan Ramezani <[email protected]>
1 parent 68563ed commit 7a6e96e

File tree

2 files changed

+137
-4
lines changed

2 files changed

+137
-4
lines changed

pydantic_settings/sources/base.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -265,12 +265,27 @@ def __init__(
265265
init_kwarg_names = set(init_kwargs.keys())
266266
for field_name, field_info in settings_cls.model_fields.items():
267267
alias_names, *_ = _get_alias_names(field_name, field_info)
268-
init_kwarg_name = init_kwarg_names & set(alias_names)
268+
# When populate_by_name is True, allow using the field name as an input key,
269+
# but normalize to the preferred alias to keep keys consistent across sources.
270+
matchable_names = set(alias_names)
271+
include_name = settings_cls.model_config.get('populate_by_name', False)
272+
if include_name:
273+
matchable_names.add(field_name)
274+
init_kwarg_name = init_kwarg_names & matchable_names
269275
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)
276+
preferred_alias = alias_names[0] if alias_names else field_name
277+
# Choose provided key deterministically: prefer the first alias in alias_names order;
278+
# fall back to field_name if allowed and provided.
279+
provided_key = next((alias for alias in alias_names if alias in init_kwarg_names), None)
280+
if provided_key is None and include_name and field_name in init_kwarg_names:
281+
provided_key = field_name
282+
# provided_key should not be None here because init_kwarg_name is non-empty
283+
assert provided_key is not None
272284
init_kwarg_names -= init_kwarg_name
273-
self.init_kwargs[preferred_alias] = init_kwargs[preferred_set_alias]
285+
self.init_kwargs[preferred_alias] = init_kwargs[provided_key]
286+
# Include any remaining init kwargs (e.g., extras) unchanged
287+
# Note: If populate_by_name is True and the provided key is the field name, but
288+
# no alias exists, we keep it as-is so it can be processed as extra if allowed.
274289
self.init_kwargs.update({key: val for key, val in init_kwargs.items() if key in init_kwarg_names})
275290

276291
super().__init__(settings_cls)
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
from __future__ import annotations as _annotations
2+
3+
from pathlib import Path
4+
5+
from pydantic import AnyHttpUrl, Field
6+
7+
from pydantic_settings import (
8+
BaseSettings,
9+
PydanticBaseSettingsSource,
10+
SettingsConfigDict,
11+
)
12+
13+
14+
def test_init_kwargs_override_env_for_alias_with_populate_by_name(env):
15+
class Settings(BaseSettings):
16+
abc: AnyHttpUrl = Field(validation_alias='my_abc')
17+
model_config = SettingsConfigDict(populate_by_name=True, extra='allow')
18+
19+
env.set('MY_ABC', 'http://localhost.com')
20+
# Passing by field name should be accepted (populate_by_name=True) and should
21+
# override env-derived value. Also ensures init > env precedence with validation_alias.
22+
assert str(Settings(abc='http://prod.localhost.com/').abc) == 'http://prod.localhost.com/'
23+
24+
25+
def test_precedence_init_over_env(tmp_path: Path, env):
26+
class Settings(BaseSettings):
27+
foo: str
28+
29+
env.set('FOO', 'from-env')
30+
s = Settings(foo='from-init')
31+
assert s.foo == 'from-init'
32+
33+
34+
def test_precedence_env_over_dotenv(tmp_path: Path, env):
35+
env_file = tmp_path / '.env'
36+
env_file.write_text('FOO=from-dotenv\n')
37+
38+
class Settings(BaseSettings):
39+
foo: str
40+
41+
model_config = SettingsConfigDict(env_file=env_file)
42+
43+
env.set('FOO', 'from-env')
44+
s = Settings()
45+
assert s.foo == 'from-env'
46+
47+
48+
def test_precedence_dotenv_over_secrets(tmp_path: Path):
49+
# create dotenv
50+
env_file = tmp_path / '.env'
51+
env_file.write_text('FOO=from-dotenv\n')
52+
53+
# create secrets directory with same key
54+
secrets_dir = tmp_path / 'secrets'
55+
secrets_dir.mkdir()
56+
(secrets_dir / 'FOO').write_text('from-secrets\n')
57+
58+
class Settings(BaseSettings):
59+
foo: str
60+
61+
model_config = SettingsConfigDict(env_file=env_file, secrets_dir=secrets_dir)
62+
63+
# No env set, dotenv should override secrets
64+
s = Settings()
65+
assert s.foo == 'from-dotenv'
66+
67+
68+
def test_precedence_secrets_over_defaults(tmp_path: Path):
69+
secrets_dir = tmp_path / 'secrets'
70+
secrets_dir.mkdir()
71+
(secrets_dir / 'FOO').write_text('from-secrets\n')
72+
73+
class Settings(BaseSettings):
74+
foo: str = 'from-default'
75+
76+
model_config = SettingsConfigDict(secrets_dir=secrets_dir)
77+
78+
s = Settings()
79+
assert s.foo == 'from-secrets'
80+
81+
82+
def test_merging_preserves_earlier_values(tmp_path: Path, env):
83+
# Prove that merging preserves earlier source values: init -> env -> dotenv -> secrets -> defaults
84+
# We'll populate nested from dotenv and env parts, then set a default for a, and init for b
85+
env_file = tmp_path / '.env'
86+
env_file.write_text('NESTED={"x":1}\n')
87+
88+
secrets_dir = tmp_path / 'secrets'
89+
secrets_dir.mkdir()
90+
(secrets_dir / 'NESTED').write_text('{"y": 2}')
91+
92+
class Settings(BaseSettings):
93+
a: int = 10
94+
b: int = 0
95+
nested: dict
96+
97+
model_config = SettingsConfigDict(env_file=env_file, secrets_dir=secrets_dir, env_nested_delimiter='__')
98+
99+
@classmethod
100+
def settings_customise_sources(
101+
cls,
102+
settings_cls: type[BaseSettings],
103+
init_settings: PydanticBaseSettingsSource,
104+
env_settings: PydanticBaseSettingsSource,
105+
dotenv_settings: PydanticBaseSettingsSource,
106+
file_secret_settings: PydanticBaseSettingsSource,
107+
):
108+
# normal order; we want to assert deep merging
109+
return init_settings, env_settings, dotenv_settings, file_secret_settings
110+
111+
# env contributes nested.y and overrides dotenv nested.x=1 if set; we'll set only y to prove merge
112+
env.set('NESTED__y', '3')
113+
# init contributes b, defaults contribute a
114+
s = Settings(b=20)
115+
assert s.a == 10 # defaults preserved
116+
assert s.b == 20 # init wins
117+
# nested: dotenv provides x=1; env provides y=3; deep merged => {x:1, y:3}
118+
assert s.nested == {'x': 1, 'y': 3}

0 commit comments

Comments
 (0)