Skip to content

Commit bcc628e

Browse files
committed
Add default_objects_copy_by_value flag.
1 parent 0dd8d74 commit bcc628e

File tree

3 files changed

+132
-65
lines changed

3 files changed

+132
-65
lines changed

pydantic_settings/main.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
class SettingsConfigDict(ConfigDict, total=False):
2626
case_sensitive: bool
27+
default_objects_copy_by_value: bool | None
2728
env_prefix: str
2829
env_file: DotenvType | None
2930
env_file_encoding: str | None
@@ -89,6 +90,8 @@ class BaseSettings(BaseModel):
8990
9091
Args:
9192
_case_sensitive: Whether environment variables names should be read with case-sensitivity. Defaults to `None`.
93+
_default_objects_copy_by_value: Whether default `BaseModel` objects should use copy-by-value instead of
94+
copy-by-reference when compiling sources. Defaults to `False`.
9295
_env_prefix: Prefix for all environment variables. Defaults to `None`.
9396
_env_file: The env file(s) to load settings values from. Defaults to `Path('')`, which
9497
means that the value from `model_config['env_file']` should be used. You can also pass
@@ -121,6 +124,7 @@ class BaseSettings(BaseModel):
121124
def __init__(
122125
__pydantic_self__,
123126
_case_sensitive: bool | None = None,
127+
_default_objects_copy_by_value: bool | None = None,
124128
_env_prefix: str | None = None,
125129
_env_file: DotenvType | None = ENV_FILE_SENTINEL,
126130
_env_file_encoding: str | None = None,
@@ -146,6 +150,7 @@ def __init__(
146150
**__pydantic_self__._settings_build_values(
147151
values,
148152
_case_sensitive=_case_sensitive,
153+
_default_objects_copy_by_value=_default_objects_copy_by_value,
149154
_env_prefix=_env_prefix,
150155
_env_file=_env_file,
151156
_env_file_encoding=_env_file_encoding,
@@ -195,6 +200,7 @@ def _settings_build_values(
195200
self,
196201
init_kwargs: dict[str, Any],
197202
_case_sensitive: bool | None = None,
203+
_default_objects_copy_by_value: bool | None = None,
198204
_env_prefix: str | None = None,
199205
_env_file: DotenvType | None = None,
200206
_env_file_encoding: str | None = None,
@@ -217,6 +223,11 @@ def _settings_build_values(
217223
# Determine settings config values
218224
case_sensitive = _case_sensitive if _case_sensitive is not None else self.model_config.get('case_sensitive')
219225
env_prefix = _env_prefix if _env_prefix is not None else self.model_config.get('env_prefix')
226+
default_objects_copy_by_value = (
227+
_default_objects_copy_by_value
228+
if _default_objects_copy_by_value is not None
229+
else self.model_config.get('default_objects_copy_by_value')
230+
)
220231
env_file = _env_file if _env_file != ENV_FILE_SENTINEL else self.model_config.get('env_file')
221232
env_file_encoding = (
222233
_env_file_encoding if _env_file_encoding is not None else self.model_config.get('env_file_encoding')
@@ -265,8 +276,12 @@ def _settings_build_values(
265276
secrets_dir = _secrets_dir if _secrets_dir is not None else self.model_config.get('secrets_dir')
266277

267278
# Configure built-in sources
268-
default_settings = DefaultSettingsSource(self.__class__)
269-
init_settings = InitSettingsSource(self.__class__, init_kwargs=init_kwargs)
279+
default_settings = DefaultSettingsSource(
280+
self.__class__, default_objects_copy_by_value=default_objects_copy_by_value
281+
)
282+
init_settings = InitSettingsSource(
283+
self.__class__, init_kwargs=init_kwargs, default_objects_copy_by_value=default_objects_copy_by_value
284+
)
270285
env_settings = EnvSettingsSource(
271286
self.__class__,
272287
case_sensitive=case_sensitive,
@@ -344,6 +359,7 @@ def _settings_build_values(
344359
validate_default=True,
345360
case_sensitive=False,
346361
env_prefix='',
362+
default_objects_copy_by_value=False,
347363
env_file=None,
348364
env_file_encoding=None,
349365
env_ignore_empty=False,

pydantic_settings/sources.py

Lines changed: 39 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@
1010
from abc import ABC, abstractmethod
1111
from argparse import SUPPRESS, ArgumentParser, HelpFormatter, Namespace, _SubParsersAction
1212
from collections import deque
13-
from dataclasses import is_dataclass
13+
from dataclasses import asdict, is_dataclass
1414
from enum import Enum
1515
from pathlib import Path
1616
from types import FunctionType
1717
from typing import (
1818
TYPE_CHECKING,
1919
Any,
2020
Callable,
21+
Dict,
2122
Generic,
2223
Iterator,
2324
List,
@@ -34,8 +35,9 @@
3435

3536
import typing_extensions
3637
from dotenv import dotenv_values
37-
from pydantic import AliasChoices, AliasPath, BaseModel, Json
38+
from pydantic import AliasChoices, AliasPath, BaseModel, Json, TypeAdapter
3839
from pydantic._internal._repr import Representation
40+
from pydantic._internal._signature import _field_name_for_signature
3941
from pydantic._internal._typing_extra import WithArgsTypes, origin_is_union, typing_base
4042
from pydantic._internal._utils import deep_update, is_model_class, lenient_issubclass
4143
from pydantic.dataclasses import is_pydantic_dataclass
@@ -248,55 +250,23 @@ def __call__(self) -> dict[str, Any]:
248250

249251
class DefaultSettingsSource(PydanticBaseSettingsSource):
250252
"""
251-
Source class for loading default values.
253+
Source class for loading default object values.
252254
"""
253255

254-
def __init__(self, settings_cls: type[BaseSettings]):
256+
def __init__(self, settings_cls: type[BaseSettings], default_objects_copy_by_value: bool | None = None):
255257
super().__init__(settings_cls)
256-
self.defaults = self._get_defaults(settings_cls)
257-
258-
def _get_defaults(self, settings_cls: type[BaseSettings]) -> dict[str, Any]:
259-
defaults: dict[str, Any] = {}
260-
if self.config.get('validate_default'):
261-
fields = (
262-
settings_cls.__pydantic_fields__ if is_pydantic_dataclass(settings_cls) else settings_cls.model_fields
263-
)
264-
for field_name, field_info in fields.items():
265-
if field_info.validate_default is not False:
266-
resolved_name = self._get_resolved_name(field_name, field_info)
267-
if field_info.default not in (PydanticUndefined, None):
268-
defaults[resolved_name] = field_info.default
269-
elif field_info.default_factory is not None:
270-
defaults[resolved_name] = field_info.default_factory
271-
return defaults
272-
273-
def _get_resolved_name(self, field_name: str, field_info: FieldInfo) -> str:
274-
if not any((field_info.alias, field_info.validation_alias)):
275-
return field_name
276-
277-
resolved_names: list[str] = []
278-
is_alias_path_only: bool = True
279-
new_alias_paths: list[AliasPath] = []
280-
for alias in (field_info.alias, field_info.validation_alias):
281-
if alias is None:
282-
continue
283-
elif isinstance(alias, str):
284-
resolved_names.append(alias)
285-
is_alias_path_only = False
286-
elif isinstance(alias, AliasChoices):
287-
for name in alias.choices:
288-
if isinstance(name, str):
289-
resolved_names.append(name)
290-
is_alias_path_only = False
291-
else:
292-
new_alias_paths.append(name)
293-
else:
294-
new_alias_paths.append(alias)
295-
for alias_path in new_alias_paths:
296-
name = cast(str, alias_path.path[0])
297-
if not resolved_names and is_alias_path_only:
298-
resolved_names.append(name)
299-
return tuple(dict.fromkeys(resolved_names))[0]
258+
self.defaults: dict[str, Any] = {}
259+
self.default_objects_copy_by_value = (
260+
default_objects_copy_by_value
261+
if default_objects_copy_by_value is not None
262+
else self.config.get('default_objects_copy_by_value', False)
263+
)
264+
if self.default_objects_copy_by_value:
265+
for field_name, field_info in settings_cls.model_fields.items():
266+
if is_dataclass(type(field_info.default)):
267+
self.defaults[_field_name_for_signature(field_name, field_info)] = asdict(field_info.default)
268+
elif is_model_class(type(field_info.default)):
269+
self.defaults[_field_name_for_signature(field_name, field_info)] = field_info.default.model_dump()
300270

301271
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
302272
# Nothing to do here. Only implement the return statement to make mypy happy
@@ -306,27 +276,44 @@ def __call__(self) -> dict[str, Any]:
306276
return self.defaults
307277

308278
def __repr__(self) -> str:
309-
return 'DefaultSettingsSource()'
279+
return f'DefaultSettingsSource(default_objects_copy_by_value={self.default_objects_copy_by_value})'
310280

311281

312282
class InitSettingsSource(PydanticBaseSettingsSource):
313283
"""
314284
Source class for loading values provided during settings class initialization.
315285
"""
316286

317-
def __init__(self, settings_cls: type[BaseSettings], init_kwargs: dict[str, Any]):
287+
def __init__(
288+
self,
289+
settings_cls: type[BaseSettings],
290+
init_kwargs: dict[str, Any],
291+
default_objects_copy_by_value: bool | None = None,
292+
):
318293
self.init_kwargs = init_kwargs
319294
super().__init__(settings_cls)
295+
self.default_objects_copy_by_value = (
296+
default_objects_copy_by_value
297+
if default_objects_copy_by_value is not None
298+
else self.config.get('default_objects_copy_by_value', False)
299+
)
320300

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

325305
def __call__(self) -> dict[str, Any]:
326-
return self.init_kwargs
306+
return (
307+
TypeAdapter(Dict[str, Any]).dump_python(self.init_kwargs)
308+
if self.default_objects_copy_by_value
309+
else self.init_kwargs
310+
)
327311

328312
def __repr__(self) -> str:
329-
return f'InitSettingsSource(init_kwargs={self.init_kwargs!r})'
313+
return (
314+
f'InitSettingsSource(init_kwargs={self.init_kwargs!r}, '
315+
f'default_objects_copy_by_value={self.default_objects_copy_by_value})'
316+
)
330317

331318

332319
class PydanticBaseEnvSettingsSource(PydanticBaseSettingsSource):

tests/test_settings.py

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,13 @@
4949
TomlConfigSettingsSource,
5050
YamlConfigSettingsSource,
5151
)
52-
from pydantic_settings.sources import CliPositionalArg, CliSettingsSource, CliSubCommand, SettingsError
52+
from pydantic_settings.sources import (
53+
CliPositionalArg,
54+
CliSettingsSource,
55+
CliSubCommand,
56+
DefaultSettingsSource,
57+
SettingsError,
58+
)
5359

5460
try:
5561
import dotenv
@@ -491,24 +497,78 @@ class ComplexSettings(BaseSettings):
491497
]
492498

493499

494-
def test_env_default_settings(env):
500+
def test_class_default_objects_copy_by_value(env):
495501
class NestedA(BaseModel):
496502
v0: bool
497503
v1: bool
498504

499-
class NestedB(BaseModel):
505+
@pydantic_dataclasses.dataclass
506+
class NestedB:
507+
v0: bool
508+
v1: bool
509+
510+
@dataclasses.dataclass
511+
class NestedC:
512+
v0: bool
513+
v1: bool
514+
515+
class NestedD(BaseModel):
500516
v0: bool = False
501517
v1: bool = True
502518

503-
class SettingsDefaultsA(BaseSettings, env_nested_delimiter='__'):
504-
nested: NestedB = NestedB()
519+
class SettingsDefaultsA(BaseSettings, env_nested_delimiter='__', default_objects_copy_by_value=True):
520+
nested_a: NestedA = NestedA(v0=False, v1=True)
521+
nested_b: NestedB = NestedB(v0=False, v1=True)
522+
nested_d: NestedC = NestedC(v0=False, v1=True)
523+
nested_c: NestedD = NestedD()
524+
525+
env.set('NESTED_A__V0', 'True')
526+
env.set('NESTED_B__V0', 'True')
527+
env.set('NESTED_C__V0', 'True')
528+
env.set('NESTED_D__V0', 'True')
529+
assert SettingsDefaultsA().model_dump() == {
530+
'nested_a': {'v0': True, 'v1': True},
531+
'nested_b': {'v0': True, 'v1': True},
532+
'nested_c': {'v0': True, 'v1': True},
533+
'nested_d': {'v0': True, 'v1': True},
534+
}
505535

506-
class SettingsDefaultsB(BaseSettings, env_nested_delimiter='__'):
507-
nested: NestedA = NestedA(v0=False, v1=True)
508536

509-
env.set('NESTED__V0', 'True')
510-
assert SettingsDefaultsA().model_dump() == {'nested': {'v0': True, 'v1': True}}
511-
assert SettingsDefaultsB().model_dump() == {'nested': {'v0': True, 'v1': True}}
537+
def test_init_kwargs_default_objects_copy_by_value(env):
538+
class DeepSubModel(BaseModel):
539+
v4: str
540+
541+
class SubModel(BaseModel):
542+
v1: str
543+
v2: bytes
544+
v3: int
545+
deep: DeepSubModel
546+
547+
class Settings(BaseSettings, env_nested_delimiter='__', default_objects_copy_by_value=True):
548+
v0: str
549+
sub_model: SubModel
550+
551+
@classmethod
552+
def settings_customise_sources(
553+
cls, settings_cls, init_settings, env_settings, dotenv_settings, file_secret_settings
554+
):
555+
return env_settings, dotenv_settings, init_settings, file_secret_settings
556+
557+
env.set('SUB_MODEL__DEEP__V4', 'override-v4')
558+
559+
s_final = {'v0': '0', 'sub_model': {'v1': 'init-v1', 'v2': b'init-v2', 'v3': 3, 'deep': {'v4': 'override-v4'}}}
560+
561+
s = Settings(v0='0', sub_model={'v1': 'init-v1', 'v2': b'init-v2', 'v3': 3, 'deep': {'v4': 'init-v4'}})
562+
assert s.model_dump() == s_final
563+
564+
s = Settings(v0='0', sub_model=SubModel(v1='init-v1', v2=b'init-v2', v3=3, deep=DeepSubModel(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={'v4': 'init-v4'}))
568+
assert s.model_dump() == s_final
569+
570+
s = Settings(v0='0', sub_model={'v1': 'init-v1', 'v2': b'init-v2', 'v3': 3, 'deep': DeepSubModel(v4='init-v4')})
571+
assert s.model_dump() == s_final
512572

513573

514574
def test_env_str(env):
@@ -1569,9 +1629,13 @@ def settings_customise_sources(cls, *args, **kwargs):
15691629

15701630

15711631
def test_builtins_settings_source_repr():
1632+
assert (
1633+
repr(DefaultSettingsSource(BaseSettings, default_objects_copy_by_value=True))
1634+
== 'DefaultSettingsSource(default_objects_copy_by_value=True)'
1635+
)
15721636
assert (
15731637
repr(InitSettingsSource(BaseSettings, init_kwargs={'apple': 'value 0', 'banana': 'value 1'}))
1574-
== "InitSettingsSource(init_kwargs={'apple': 'value 0', 'banana': 'value 1'})"
1638+
== "InitSettingsSource(init_kwargs={'apple': 'value 0', 'banana': 'value 1'}, default_objects_copy_by_value=False)"
15751639
)
15761640
assert (
15771641
repr(EnvSettingsSource(BaseSettings, env_nested_delimiter='__'))

0 commit comments

Comments
 (0)