Skip to content

Commit 4f24fad

Browse files
authored
feat: ignore empty env vars (#198)
1 parent 1d6950f commit 4f24fad

File tree

4 files changed

+111
-22
lines changed

4 files changed

+111
-22
lines changed

docs/index.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,11 @@ except ValidationError as e:
253253

254254
## Parsing environment variable values
255255

256+
By default environment variables are parsed verbatim, including if the value is empty. You can choose to
257+
ignore empty environment variables by setting the `env_ignore_empty` config setting to `True`. This can be
258+
useful if you would prefer to use the default value for a field rather than an empty value from the
259+
environment.
260+
256261
For most simple field types (such as `int`, `float`, `str`, etc.), the environment variable value is parsed
257262
the same way it would be if passed directly to the initialiser (as a string).
258263

pydantic_settings/main.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class SettingsConfigDict(ConfigDict, total=False):
2424
env_prefix: str
2525
env_file: DotenvType | None
2626
env_file_encoding: str | None
27+
env_ignore_empty: bool
2728
env_nested_delimiter: str | None
2829
secrets_dir: str | Path | None
2930

@@ -53,6 +54,7 @@ class BaseSettings(BaseModel):
5354
means that the value from `model_config['env_file']` should be used. You can also pass
5455
`None` to indicate that environment variables should not be loaded from an env file.
5556
_env_file_encoding: The env file encoding, e.g. `'latin-1'`. Defaults to `None`.
57+
_env_ignore_empty: Ignore environment variables where the value is an empty string. Default to `False`.
5658
_env_nested_delimiter: The nested env values delimiter. Defaults to `None`.
5759
_secrets_dir: The secret files directory. Defaults to `None`.
5860
"""
@@ -63,6 +65,7 @@ def __init__(
6365
_env_prefix: str | None = None,
6466
_env_file: DotenvType | None = ENV_FILE_SENTINEL,
6567
_env_file_encoding: str | None = None,
68+
_env_ignore_empty: bool | None = None,
6669
_env_nested_delimiter: str | None = None,
6770
_secrets_dir: str | Path | None = None,
6871
**values: Any,
@@ -75,6 +78,7 @@ def __init__(
7578
_env_prefix=_env_prefix,
7679
_env_file=_env_file,
7780
_env_file_encoding=_env_file_encoding,
81+
_env_ignore_empty=_env_ignore_empty,
7882
_env_nested_delimiter=_env_nested_delimiter,
7983
_secrets_dir=_secrets_dir,
8084
)
@@ -111,6 +115,7 @@ def _settings_build_values(
111115
_env_prefix: str | None = None,
112116
_env_file: DotenvType | None = None,
113117
_env_file_encoding: str | None = None,
118+
_env_ignore_empty: bool | None = None,
114119
_env_nested_delimiter: str | None = None,
115120
_secrets_dir: str | Path | None = None,
116121
) -> dict[str, Any]:
@@ -121,6 +126,9 @@ def _settings_build_values(
121126
env_file_encoding = (
122127
_env_file_encoding if _env_file_encoding is not None else self.model_config.get('env_file_encoding')
123128
)
129+
env_ignore_empty = (
130+
_env_ignore_empty if _env_ignore_empty is not None else self.model_config.get('env_ignore_empty')
131+
)
124132
env_nested_delimiter = (
125133
_env_nested_delimiter
126134
if _env_nested_delimiter is not None
@@ -135,6 +143,7 @@ def _settings_build_values(
135143
case_sensitive=case_sensitive,
136144
env_prefix=env_prefix,
137145
env_nested_delimiter=env_nested_delimiter,
146+
env_ignore_empty=env_ignore_empty,
138147
)
139148
dotenv_settings = DotEnvSettingsSource(
140149
self.__class__,
@@ -143,6 +152,7 @@ def _settings_build_values(
143152
case_sensitive=case_sensitive,
144153
env_prefix=env_prefix,
145154
env_nested_delimiter=env_nested_delimiter,
155+
env_ignore_empty=env_ignore_empty,
146156
)
147157

148158
file_secret_settings = SecretsSettingsSource(
@@ -171,6 +181,7 @@ def _settings_build_values(
171181
env_prefix='',
172182
env_file=None,
173183
env_file_encoding=None,
184+
env_ignore_empty=False,
174185
env_nested_delimiter=None,
175186
secrets_dir=None,
176187
protected_namespaces=('model_', 'settings_'),

pydantic_settings/sources.py

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -129,11 +129,18 @@ def __repr__(self) -> str:
129129

130130
class PydanticBaseEnvSettingsSource(PydanticBaseSettingsSource):
131131
def __init__(
132-
self, settings_cls: type[BaseSettings], case_sensitive: bool | None = None, env_prefix: str | None = None
132+
self,
133+
settings_cls: type[BaseSettings],
134+
case_sensitive: bool | None = None,
135+
env_prefix: str | None = None,
136+
env_ignore_empty: bool | None = None,
133137
) -> None:
134138
super().__init__(settings_cls)
135139
self.case_sensitive = case_sensitive if case_sensitive is not None else self.config.get('case_sensitive', False)
136140
self.env_prefix = env_prefix if env_prefix is not None else self.config.get('env_prefix', '')
141+
self.env_ignore_empty = (
142+
env_ignore_empty if env_ignore_empty is not None else self.config.get('env_ignore_empty', False)
143+
)
137144

138145
def _apply_case_sensitive(self, value: str) -> str:
139146
return value.lower() if not self.case_sensitive else value
@@ -279,8 +286,9 @@ def __init__(
279286
secrets_dir: str | Path | None = None,
280287
case_sensitive: bool | None = None,
281288
env_prefix: str | None = None,
289+
env_ignore_empty: bool | None = None,
282290
) -> None:
283-
super().__init__(settings_cls, case_sensitive, env_prefix)
291+
super().__init__(settings_cls, case_sensitive, env_prefix, env_ignore_empty)
284292
self.secrets_dir = secrets_dir if secrets_dir is not None else self.config.get('secrets_dir')
285293

286294
def __call__(self) -> dict[str, Any]:
@@ -367,8 +375,9 @@ def __init__(
367375
case_sensitive: bool | None = None,
368376
env_prefix: str | None = None,
369377
env_nested_delimiter: str | None = None,
378+
env_ignore_empty: bool | None = None,
370379
) -> None:
371-
super().__init__(settings_cls, case_sensitive, env_prefix)
380+
super().__init__(settings_cls, case_sensitive, env_prefix, env_ignore_empty)
372381
self.env_nested_delimiter = (
373382
env_nested_delimiter if env_nested_delimiter is not None else self.config.get('env_nested_delimiter')
374383
)
@@ -377,9 +386,7 @@ def __init__(
377386
self.env_vars = self._load_env_vars()
378387

379388
def _load_env_vars(self) -> Mapping[str, str | None]:
380-
if self.case_sensitive:
381-
return os.environ
382-
return {k.lower(): v for k, v in os.environ.items()}
389+
return parse_env_vars(os.environ, self.case_sensitive, self.env_ignore_empty)
383390

384391
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
385392
"""
@@ -562,17 +569,18 @@ def __init__(
562569
case_sensitive: bool | None = None,
563570
env_prefix: str | None = None,
564571
env_nested_delimiter: str | None = None,
572+
env_ignore_empty: bool | None = None,
565573
) -> None:
566574
self.env_file = env_file if env_file != ENV_FILE_SENTINEL else settings_cls.model_config.get('env_file')
567575
self.env_file_encoding = (
568576
env_file_encoding if env_file_encoding is not None else settings_cls.model_config.get('env_file_encoding')
569577
)
570-
super().__init__(settings_cls, case_sensitive, env_prefix, env_nested_delimiter)
578+
super().__init__(settings_cls, case_sensitive, env_prefix, env_nested_delimiter, env_ignore_empty)
571579

572580
def _load_env_vars(self) -> Mapping[str, str | None]:
573-
return self._read_env_files(self.case_sensitive)
581+
return self._read_env_files()
574582

575-
def _read_env_files(self, case_sensitive: bool) -> Mapping[str, str | None]:
583+
def _read_env_files(self) -> Mapping[str, str | None]:
576584
env_files = self.env_file
577585
if env_files is None:
578586
return {}
@@ -585,7 +593,12 @@ def _read_env_files(self, case_sensitive: bool) -> Mapping[str, str | None]:
585593
env_path = Path(env_file).expanduser()
586594
if env_path.is_file():
587595
dotenv_vars.update(
588-
read_env_file(env_path, encoding=self.env_file_encoding, case_sensitive=case_sensitive)
596+
read_env_file(
597+
env_path,
598+
encoding=self.env_file_encoding,
599+
case_sensitive=self.case_sensitive,
600+
ignore_empty=self.env_ignore_empty,
601+
)
589602
)
590603

591604
return dotenv_vars
@@ -618,14 +631,25 @@ def __repr__(self) -> str:
618631
)
619632

