Skip to content

Commit 462d261

Browse files
kschwabhramezani
andauthored
Add nested_model_default_partial_update flag and DefaultSettingsSource (#348)
Co-authored-by: Hasan Ramezani <[email protected]>
1 parent 0145d7e commit 462d261

File tree

4 files changed

+193
-8
lines changed

4 files changed

+193
-8
lines changed

docs/index.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,39 @@ print(Settings().model_dump())
371371
#> {'numbers': [1, 2, 3]}
372372
```
373373

374+
## Nested model default partial updates
375+
376+
By default, Pydantic settings does not allow partial updates to nested model default objects. This behavior can be
377+
overriden by setting the `nested_model_default_partial_update` flag to `True`, which will allow partial updates on
378+
nested model default object fields.
379+
380+
```py
381+
import os
382+
383+
from pydantic import BaseModel
384+
385+
from pydantic_settings import BaseSettings, SettingsConfigDict
386+
387+
388+
class SubModel(BaseModel):
389+
val: int = 0
390+
flag: bool = False
391+
392+
393+
class Settings(BaseSettings):
394+
model_config = SettingsConfigDict(
395+
env_nested_delimiter='__', nested_model_default_partial_update=True
396+
)
397+
398+
nested_model: SubModel = SubModel()
399+
400+
401+
# Apply a partial update to the default object using environment variables
402+
os.environ['NESTED_MODEL__FLAG'] = 'True'
403+
404+
assert Settings().model_dump() == {'nested_model': {'val': 0, 'flag': True}}
405+
```
406+
374407
## Dotenv (.env) support
375408

376409
Dotenv files (generally named `.env`) are a common pattern that make it easy to use environment variables in a
@@ -474,7 +507,8 @@ models. There are two primary use cases for Pydantic settings CLI:
474507

475508
By default, the experience is tailored towards use case #1 and builds on the foundations established in [parsing
476509
environment variables](#parsing-environment-variable-values). If your use case primarily falls into #2, you will likely
477-
want to enable [enforcing required arguments at the CLI](#enforce-required-arguments-at-cli).
510+
want to enable [enforcing required arguments at the CLI](#enforce-required-arguments-at-cli) and [nested model default
511+
partial updates](#nested-model-default-partial-updates).
478512

479513
### The Basics
480514

pydantic_settings/main.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .sources import (
1212
ENV_FILE_SENTINEL,
1313
CliSettingsSource,
14+
DefaultSettingsSource,
1415
DotEnvSettingsSource,
1516
DotenvType,
1617
EnvSettingsSource,
@@ -23,6 +24,7 @@
2324

2425
class SettingsConfigDict(ConfigDict, total=False):
2526
case_sensitive: bool
27+
nested_model_default_partial_update: bool | None
2628
env_prefix: str
2729
env_file: DotenvType | None
2830
env_file_encoding: str | None
@@ -89,6 +91,8 @@ class BaseSettings(BaseModel):
8991
9092
Args:
9193
_case_sensitive: Whether environment variables names should be read with case-sensitivity. Defaults to `None`.
94+
_nested_model_default_partial_update: Whether to allow partial updates on nested model default object fields.
95+
Defaults to `False`.
9296
_env_prefix: Prefix for all environment variables. Defaults to `None`.
9397
_env_file: The env file(s) to load settings values from. Defaults to `Path('')`, which
9498
means that the value from `model_config['env_file']` should be used. You can also pass
@@ -123,6 +127,7 @@ class BaseSettings(BaseModel):
123127
def __init__(
124128
__pydantic_self__,
125129
_case_sensitive: bool | None = None,
130+
_nested_model_default_partial_update: bool | None = None,
126131
_env_prefix: str | None = None,
127132
_env_file: DotenvType | None = ENV_FILE_SENTINEL,
128133
_env_file_encoding: str | None = None,
@@ -149,6 +154,7 @@ def __init__(
149154
**__pydantic_self__._settings_build_values(
150155
values,
151156
_case_sensitive=_case_sensitive,
157+
_nested_model_default_partial_update=_nested_model_default_partial_update,
152158
_env_prefix=_env_prefix,
153159
_env_file=_env_file,
154160
_env_file_encoding=_env_file_encoding,
@@ -199,6 +205,7 @@ def _settings_build_values(
199205
self,
200206
init_kwargs: dict[str, Any],
201207
_case_sensitive: bool | None = None,
208+
_nested_model_default_partial_update: bool | None = None,
202209
_env_prefix: str | None = None,
203210
_env_file: DotenvType | None = None,
204211
_env_file_encoding: str | None = None,
@@ -222,6 +229,11 @@ def _settings_build_values(
222229
# Determine settings config values
223230
case_sensitive = _case_sensitive if _case_sensitive is not None else self.model_config.get('case_sensitive')
224231
env_prefix = _env_prefix if _env_prefix is not None else self.model_config.get('env_prefix')
232+
nested_model_default_partial_update = (
233+
_nested_model_default_partial_update
234+
if _nested_model_default_partial_update is not None
235+
else self.model_config.get('nested_model_default_partial_update')
236+
)
225237
env_file = _env_file if _env_file != ENV_FILE_SENTINEL else self.model_config.get('env_file')
226238
env_file_encoding = (
227239
_env_file_encoding if _env_file_encoding is not None else self.model_config.get('env_file_encoding')
@@ -273,7 +285,14 @@ def _settings_build_values(
273285
secrets_dir = _secrets_dir if _secrets_dir is not None else self.model_config.get('secrets_dir')
274286

275287
# Configure built-in sources
276-
init_settings = InitSettingsSource(self.__class__, init_kwargs=init_kwargs)
288+
default_settings = DefaultSettingsSource(
289+
self.__class__, nested_model_default_partial_update=nested_model_default_partial_update
290+
)
291+
init_settings = InitSettingsSource(
292+
self.__class__,
293+
init_kwargs=init_kwargs,
294+
nested_model_default_partial_update=nested_model_default_partial_update,
295+
)
277296
env_settings = EnvSettingsSource(
278297
self.__class__,
279298
case_sensitive=case_sensitive,
@@ -305,7 +324,7 @@ def _settings_build_values(
305324
env_settings=env_settings,
306325
dotenv_settings=dotenv_settings,
307326
file_secret_settings=file_secret_settings,
308-
)
327+
) + (default_settings,)
309328
if not any([source for source in sources if isinstance(source, CliSettingsSource)]):
310329
if cli_parse_args is not None or cli_settings_source is not None:
311330
cli_settings = (
@@ -352,6 +371,7 @@ def _settings_build_values(
352371
validate_default=True,
353372
case_sensitive=False,
354373
env_prefix='',
374+
nested_model_default_partial_update=False,
355375
env_file=None,
356376
env_file_encoding=None,
357377
env_ignore_empty=False,

pydantic_settings/sources.py

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from argparse import BooleanOptionalAction
1414
from argparse import SUPPRESS, ArgumentParser, Namespace, RawDescriptionHelpFormatter, _SubParsersAction
1515
from collections import deque
16-
from dataclasses import is_dataclass
16+
from dataclasses import asdict, is_dataclass
1717
from enum import Enum
1818
from pathlib import Path
1919
from textwrap import dedent
@@ -22,6 +22,7 @@
2222
TYPE_CHECKING,
2323
Any,
2424
Callable,
25+
Dict,
2526
Generic,
2627
Iterator,
2728
List,
@@ -38,8 +39,9 @@
3839

3940
import typing_extensions
4041
from dotenv import dotenv_values
41-
from pydantic import AliasChoices, AliasPath, BaseModel, Json, RootModel
42+
from pydantic import AliasChoices, AliasPath, BaseModel, Json, RootModel, TypeAdapter
4243
from pydantic._internal._repr import Representation
44+
from pydantic._internal._signature import _field_name_for_signature
4345
from pydantic._internal._typing_extra import WithArgsTypes, origin_is_union, typing_base
4446
from pydantic._internal._utils import deep_update, is_model_class, lenient_issubclass
4547
from pydantic.dataclasses import is_pydantic_dataclass
@@ -261,21 +263,71 @@ def __call__(self) -> dict[str, Any]:
261263
pass
262264

263265

266+
class DefaultSettingsSource(PydanticBaseSettingsSource):
267+
"""
268+
Source class for loading default object values.
269+
270+
Args:
271+
settings_cls: The Settings class.
272+
nested_model_default_partial_update: Whether to allow partial updates on nested model default object fields.
273+
Defaults to `False`.
274+
"""
275+
276+
def __init__(self, settings_cls: type[BaseSettings], nested_model_default_partial_update: bool | None = None):
277+
super().__init__(settings_cls)
278+
self.defaults: dict[str, Any] = {}
279+
self.nested_model_default_partial_update = (
280+
nested_model_default_partial_update
281+
if nested_model_default_partial_update is not None
282+
else self.config.get('nested_model_default_partial_update', False)
283+
)
284+
if self.nested_model_default_partial_update:
285+
for field_name, field_info in settings_cls.model_fields.items():
286+
if is_dataclass(type(field_info.default)):
287+
self.defaults[_field_name_for_signature(field_name, field_info)] = asdict(field_info.default)
288+
elif is_model_class(type(field_info.default)):
289+
self.defaults[_field_name_for_signature(field_name, field_info)] = field_info.default.model_dump()
290+
291+
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
292+
# Nothing to do here. Only implement the return statement to make mypy happy
293+
return None, '', False
294+
295+
def __call__(self) -> dict[str, Any]:
296+
return self.defaults
297+
298+
def __repr__(self) -> str:
299+
return f'DefaultSettingsSource(nested_model_default_partial_update={self.nested_model_default_partial_update})'
300+
301+
264302
class InitSettingsSource(PydanticBaseSettingsSource):
265303
"""
266304
Source class for loading values provided during settings class initialization.
267305
"""
268306

269-
def __init__(self, settings_cls: type[BaseSettings], init_kwargs: dict[str, Any]):
307+
def __init__(
308+
self,
309+
settings_cls: type[BaseSettings],
310+
init_kwargs: dict[str, Any],
311+
nested_model_default_partial_update: bool | None = None,
312+
):
270313
self.init_kwargs = init_kwargs
271314
super().__init__(settings_cls)
315+
self.nested_model_default_partial_update = (
316+
nested_model_default_partial_update
317+
if nested_model_default_partial_update is not None
318+
else self.config.get('nested_model_default_partial_update', False)
319+
)
272320

273321
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
274322
# Nothing to do here. Only implement the return statement to make mypy happy
275323
return None, '', False
276324

277325
def __call__(self) -> dict[str, Any]:
278-
return self.init_kwargs
326+
return (
327+
TypeAdapter(Dict[str, Any]).dump_python(self.init_kwargs)
328+
if self.nested_model_default_partial_update
329+
else self.init_kwargs
330+
)
279331

280332
def __repr__(self) -> str:
281333
return f'InitSettingsSource(init_kwargs={self.init_kwargs!r})'
@@ -1581,7 +1633,7 @@ def _add_parser_submodels(
15811633
if self.cli_use_class_docs_for_groups and len(sub_models) == 1:
15821634
model_group_kwargs['description'] = None if sub_models[0].__doc__ is None else dedent(sub_models[0].__doc__)
15831635

1584-
if model_default not in (PydanticUndefined, None):
1636+
if model_default is not PydanticUndefined:
15851637
if is_model_class(type(model_default)) or is_pydantic_dataclass(type(model_default)):
15861638
model_default = getattr(model_default, field_name)
15871639
else:

tests/test_settings.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
CliPositionalArg,
5757
CliSettingsSource,
5858
CliSubCommand,
59+
DefaultSettingsSource,
5960
SettingsError,
6061
)
6162

@@ -499,6 +500,80 @@ class ComplexSettings(BaseSettings):
499500
]
500501

501502

503+
def test_class_nested_model_default_partial_update(env):
504+
class NestedA(BaseModel):
505+
v0: bool
506+
v1: bool
507+
508+
@pydantic_dataclasses.dataclass
509+
class NestedB:
510+
v0: bool
511+
v1: bool
512+
513+
@dataclasses.dataclass
514+
class NestedC:
515+
v0: bool
516+
v1: bool
517+
518+
class NestedD(BaseModel):
519+
v0: bool = False
520+
v1: bool = True
521+
522+
class SettingsDefaultsA(BaseSettings, env_nested_delimiter='__', nested_model_default_partial_update=True):
523+
nested_a: NestedA = NestedA(v0=False, v1=True)
524+
nested_b: NestedB = NestedB(v0=False, v1=True)
525+
nested_d: NestedC = NestedC(v0=False, v1=True)
526+
nested_c: NestedD = NestedD()
527+
528+
env.set('NESTED_A__V0', 'True')
529+
env.set('NESTED_B__V0', 'True')
530+
env.set('NESTED_C__V0', 'True')
531+
env.set('NESTED_D__V0', 'True')
532+
assert SettingsDefaultsA().model_dump() == {
533+
'nested_a': {'v0': True, 'v1': True},
534+
'nested_b': {'v0': True, 'v1': True},
535+
'nested_c': {'v0': True, 'v1': True},
536+
'nested_d': {'v0': True, 'v1': True},
537+
}
538+
539+
540+
def test_init_kwargs_nested_model_default_partial_update(env):
541+
class DeepSubModel(BaseModel):
542+
v4: str
543+
544+
class SubModel(BaseModel):
545+
v1: str
546+
v2: bytes
547+
v3: int
548+
deep: DeepSubModel
549+
550+
class Settings(BaseSettings, env_nested_delimiter='__', nested_model_default_partial_update=True):
551+
v0: str
552+
sub_model: SubModel
553+
554+
@classmethod
555+
def settings_customise_sources(
556+
cls, settings_cls, init_settings, env_settings, dotenv_settings, file_secret_settings
557+
):
558+
return env_settings, dotenv_settings, init_settings, file_secret_settings
559+
560+
env.set('SUB_MODEL__DEEP__V4', 'override-v4')
561+
562+
s_final = {'v0': '0', 'sub_model': {'v1': 'init-v1', 'v2': b'init-v2', 'v3': 3, 'deep': {'v4': 'override-v4'}}}
563+
564+
s = Settings(v0='0', sub_model={'v1': 'init-v1', 'v2': b'init-v2', 'v3': 3, 'deep': {'v4': 'init-v4'}})
565+
assert s.model_dump() == s_final
566+
567+
s = Settings(v0='0', sub_model=SubModel(v1='init-v1', v2=b'init-v2', v3=3, deep=DeepSubModel(v4='init-v4')))
568+
assert s.model_dump() == s_final
569+
570+
s = Settings(v0='0', sub_model=SubModel(v1='init-v1', v2=b'init-v2', v3=3, deep={'v4': 'init-v4'}))
571+
assert s.model_dump() == s_final
572+
573+
s = Settings(v0='0', sub_model={'v1': 'init-v1', 'v2': b'init-v2', 'v3': 3, 'deep': DeepSubModel(v4='init-v4')})
574+
assert s.model_dump() == s_final
575+
576+
502577
def test_env_str(env):
503578
class Settings(BaseSettings):
504579
apple: str = Field(None, validation_alias='BOOM')
@@ -1575,6 +1650,10 @@ def settings_customise_sources(cls, *args, **kwargs):
15751650

15761651

15771652
def test_builtins_settings_source_repr():
1653+
assert (
1654+
repr(DefaultSettingsSource(BaseSettings, nested_model_default_partial_update=True))
1655+
== 'DefaultSettingsSource(nested_model_default_partial_update=True)'
1656+
)
15781657
assert (
15791658
repr(InitSettingsSource(BaseSettings, init_kwargs={'apple': 'value 0', 'banana': 'value 1'}))
15801659
== "InitSettingsSource(init_kwargs={'apple': 'value 0', 'banana': 'value 1'})"

0 commit comments

Comments
 (0)