Skip to content

Commit cc6dc25

Browse files
authored
Add support for parsing environment "None" strings to None. (#206)
1 parent 9c26c1e commit cc6dc25

File tree

3 files changed

+104
-7
lines changed

3 files changed

+104
-7
lines changed

pydantic_settings/main.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class SettingsConfigDict(ConfigDict, total=False):
2626
env_file_encoding: str | None
2727
env_ignore_empty: bool
2828
env_nested_delimiter: str | None
29+
env_parse_none_str: str | None
2930
secrets_dir: str | Path | None
3031

3132

@@ -56,6 +57,8 @@ class BaseSettings(BaseModel):
5657
_env_file_encoding: The env file encoding, e.g. `'latin-1'`. Defaults to `None`.
5758
_env_ignore_empty: Ignore environment variables where the value is an empty string. Default to `False`.
5859
_env_nested_delimiter: The nested env values delimiter. Defaults to `None`.
60+
_env_parse_none_str: The env string value that should be parsed (e.g. "null", "void", "None", etc.)
61+
into `None` type(None). Defaults to `None` type(None), which means no parsing should occur.
5962
_secrets_dir: The secret files directory. Defaults to `None`.
6063
"""
6164

@@ -67,6 +70,7 @@ def __init__(
6770
_env_file_encoding: str | None = None,
6871
_env_ignore_empty: bool | None = None,
6972
_env_nested_delimiter: str | None = None,
73+
_env_parse_none_str: str | None = None,
7074
_secrets_dir: str | Path | None = None,
7175
**values: Any,
7276
) -> None:
@@ -80,6 +84,7 @@ def __init__(
8084
_env_file_encoding=_env_file_encoding,
8185
_env_ignore_empty=_env_ignore_empty,
8286
_env_nested_delimiter=_env_nested_delimiter,
87+
_env_parse_none_str=_env_parse_none_str,
8388
_secrets_dir=_secrets_dir,
8489
)
8590
)
@@ -117,6 +122,7 @@ def _settings_build_values(
117122
_env_file_encoding: str | None = None,
118123
_env_ignore_empty: bool | None = None,
119124
_env_nested_delimiter: str | None = None,
125+
_env_parse_none_str: str | None = None,
120126
_secrets_dir: str | Path | None = None,
121127
) -> dict[str, Any]:
122128
# Determine settings config values
@@ -134,6 +140,9 @@ def _settings_build_values(
134140
if _env_nested_delimiter is not None
135141
else self.model_config.get('env_nested_delimiter')
136142
)
143+
env_parse_none_str = (
144+
_env_parse_none_str if _env_parse_none_str is not None else self.model_config.get('env_parse_none_str')
145+
)
137146
secrets_dir = _secrets_dir if _secrets_dir is not None else self.model_config.get('secrets_dir')
138147

139148
# Configure built-in sources
@@ -144,6 +153,7 @@ def _settings_build_values(
144153
env_prefix=env_prefix,
145154
env_nested_delimiter=env_nested_delimiter,
146155
env_ignore_empty=env_ignore_empty,
156+
env_parse_none_str=env_parse_none_str,
147157
)
148158
dotenv_settings = DotEnvSettingsSource(
149159
self.__class__,
@@ -153,6 +163,7 @@ def _settings_build_values(
153163
env_prefix=env_prefix,
154164
env_nested_delimiter=env_nested_delimiter,
155165
env_ignore_empty=env_ignore_empty,
166+
env_parse_none_str=env_parse_none_str,
156167
)
157168

158169
file_secret_settings = SecretsSettingsSource(
@@ -183,6 +194,7 @@ def _settings_build_values(
183194
env_file_encoding=None,
184195
env_ignore_empty=False,
185196
env_nested_delimiter=None,
197+
env_parse_none_str=None,
186198
secrets_dir=None,
187199
protected_namespaces=('model_', 'settings_'),
188200
)

pydantic_settings/sources.py

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
ENV_FILE_SENTINEL: DotenvType = Path('')
3131

3232

33+
class EnvNoneType(str):
34+
pass
35+
36+
3337
class SettingsError(ValueError):
3438
pass
3539

@@ -134,13 +138,17 @@ def __init__(
134138
case_sensitive: bool | None = None,
135139
env_prefix: str | None = None,
136140
env_ignore_empty: bool | None = None,
141+
env_parse_none_str: str | None = None,
137142
) -> None:
138143
super().__init__(settings_cls)
139144
self.case_sensitive = case_sensitive if case_sensitive is not None else self.config.get('case_sensitive', False)
140145
self.env_prefix = env_prefix if env_prefix is not None else self.config.get('env_prefix', '')
141146
self.env_ignore_empty = (
142147
env_ignore_empty if env_ignore_empty is not None else self.config.get('env_ignore_empty', False)
143148
)
149+
self.env_parse_none_str = (
150+
env_parse_none_str if env_parse_none_str is not None else self.config.get('env_parse_none_str')
151+
)
144152

145153
def _apply_case_sensitive(self, value: str) -> str:
146154
return value.lower() if not self.case_sensitive else value
@@ -244,6 +252,20 @@ class Settings(BaseSettings):
244252

245253
return values
246254

255+
def _replace_env_none_type_values(self, field_value: dict[str, Any]) -> dict[str, Any]:
256+
"""
257+
Recursively parse values that are of "None" type(EnvNoneType) to `None` type(None).
258+
"""
259+
values: dict[str, Any] = {}
260+
261+
for key, value in field_value.items():
262+
if not isinstance(value, EnvNoneType):
263+
values[key] = value if not isinstance(value, dict) else self._replace_env_none_type_values(value)
264+
else:
265+
values[key] = None
266+
267+
return values
268+
247269
def __call__(self) -> dict[str, Any]:
248270
data: dict[str, Any] = {}
249271

@@ -263,6 +285,11 @@ def __call__(self) -> dict[str, Any]:
263285
) from e
264286

265287
if field_value is not None:
288+
if self.env_parse_none_str is not None:
289+
if isinstance(field_value, dict):
290+
field_value = self._replace_env_none_type_values(field_value)
291+
elif isinstance(field_value, EnvNoneType):
292+
field_value = None
266293
if (
267294
not self.case_sensitive
268295
and lenient_issubclass(field.annotation, BaseModel)
@@ -287,8 +314,9 @@ def __init__(
287314
case_sensitive: bool | None = None,
288315
env_prefix: str | None = None,
289316
env_ignore_empty: bool | None = None,
317+
env_parse_none_str: str | None = None,
290318
) -> None:
291-
super().__init__(settings_cls, case_sensitive, env_prefix, env_ignore_empty)
319+
super().__init__(settings_cls, case_sensitive, env_prefix, env_ignore_empty, env_parse_none_str)
292320
self.secrets_dir = secrets_dir if secrets_dir is not None else self.config.get('secrets_dir')
293321

294322
def __call__(self) -> dict[str, Any]:
@@ -376,8 +404,9 @@ def __init__(
376404
env_prefix: str | None = None,
377405
env_nested_delimiter: str | None = None,
378406
env_ignore_empty: bool | None = None,
407+
env_parse_none_str: str | None = None,
379408
) -> None:
380-
super().__init__(settings_cls, case_sensitive, env_prefix, env_ignore_empty)
409+
super().__init__(settings_cls, case_sensitive, env_prefix, env_ignore_empty, env_parse_none_str)
381410
self.env_nested_delimiter = (
382411
env_nested_delimiter if env_nested_delimiter is not None else self.config.get('env_nested_delimiter')
383412
)
@@ -386,7 +415,7 @@ def __init__(
386415
self.env_vars = self._load_env_vars()
387416

388417
def _load_env_vars(self) -> Mapping[str, str | None]:
389-
return parse_env_vars(os.environ, self.case_sensitive, self.env_ignore_empty)
418+
return parse_env_vars(os.environ, self.case_sensitive, self.env_ignore_empty, self.env_parse_none_str)
390419

391420
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
392421
"""
@@ -570,12 +599,15 @@ def __init__(
570599
env_prefix: str | None = None,
571600
env_nested_delimiter: str | None = None,
572601
env_ignore_empty: bool | None = None,
602+
env_parse_none_str: str | None = None,
573603
) -> None:
574604
self.env_file = env_file if env_file != ENV_FILE_SENTINEL else settings_cls.model_config.get('env_file')
575605
self.env_file_encoding = (
576606
env_file_encoding if env_file_encoding is not None else settings_cls.model_config.get('env_file_encoding')
577607
)
578-
super().__init__(settings_cls, case_sensitive, env_prefix, env_nested_delimiter, env_ignore_empty)
608+
super().__init__(
609+
settings_cls, case_sensitive, env_prefix, env_nested_delimiter, env_ignore_empty, env_parse_none_str
610+
)
579611

580612
def _load_env_vars(self) -> Mapping[str, str | None]:
581613
return self._read_env_files()
@@ -598,6 +630,7 @@ def _read_env_files(self) -> Mapping[str, str | None]:
598630
encoding=self.env_file_encoding,
599631
case_sensitive=self.case_sensitive,
600632
ignore_empty=self.env_ignore_empty,
633+
parse_none_str=self.env_parse_none_str,
601634
)
602635
)
603636

