Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support multiple dotenv files (#1497) #3222

Merged
merged 18 commits into from
Aug 12, 2022
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/3222-rekyungmin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
add support multiple dotenv files
12 changes: 12 additions & 0 deletions docs/usage/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,18 @@ Passing a file path via the `_env_file` keyword argument on instantiation (metho
the value (if any) set on the `Config` class. If the above snippets were used in conjunction, `prod.env` would be loaded
while `.env` would be ignored.

If you need to load multiple dotenv files, you can pass the file paths as `list` or `tuple`.
First item is the highest priority.
```py
class Settings(BaseSettings):
...

class Config:
# `.env.prod` takes priority over `.env`
env_file = ['.env.prod', '.env']
env_file_encoding = 'utf-8'
```
rekyungmin marked this conversation as resolved.
Show resolved Hide resolved

You can also use the keyword argument override to tell Pydantic not to load any file at all (even if one is set in
the `Config` class) by passing `None` as the instantiation keyword argument, e.g. `settings = Settings(_env_file=None)`.

Expand Down
66 changes: 48 additions & 18 deletions pydantic/env_settings.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import os
import warnings
from pathlib import Path
from typing import AbstractSet, Any, Callable, ClassVar, Dict, List, Mapping, Optional, Tuple, Type, Union
from typing import (
AbstractSet,
Any,
Callable,
ClassVar,
Dict,
List,
Mapping,
MutableMapping,
Optional,
Tuple,
Type,
Union,
)

from .config import BaseConfig, Extra
from .fields import ModelField
Expand All @@ -12,6 +25,8 @@
env_file_sentinel = str(object())

SettingsSourceCallable = Callable[['BaseSettings'], Dict[str, Any]]
MultiDotenvType = Union[List[StrPath], Tuple[StrPath, ...]]
DotenvType = Union[StrPath, MultiDotenvType]
rekyungmin marked this conversation as resolved.
Show resolved Hide resolved


class SettingsError(ValueError):
Expand All @@ -28,7 +43,7 @@ class BaseSettings(BaseModel):

def __init__(
__pydantic_self__,
_env_file: Optional[StrPath] = env_file_sentinel,
_env_file: Optional[DotenvType] = env_file_sentinel,
_env_file_encoding: Optional[str] = None,
_env_nested_delimiter: Optional[str] = None,
_secrets_dir: Optional[StrPath] = None,
Expand All @@ -48,7 +63,7 @@ def __init__(
def _build_values(
self,
init_kwargs: Dict[str, Any],
_env_file: Optional[StrPath] = None,
_env_file: Optional[DotenvType] = None,
_env_file_encoding: Optional[str] = None,
_env_nested_delimiter: Optional[str] = None,
_secrets_dir: Optional[StrPath] = None,
Expand Down Expand Up @@ -145,9 +160,12 @@ class EnvSettingsSource:
__slots__ = ('env_file', 'env_file_encoding', 'env_nested_delimiter')

def __init__(
self, env_file: Optional[StrPath], env_file_encoding: Optional[str], env_nested_delimiter: Optional[str] = None
self,
env_file: Optional[DotenvType],
env_file_encoding: Optional[str],
env_nested_delimiter: Optional[str] = None,
):
self.env_file: Optional[StrPath] = env_file
self.env_file: Optional[DotenvType] = env_file
self.env_file_encoding: Optional[str] = env_file_encoding
self.env_nested_delimiter: Optional[str] = env_nested_delimiter

Expand All @@ -157,20 +175,14 @@ def __call__(self, settings: BaseSettings) -> Dict[str, Any]: # noqa C901
"""
d: Dict[str, Any] = {}

if settings.__config__.case_sensitive:
env_vars: Mapping[str, Optional[str]] = os.environ
case_sensitive = settings.__config__.case_sensitive
process_env_vars: MutableMapping[str, str]
if case_sensitive:
process_env_vars = os.environ
else:
env_vars = {k.lower(): v for k, v in os.environ.items()}

if self.env_file is not None:
env_path = Path(self.env_file).expanduser()
if env_path.is_file():
env_vars = {
**read_env_file(
env_path, encoding=self.env_file_encoding, case_sensitive=settings.__config__.case_sensitive
),
**env_vars,
}
process_env_vars = {k.lower(): v for k, v in os.environ.items()}
dotenv_vars = self._read_env_files(case_sensitive)
env_vars = {**dotenv_vars, **process_env_vars}

for field in settings.__fields__.values():
env_val: Optional[str] = None
Expand Down Expand Up @@ -204,6 +216,24 @@ def __call__(self, settings: BaseSettings) -> Dict[str, Any]: # noqa C901

return d

def _read_env_files(self, case_sensitive: bool) -> Dict[str, Optional[str]]:
env_files = self.env_file
if env_files is None:
return {}

if isinstance(env_files, (str, os.PathLike)):
env_files = [env_files]

dotenv_vars = {}
for env_file in reversed(env_files):
env_path = Path(env_file).expanduser()
if env_path.is_file():
dotenv_vars.update(
read_env_file(env_path, encoding=self.env_file_encoding, case_sensitive=case_sensitive)
)

return dotenv_vars

def field_is_complex(self, field: ModelField) -> Tuple[bool, bool]:
"""
Find out if a field is complex, and if so whether JSON errors should be ignored
Expand Down
2 changes: 1 addition & 1 deletion pydantic/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ def __init__(__pydantic_self__, **data: Any) -> None:
object_setattr(__pydantic_self__, '__fields_set__', fields_set)
__pydantic_self__._init_private_attributes()

@no_type_check
@no_type_check # noqa: C901 (ignore complexity)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@no_type_check # noqa: C901 (ignore complexity)
@no_type_check

def __setattr__(self, name, value): # noqa: C901 (ignore complexity)
if name in self.__private_attributes__:
return object_setattr(self, name, value)
Expand Down
73 changes: 73 additions & 0 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,79 @@ class Settings(BaseSettings):
assert s.dict() == {'pika': 'p!±@'}


test_default_env_file = """\
debug_mode=true
host=localhost
Port=8000
"""

test_prod_env_file = """\
debug_mode=false
host=https://example.com/services
"""


@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
def test_multiple_env_file(tmp_path):
base_env = tmp_path / '.env'
base_env.write_text(test_default_env_file)
prod_env = tmp_path / '.env.prod'
prod_env.write_text(test_prod_env_file)

class Settings(BaseSettings):
debug_mode: bool
host: str
port: int

class Config:
env_file = [prod_env, base_env]

s = Settings()
assert s.debug_mode is False
assert s.host == 'https://example.com/services'
assert s.port == 8000


@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
def test_multiple_env_file_encoding(tmp_path):
base_env = tmp_path / '.env'
base_env.write_text('pika=p!±@', encoding='latin-1')
prod_env = tmp_path / '.env.prod'
prod_env.write_text('pika=chu!±@', encoding='latin-1')

class Settings(BaseSettings):
pika: str

s = Settings(_env_file=[prod_env, base_env], _env_file_encoding='latin-1')
assert s.pika == 'chu!±@'


@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
def test_read_dotenv_vars(tmp_path):
base_env = tmp_path / '.env'
base_env.write_text(test_default_env_file)
prod_env = tmp_path / '.env.prod'
prod_env.write_text(test_prod_env_file)

source = EnvSettingsSource(env_file=[prod_env, base_env], env_file_encoding='utf8')
assert source._read_env_files(case_sensitive=False) == {
'debug_mode': 'false',
'host': 'https://example.com/services',
'port': '8000',
}

assert source._read_env_files(case_sensitive=True) == {
'debug_mode': 'false',
'host': 'https://example.com/services',
'Port': '8000',
}


@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
def test_read_dotenv_vars_when_env_file_is_none():
assert EnvSettingsSource(env_file=None, env_file_encoding=None)._read_env_files(case_sensitive=False) == {}


@pytest.mark.skipif(dotenv, reason='python-dotenv is installed')
def test_dotenv_not_installed(tmp_path):
p = tmp_path / '.env'
Expand Down