-
-
Notifications
You must be signed in to change notification settings - Fork 64
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
BaseSettings fails to populate nested module with loaded secrets #154
Comments
Thanks @bkis for this 🙏 Secret source only looks for the secret file based on the field name in the directories. So, if you want to load nested model values from the secret you need to change the filename to
|
Oh, I see. Thank you for your reply! That's a bit unfortunate, though. Just to make sure I got this right: It takes all the attributes of the settings model that has a Wouldn't it be more efficient (and universal) to do it the other way around? Grab a list of all the files in the I think that my use case (the one I've shown above) isn't very... niche. And the way it is now leaves me with two rather bad possibilities:
|
Currently, we can't change it because it will introduce a breaking change. but you have some options:
|
Thanks, @hramezani , I'll look into that and see if I find the time to contribute. I'd love to do so, but I am also not sure if I'm up to the task ;) |
Before I jump into this missing some obvious gotchas of my conceptual idea: What do you think of it? You have more experience with the inner workings of this library - is there anything problematic about my idea that I am not seeing? |
complexity I would say. If you take a look at the code for env settings source in If you check the nested model tests in our tests you will find different cases that your suggested secret source has to support. BTW, it might be complex but not impossible :) |
Just chiming in with a +1 for this feature. I'd like to be able to merge nested structures as well. :) |
@criccomini You're very welcome to have a try on implementing it. The strategy I was thinking about goes like this:
It's kinda straight forward in theory, but I had a look at the existing implementation and had to learn that it'll take some time to understand what's happening and how to integrate the functionality in a way that respects the existing design of the codebase. |
I can confirm that this occurs too - the SecretsSettingSource class doesn't receive any With Did anyone make progress on a fork already? |
Running this just before building the settings object is a workaround for me. (source priority not a big deal) # Workaround: pydantic-settings doesn't yet support importing secrets with nesting,
# Load them into the environment variable space.
for child in Path("/run/secrets").iterdir():
if child.is_file():
environ[child.name] = child.read_text().strip() |
I just created my own secrets setting source as suggested, then defaulted to the EnvSettingsSource behaviour, except I read in the secrets_dir instead of using os.environ from pydantic_settings.sources import EnvSettingsSource, parse_env_vars
from typing import Mapping
from pathlib import Path
from pydantic_settings import BaseSettings
class SecretsSettingsSource(EnvSettingsSource):
"""
Source class for loading settings values from secret files.
"""
def __init__(
self,
settings_cls: type[BaseSettings],
secrets_dir: str | Path | None = None,
case_sensitive: bool | None = None,
env_prefix: str | None = None,
env_nested_delimiter: str | None = None,
env_ignore_empty: bool | None = None,
env_parse_none_str: str | None = None
) -> None:
self.secrets_dir = secrets_dir if secrets_dir is not None else self.config.get('secrets_dir')
super().__init__(
settings_cls,
case_sensitive=case_sensitive,
env_prefix=env_prefix,
env_nested_delimiter=env_nested_delimiter,
env_ignore_empty=env_ignore_empty,
env_parse_none_str=env_parse_none_str
)
def _load_env_vars(self) -> Mapping[str, str | None]:
if not Path(self.secrets_dir).is_dir():
return {}
file_dict = {
str(f.stem): open(f).read() for f in Path(self.secrets_dir).iterdir() if f.is_file()
}
return parse_env_vars(
file_dict,
self.case_sensitive,
self.env_ignore_empty,
self.env_parse_none_str,
)
def __repr__(self) -> str:
return f'SecretsSettingsSource(secrets_dir={self.secrets_dir!r})' |
@A-Telfer Nice! I took your
When running my example, I noticed three issues I had with your code. I am listing them here from most to least problematic (also see the comments in the code below):
After making these changes my example looks as follows (and it seems to work just fine!). Please note that the project I use this in requires Python >=3.10 – hence the changes in the way I make type hints. import os
from collections.abc import Mapping
from pathlib import Path
from pydantic import BaseModel, SecretStr
from pydantic_settings import (
BaseSettings,
PydanticBaseSettingsSource,
SettingsConfigDict,
)
from pydantic_settings.sources import EnvSettingsSource, parse_env_vars
os.environ["APPNAME_NESTED__SOMETHING"] = "nested-env-value"
class SecretsSettingsSource(EnvSettingsSource):
"""
Source class for loading settings values from secret files.
"""
def __init__(
self,
settings_cls: type[BaseSettings],
secrets_dir: str | Path | None = None,
case_sensitive: bool | None = None,
env_prefix: str | None = None,
env_nested_delimiter: str | None = None,
env_ignore_empty: bool | None = None,
env_parse_none_str: str | None = None,
) -> None:
# take secrets_dir from settings_cls.model_config instead of from self.config,
# as self.config is only set AFTER super().__init__() is called later on
# (it's in PydanticBaseSettingsSource.__init__()
# which is like 4 classesc up in the hierarchy)
self.secrets_dir = (
secrets_dir
if secrets_dir is not None
else settings_cls.model_config.get("secrets_dir")
)
super().__init__(
settings_cls,
case_sensitive=case_sensitive,
env_prefix=env_prefix,
env_nested_delimiter=env_nested_delimiter,
env_ignore_empty=env_ignore_empty,
env_parse_none_str=env_parse_none_str,
)
def _load_env_vars(self) -> Mapping[str, str | None]:
if not Path(self.secrets_dir).is_dir():
return {}
# use Path.read_text() and strip() it to avoid trailing newlines
# (the original approach was to open() the file without using a context manager)
file_dict = {
str(f.stem): f.read_text(encoding="utf-8").strip()
for f in Path(self.secrets_dir).iterdir()
if f.is_file()
}
return parse_env_vars(
file_dict,
self.case_sensitive,
self.env_ignore_empty,
self.env_parse_none_str,
)
def __repr__(self) -> str:
return f"SecretsSettingsSource(secrets_dir={self.secrets_dir!r})"
class SubSettings(BaseModel):
secret: SecretStr = SecretStr("default")
something: str = "default"
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="APPNAME_",
env_nested_delimiter="__",
case_sensitive=False,
secrets_dir="./secrets",
)
nested: SubSettings = SubSettings()
secret: SecretStr = SecretStr("default")
@classmethod
def settings_customise_sources(
cls,
settings_cls: type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
return (
init_settings,
SecretsSettingsSource(settings_cls),
env_settings,
file_secret_settings,
)
settings = Settings()
print(f"Settings.secret: {settings.secret.get_secret_value()}")
print(f"Settings.nested.something: {settings.nested.something}")
print(f"Settings.nested.secret: {settings.nested.secret.get_secret_value()}") ...it prints the following output:
...which is exactly what I'd expect. Now I am not sure how robust this is, but it's looking good! |
Just wrote a small package that solves this problem: Some features:
|
The documentation explains how nested settings can be populated via env vars and also how secrets can be loaded from secrets files.
I'd expect
BaseSettings
to be able to pass a loaded secret down to nested settings if the name of the secret matches the configured notation.I have a scenario for easy reproduction here:
app.py
:./secrets/APPNAME_SECRET
:./secrets/APPNAME_NESTED__SECRET
:SubSettings.something
is only to demonstrate that populating a nested field via env var is working as expected with this setup!Running
app.py
generates the following output:...so the secret is not passed down to
Settings.nested.secret
.I am using Pydantic
2.3.0
and Pydantic-Settings2.0.3
Selected Assignee: @dmontagu
The text was updated successfully, but these errors were encountered: