Skip to content

Commit 287793f

Browse files
authored
Merge branch 'pydantic:main' into issue-536-fix
2 parents b331a2b + 4433101 commit 287793f

File tree

15 files changed

+2046
-888
lines changed

15 files changed

+2046
-888
lines changed

docs/index.md

Lines changed: 245 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1354,7 +1354,9 @@ print(Settings().model_dump())
13541354

13551355
#### CLI Kebab Case for Arguments
13561356

1357-
Change whether CLI arguments should use kebab case by enabling `cli_kebab_case`.
1357+
Change whether CLI arguments should use kebab case by enabling `cli_kebab_case`. By default, `cli_kebab_case=True` will
1358+
ignore enum fields, and is equivalent to `cli_kebab_case='no_enums'`. To apply kebab case to everything, including
1359+
enums, use `cli_kebab_case='all'`.
13581360

13591361
```py
13601362
import sys
@@ -1857,6 +1859,248 @@ Last, run your application inside a Docker container and supply your newly creat
18571859
docker service create --name pydantic-with-secrets --secret my_secret_data pydantic-app:latest
18581860
```
18591861

1862+
## Nested Secrets
1863+
1864+
The default secrets implementation, `SecretsSettingsSource`, has behaviour that is not always desired or sufficient.
1865+
For example, the default implementation does not support secret fields in nested submodels.
1866+
1867+
`NestedSecretsSettingsSource` can be used as a drop-in replacement to `SecretsSettingsSource` to adjust the default behaviour.
1868+
All differences are summarized in the table below.
1869+
1870+
| `SecretsSettingsSource` | `NestedSecretsSettingsSourcee` |
1871+
|-----------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
1872+
| Secret fields must belong to a top level model. | Secrets can be fields of nested models. |
1873+
| Secret files can be placed in `secrets_dir`s only. | Secret files can be placed in subdirectories for nested models. |
1874+
| Secret files discovery is based on the same configuration options that are used by `EnvSettingsSource`: `case_sensitive`, `env_nested_delimiter`, `env_prefix`. | Default options are respected, but can be overridden with `secrets_case_sensitive`, `secrets_nested_delimiter`, `secrets_prefix`. |
1875+
| When `secrets_dir` is missing on the file system, a warning is generated. | Use `secrets_dir_missing` options to choose whether to issue warning, raise error, or silently ignore. |
1876+
1877+
### Use Case: Plain Directory Layout
1878+
1879+
```text
1880+
📂 secrets
1881+
├── 📄 app_key
1882+
└── 📄 db_passwd
1883+
```
1884+
1885+
In the example below, secrets nested delimiter `'_'` is different from env nested delimiter `'__'`.
1886+
Value for `Settings.db.user` can be passed in env variable `MY_DB__USER`.
1887+
1888+
```py
1889+
from pydantic import BaseModel, SecretStr
1890+
1891+
from pydantic_settings import (
1892+
BaseSettings,
1893+
NestedSecretsSettingsSource,
1894+
SettingsConfigDict,
1895+
)
1896+
1897+
1898+
class AppSettings(BaseModel):
1899+
key: SecretStr
1900+
1901+
1902+
class DbSettings(BaseModel):
1903+
user: str
1904+
passwd: SecretStr
1905+
1906+
1907+
class Settings(BaseSettings):
1908+
app: AppSettings
1909+
db: DbSettings
1910+
1911+
model_config = SettingsConfigDict(
1912+
env_prefix='MY_',
1913+
env_nested_delimiter='__',
1914+
secrets_dir='secrets',
1915+
secrets_nested_delimiter='_',
1916+
)
1917+
1918+
@classmethod
1919+
def settings_customise_sources(
1920+
cls,
1921+
settings_cls,
1922+
init_settings,
1923+
env_settings,
1924+
dotenv_settings,
1925+
file_secret_settings,
1926+
):
1927+
return (
1928+
init_settings,
1929+
env_settings,
1930+
dotenv_settings,
1931+
NestedSecretsSettingsSource(file_secret_settings),
1932+
)
1933+
```
1934+
1935+
### Use Case: Nested Directory Layout
1936+
1937+
```text
1938+
📂 secrets
1939+
├── 📂 app
1940+
│ └── 📄 key
1941+
└── 📂 db
1942+
└── 📄 passwd
1943+
```
1944+
```py
1945+
from pydantic import BaseModel, SecretStr
1946+
1947+
from pydantic_settings import (
1948+
BaseSettings,
1949+
NestedSecretsSettingsSource,
1950+
SettingsConfigDict,
1951+
)
1952+
1953+
1954+
class AppSettings(BaseModel):
1955+
key: SecretStr
1956+
1957+
1958+
class DbSettings(BaseModel):
1959+
user: str
1960+
passwd: SecretStr
1961+
1962+
1963+
class Settings(BaseSettings):
1964+
app: AppSettings
1965+
db: DbSettings
1966+
1967+
model_config = SettingsConfigDict(
1968+
env_prefix='MY_',
1969+
env_nested_delimiter='__',
1970+
secrets_dir='secrets',
1971+
secrets_nested_subdir=True,
1972+
)
1973+
1974+
@classmethod
1975+
def settings_customise_sources(
1976+
cls,
1977+
settings_cls,
1978+
init_settings,
1979+
env_settings,
1980+
dotenv_settings,
1981+
file_secret_settings,
1982+
):
1983+
return (
1984+
init_settings,
1985+
env_settings,
1986+
dotenv_settings,
1987+
NestedSecretsSettingsSource(file_secret_settings),
1988+
)
1989+
```
1990+
1991+
### Use Case: Multiple Nested Directories
1992+
1993+
```text
1994+
📂 secrets
1995+
├── 📂 default
1996+
│ ├── 📂 app
1997+
│ │ └── 📄 key
1998+
│ └── 📂 db
1999+
│ └── 📄 passwd
2000+
└── 📂 override
2001+
├── 📂 app
2002+
│ └── 📄 key
2003+
└── 📂 db
2004+
└── 📄 passwd
2005+
```
2006+
```py
2007+
from pydantic import BaseModel, SecretStr
2008+
2009+
from pydantic_settings import (
2010+
BaseSettings,
2011+
NestedSecretsSettingsSource,
2012+
SettingsConfigDict,
2013+
)
2014+
2015+
2016+
class AppSettings(BaseModel):
2017+
key: SecretStr
2018+
2019+
2020+
class DbSettings(BaseModel):
2021+
user: str
2022+
passwd: SecretStr
2023+
2024+
2025+
class Settings(BaseSettings):
2026+
app: AppSettings
2027+
db: DbSettings
2028+
2029+
model_config = SettingsConfigDict(
2030+
env_prefix='MY_',
2031+
env_nested_delimiter='__',
2032+
secrets_dir=['secrets/default', 'secrets/override'],
2033+
secrets_nested_subdir=True,
2034+
)
2035+
2036+
@classmethod
2037+
def settings_customise_sources(
2038+
cls,
2039+
settings_cls,
2040+
init_settings,
2041+
env_settings,
2042+
dotenv_settings,
2043+
file_secret_settings,
2044+
):
2045+
return (
2046+
init_settings,
2047+
env_settings,
2048+
dotenv_settings,
2049+
NestedSecretsSettingsSource(file_secret_settings),
2050+
)
2051+
```
2052+
2053+
### Configuration Options
2054+
2055+
#### secrets_dir
2056+
2057+
Path to secrets directory, same as `SecretsSettingsSource.secrets_dir`. If `list`, the last match wins.
2058+
If `secrets_dir` is passed in both source constructor and model config, values are not merged (constructor wins).
2059+
2060+
#### secrets_dir_missing
2061+
2062+
If `secrets_dir` does not exist, original `SecretsSettingsSource` issues a warning.
2063+
However, this may be undesirable, for example if we don't mount Docker Secrets in e.g. dev environment.
2064+
Use `secrets_dir_missing` to choose:
2065+
2066+
* `'ok'` — do nothing if `secrets_dir` does not exist
2067+
* `'warn'` (default) — print warning, same as `SecretsSettingsSource`
2068+
* `'error'` — raise `SettingsError`
2069+
2070+
If multiple `secrets_dir` passed, the same `secrets_dir_missing` action applies to each of them.
2071+
2072+
#### secrets_dir_max_size
2073+
2074+
Limit the size of `secrets_dir` for security reasons, defaults to `SECRETS_DIR_MAX_SIZE` equal to 16 MiB.
2075+
2076+
`NestedSecretsSettingsSource` is a thin wrapper around `EnvSettingsSource`,
2077+
which loads all potential secrets on initialization. This could lead to `MemoryError` if we mount
2078+
a large file under `secrets_dir`.
2079+
2080+
If multiple `secrets_dir` passed, the limit applies to each directory independently.
2081+
2082+
#### secrets_case_sensitive
2083+
2084+
Same as `case_sensitive`, but works for secrets only. If not specified, defaults to `case_sensitive`.
2085+
2086+
#### secrets_nested_delimiter
2087+
2088+
Same as `env_nested_delimiter`, but works for secrets only. If not specified, defaults to `env_nested_delimiter`.
2089+
This option is used to implement _nested secrets directory_ layout and allows to do even nasty things
2090+
like `/run/secrets/model/delim/nested1/delim/nested2`.
2091+
2092+
#### secrets_nested_subdir
2093+
2094+
Boolean flag to turn on _nested secrets directory_ mode, `False` by default. If `True`, sets `secrets_nested_delimiter`
2095+
to `os.sep`. Raises `SettingsError` if `secrets_nested_delimiter` is already specified.
2096+
2097+
#### secrets_prefix
2098+
2099+
Secret path prefix, similar to `env_prefix`, but works for secrets only. Defaults to `env_prefix`
2100+
if not specified. Works in both plain and nested directory modes, like
2101+
`'/run/secrets/prefix_model__nested'` and `'/run/secrets/prefix_model/nested'`.
2102+
2103+
18602104
## AWS Secrets Manager
18612105

