Skip to content
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

Add nested_model_default_partial_update flag and DefaultSettingsSource #348

Merged
merged 16 commits into from
Aug 23, 2024
Merged
69 changes: 68 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,72 @@ print(Settings().model_dump())
#> {'numbers': [1, 2, 3]}
```

## Parsing default objects

Pydantic settings uses copy-by-reference when merging default `BaseModel` objects from different sources. This ensures
the original object reference is maintained in the final object instantiation. However, due to an internal limitation,
it is not possible to partially update a nested sub model field in a default object when using copy-by-reference; the
entirety of the sub model must be provided.

This behavior can be overriden by setting the `default_objects_copy_by_value` flag to `True`, which will allow partial
updates to sub model fields. Note of course the original default object reference will not be retained.

```py
import os

from pydantic import BaseModel, ValidationError

from pydantic_settings import BaseSettings, SettingsConfigDict


class SubModel(BaseModel):
val: int = 0
flag: bool = False


ORIGINAL_OBJECT = SubModel()


class SettingsCopyByReference(BaseSettings):
model_config = SettingsConfigDict(env_nested_delimiter='__')

default_object: SubModel = ORIGINAL_OBJECT


class SettingsCopyByValue(BaseSettings):
model_config = SettingsConfigDict(
env_nested_delimiter='__', default_objects_copy_by_value=True
)

default_object: SubModel = ORIGINAL_OBJECT


by_ref = SettingsCopyByReference()
assert by_ref.default_object is ORIGINAL_OBJECT

# Apply a partial update to the default object using environment variables
os.environ['DEFAULT_OBJECT__FLAG'] = 'True'

try:
# Copy by reference will fail
SettingsCopyByReference()
except ValidationError as err:
print(err)
"""
1 validation error for SettingsCopyByReference
nested.val
Field required [type=missing, input_value={'flag': 'TRUE'}, input_type=dict]
For further information visit https://errors.pydantic.dev/2/v/missing
"""

# Copy by value will pass
by_val = SettingsCopyByValue()
assert by_val.default_object is not ORIGINAL_OBJECT

