Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,58 @@ print(Settings().model_dump())
`env_nested_delimiter` can be configured via the `model_config` as shown above, or via the
`_env_nested_delimiter` keyword argument on instantiation.

By default environment variables are split by `env_nested_delimiter` into arbitrarily deep nested fields. You can limit
the depth of the nested fields with the `env_nested_depth` config setting. A common use case this is particularly useful
is for two-level deep settings, where the `env_nested_delimiter` (usually a single `_`) may be a substring of model
field names. For example:

```bash
# your environment
export GENERATION_LLM_PROVIDER='anthropic'
export GENERATION_LLM_API_KEY='your-api-key'
export GENERATION_LLM_API_VERSION='2024-03-15'
```

You could load them into the following settings model:

```py
from pydantic import BaseModel

from pydantic_settings import BaseSettings, SettingsConfigDict


class LLMConfig(BaseModel):
provider: str = 'openai'
api_key: str
api_type: str = 'azure'
api_version: str = '2023-03-15-preview'


class GenerationConfig(BaseSettings):
model_config = SettingsConfigDict(
env_nested_delimiter='_', env_nested_depth=1, env_prefix='GENERATION_'
)

llm: LLMConfig
...


print(GenerationConfig().model_dump())
"""
{
'llm': {
'provider': 'anthropic',
'api_key': 'your-api-key',
'api_type': 'azure',
'api_version': '2024-03-15',
}
}
"""
```

Without `env_nested_depth=1` set, `GENERATION_LLM_API_KEY` would be parsed as `llm.api.key` instead of `llm.api_key`
and it would raise a `ValidationError`.

Nested environment variables take precedence over the top-level environment variable JSON
(e.g. in the example above, `SUB_MODEL__V2` trumps `SUB_MODEL`).