18622106
You must set one parameter:

pydantic_settings/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
GoogleSecretManagerSettingsSource,
1919
InitSettingsSource,
2020
JsonConfigSettingsSource,
21+
NestedSecretsSettingsSource,
2122
NoDecode,
2223
PydanticBaseSettingsSource,
2324
PyprojectTomlConfigSettingsSource,
@@ -48,6 +49,7 @@
4849
'GoogleSecretManagerSettingsSource',
4950
'InitSettingsSource',
5051
'JsonConfigSettingsSource',
52+
'NestedSecretsSettingsSource',
5153
'NoDecode',
5254
'PydanticBaseSettingsSource',
5355
'PyprojectTomlConfigSettingsSource',

pydantic_settings/main.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from argparse import Namespace
88
from collections.abc import Mapping
99
from types import SimpleNamespace
10-
from typing import Any, ClassVar, TypeVar
10+
from typing import Any, ClassVar, Literal, TypeVar
1111

1212
from pydantic import ConfigDict
1313
from pydantic._internal._config import config_keys
@@ -62,7 +62,7 @@ class SettingsConfigDict(ConfigDict, total=False):
6262
cli_flag_prefix_char: str
6363
cli_implicit_flags: bool | None
6464
cli_ignore_unknown_args: bool | None
65-
cli_kebab_case: bool | None
65+
cli_kebab_case: bool | Literal['all', 'no_enums'] | None
6666
cli_shortcuts: Mapping[str, str | list[str]] | None
6767
secrets_dir: PathType | None
6868
json_file: PathType | None
@@ -185,7 +185,7 @@ def __init__(
185185
_cli_flag_prefix_char: str | None = None,
186186
_cli_implicit_flags: bool | None = None,
187187
_cli_ignore_unknown_args: bool | None = None,
188-
_cli_kebab_case: bool | None = None,
188+
_cli_kebab_case: bool | Literal['all', 'no_enums'] | None = None,
189189
_cli_shortcuts: Mapping[str, str | list[str]] | None = None,
190190
_secrets_dir: PathType | None = None,
191191
**values: Any,
@@ -272,7 +272,7 @@ def _settings_build_values(
272272
_cli_flag_prefix_char: str | None = None,
273273
_cli_implicit_flags: bool | None = None,
274274
_cli_ignore_unknown_args: bool | None = None,
275-
_cli_kebab_case: bool | None = None,
275+
_cli_kebab_case: bool | Literal['all', 'no_enums'] | None = None,
276276
_cli_shortcuts: Mapping[str, str | list[str]] | None = None,
277277
_secrets_dir: PathType | None = None,
278278
) -> dict[str, Any]:
@@ -426,6 +426,7 @@ def _settings_build_values(
426426

427427
if sources:
428428
state: dict[str, Any] = {}
429+
defaults: dict[str, Any] = {}
429430
states: dict[str, dict[str, Any]] = {}
430431
for source in sources:
431432
if isinstance(source, PydanticBaseSettingsSource):
@@ -435,8 +436,15 @@ def _settings_build_values(
435436
source_name = source.__name__ if hasattr(source, '__name__') else type(source).__name__
436437
source_state = source()
437438

439+
if isinstance(source, DefaultSettingsSource):
440+
defaults = source_state
441+
438442
states[source_name] = source_state
439443
state = deep_update(source_state, state)
444+
445+
# Strip any default values not explicity set before returning final state
446+
state = {key: val for key, val in state.items() if key not in defaults or defaults[key] != val}
447+
440448
return state
441449
else:
442450
# no one should mean to do this, but I think returning an empty dict is marginally preferable

pydantic_settings/sources/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from .providers.env import EnvSettingsSource
2626
from .providers.gcp import GoogleSecretManagerSettingsSource
2727
from .providers.json import JsonConfigSettingsSource
28+
from .providers.nested_secrets import NestedSecretsSettingsSource
2829
from .providers.pyproject import PyprojectTomlConfigSettingsSource
2930
from .providers.secrets import SecretsSettingsSource
3031
from .providers.toml import TomlConfigSettingsSource
@@ -53,6 +54,7 @@
5354
'GoogleSecretManagerSettingsSource',
5455
'InitSettingsSource',
5556
'JsonConfigSettingsSource',
57+
'NestedSecretsSettingsSource',
5658
'NoDecode',
5759
'PathType',
5860
'PydanticBaseEnvSettingsSource',

pydantic_settings/sources/base.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -266,12 +266,27 @@ def __init__(
266266
init_kwarg_names = set(init_kwargs.keys())
267267
for field_name, field_info in settings_cls.model_fields.items():
268268
alias_names, *_ = _get_alias_names(field_name, field_info)
269-
init_kwarg_name = init_kwarg_names & set(alias_names)
269+
# When populate_by_name is True, allow using the field name as an input key,
270+
# but normalize to the preferred alias to keep keys consistent across sources.
271+
matchable_names = set(alias_names)
272+
include_name = settings_cls.model_config.get('populate_by_name', False)
273+
if include_name:
274+
matchable_names.add(field_name)
275+
init_kwarg_name = init_kwarg_names & matchable_names
270276
if init_kwarg_name:
271-
preferred_alias = alias_names[0]
272-
preferred_set_alias = next(alias for alias in alias_names if alias in init_kwarg_name)
277+
preferred_alias = alias_names[0] if alias_names else field_name
278+
# Choose provided key deterministically: prefer the first alias in alias_names order;
279+
# fall back to field_name if allowed and provided.
280+
provided_key = next((alias for alias in alias_names if alias in init_kwarg_names), None)
281+
if provided_key is None and include_name and field_name in init_kwarg_names:
282+
provided_key = field_name
283+
# provided_key should not be None here because init_kwarg_name is non-empty
284+
assert provided_key is not None
273285
init_kwarg_names -= init_kwarg_name
274-
self.init_kwargs[preferred_alias] = init_kwargs[preferred_set_alias]
286+
self.init_kwargs[preferred_alias] = init_kwargs[provided_key]
287+
# Include any remaining init kwargs (e.g., extras) unchanged
288+
# Note: If populate_by_name is True and the provided key is the field name, but
289+
# no alias exists, we keep it as-is so it can be processed as extra if allowed.
275290
self.init_kwargs.update({key: val for key, val in init_kwargs.items() if key in init_kwarg_names})
276291

277292
super().__init__(settings_cls)

0 commit comments

Comments
 (0)