620633

634+
def _get_env_var_key(key: str, case_sensitive: bool = False) -> str:
635+
return key if case_sensitive else key.lower()
636+
637+
638+
def parse_env_vars(
639+
env_vars: Mapping[str, str | None], case_sensitive: bool = False, ignore_empty: bool = False
640+
) -> Mapping[str, str | None]:
641+
return {_get_env_var_key(k, case_sensitive): v for k, v in env_vars.items() if not (ignore_empty and v == '')}
642+
643+
621644
def read_env_file(
622-
file_path: Path, *, encoding: str | None = None, case_sensitive: bool = False
645+
file_path: Path,
646+
*,
647+
encoding: str | None = None,
648+
case_sensitive: bool = False,
649+
ignore_empty: bool = False,
623650
) -> Mapping[str, str | None]:
624651
file_vars: dict[str, str | None] = dotenv_values(file_path, encoding=encoding or 'utf8')
625-
if not case_sensitive:
626-
return {k.lower(): v for k, v in file_vars.items()}
627-
else:
628-
return file_vars
652+
return parse_env_vars(file_vars, case_sensitive, ignore_empty)
629653

630654

631655
def _annotation_is_complex(annotation: type[Any] | None, metadata: list[Any]) -> bool:

tests/test_settings.py

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ class SimpleSettings(BaseSettings):
4646
apple: str
4747

