Skip to content

Commit ef63672

Browse files
committed
feat: implement lazy loading in base settings
1 parent 41f3413 commit ef63672

File tree

3 files changed

+130
-1
lines changed

3 files changed

+130
-1
lines changed

pydantic_settings/main.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ class SettingsConfigDict(ConfigDict, total=False):
100100

101101
toml_file: PathType | None
102102
enable_decoding: bool
103+
lazy_load: bool
103104

104105

105106
# Extend `config_keys` by pydantic settings config keys to
@@ -159,6 +160,8 @@ class BaseSettings(BaseModel):
159160
_cli_kebab_case: CLI args use kebab case. Defaults to `False`.
160161
_cli_shortcuts: Mapping of target field name to alias names. Defaults to `None`.
161162
_secrets_dir: The secret files directory or a sequence of directories. Defaults to `None`.
163+
_lazy_load: Defer field value resolution until fields are accessed. When enabled, field values
164+
are only fetched from the source when explicitly accessed, not during settings initialization.
162165
"""
163166

164167
def __init__(
@@ -191,6 +194,10 @@ def __init__(
191194
_secrets_dir: PathType | None = None,
192195
**values: Any,
193196
) -> None:
197+
# Temp storage for lazy sources collected during _settings_build_values
198+
_temp_lazy_sources: dict[str, Any] = {}
199+
__pydantic_self__._temp_lazy_sources = _temp_lazy_sources
200+
194201
super().__init__(
195202
**__pydantic_self__._settings_build_values(
196203
values,
@@ -223,6 +230,33 @@ def __init__(
223230
)
224231
)
225232

233+
# Now that super().__init__() has completed, set the lazy sources on the instance
234+
# using object.__setattr__ to bypass any Pydantic restrictions
235+
object.__setattr__(__pydantic_self__, '_lazy_sources', _temp_lazy_sources)
236+
237+
def __getattribute__(self, name: str) -> Any:
238+
"""Intercept field access to support lazy loading on demand."""
239+
# Get the actual value from the model
240+
value = super().__getattribute__(name)
241+
242+
# Return private attributes and methods as-is
243+
if name.startswith('_') or callable(value):
244+
return value
245+
246+
# For model fields, try to get value from lazy sources
247+
try:
248+
if name in type(self).model_fields:
249+
lazy_sources = object.__getattribute__(self, '_lazy_sources')
250+
for lazy_mapping in lazy_sources.values():
251+
try:
252+
return lazy_mapping[name]
253+
except KeyError:
254+
pass
255+
except AttributeError:
256+
pass
257+
258+
return value
259+
226260
@classmethod
227261
def settings_customise_sources(
228262
cls,
@@ -437,6 +471,11 @@ def _settings_build_values(
437471
source_name = source.__name__ if hasattr(source, '__name__') else type(source).__name__
438472
source_state = source()
439473

474+
# Collect lazy mappings from sources for later field access
475+
if hasattr(source, '_lazy_mapping'):
476+
temp_lazy_sources = self._temp_lazy_sources
477+
temp_lazy_sources[source_name] = source._lazy_mapping
478+
440479
if isinstance(source, DefaultSettingsSource):
441480
defaults = source_state
442481

pydantic_settings/sources/base.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from ..exceptions import SettingsError
2222
from ..utils import _lenient_issubclass
23+
from .lazy import LazyMapping
2324
from .types import EnvNoneType, ForceDecode, NoDecode, PathType, PydanticModel, _CliSubCommand
2425
from .utils import (
2526
_annotation_is_complex,
@@ -326,6 +327,7 @@ def __init__(
326327
env_ignore_empty: bool | None = None,
327328
env_parse_none_str: str | None = None,
328329
env_parse_enums: bool | None = None,
330+
lazy_load: bool | None = None,
329331
) -> None:
330332
super().__init__(settings_cls)
331333
self.case_sensitive = case_sensitive if case_sensitive is not None else self.config.get('case_sensitive', False)
@@ -337,6 +339,7 @@ def __init__(
337339
env_parse_none_str if env_parse_none_str is not None else self.config.get('env_parse_none_str')
338340
)
339341
self.env_parse_enums = env_parse_enums if env_parse_enums is not None else self.config.get('env_parse_enums')
342+
self.lazy_load = lazy_load if lazy_load is not None else self.config.get('lazy_load', False)
340343

341344
def _apply_case_sensitive(self, value: str) -> str:
342345
return value.lower() if not self.case_sensitive else value
@@ -510,6 +513,14 @@ def _get_resolved_field_value(self, field: FieldInfo, field_name: str) -> tuple[
510513
return field_value, field_key, value_is_complex
511514

512515
def __call__(self) -> dict[str, Any]:
516+
# If lazy loading is enabled, defer field resolution to access time
517+
if self.lazy_load:
518+
# Store the LazyMapping on the source for later retrieval
519+
self._lazy_mapping = LazyMapping(self)
520+
# Return empty dict to avoid eager evaluation during initialization
521+
return {}
522+
523+
# Otherwise, use eager field loading
513524
data: dict[str, Any] = {}
514525

515526
for field_name, field in self.settings_cls.model_fields.items():
@@ -541,14 +552,14 @@ def __call__(self) -> dict[str, Any]:
541552
data[field_key] = self._replace_field_names_case_insensitively(field, field_value)
542553
else:
543554
data[field_key] = field_value
544-
545555
return data
546556

547557

548558
__all__ = [
549559
'ConfigFileSourceMixin',
550560
'DefaultSettingsSource',
551561
'InitSettingsSource',
562+
'LazyMapping',
552563
'PydanticBaseEnvSettingsSource',
553564
'PydanticBaseSettingsSource',
554565
'SettingsError',

pydantic_settings/sources/lazy.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Lazy loading support for settings sources."""
2+
3+
from __future__ import annotations as _annotations
4+
5+
from collections.abc import Iterator, Mapping
6+
from typing import TYPE_CHECKING, Any
7+
8+
from pydantic.fields import FieldInfo
9+
10+
from ..exceptions import SettingsError
11+
from .utils import _get_alias_names
12+
13+
if TYPE_CHECKING:
14+
from .base import PydanticBaseEnvSettingsSource
15+
16+
17+
class LazyMapping(Mapping[str, Any]):
18+
"""Dict-like mapping that defers field value resolution until keys are accessed."""
19+
20+
def __init__(self, source: PydanticBaseEnvSettingsSource) -> None:
21+
"""Initialize with a source instance that will compute values on demand."""
22+
self._source = source
23+
self._cached_values: dict[str, Any] = {}
24+
25+
def __getitem__(self, key: str) -> Any:
26+
"""Get a field value, computing it lazily on first access."""
27+
# Return cached value if available
28+
if key in self._cached_values:
29+
return self._cached_values[key]
30+
31+
# Find the field in the settings class
32+
field_name: str | None = None
33+
field_info: FieldInfo | None = None
34+
35+
for fname, finfo in self._source.settings_cls.model_fields.items():
36+
alias_names, *_ = _get_alias_names(fname, finfo)
37+
if key in alias_names or key == fname:
38+
field_name = fname
39+
field_info = finfo
40+
break
41+
42+
if field_name is None or field_info is None:
43+
raise KeyError(key)
44+
45+
# Resolve and cache the field value
46+
try:
47+
field_value, _, value_is_complex = self._source._get_resolved_field_value(field_info, field_name)
48+
prepared_value = self._source.prepare_field_value(field_name, field_info, field_value, value_is_complex)
49+
self._cached_values[key] = prepared_value
50+
return prepared_value
51+
except Exception as e:
52+
raise SettingsError(
53+
f'error getting value for field "{field_name}" from source "{self._source.__class__.__name__}"'
54+
) from e
55+
56+
def __iter__(self) -> Iterator[str]:
57+
"""Iterate over all possible field keys."""
58+
seen: set[str] = set()
59+
for field_name, field_info in self._source.settings_cls.model_fields.items():
60+
alias_names, *_ = _get_alias_names(field_name, field_info)
61+
for alias in alias_names:
62+
if alias not in seen:
63+
seen.add(alias)
64+
yield alias
65+
if field_name not in seen:
66+
yield field_name
67+
68+
def __len__(self) -> int:
69+
"""Return the count of fields in the settings class."""
70+
return len(self._source.settings_cls.model_fields)
71+
72+
def copy(self) -> LazyMapping:
73+
"""Return a copy to preserve lazy behavior through Pydantic's deep_update()."""
74+
new_mapping = LazyMapping(self._source)
75+
new_mapping._cached_values = self._cached_values.copy()
76+
return new_mapping
77+
78+
79+
__all__ = ['LazyMapping']

0 commit comments

Comments
 (0)