print(by_val.model_dump())
#> {'default_object': {'val': 0, 'flag': True}}
```

## Dotenv (.env) support

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

By default, the experience is tailored towards use case #1 and builds on the foundations established in [parsing
environment variables](#parsing-environment-variable-values). If your use case primarily falls into #2, you will likely
want to enable [enforcing required arguments at the CLI](#enforce-required-arguments-at-cli).
want to enable [enforcing required arguments at the CLI](#enforce-required-arguments-at-cli) and copy-by-value when
[parsing default objects](#parsing-default-objects).

### The Basics

Expand Down
22 changes: 20 additions & 2 deletions pydantic_settings/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from .sources import (
ENV_FILE_SENTINEL,
CliSettingsSource,
DefaultSettingsSource,
DotEnvSettingsSource,
DotenvType,
EnvSettingsSource,
Expand All @@ -23,6 +24,7 @@

class SettingsConfigDict(ConfigDict, total=False):
case_sensitive: bool
default_objects_copy_by_value: bool | None
env_prefix: str
env_file: DotenvType | None
env_file_encoding: str | None
Expand Down Expand Up @@ -88,6 +90,8 @@ class BaseSettings(BaseModel):

Args:
_case_sensitive: Whether environment variables names should be read with case-sensitivity. Defaults to `None`.
_default_objects_copy_by_value: Whether default `BaseModel` objects should use copy-by-value instead of
copy-by-reference when compiling sources. Defaults to `False`.
_env_prefix: Prefix for all environment variables. Defaults to `None`.
_env_file: The env file(s) to load settings values from. Defaults to `Path('')`, which
means that the value from `model_config['env_file']` should be used. You can also pass
Expand Down Expand Up @@ -120,6 +124,7 @@ class BaseSettings(BaseModel):
def __init__(
__pydantic_self__,
_case_sensitive: bool | None = None,
_default_objects_copy_by_value: bool | None = None,
_env_prefix: str | None = None,
_env_file: DotenvType | None = ENV_FILE_SENTINEL,
_env_file_encoding: str | None = None,
Expand All @@ -145,6 +150,7 @@ def __init__(
**__pydantic_self__._settings_build_values(
values,
_case_sensitive=_case_sensitive,
_default_objects_copy_by_value=_default_objects_copy_by_value,
_env_prefix=_env_prefix,
_env_file=_env_file,
_env_file_encoding=_env_file_encoding,
Expand Down Expand Up @@ -194,6 +200,7 @@ def _settings_build_values(
self,
init_kwargs: dict[str, Any],
_case_sensitive: bool | None = None,
_default_objects_copy_by_value: bool | None = None,
_env_prefix: str | None = None,
_env_file: DotenvType | None = None,
_env_file_encoding: str | None = None,
Expand All @@ -216,6 +223,11 @@ def _settings_build_values(
# Determine settings config values
case_sensitive = _case_sensitive if _case_sensitive is not None else self.model_config.get('case_sensitive')
env_prefix = _env_prefix if _env_prefix is not None else self.model_config.get('env_prefix')
default_objects_copy_by_value = (
_default_objects_copy_by_value
if _default_objects_copy_by_value is not None
else self.model_config.get('default_objects_copy_by_value')
)
env_file = _env_file if _env_file != ENV_FILE_SENTINEL else self.model_config.get('env_file')
env_file_encoding = (
_env_file_encoding if _env_file_encoding is not None else self.model_config.get('env_file_encoding')
Expand Down Expand Up @@ -264,7 +276,12 @@ def _settings_build_values(
secrets_dir = _secrets_dir if _secrets_dir is not None else self.model_config.get('secrets_dir')

# Configure built-in sources
init_settings = InitSettingsSource(self.__class__, init_kwargs=init_kwargs)
default_settings = DefaultSettingsSource(
self.__class__, default_objects_copy_by_value=default_objects_copy_by_value
)
init_settings = InitSettingsSource(
self.__class__, init_kwargs=init_kwargs, default_objects_copy_by_value=default_objects_copy_by_value
)
env_settings = EnvSettingsSource(
self.__class__,
case_sensitive=case_sensitive,
Expand Down Expand Up @@ -296,7 +313,7 @@ def _settings_build_values(
env_settings=env_settings,
dotenv_settings=dotenv_settings,
file_secret_settings=file_secret_settings,
)
) + (default_settings,)
if not any([source for source in sources if isinstance(source, CliSettingsSource)]):
if cli_parse_args is not None or cli_settings_source is not None:
cli_settings = (
Expand Down Expand Up @@ -342,6 +359,7 @@ def _settings_build_values(
validate_default=True,
case_sensitive=False,
env_prefix='',
default_objects_copy_by_value=False,
env_file=None,
env_file_encoding=None,
env_ignore_empty=False,
Expand Down
64 changes: 58 additions & 6 deletions pydantic_settings/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from abc import ABC, abstractmethod
from argparse import SUPPRESS, ArgumentParser, Namespace, RawDescriptionHelpFormatter, _SubParsersAction
from collections import deque
from dataclasses import is_dataclass
from dataclasses import asdict, is_dataclass
from enum import Enum
from pathlib import Path
from textwrap import dedent
Expand All @@ -19,6 +19,7 @@
TYPE_CHECKING,
Any,
Callable,
Dict,
Generic,
Iterator,
List,
Expand All @@ -35,8 +36,9 @@

import typing_extensions
from dotenv import dotenv_values
from pydantic import AliasChoices, AliasPath, BaseModel, Json
from pydantic import AliasChoices, AliasPath, BaseModel, Json, TypeAdapter
from pydantic._internal._repr import Representation
from pydantic._internal._signature import _field_name_for_signature
from pydantic._internal._typing_extra import WithArgsTypes, origin_is_union, typing_base
from pydantic._internal._utils import deep_update, is_model_class, lenient_issubclass
from pydantic.dataclasses import is_pydantic_dataclass
Expand Down Expand Up @@ -247,24 +249,74 @@ def __call__(self) -> dict[str, Any]:
pass


class DefaultSettingsSource(PydanticBaseSettingsSource):
"""
Source class for loading default object values.
"""

def __init__(self, settings_cls: type[BaseSettings], default_objects_copy_by_value: bool | None = None):
super().__init__(settings_cls)
self.defaults: dict[str, Any] = {}
self.default_objects_copy_by_value = (
default_objects_copy_by_value
if default_objects_copy_by_value is not None
else self.config.get('default_objects_copy_by_value', False)
)
for field_name, field_info in settings_cls.model_fields.items():
if not self.default_objects_copy_by_value:
if is_dataclass(type(field_info.default)) or is_model_class(type(field_info.default)):
self.defaults[_field_name_for_signature(field_name, field_info)] = field_info.default
kschwab marked this conversation as resolved.
Show resolved Hide resolved
elif is_dataclass(type(field_info.default)):
self.defaults[_field_name_for_signature(field_name, field_info)] = asdict(field_info.default)
elif is_model_class(type(field_info.default)):
self.defaults[_field_name_for_signature(field_name, field_info)] = field_info.default.model_dump()

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

def __call__(self) -> dict[str, Any]:
return self.defaults

def __repr__(self) -> str:
return f'DefaultSettingsSource(default_objects_copy_by_value={self.default_objects_copy_by_value})'


class InitSettingsSource(PydanticBaseSettingsSource):
"""
Source class for loading values provided during settings class initialization.
"""

def __init__(self, settings_cls: type[BaseSettings], init_kwargs: dict[str, Any]):
def __init__(
self,
settings_cls: type[BaseSettings],
init_kwargs: dict[str, Any],
default_objects_copy_by_value: bool | None = None,
):
self.init_kwargs = init_kwargs
super().__init__(settings_cls)
self.default_objects_copy_by_value = (
default_objects_copy_by_value
if default_objects_copy_by_value is not None
else self.config.get('default_objects_copy_by_value', False)
)

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

def __call__(self) -> dict[str, Any]:
return self.init_kwargs
return (
TypeAdapter(Dict[str, Any]).dump_python(self.init_kwargs)
if self.default_objects_copy_by_value
else self.init_kwargs
)

def __repr__(self) -> str:
return f'InitSettingsSource(init_kwargs={self.init_kwargs!r})'
return (
f'InitSettingsSource(init_kwargs={self.init_kwargs!r}, '
f'default_objects_copy_by_value={self.default_objects_copy_by_value})'
kschwab marked this conversation as resolved.
Show resolved Hide resolved
)


class PydanticBaseEnvSettingsSource(PydanticBaseSettingsSource):
Expand Down Expand Up @@ -1521,7 +1573,7 @@ def _add_parser_submodels(
if self.cli_use_class_docs_for_groups and len(sub_models) == 1:
model_group_kwargs['description'] = None if sub_models[0].__doc__ is None else dedent(sub_models[0].__doc__)

if model_default not in (PydanticUndefined, None):
if model_default is not PydanticUndefined:
if is_model_class(type(model_default)) or is_pydantic_dataclass(type(model_default)):
model_default = getattr(model_default, field_name)
else:
Expand Down
Loading
Loading