@@ -635,10 +668,21 @@ def _get_env_var_key(key: str, case_sensitive: bool = False) -> str:
635668
return key if case_sensitive else key.lower()
636669

637670

671+
def _parse_env_none_str(value: str | None, parse_none_str: str | None = None) -> str | None | EnvNoneType:
672+
return value if not (value == parse_none_str and parse_none_str is not None) else EnvNoneType(value)
673+
674+
638675
def parse_env_vars(
639-
env_vars: Mapping[str, str | None], case_sensitive: bool = False, ignore_empty: bool = False
676+
env_vars: Mapping[str, str | None],
677+
case_sensitive: bool = False,
678+
ignore_empty: bool = False,
679+
parse_none_str: str | None = None,
640680
) -> 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 == '')}
681+
return {
682+
_get_env_var_key(k, case_sensitive): _parse_env_none_str(v, parse_none_str)
683+
for k, v in env_vars.items()
684+
if not (ignore_empty and v == '')
685+
}
642686

643687

644688
def read_env_file(
@@ -647,9 +691,10 @@ def read_env_file(
647691
encoding: str | None = None,
648692
case_sensitive: bool = False,
649693
ignore_empty: bool = False,
694+
parse_none_str: str | None = None,
650695
) -> Mapping[str, str | None]:
651696
file_vars: dict[str, str | None] = dotenv_values(file_path, encoding=encoding or 'utf8')
652-
return parse_env_vars(file_vars, case_sensitive, ignore_empty)
697+
return parse_env_vars(file_vars, case_sensitive, ignore_empty, parse_none_str)
653698

654699

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

tests/test_settings.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1785,6 +1785,46 @@ class Settings(BaseSettings):
17851785
]
17861786

