Skip to content

Commit 45f63f9

Browse files
authored
Considering extra config in dotenv source (#39)
1 parent 8e9236d commit 45f63f9

File tree

3 files changed

+106
-14
lines changed

3 files changed

+106
-14
lines changed

docs/index.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,10 @@ Because python-dotenv is used to parse the file, bash-like semantics such as `ex
299299
(depending on your OS and environment) may allow your dotenv file to also be used with `source`,
300300
see [python-dotenv's documentation](https://saurabh-kumar.com/python-dotenv/#usages) for more details.
301301

302+
Pydantic settings consider `extra` config in case of dotenv file. It means if you set the `extra=forbid`
303+
on `model_config` and your dotenv file contains an entry for a field that is not defined in settings model,
304+
it will raise `ValidationError` in settings construction.
305+
302306
## Secret Support
303307

304308
Placing secret values in files is a common pattern to provide sensitive configuration to an application.

pydantic_settings/sources.py

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ class Settings(BaseSettings):
204204
return values
205205

206206
def __call__(self) -> Dict[str, Any]:
207-
d: Dict[str, Any] = {}
207+
data: Dict[str, Any] = {}
208208

209209
for field_name, field in self.settings_cls.model_fields.items():
210210
try:
@@ -223,11 +223,11 @@ def __call__(self) -> Dict[str, Any]:
223223

224224
if field_value is not None:
225225
if not self.config.get('case_sensitive', False) and lenient_issubclass(field.annotation, BaseModel):
226-
d[field_key] = self._replace_field_names_case_insensitively(field, field_value)
226+
data[field_key] = self._replace_field_names_case_insensitively(field, field_value)
227227
else:
228-
d[field_key] = field_value
228+
data[field_key] = field_value
229229

230-
return d
230+
return data
231231

232232

233233
class SecretsSettingsSource(PydanticBaseEnvSettingsSource):
@@ -449,12 +449,7 @@ def __init__(
449449
super().__init__(settings_cls, env_nested_delimiter, env_prefix_len)
450450

451451
def _load_env_vars(self) -> Mapping[str, Optional[str]]:
452-
env_vars = super()._load_env_vars()
453-
dotenv_vars = self._read_env_files(self.settings_cls.model_config.get('case_sensitive', False))
454-
if dotenv_vars:
455-
env_vars = {**dotenv_vars, **env_vars}
456-
457-
return env_vars
452+
return self._read_env_files(self.settings_cls.model_config.get('case_sensitive', False))
458453

459454
def _read_env_files(self, case_sensitive: bool) -> Mapping[str, Optional[str]]:
460455
env_files = self.env_file
@@ -464,7 +459,7 @@ def _read_env_files(self, case_sensitive: bool) -> Mapping[str, Optional[str]]:
464459
if isinstance(env_files, (str, os.PathLike)):
465460
env_files = [env_files]
466461

467-
dotenv_vars = {}
462+
dotenv_vars: Dict[str, Optional[str]] = {}
468463
for env_file in env_files:
469464
env_path = Path(env_file).expanduser()
470465
if env_path.is_file():
@@ -474,14 +469,37 @@ def _read_env_files(self, case_sensitive: bool) -> Mapping[str, Optional[str]]:
474469

475470
return dotenv_vars
476471

472+
def __call__(self) -> Dict[str, Any]:
473+
data: Dict[str, Any] = super().__call__()
474+
475+
data_lower_keys: List[str] = []
476+
if not self.settings_cls.model_config.get('case_sensitive', False):
477+
data_lower_keys = [x.lower() for x in data.keys()]
478+
479+
# As `extra` config is allowed in dotenv settings source, We have to
480+
# update data with extra env variabels from dotenv file.
481+
for env_name, env_value in self.env_vars.items():
482+
if env_value is not None:
483+
env_name_without_prefix = env_name[self.env_prefix_len :]
484+
first_key, *_ = env_name_without_prefix.split(self.env_nested_delimiter)
485+
486+
if (data_lower_keys and first_key not in data_lower_keys) or (
487+
not data_lower_keys and first_key not in data
488+
):
489+
data[first_key] = env_value
490+
491+
return data
492+
477493
def __repr__(self) -> str:
478494
return (
479495
f'DotEnvSettingsSource(env_file={self.env_file!r}, env_file_encoding={self.env_file_encoding!r}, '
480496
f'env_nested_delimiter={self.env_nested_delimiter!r}, env_prefix_len={self.env_prefix_len!r})'
481497
)
482498

483499

484-
def read_env_file(file_path: Path, *, encoding: str = None, case_sensitive: bool = False) -> Dict[str, Optional[str]]:
500+
def read_env_file(
501+
file_path: Path, *, encoding: Optional[str] = None, case_sensitive: bool = False
502+
) -> Mapping[str, Optional[str]]:
485503
try:
486504
from dotenv import dotenv_values
487505
except ImportError as e:

tests/test_settings.py

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -680,12 +680,17 @@ class Settings(BaseSettings):
680680
b: str
681681
c: str
682682

683-
model_config = ConfigDict(env_file=p, case_sensitive=True)
683+
model_config = ConfigDict(env_file=p, case_sensitive=True, extra='ignore')
684684

685685
with pytest.raises(ValidationError) as exc_info:
686686
Settings()
687687
assert exc_info.value.errors() == [
688-
{'type': 'missing', 'loc': ('a',), 'msg': 'Field required', 'input': {'b': 'better string', 'c': 'best string'}}
688+
{
689+
'type': 'missing',
690+
'loc': ('a',),
691+
'msg': 'Field required',
692+
'input': {'b': 'better string', 'c': 'best string', 'A': 'good string'},
693+
}
689694
]
690695

691696

@@ -1512,3 +1517,68 @@ class Settings(BaseSettings):
15121517
assert s.nested.SUB_sub.Val2 == 'v2'
15131518
assert s.nested.SUB_sub.SUB_sub_SuB.VaL3 == 'v3'
15141519
assert s.nested.SUB_sub.SUB_sub_SuB.val4 == 'v4'
1520+
1521+
1522+
@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
1523+
def test_dotenv_extra_allow(tmp_path):
1524+
p = tmp_path / '.env'
1525+
p.write_text('a=b\nx=y')
1526+
1527+
class Settings(BaseSettings):
1528+
a: str
1529+
1530+
model_config = ConfigDict(env_file=p, extra='allow')
1531+
1532+
s = Settings()
1533+
assert s.a == 'b'
1534+
assert s.x == 'y'
1535+
1536+
1537+
@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
1538+
def test_dotenv_extra_forbid(tmp_path):
1539+
p = tmp_path / '.env'
1540+
p.write_text('a=b\nx=y')
1541+
1542+
class Settings(BaseSettings):
1543+
a: str
1544+
1545+
model_config = ConfigDict(env_file=p, extra='forbid')
1546+
1547+
with pytest.raises(ValidationError) as exc_info:
1548+
Settings()
1549+
assert exc_info.value.errors() == [
1550+
{'type': 'extra_forbidden', 'loc': ('x',), 'msg': 'Extra inputs are not permitted', 'input': 'y'}
1551+
]
1552+
1553+
1554+
@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
1555+
def test_dotenv_extra_case_insensitive(tmp_path):
1556+
p = tmp_path / '.env'
1557+
p.write_text('a=b')
1558+
1559+
class Settings(BaseSettings):
1560+
A: str
1561+
1562+
model_config = ConfigDict(env_file=p, extra='forbid')
1563+
1564+
s = Settings()
1565+
assert s.A == 'b'
1566+
1567+
1568+
@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
1569+
def test_dotenv_extra_sub_model_case_insensitive(tmp_path):
1570+
p = tmp_path / '.env'
1571+
p.write_text('a=b\nSUB_model={"v": "v1"}')
1572+
1573+
class SubModel(BaseModel):
1574+
v: str
1575+
1576+
class Settings(BaseSettings):
1577+
A: str
1578+
sub_MODEL: SubModel
1579+
1580+
model_config = ConfigDict(env_file=p, extra='forbid')
1581+
1582+
s = Settings()
1583+
assert s.A == 'b'
1584+
assert s.sub_MODEL.v == 'v1'

0 commit comments

Comments
 (0)