Skip to content

Commit

Permalink
Considering extra config in dotenv source
Browse files Browse the repository at this point in the history
  • Loading branch information
hramezani committed Apr 28, 2023
1 parent 0ff45ea commit ffdd7c3
Show file tree
Hide file tree
Showing 3 changed files with 63 additions and 14 deletions.
4 changes: 4 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,10 @@ Because python-dotenv is used to parse the file, bash-like semantics such as `ex
(depending on your OS and environment) may allow your dotenv file to also be used with `source`,
see [python-dotenv's documentation](https://saurabh-kumar.com/python-dotenv/#usages) for more details.

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

## Secret Support

Placing secret values in files is a common pattern to provide sensitive configuration to an application.
Expand Down
32 changes: 20 additions & 12 deletions pydantic_settings/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ class Settings(BaseSettings):
return values

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

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

if field_value is not None:
if not self.config.get('case_sensitive', False) and lenient_issubclass(field.annotation, BaseModel):
d[field_key] = self._replace_field_names_case_insensitively(field, field_value)
data[field_key] = self._replace_field_names_case_insensitively(field, field_value)
else:
d[field_key] = field_value
data[field_key] = field_value

return d
return data


class SecretsSettingsSource(PydanticBaseEnvSettingsSource):
Expand Down Expand Up @@ -449,12 +449,7 @@ def __init__(
super().__init__(settings_cls, env_nested_delimiter, env_prefix_len)

def _load_env_vars(self) -> Mapping[str, Optional[str]]:
env_vars = super()._load_env_vars()
dotenv_vars = self._read_env_files(self.settings_cls.model_config.get('case_sensitive', False))
if dotenv_vars:
env_vars = {**dotenv_vars, **env_vars}

return env_vars
return self._read_env_files(self.settings_cls.model_config.get('case_sensitive', False))

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

dotenv_vars = {}
dotenv_vars: Dict[str, Optional[str]] = {}
for env_file in env_files:
env_path = Path(env_file).expanduser()
if env_path.is_file():
Expand All @@ -474,14 +469,27 @@ def _read_env_files(self, case_sensitive: bool) -> Mapping[str, Optional[str]]:

return dotenv_vars

def __call__(self) -> Dict[str, Any]:
data: Dict[str, Any] = super().__call__()

# As `extra` config is allowed in dotenv settings source, We have to
# update data with extra env variabels from dotenv file.
for k, v in self.env_vars.items():
if v is not None and k not in data:
data[k] = v

return data

def __repr__(self) -> str:
return (
f'DotEnvSettingsSource(env_file={self.env_file!r}, env_file_encoding={self.env_file_encoding!r}, '
f'env_nested_delimiter={self.env_nested_delimiter!r}, env_prefix_len={self.env_prefix_len!r})'
)


def read_env_file(file_path: Path, *, encoding: str = None, case_sensitive: bool = False) -> Dict[str, Optional[str]]:
def read_env_file(
file_path: Path, *, encoding: Optional[str] = None, case_sensitive: bool = False
) -> Mapping[str, Optional[str]]:
try:
from dotenv import dotenv_values
except ImportError as e:
Expand Down
41 changes: 39 additions & 2 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -680,12 +680,17 @@ class Settings(BaseSettings):
b: str
c: str

model_config = ConfigDict(env_file=p, case_sensitive=True)
model_config = ConfigDict(env_file=p, case_sensitive=True, extra='ignore')

with pytest.raises(ValidationError) as exc_info:
Settings()
assert exc_info.value.errors() == [
{'type': 'missing', 'loc': ('a',), 'msg': 'Field required', 'input': {'b': 'better string', 'c': 'best string'}}
{
'type': 'missing',
'loc': ('a',),
'msg': 'Field required',
'input': {'b': 'better string', 'c': 'best string', 'A': 'good string'},
}
]


Expand Down Expand Up @@ -1512,3 +1517,35 @@ class Settings(BaseSettings):
assert s.nested.SUB_sub.Val2 == 'v2'
assert s.nested.SUB_sub.SUB_sub_SuB.VaL3 == 'v3'
assert s.nested.SUB_sub.SUB_sub_SuB.val4 == 'v4'


@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
def test_dotenv_extra_allow(tmp_path):
p = tmp_path / '.env'
p.write_text('a=b\nx=y')

class Settings(BaseSettings):
a: str

model_config = ConfigDict(env_file=p, extra='allow')

s = Settings()
assert s.a == 'b'
assert s.x == 'y'


@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
def test_dotenv_extra_forbid(tmp_path):
p = tmp_path / '.env'
p.write_text('a=b\nx=y')

class Settings(BaseSettings):
a: str

model_config = ConfigDict(env_file=p, extra='forbid')

with pytest.raises(ValidationError) as exc_info:
Settings()
assert exc_info.value.errors() == [
{'type': 'extra_forbidden', 'loc': ('x',), 'msg': 'Extra inputs are not permitted', 'input': 'y'}
]

0 comments on commit ffdd7c3

Please sign in to comment.