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
Nested env #3159
Nested env #3159
Changes from 11 commits
46eea4b
730c039
3518f5e
b9ed167
6c457ce
ae9592e
88aae14
1994a92
581906b
0ab431a
63ffab3
4a83110
67d5680
c97138a
5e9b229
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
Nested env variables can now be configured regarding the `env_nested_delimiter` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
import os | ||
|
||
from pydantic import BaseModel, BaseSettings | ||
|
||
|
||
class SubSubValue(BaseModel): | ||
v6: str | ||
|
||
|
||
class SubValue(BaseModel): | ||
v4: str | ||
v5: str | ||
sub_sub: SubSubValue | ||
|
||
|
||
class TopValue(BaseModel): | ||
v1: str | ||
v2: str | ||
v3: str | ||
sub: SubValue | ||
|
||
|
||
class Settings(BaseSettings): | ||
v0: str | ||
top: TopValue | ||
|
||
class Config: | ||
env_nested_delimiter = '__' | ||
|
||
|
||
os.environ.update({ | ||
'top': '{"v1": "1", "v2": "2"}', | ||
'v0': '0', | ||
'top__v3': '3', | ||
'top__sub': '{"sub_sub": {"v6": "6"}}', | ||
'top__sub__v4': '4', | ||
'top__sub__v5': '5', | ||
}) | ||
|
||
|
||
print(Settings()) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -30,13 +30,18 @@ def __init__( | |
__pydantic_self__, | ||
_env_file: Optional[StrPath] = env_file_sentinel, | ||
_env_file_encoding: Optional[str] = None, | ||
_env_nested_delimiter: Optional[str] = None, | ||
_secrets_dir: Optional[StrPath] = None, | ||
**values: Any, | ||
) -> None: | ||
# Uses something other than `self` the first arg to allow "self" as a settable attribute | ||
super().__init__( | ||
**__pydantic_self__._build_values( | ||
values, _env_file=_env_file, _env_file_encoding=_env_file_encoding, _secrets_dir=_secrets_dir | ||
values, | ||
_env_file=_env_file, | ||
_env_file_encoding=_env_file_encoding, | ||
_env_nested_delimiter=_env_nested_delimiter, | ||
_secrets_dir=_secrets_dir, | ||
) | ||
) | ||
|
||
|
@@ -45,6 +50,7 @@ def _build_values( | |
init_kwargs: Dict[str, Any], | ||
_env_file: Optional[StrPath] = None, | ||
_env_file_encoding: Optional[str] = None, | ||
_env_nested_delimiter: Optional[str] = None, | ||
_secrets_dir: Optional[StrPath] = None, | ||
) -> Dict[str, Any]: | ||
# Configure built-in sources | ||
|
@@ -54,6 +60,9 @@ def _build_values( | |
env_file_encoding=( | ||
_env_file_encoding if _env_file_encoding is not None else self.__config__.env_file_encoding | ||
), | ||
env_nested_delimiter=( | ||
_env_nested_delimiter if _env_nested_delimiter is not None else self.__config__.env_nested_delimiter | ||
), | ||
) | ||
file_secret_settings = SecretsSettingsSource(secrets_dir=_secrets_dir or self.__config__.secrets_dir) | ||
# Provide a hook to set built-in sources priority and add / remove sources | ||
|
@@ -71,6 +80,7 @@ class Config(BaseConfig): | |
env_prefix = '' | ||
env_file = None | ||
env_file_encoding = None | ||
env_nested_delimiter = None | ||
secrets_dir = None | ||
validate_all = True | ||
extra = Extra.forbid | ||
|
@@ -132,32 +142,24 @@ def __repr__(self) -> str: | |
|
||
|
||
class EnvSettingsSource: | ||
samuelcolvin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
__slots__ = ('env_file', 'env_file_encoding') | ||
__slots__ = ('env_file', 'env_file_encoding', 'env_nested_delimiter') | ||
|
||
def __init__(self, env_file: Optional[StrPath], env_file_encoding: Optional[str]): | ||
def __init__( | ||
self, env_file: Optional[StrPath], env_file_encoding: Optional[str], env_nested_delimiter: Optional[str] = None | ||
): | ||
self.env_file: Optional[StrPath] = env_file | ||
self.env_file_encoding: Optional[str] = env_file_encoding | ||
self.env_nested_delimiter: Optional[str] = env_nested_delimiter | ||
|
||
def __call__(self, settings: BaseSettings) -> Dict[str, Any]: | ||
Air-Mark marked this conversation as resolved.
Show resolved
Hide resolved
|
||
""" | ||
Build environment variables suitable for passing to the Model. | ||
""" | ||
d: Dict[str, Optional[str]] = {} | ||
|
||
if settings.__config__.case_sensitive: | ||
env_vars: Mapping[str, Optional[str]] = 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, | ||
} | ||
samuelcolvin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
env_vars = self.get_env_vars(settings) | ||
if self.env_nested_delimiter is not None: | ||
env_vars = self.explode_env_vars(settings, env_vars) | ||
|
||
for field in settings.__fields__.values(): | ||
env_val: Optional[str] = None | ||
|
@@ -170,25 +172,70 @@ def __call__(self, settings: BaseSettings) -> Dict[str, Any]: | |
continue | ||
|
||
if field.is_complex(): | ||
try: | ||
env_val = settings.__config__.json_loads(env_val) | ||
except ValueError as e: | ||
raise SettingsError(f'error parsing JSON for "{env_name}"') from e | ||
if isinstance(env_val, (str, bytes, bytearray)): | ||
try: | ||
env_val = settings.__config__.json_loads(env_val) | ||
except ValueError as e: | ||
raise SettingsError(f'error parsing JSON for "{env_name}"') from e | ||
elif ( | ||
is_union_origin(get_origin(field.type_)) | ||
and field.sub_fields | ||
and any(f.is_complex() for f in field.sub_fields) | ||
): | ||
try: | ||
env_val = settings.__config__.json_loads(env_val) | ||
except ValueError: | ||
pass | ||
if isinstance(env_val, (str, bytes, bytearray)): | ||
try: | ||
env_val = settings.__config__.json_loads(env_val) | ||
except ValueError: | ||
pass | ||
d[field.alias] = env_val | ||
return d | ||
|
||
def __repr__(self) -> str: | ||
return f'EnvSettingsSource(env_file={self.env_file!r}, env_file_encoding={self.env_file_encoding!r})' | ||
|
||
def get_env_vars(self, settings: BaseSettings) -> Mapping[str, Optional[str]]: | ||
if settings.__config__.case_sensitive: | ||
env_vars: Mapping[str, Optional[str]] = 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, | ||
} | ||
return env_vars | ||
|
||
def explode_env_vars(self, settings: BaseSettings, env_vars: Mapping[str, Optional[str]]) -> Dict[str, Any]: | ||
""" | ||
Go trough environment variables. | ||
Within the each key-value pair: | ||
- Split key with the env_nested_delimiter | ||
- Before going through each key define env_var as a root (same as the final result) | ||
- Go trough each key, add a new dict item to the env_var and redefine env_var within this item | ||
- At the last iteration: | ||
- Silently try to parse value with the json, leave it as it is if it fails | ||
- Assign the value to the deepest key | ||
samuelcolvin marked this conversation as resolved.
Show resolved
Hide resolved
|
||
""" | ||
result: Dict[str, Any] = {} | ||
for env_name, env_val in env_vars.items(): | ||
keys = env_name.split(self.env_nested_delimiter) | ||
env_var = result | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think this approach works. If you change the order of env variables in your test below, the result changes. E.g. put There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was the right logic, because this variable should be overwritten by the next one |
||
for idx, key in enumerate(keys): | ||
if idx == len(keys) - 1: | ||
try: | ||
env_val = settings.__config__.json_loads(env_val) # type: ignore | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't understand why you need this here? Also not sure why we're catching and ignoring There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed about the export TOP__SUB__V5='5'
export TOP='{"sub": {"v6": "6"}}' So the correct behaviour in my mind was that we should merge those variables into one, instead of overwriting. |
||
except (ValueError, TypeError): | ||
... | ||
env_var[key] = env_val | ||
else: | ||
env_var = env_var.setdefault(key, {}) | ||
return result | ||
|
||
|
||
class SecretsSettingsSource: | ||
__slots__ = ('secrets_dir',) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
as mentioned before, please move this to
exec_examples.py
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed. Misunderstood you last time about where exactly I should place this.