17871787

1788+
def test_env_parse_none_str(env):
1789+
env.set('x', 'null')
1790+
env.set('y', 'y_override')
1791+
1792+
class Settings(BaseSettings):
1793+
x: Optional[str] = 'x_default'
1794+
y: Optional[str] = 'y_default'
1795+
1796+
s = Settings()
1797+
assert s.x == 'null'
1798+
assert s.y == 'y_override'
1799+
s = Settings(_env_parse_none_str='null')
1800+
assert s.x is None
1801+
assert s.y == 'y_override'
1802+
1803+
env.set('nested__x', 'None')
1804+
env.set('nested__y', 'y_override')
1805+
env.set('nested__deep__z', 'None')
1806+
1807+
class NestedBaseModel(BaseModel):
1808+
x: Optional[str] = 'x_default'
1809+
y: Optional[str] = 'y_default'
1810+
deep: Optional[dict] = {'z': 'z_default'}
1811+
keep: Optional[dict] = {'z': 'None'}
1812+
1813+
class NestedSettings(BaseSettings, env_nested_delimiter='__'):
1814+
nested: Optional[NestedBaseModel] = NestedBaseModel()
1815+
1816+
s = NestedSettings()
1817+
assert s.nested.x == 'None'
1818+
assert s.nested.y == 'y_override'
1819+
assert s.nested.deep['z'] == 'None'
1820+
assert s.nested.keep['z'] == 'None'
1821+
s = NestedSettings(_env_parse_none_str='None')
1822+
assert s.nested.x is None
1823+
assert s.nested.y == 'y_override'
1824+
assert s.nested.deep['z'] is None
1825+
assert s.nested.keep['z'] == 'None'
1826+
1827+
17881828
def test_env_json_field_dict(env):
17891829
class Settings(BaseSettings):
17901830
x: Json[Dict[str, int]]

0 commit comments

Comments
 (0)