Expand Down
11 changes: 11 additions & 0 deletions pydantic_settings/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class SettingsConfigDict(ConfigDict, total=False):
env_file_encoding: str | None
env_ignore_empty: bool
env_nested_delimiter: str | None
env_nested_depth: int
env_parse_none_str: str | None
env_parse_enums: bool | None
cli_prog_name: str | None
Expand Down Expand Up @@ -112,6 +113,7 @@ class BaseSettings(BaseModel):
_env_file_encoding: The env file encoding, e.g. `'latin-1'`. Defaults to `None`.
_env_ignore_empty: Ignore environment variables where the value is an empty string. Default to `False`.
_env_nested_delimiter: The nested env values delimiter. Defaults to `None`.
_env_nested_depth: The nested env values maximum nesting. Defaults to `-1`, which means no limit.
_env_parse_none_str: The env string value that should be parsed (e.g. "null", "void", "None", etc.)
into `None` type(None). Defaults to `None` type(None), which means no parsing should occur.
_env_parse_enums: Parse enum field names to values. Defaults to `None.`, which means no parsing should occur.
Expand Down Expand Up @@ -148,6 +150,7 @@ def __init__(
_env_file_encoding: str | None = None,
_env_ignore_empty: bool | None = None,
_env_nested_delimiter: str | None = None,
_env_nested_depth: int | None = None,
_env_parse_none_str: str | None = None,
_env_parse_enums: bool | None = None,
_cli_prog_name: str | None = None,
Expand Down Expand Up @@ -178,6 +181,7 @@ def __init__(
_env_file_encoding=_env_file_encoding,
_env_ignore_empty=_env_ignore_empty,
_env_nested_delimiter=_env_nested_delimiter,
_env_nested_depth=_env_nested_depth,
_env_parse_none_str=_env_parse_none_str,
_env_parse_enums=_env_parse_enums,
_cli_prog_name=_cli_prog_name,
Expand Down Expand Up @@ -232,6 +236,7 @@ def _settings_build_values(
_env_file_encoding: str | None = None,
_env_ignore_empty: bool | None = None,
_env_nested_delimiter: str | None = None,
_env_nested_depth: int | None = None,
_env_parse_none_str: str | None = None,
_env_parse_enums: bool | None = None,
_cli_prog_name: str | None = None,
Expand Down Expand Up @@ -270,6 +275,9 @@ def _settings_build_values(
if _env_nested_delimiter is not None
else self.model_config.get('env_nested_delimiter')
)
env_nested_depth = (
_env_nested_depth if _env_nested_depth is not None else self.model_config.get('env_nested_depth')
)
env_parse_none_str = (
_env_parse_none_str if _env_parse_none_str is not None else self.model_config.get('env_parse_none_str')
)
Expand Down Expand Up @@ -333,6 +341,7 @@ def _settings_build_values(
case_sensitive=case_sensitive,
env_prefix=env_prefix,
env_nested_delimiter=env_nested_delimiter,
env_nested_depth=env_nested_depth,
env_ignore_empty=env_ignore_empty,
env_parse_none_str=env_parse_none_str,
env_parse_enums=env_parse_enums,
Expand All @@ -344,6 +353,7 @@ def _settings_build_values(
case_sensitive=case_sensitive,
env_prefix=env_prefix,
env_nested_delimiter=env_nested_delimiter,
env_nested_depth=env_nested_depth,
env_ignore_empty=env_ignore_empty,
env_parse_none_str=env_parse_none_str,
env_parse_enums=env_parse_enums,
Expand Down Expand Up @@ -412,6 +422,7 @@ def _settings_build_values(
env_file_encoding=None,
env_ignore_empty=False,
env_nested_delimiter=None,
env_nested_depth=-1,
env_parse_none_str=None,
env_parse_enums=None,
cli_prog_name=None,
Expand Down
20 changes: 15 additions & 5 deletions pydantic_settings/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,7 @@ def __init__(
case_sensitive: bool | None = None,
env_prefix: str | None = None,
env_nested_delimiter: str | None = None,
env_nested_depth: int | None = None,
env_ignore_empty: bool | None = None,
env_parse_none_str: str | None = None,
env_parse_enums: bool | None = None,
Expand All @@ -745,6 +746,10 @@ def __init__(
self.env_nested_delimiter = (
env_nested_delimiter if env_nested_delimiter is not None else self.config.get('env_nested_delimiter')
)
self.env_nested_depth = (
env_nested_depth if env_nested_depth is not None else self.config.get('env_nested_depth', -1)
)
self.maxsplit = self.env_nested_depth - 1 if self.env_nested_depth > 0 else self.env_nested_depth
self.env_prefix_len = len(self.env_prefix)

self.env_vars = self._load_env_vars()
Expand Down Expand Up @@ -910,11 +915,13 @@ def explode_env_vars(self, field_name: str, field: FieldInfo, env_vars: Mapping[
]
result: dict[str, Any] = {}
for env_name, env_val in env_vars.items():
if not any(env_name.startswith(prefix) for prefix in prefixes):
try:
prefix = next(prefix for prefix in prefixes if env_name.startswith(prefix))
except StopIteration:
continue
# we remove the prefix before splitting in case the prefix has characters in common with the delimiter
env_name_without_prefix = env_name[self.env_prefix_len :]
_, *keys, last_key = env_name_without_prefix.split(self.env_nested_delimiter)
env_name_without_prefix = env_name[len(prefix) :]
*keys, last_key = env_name_without_prefix.split(self.env_nested_delimiter, self.maxsplit)
env_var = result
target_field: FieldInfo | None = field
for key in keys:
Expand Down Expand Up @@ -947,7 +954,7 @@ def explode_env_vars(self, field_name: str, field: FieldInfo, env_vars: Mapping[
def __repr__(self) -> str:
return (
f'{self.__class__.__name__}(env_nested_delimiter={self.env_nested_delimiter!r}, '
f'env_prefix_len={self.env_prefix_len!r})'
f'env_nested_depth={self.env_nested_depth}, env_prefix_len={self.env_prefix_len!r})'
)


Expand All @@ -964,6 +971,7 @@ def __init__(
case_sensitive: bool | None = None,
env_prefix: str | None = None,
env_nested_delimiter: str | None = None,
env_nested_depth: int | None = None,
env_ignore_empty: bool | None = None,
env_parse_none_str: str | None = None,
env_parse_enums: bool | None = None,
Expand All @@ -977,6 +985,7 @@ def __init__(
case_sensitive,
env_prefix,
env_nested_delimiter,
env_nested_depth,
env_ignore_empty,
env_parse_none_str,
env_parse_enums,
Expand Down Expand Up @@ -1063,7 +1072,8 @@ def __call__(self) -> dict[str, Any]:
def __repr__(self) -> str:
return (
f'{self.__class__.__name__}(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})'
f'env_nested_delimiter={self.env_nested_delimiter!r}, env_nested_depth={self.env_nested_depth}, '
f'env_prefix_len={self.env_prefix_len!r})'
)


Expand Down
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ def docs_test_env():
setenv.set('SUB_MODEL__V3', '3')
setenv.set('SUB_MODEL__DEEP__V4', 'v4')

# envs for parsing environment variable values example with env_nested_depth=1
setenv.set('GENERATION_LLM_PROVIDER', 'anthropic')
setenv.set('GENERATION_LLM_API_KEY', 'your-api-key')
setenv.set('GENERATION_LLM_API_VERSION', '2024-03-15')

yield setenv

setenv.clear()
Expand Down
40 changes: 37 additions & 3 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import pathlib
import sys
import uuid
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from enum import IntEnum
from pathlib import Path
from typing import Any, Callable, Dict, Generic, Hashable, List, Optional, Set, Tuple, Type, TypeVar, Union
Expand Down Expand Up @@ -398,6 +398,40 @@ class Cfg(BaseSettings):
assert Cfg().model_dump() == {'sub_model': {'v1': '-1-', 'v2': '-2-'}}


@pytest.mark.parametrize('env_prefix', [None, 'prefix_', 'prefix__'])
def test_nested_env_depth(env, env_prefix):
class Person(BaseModel):
sex: Literal['M', 'F']
first_name: str
date_of_birth: date

class Cfg(BaseSettings):
caregiver: Person
significant_other: Optional[Person] = None
next_of_kin: Optional[Person] = None

model_config = SettingsConfigDict(env_nested_delimiter='_', env_nested_depth=1)
if env_prefix is not None:
model_config['env_prefix'] = env_prefix

env_prefix = env_prefix or ''
env.set(env_prefix + 'caregiver_sex', 'M')
env.set(env_prefix + 'caregiver_first_name', 'Joe')
env.set(env_prefix + 'caregiver_date_of_birth', '1975-09-12')
env.set(env_prefix + 'significant_other_sex', 'F')
env.set(env_prefix + 'significant_other_first_name', 'Jill')
env.set(env_prefix + 'significant_other_date_of_birth', '1998-04-19')
env.set(env_prefix + 'next_of_kin_sex', 'M')
env.set(env_prefix + 'next_of_kin_first_name', 'Jack')
env.set(env_prefix + 'next_of_kin_date_of_birth', '1999-04-19')

assert Cfg().model_dump() == {
'caregiver': {'sex': 'M', 'first_name': 'Joe', 'date_of_birth': date(1975, 9, 12)},
'significant_other': {'sex': 'F', 'first_name': 'Jill', 'date_of_birth': date(1998, 4, 19)},
'next_of_kin': {'sex': 'M', 'first_name': 'Jack', 'date_of_birth': date(1999, 4, 19)},
}


class DateModel(BaseModel):
pips: bool = False

Expand Down Expand Up @@ -1823,11 +1857,11 @@ def test_builtins_settings_source_repr():
)
assert (
repr(EnvSettingsSource(BaseSettings, env_nested_delimiter='__'))
== "EnvSettingsSource(env_nested_delimiter='__', env_prefix_len=0)"
== "EnvSettingsSource(env_nested_delimiter='__', env_nested_depth=-1, env_prefix_len=0)"
)
assert repr(DotEnvSettingsSource(BaseSettings, env_file='.env', env_file_encoding='utf-8')) == (
"DotEnvSettingsSource(env_file='.env', env_file_encoding='utf-8', "
'env_nested_delimiter=None, env_prefix_len=0)'
'env_nested_delimiter=None, env_nested_depth=-1, env_prefix_len=0)'
)
assert (
repr(SecretsSettingsSource(BaseSettings, secrets_dir='/secrets'))
Expand Down
Loading