Skip to content

Commit 12d85cf

Browse files
makukhahramezani
andauthored
Enable multiple secrets dirs (#372)
Co-authored-by: Hasan Ramezani <[email protected]>
1 parent 94f3a90 commit 12d85cf

File tree

5 files changed

+162
-22
lines changed

5 files changed

+162
-22
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
.idea/
22
env/
3+
.envrc
34
venv/
45
.venv/
56
env3*/
@@ -18,6 +19,7 @@ test.py
1819
/site/
1920
/site.zip
2021
.pytest_cache/
22+
.python-version
2123
.vscode/
2224
_build/
2325
.auto-format

docs/index.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1214,6 +1214,22 @@ Even when using a secrets directory, *pydantic* will still read environment vari
12141214
Passing a file path via the `_secrets_dir` keyword argument on instantiation (method 2) will override
12151215
the value (if any) set on the `model_config` class.
12161216

1217+
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.
1218+
1219+
````python
1220+
from pydantic_settings import BaseSettings, SettingsConfigDict
1221+
1222+
1223+
class Settings(BaseSettings):
1224+
# files in '/run/secrets' take priority over '/var/run'
1225+
model_config = SettingsConfigDict(secrets_dir=('/var/run', '/run/secrets'))
1226+
1227+
database_password: str
1228+
````
1229+
1230+
If any of `secrets_dir` is missing, it is ignored, and warning is shown. If any of `secrets_dir` is a file, error is raised.
1231+
1232+
12171233
### Use Case: Docker Secrets
12181234

12191235
Docker Secrets can be used to provide sensitive configuration to an application running in a Docker container.

pydantic_settings/main.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from __future__ import annotations as _annotations
22

3-
from pathlib import Path
43
from typing import Any, ClassVar
54

65
from pydantic import ConfigDict
@@ -43,7 +42,7 @@ class SettingsConfigDict(ConfigDict, total=False):
4342
cli_exit_on_error: bool
4443
cli_prefix: str
4544
cli_implicit_flags: bool | None
46-
secrets_dir: str | Path | None
45+
secrets_dir: PathType | None
4746
json_file: PathType | None
4847
json_file_encoding: str | None
4948
yaml_file: PathType | None
@@ -121,7 +120,7 @@ class BaseSettings(BaseModel):
121120
_cli_prefix: The root parser command line arguments prefix. Defaults to "".
122121
_cli_implicit_flags: Whether `bool` fields should be implicitly converted into CLI boolean flags.
123122
(e.g. --flag, --no-flag). Defaults to `False`.
124-
_secrets_dir: The secret files directory. Defaults to `None`.
123+
_secrets_dir: The secret files directory or a sequence of directories. Defaults to `None`.
125124
"""
126125

127126
def __init__(
@@ -146,7 +145,7 @@ def __init__(
146145
_cli_exit_on_error: bool | None = None,
147146
_cli_prefix: str | None = None,
148147
_cli_implicit_flags: bool | None = None,
149-
_secrets_dir: str | Path | None = None,
148+
_secrets_dir: PathType | None = None,
150149
**values: Any,
151150
) -> None:
152151
# Uses something other than `self` the first arg to allow "self" as a settable attribute
@@ -224,7 +223,7 @@ def _settings_build_values(
224223
_cli_exit_on_error: bool | None = None,
225224
_cli_prefix: str | None = None,
226225
_cli_implicit_flags: bool | None = None,
227-
_secrets_dir: str | Path | None = None,
226+
_secrets_dir: PathType | None = None,
228227
) -> dict[str, Any]:
229228
# Determine settings config values
230229
case_sensitive = _case_sensitive if _case_sensitive is not None else self.model_config.get('case_sensitive')

pydantic_settings/sources.py

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,7 @@ class SecretsSettingsSource(PydanticBaseEnvSettingsSource):
574574
def __init__(
575575
self,
576576
settings_cls: type[BaseSettings],
577-
secrets_dir: str | Path | None = None,
577+
secrets_dir: PathType | None = None,
578578
case_sensitive: bool | None = None,
579579
env_prefix: str | None = None,
580580
env_ignore_empty: bool | None = None,
@@ -595,14 +595,22 @@ def __call__(self) -> dict[str, Any]:
595595
if self.secrets_dir is None:
596596
return secrets
597597

598-
self.secrets_path = Path(self.secrets_dir).expanduser()
598+
secrets_dirs = [self.secrets_dir] if isinstance(self.secrets_dir, (str, os.PathLike)) else self.secrets_dir
599+
secrets_paths = [Path(p).expanduser() for p in secrets_dirs]
600+
self.secrets_paths = []
599601

600-
if not self.secrets_path.exists():
601-
warnings.warn(f'directory "{self.secrets_path}" does not exist')
602+
for path in secrets_paths:
603+
if not path.exists():
604+
warnings.warn(f'directory "{path}" does not exist')
605+
else:
606+
self.secrets_paths.append(path)
607+
608+
if not len(self.secrets_paths):
602609
return secrets
603610

604-
if not self.secrets_path.is_dir():
605-
raise SettingsError(f'secrets_dir must reference a directory, not a {path_type_label(self.secrets_path)}')
611+
for path in self.secrets_paths:
612+
if not path.is_dir():
613+
raise SettingsError(f'secrets_dir must reference a directory, not a {path_type_label(path)}')
606614

607615
return super().__call__()
608616

@@ -640,18 +648,20 @@ def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str,
640648
"""
641649

642650
for field_key, env_name, value_is_complex in self._extract_field_info(field, field_name):
643-
path = self.find_case_path(self.secrets_path, env_name, self.case_sensitive)
644-
if not path:
645-
# path does not exist, we currently don't return a warning for this
646-
continue
651+
# paths reversed to match the last-wins behaviour of `env_file`
652+
for secrets_path in reversed(self.secrets_paths):
653+
path = self.find_case_path(secrets_path, env_name, self.case_sensitive)
654+
if not path:
655+
# path does not exist, we currently don't return a warning for this
656+
continue
647657

648-
if path.is_file():
649-
return path.read_text().strip(), field_key, value_is_complex
650-
else:
651-
warnings.warn(
652-
f'attempted to load secret file "{path}" but found a {path_type_label(path)} instead.',
653-
stacklevel=4,
654-
)
658+
if path.is_file():
659+
return path.read_text().strip(), field_key, value_is_complex
660+
else:
661+
warnings.warn(
662+
f'attempted to load secret file "{path}" but found a {path_type_label(path)} instead.',
663+
stacklevel=4,
664+
)
655665

656666
return None, field_key, value_is_complex
657667

tests/test_settings.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1416,6 +1416,33 @@ class Settings(BaseSettings):
14161416
assert Settings().model_dump() == {'foo': 'foo_secret_value_str'}
14171417

14181418

1419+
def test_secrets_path_multiple(tmp_path):
1420+
d1 = tmp_path / 'dir1'
1421+
d2 = tmp_path / 'dir2'
1422+
d1.mkdir()
1423+
d2.mkdir()
1424+
(d1 / 'foo1').write_text('foo1_dir1_secret_value_str')
1425+
(d1 / 'foo2').write_text('foo2_dir1_secret_value_str')
1426+
(d2 / 'foo2').write_text('foo2_dir2_secret_value_str')
1427+
(d2 / 'foo3').write_text('foo3_dir2_secret_value_str')
1428+
1429+
class Settings(BaseSettings):
1430+
foo1: str
1431+
foo2: str
1432+
foo3: str
1433+
1434+
assert Settings(_secrets_dir=(d1, d2)).model_dump() == {
1435+
'foo1': 'foo1_dir1_secret_value_str',
1436+
'foo2': 'foo2_dir2_secret_value_str', # dir2 takes priority
1437+
'foo3': 'foo3_dir2_secret_value_str',
1438+
}
1439+
assert Settings(_secrets_dir=(d2, d1)).model_dump() == {
1440+
'foo1': 'foo1_dir1_secret_value_str',
1441+
'foo2': 'foo2_dir1_secret_value_str', # dir1 takes priority
1442+
'foo3': 'foo3_dir2_secret_value_str',
1443+
}
1444+
1445+
14191446
def test_secrets_path_with_validation_alias(tmp_path):
14201447
p = tmp_path / 'foo'
14211448
p.write_text('{"bar": ["test"]}')
@@ -1535,6 +1562,28 @@ class Settings(BaseSettings):
15351562
Settings()
15361563

15371564

1565+
def test_secrets_invalid_secrets_dir_multiple_all(tmp_path):
1566+
class Settings(BaseSettings):
1567+
foo: str
1568+
1569+
(d1 := tmp_path / 'dir1').write_text('')
1570+
(d2 := tmp_path / 'dir2').write_text('')
1571+
1572+
with pytest.raises(SettingsError, match='secrets_dir must reference a directory, not a file'):
1573+
Settings(_secrets_dir=[d1, d2])
1574+
1575+
1576+
def test_secrets_invalid_secrets_dir_multiple_one(tmp_path):
1577+
class Settings(BaseSettings):
1578+
foo: str
1579+
1580+
(d1 := tmp_path / 'dir1').mkdir()
1581+
(d2 := tmp_path / 'dir2').write_text('')
1582+
1583+
with pytest.raises(SettingsError, match='secrets_dir must reference a directory, not a file'):
1584+
Settings(_secrets_dir=[d1, d2])
1585+
1586+
15381587
@pytest.mark.skipif(sys.platform.startswith('win'), reason='windows paths break regex')
15391588
def test_secrets_missing_location(tmp_path):
15401589
class Settings(BaseSettings):
@@ -1544,6 +1593,34 @@ class Settings(BaseSettings):
15441593
Settings()
15451594

15461595

1596+
@pytest.mark.skipif(sys.platform.startswith('win'), reason='windows paths break regex')
1597+
def test_secrets_missing_location_multiple_all(tmp_path):
1598+
class Settings(BaseSettings):
1599+
foo: Optional[str] = None
1600+
1601+
with pytest.warns() as record:
1602+
Settings(_secrets_dir=[tmp_path / 'dir1', tmp_path / 'dir2'])
1603+
1604+
assert len(record) == 2
1605+
assert record[0].category is UserWarning and record[1].category is UserWarning
1606+
assert str(record[0].message) == f'directory "{tmp_path}/dir1" does not exist'
1607+
assert str(record[1].message) == f'directory "{tmp_path}/dir2" does not exist'
1608+
1609+
1610+
@pytest.mark.skipif(sys.platform.startswith('win'), reason='windows paths break regex')
1611+
def test_secrets_missing_location_multiple_one(tmp_path):
1612+
class Settings(BaseSettings):
1613+
foo: Optional[str] = None
1614+
1615+
(d1 := tmp_path / 'dir1').mkdir()
1616+
(d1 / 'foo').write_text('secret_value')
1617+
1618+
with pytest.warns(UserWarning, match=f'directory "{tmp_path}/dir2" does not exist'):
1619+
conf = Settings(_secrets_dir=[d1, tmp_path / 'dir2'])
1620+
1621+
assert conf.foo == 'secret_value' # value obtained from first directory
1622+
1623+
15471624
@pytest.mark.skipif(sys.platform.startswith('win'), reason='windows paths break regex')
15481625
def test_secrets_file_is_a_directory(tmp_path):
15491626
p1 = tmp_path / 'foo'
@@ -1558,6 +1635,42 @@ class Settings(BaseSettings):
15581635
Settings()
15591636

15601637

1638+
@pytest.mark.skipif(sys.platform.startswith('win'), reason='windows paths break regex')
1639+
def test_secrets_file_is_a_directory_multiple_all(tmp_path):
1640+
class Settings(BaseSettings):
1641+
foo: Optional[str] = None
1642+
1643+
(d1 := tmp_path / 'dir1').mkdir()
1644+
(d2 := tmp_path / 'dir2').mkdir()
1645+
(d1 / 'foo').mkdir()
1646+
(d2 / 'foo').mkdir()
1647+
1648+
with pytest.warns() as record:
1649+
Settings(_secrets_dir=[d1, d2])
1650+
1651+
assert len(record) == 2
1652+
assert record[0].category is UserWarning and record[1].category is UserWarning
1653+
# warnings are emitted in reverse order
1654+
assert str(record[0].message) == f'attempted to load secret file "{d2}/foo" but found a directory instead.'
1655+
assert str(record[1].message) == f'attempted to load secret file "{d1}/foo" but found a directory instead.'
1656+
1657+
1658+
@pytest.mark.skipif(sys.platform.startswith('win'), reason='windows paths break regex')
1659+
def test_secrets_file_is_a_directory_multiple_one(tmp_path):
1660+
class Settings(BaseSettings):
1661+
foo: Optional[str] = None
1662+
1663+
(d1 := tmp_path / 'dir1').mkdir()
1664+
(d2 := tmp_path / 'dir2').mkdir()
1665+
(d1 / 'foo').write_text('secret_value')
1666+
(d2 / 'foo').mkdir()
1667+
1668+
with pytest.warns(UserWarning, match=f'attempted to load secret file "{d2}/foo" but found a directory instead.'):
1669+
conf = Settings(_secrets_dir=[d1, d2])
1670+
1671+
assert conf.foo == 'secret_value' # value obtained from first directory
1672+
1673+
15611674
def test_secrets_dotenv_precedence(tmp_path):
15621675
s = tmp_path / 'foo'
15631676
s.write_text('foo_secret_value_str')

0 commit comments

Comments
 (0)