4848

49+
class SettingWithIgnoreEmpty(BaseSettings):
50+
apple: str = 'default'
51+
52+
model_config = SettingsConfigDict(env_ignore_empty=True)
53+
54+
4955
def test_sub_env(env):
5056
env.set('apple', 'hello')
5157
s = SimpleSettings()
@@ -71,6 +77,44 @@ def test_other_setting():
7177
SimpleSettings(apple='a', foobar=42)
7278

7379

80+
def test_ignore_empty_when_empty_uses_default(env):
81+
env.set('apple', '')
82+
s = SettingWithIgnoreEmpty()
83+
assert s.apple == 'default'
84+
85+
86+
def test_ignore_empty_when_not_empty_uses_value(env):
87+
env.set('apple', 'a')
88+
s = SettingWithIgnoreEmpty()
89+
assert s.apple == 'a'
90+
91+
92+
def test_ignore_empty_with_dotenv_when_empty_uses_default(tmp_path):
93+
p = tmp_path / '.env'
94+
p.write_text('a=')
95+
96+
class Settings(BaseSettings):
97+
a: str = 'default'
98+
99+
model_config = SettingsConfigDict(env_file=p, env_ignore_empty=True)
100+
101+
s = Settings()
102+
assert s.a == 'default'
103+
104+
105+
def test_ignore_empty_with_dotenv_when_not_empty_uses_value(tmp_path):
106+
p = tmp_path / '.env'
107+
p.write_text('a=b')
108+
109+
class Settings(BaseSettings):
110+
a: str = 'default'
111+
112+
model_config = SettingsConfigDict(env_file=p, env_ignore_empty=True)
113+
114+
s = Settings()
115+
assert s.a == 'b'
116+
117+
74118
def test_with_prefix(env):
75119
class Settings(BaseSettings):
76120
apple: str
@@ -851,7 +895,7 @@ class Settings(BaseSettings):
851895
assert s.a == 'ignore non-file'
852896

853897

854-
def test_read_env_file_cast_sensitive(tmp_path):
898+
def test_read_env_file_case_sensitive(tmp_path):
855899
p = tmp_path / '.env'
856900
p.write_text('a="test"\nB=123')
857901

@@ -976,14 +1020,19 @@ def test_read_dotenv_vars(tmp_path):
9761020
prod_env = tmp_path / '.env.prod'
9771021
prod_env.write_text(test_prod_env_file)
9781022

979-
source = DotEnvSettingsSource(BaseSettings(), env_file=[base_env, prod_env], env_file_encoding='utf8')
980-
assert source._read_env_files(case_sensitive=False) == {
1023+
source = DotEnvSettingsSource(
1024+
BaseSettings(), env_file=[base_env, prod_env], env_file_encoding='utf8', case_sensitive=False
1025+
)
1026+
assert source._read_env_files() == {
9811027
'debug_mode': 'false',
9821028
'host': 'https://example.com/services',
9831029
'port': '8000',
9841030
}
9851031

986-
assert source._read_env_files(case_sensitive=True) == {
1032+
source = DotEnvSettingsSource(
1033+
BaseSettings(), env_file=[base_env, prod_env], env_file_encoding='utf8', case_sensitive=True
1034+
)
1035+
assert source._read_env_files() == {
9871036
'debug_mode': 'false',
9881037
'host': 'https://example.com/services',
9891038
'Port': '8000',
@@ -992,9 +1041,9 @@ def test_read_dotenv_vars(tmp_path):
9921041

9931042
def test_read_dotenv_vars_when_env_file_is_none():
9941043
assert (
995-
DotEnvSettingsSource(BaseSettings(), env_file=None, env_file_encoding=None)._read_env_files(
996-
case_sensitive=False
997-
)
1044+
DotEnvSettingsSource(
1045+
BaseSettings(), env_file=None, env_file_encoding=None, case_sensitive=False
1046+
)._read_env_files()
9981047
== {}
9991048
)
10001049

0 commit comments

Comments
 (0)