8000 feat(settings): allow custom encoding for `dotenv` files (#1620) · ag-python/pydantic@0cee311 · GitHub
[go: up one dir, main page]

Skip to content

Commit 0cee311

Browse files
authored
feat(settings): allow custom encoding for dotenv files (pydantic#1620)
closes pydantic#1615
1 parent 329b1d3 commit 0cee311

File tree

4 files changed

+71
-15
lines changed

4 files changed

+71
-15
lines changed

changes/1615-PrettyWood.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Allow custom encoding for `dotenv` files.

docs/usage/settings.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,20 +91,23 @@ MY_VAR='Hello world'
9191

9292
Once you have your `.env` file filled with variables, *pydantic* supports loading it in two ways:
9393

94-
**1.** setting `env_file` on `Config` in a `BaseSettings` class:
94+
**1.** setting `env_file` (and `env_file_encoding` if you don't want the default encoding of your OS) on `Config`
95+
in a `BaseSettings` class:
9596

9697
```py
9798
class Settings(BaseSettings):
9899
...
99100

100101
class Config:
101102
env_file = '.env'
103+
env_file_encoding = 'utf-8'
102104
```
103105

104-
**2.** instantiating a `BaseSettings` derived class with the `_env_file` keyword argument:
106+
**2.** instantiating a `BaseSettings` derived class with the `_env_file` keyword argument
107+
(and the `_env_file_encoding` if needed):
105108

106109
```py
107-
settings = Settings(_env_file='prod.env')
110+
settings = Settings(_env_file='prod.env', _env_file_encoding='utf-8')
108111
```
109112

110113
In either case, the value of the passed argument can be any valid path or filename, either absolute or relative to the

pydantic/env_settings.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,28 @@ class BaseSettings(BaseModel):
2323
Heroku and any 12 factor app design.
2424
"""
2525

26-
def __init__(__pydantic_self__, _env_file: Union[Path, str, None] = env_file_sentinel, **values: Any) -> None:
26+
def __init__(
27+
__pydantic_self__,
28+
_env_file: Union[Path, str, None] = env_file_sentinel,
29+
_env_file_encoding: Optional[str] = None,
30+
**values: Any,
31+
) -> None:
2732
# Uses something other than `self` the first arg to allow "self" as a settable attribute
28-
super().__init__(**__pydantic_self__._build_values(values, _env_file=_env_file))
29-
30-
def _build_values(self, init_kwargs: Dict[str, Any], _env_file: Union[Path, str, None] = None) -> Dict[str, Any]:
31-
return deep_update(self._build_environ(_env_file), init_kwargs)
32-
33-
def _build_environ(self, _env_file: Union[Path, str, None] = None) -> Dict[str, Optional[str]]:
33+
super().__init__(
34+
**__pydantic_self__._build_values(values, _env_file=_env_file, _env_file_encoding=_env_file_encoding)
35+
)
36+
37+
def _build_values(
38+
self,
39+
init_kwargs: Dict[str, Any],
40+
_env_file: Union[Path, str, None] = None,
41+
_env_file_encoding: Optional[str] = None,
42+
) -> Dict[str, Any]:
43+
return deep_update(self._build_environ(_env_file, _env_file_encoding), init_kwargs)
44+
45+
def _build_environ(
46+
self, _env_file: Union[Path, str, None] = None, _env_file_encoding: Optional[str] = None
47+
) -> Dict[str, Optional[str]]:
3448
"""
3549
Build environment variables suitable for passing to the Model.
3650
"""
@@ -42,10 +56,16 @@ def _build_environ(self, _env_file: Union[Path, str, None] = None) -> Dict[str,
4256
env_vars = {k.lower(): v for k, v in os.environ.items()}
4357

4458
env_file = _env_file if _env_file != env_file_sentinel else self.__config__.env_file
59+
env_file_encoding = _env_file_encoding if _env_file_encoding is not None else self.__config__.env_file_encoding
4560
if env_file is not None:
4661
env_path = Path(env_file)
4762
if env_path.is_file():
48-
env_vars = {**read_env_file(env_path, case_sensitive=self.__config__.case_sensitive), **env_vars}
63+
env_vars = {
64+
**read_env_file(
65+
env_path, encoding=env_file_encoding, case_sensitive=self.__config__.case_sensitive
66+
),
67+
**env_vars,
68+
}
4969

5070
for field in self.__fields__.values():
5171
env_val: Optional[str] = None
@@ -68,6 +88,7 @@ def _build_environ(self, _env_file: Union[Path, str, None] = None) -> Dict[str,
6888
class Config:
6989
env_prefix = ''
7090
env_file = None
91+
env_file_encoding = None
7192
validate_all = True
7293
extra = Extra.forbid
7394
arbitrary_types_allowed = True
@@ -102,13 +123,13 @@ def prepare_field(cls, field: ModelField) -> None:
102123
__config__: Config # type: ignore
103124

104125

105-
def read_env_file(file_path: Path, *, case_sensitive: bool = False) -> Dict[str, Optional[str]]:
126+
def read_env_file(file_path: Path, *, encoding: str = None, case_sensitive: bool = False) -> Dict[str, Optional[str]]:
106127
try:
107128
from dotenv import dotenv_values
108129
except ImportError as e:
109130
raise ImportError('python-dotenv is not installed, run `pip install pydantic[dotenv]`') from e
110131

111-
file_vars: Dict[str, Optional[str]] = dotenv_values(file_path)
132+
file_vars: Dict[str, Optional[str]] = dotenv_values(file_path, encoding=encoding)
112133
if not case_sensitive:
113134
return {k.lower(): v for k, v in file_vars.items()}
114135
else:

tests/test_settings.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ class Settings(BaseSettings):
320320
foo: int
321321
bar: str
322322

323-
def _build_values(self, init_kwargs, _env_file):
323+
def _build_values(self, init_kwargs, _env_file, _env_file_encoding):
324324
return {**init_kwargs, **self._build_environ()}
325325

326326
env.set('BAR', 'env setting')
@@ -340,7 +340,7 @@ class Settings(BaseSettings):
340340
b: str
341341
c: str
342342

343-
def _build_values(self, init_kwargs, _env_file):
343+
def _build_values(self, init_kwargs, _env_file, _env_file_encoding):
344344
config_settings = init_kwargs.pop('__config_settings__')
345345
return {**config_settings, **init_kwargs, **self._build_environ()}
346346

@@ -430,6 +430,22 @@ class Config:
430430
assert s.c == 'best string'
431431

432432

433+
@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
434+
def test_env_file_config_custom_encoding(tmp_path):
435+
p = tmp_path / '.env'
436+
p.write_text('pika=p!±@', encoding='latin-1')
437+
438+
class Settings(BaseSettings):
439+
pika: str
440+
441+
class Config:
442+
env_file = p
443+
env_file_encoding = 'latin-1'
444+
445+
s = Settings()
446+
assert s.pika == 'p!±@'
447+
448+
433449
@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
434450
def test_env_file_none(tmp_path):
435451
p = tmp_path / '.env'
@@ -529,6 +545,21 @@ class Settings(BaseSettings):
529545
}
530546

531547

548+
@pytest.mark.skipif(not dotenv, reason='python-dotenv not installed')
549+
def test_env_file_custom_encoding(tmp_path):
550+
p = tmp_path / '.env'
551+
p.write_text('pika=p!±@', encoding='latin-1')
552+
553+
class Settings(BaseSettings):
554+
pika: str
555+
556+
with pytest.raises(UnicodeDecodeError):
557+
Settings(_env_file=str(p))
558+
559+
s = Settings(_env_file=str(p), _env_file_encoding='latin-1')
560+
assert s.dict() == {'pika': 'p!±@'}
561+
562+
532563
@pytest.mark.skipif(dotenv, reason='python-dotenv is installed')
533564
def test_dotenv_not_installed(tmp_path):
534565
p = tmp_path / '.env'

0 commit comments

Comments
 (0)
0