Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.idea/
env/
.envrc
venv/
.venv/
env3*/
Expand All @@ -18,6 +19,7 @@ test.py
/site/
/site.zip
.pytest_cache/
.python-version
.vscode/
_build/
.auto-format
Expand Down
13 changes: 13 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -1214,6 +1214,19 @@ Even when using a secrets directory, *pydantic* will still read environment vari
Passing a file path via the `_secrets_dir` keyword argument on instantiation (method 2) will override
the value (if any) set on the `model_config` class.

If you need to load settings from multiple secrets directories, you can pass multiple paths as a tuple or list. Just like for `env_file`, values from subsequent paths override previous ones.

````python
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
# files in '/run/secrets' take priority over '/var/run'
model_config = SettingsConfigDict(secrets_dir=('/var/run', '/run/secrets'))

database_password: str
````

### Use Case: Docker Secrets

Docker Secrets can be used to provide sensitive configuration to an application running in a Docker container.
Expand Down
9 changes: 4 additions & 5 deletions pydantic_settings/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations as _annotations

from pathlib import Path
from typing import Any, ClassVar

from pydantic import ConfigDict
Expand Down Expand Up @@ -43,7 +42,7 @@ class SettingsConfigDict(ConfigDict, total=False):
cli_exit_on_error: bool
cli_prefix: str
cli_implicit_flags: bool | None
secrets_dir: str | Path | None
secrets_dir: PathType | None
json_file: PathType | None
json_file_encoding: str | None
yaml_file: PathType | None
Expand Down Expand Up @@ -121,7 +120,7 @@ class BaseSettings(BaseModel):
_cli_prefix: The root parser command line arguments prefix. Defaults to "".
_cli_implicit_flags: Whether `bool` fields should be implicitly converted into CLI boolean flags.
(e.g. --flag, --no-flag). Defaults to `False`.
_secrets_dir: The secret files directory. Defaults to `None`.
_secrets_dir: The secret files directory or a sequence of directories. Defaults to `None`.
"""

def __init__(
Expand All @@ -146,7 +145,7 @@ def __init__(
_cli_exit_on_error: bool | None = None,
_cli_prefix: str | None = None,
_cli_implicit_flags: bool | None = None,
_secrets_dir: str | Path | None = None,
_secrets_dir: PathType | None = None,
**values: Any,
) -> None:
# Uses something other than `self` the first arg to allow "self" as a settable attribute
Expand Down Expand Up @@ -224,7 +223,7 @@ def _settings_build_values(
_cli_exit_on_error: bool | None = None,
_cli_prefix: str | None = None,
_cli_implicit_flags: bool | None = None,
_secrets_dir: str | Path | None = None,
_secrets_dir: PathType | None = None,
) -> dict[str, Any]:
# Determine settings config values
case_sensitive = _case_sensitive if _case_sensitive is not None else self.model_config.get('case_sensitive')
Expand Down
41 changes: 23 additions & 18 deletions pydantic_settings/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -573,7 +573,7 @@ class SecretsSettingsSource(PydanticBaseEnvSettingsSource):
def __init__(
self,
settings_cls: type[BaseSettings],
secrets_dir: str | Path | None = None,
secrets_dir: PathType | None = None,
case_sensitive: bool | None = None,
env_prefix: str | None = None,
env_ignore_empty: bool | None = None,
Expand All @@ -594,14 +594,17 @@ def __call__(self) -> dict[str, Any]:
if self.secrets_dir is None:
return secrets

self.secrets_path = Path(self.secrets_dir).expanduser()
secrets_dirs = [self.secrets_dir] if isinstance(self.secrets_dir, (str, os.PathLike)) else self.secrets_dir
self.secrets_paths = [Path(p).expanduser() for p in secrets_dirs]

if not self.secrets_path.exists():
warnings.warn(f'directory "{self.secrets_path}" does not exist')
return secrets
for path in self.secrets_paths:
if not path.exists():
warnings.warn(f'directory "{path}" does not exist')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a test for this warning that tests single dir. please add a test for multiple directories as well.

return secrets

if not self.secrets_path.is_dir():
raise SettingsError(f'secrets_dir must reference a directory, not a {path_type_label(self.secrets_path)}')
for path in self.secrets_paths:
if not path.is_dir():
raise SettingsError(f'secrets_dir must reference a directory, not a {path_type_label(path)}')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a test for this error that tests single dir. please add a test for multiple directories as well.


return super().__call__()

Expand Down Expand Up @@ -639,18 +642,20 @@ def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str,
"""

for field_key, env_name, value_is_complex in self._extract_field_info(field, field_name):
path = self.find_case_path(self.secrets_path, env_name, self.case_sensitive)
if not path:
# path does not exist, we currently don't return a warning for this
continue
# paths reversed to match the last-wins behaviour of `env_file`
for secrets_path in reversed(self.secrets_paths):
path = self.find_case_path(secrets_path, env_name, self.case_sensitive)
if not path:
# path does not exist, we currently don't return a warning for this
continue

if path.is_file():
return path.read_text().strip(), field_key, value_is_complex
else:
warnings.warn(
f'attempted to load secret file "{path}" but found a {path_type_label(path)} instead.',
stacklevel=4,
)
if path.is_file():
return path.read_text().strip(), field_key, value_is_complex
else:
warnings.warn(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a test for this warning that tests single dir. please add a test for multiple directories as well.

f'attempted to load secret file "{path}" but found a {path_type_label(path)} instead.',
stacklevel=4,
)

return None, field_key, value_is_complex

Expand Down
24 changes: 24 additions & 0 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -1416,6 +1416,30 @@ class Settings(BaseSettings):
assert Settings().model_dump() == {'foo': 'foo_secret_value_str'}


def test_secrets_path_multiple(tmp_path):
d1 = tmp_path / 'dir1'
d2 = tmp_path / 'dir2'
d1.mkdir()
d2.mkdir()
(d1 / 'foo1').write_text('foo1_dir1_secret_value_str')
(d1 / 'foo2').write_text('foo2_dir1_secret_value_str')
(d2 / 'foo2').write_text('foo2_dir2_secret_value_str')
(d2 / 'foo3').write_text('foo3_dir2_secret_value_str')

class Settings(BaseSettings):
foo1: str
foo2: str
foo3: str

model_config = SettingsConfigDict(secrets_dir=(d1, d2))

assert Settings().model_dump() == {
'foo1': 'foo1_dir1_secret_value_str',
'foo2': 'foo2_dir2_secret_value_str', # dir2 takes priority
'foo3': 'foo3_dir2_secret_value_str',
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please add another model with reverse secrets_dir



def test_secrets_path_with_validation_alias(tmp_path):
p = tmp_path / 'foo'
p.write_text('{"bar": ["test"]}')
Expand Down
Loading