Skip to content

Commit 5cc0089

Browse files
committed
feat: implement lazy loading in base settings
1 parent 11c6db2 commit 5cc0089

File tree

3 files changed

+214
-1
lines changed

3 files changed

+214
-1
lines changed

pydantic_settings/main.py

Lines changed: 123 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,117 @@ 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 only if not set by
247+
# higher-priority sources. We detect this by checking if the value is the field's default.
248+
try:
249+
model_cls = type(self)
250+
if name in model_cls.model_fields:
251+
field_info = model_cls.model_fields[name]
252+
# Only try lazy sources if the value is the default (wasn't set by higher-priority source)
253+
# Check if value is the field's default value
254+
is_default = False
255+
if field_info.is_required():
256+
# Required fields have no default, so if value is not None, it was set
257+
is_default = value is None
258+
elif field_info.default is not None:
259+
is_default = value == field_info.default
260+
elif field_info.default_factory is not None:
261+
# For fields with default_factory, comparing to the factory output
262+
# would require calling the factory, so we check if value is unset
263+
is_default = value is None or value == field_info.default
264+
else:
265+
is_default = value is None or value == field_info.default
266+
267+
if is_default:
268+
lazy_sources = object.__getattribute__(self, '_lazy_sources')
269+
for lazy_mapping in lazy_sources.values():
270+
try:
271+
return lazy_mapping[name]
272+
except KeyError:
273+
pass
274+
except AttributeError:
275+
pass
276+
277+
return value
278+
279+
def model_dump(
280+
self,
281+
*,
282+
mode: str | Literal['json', 'python'] = 'python',
283+
include: Any = None,
284+
exclude: Any = None,
285+
context: Any = None,
286+
by_alias: bool | None = None,
287+
exclude_unset: bool = False,
288+
exclude_defaults: bool = False,
289+
exclude_none: bool = False,
290+
round_trip: bool = False,
291+
warnings: Literal['none', 'warn', 'error'] | bool = True,
292+
**kwargs: Any,
293+
) -> dict[str, Any]:
294+
"""Override model_dump to include cached lazy-loaded values."""
295+
# Get base dump from parent class
296+
dump = super().model_dump(
297+
mode=mode,
298+
include=include,
299+
exclude=exclude,
300+
context=context,
301+
by_alias=by_alias,
302+
exclude_unset=exclude_unset,
303+
exclude_defaults=exclude_defaults,
304+
exclude_none=exclude_none,
305+
round_trip=round_trip,
306+
warnings=warnings,
307+
**kwargs,
308+
)
309+
310+
# Merge lazy values from _lazy_sources, triggering loads if needed
311+
try:
312+
lazy_sources = object.__getattribute__(self, '_lazy_sources')
313+
for source_name, lazy_mapping in lazy_sources.items():
314+
# Iterate through all fields in the lazy mapping and load any that are
315+
# still at their default value (not set by higher-priority sources)
316+
for field_name in lazy_mapping:
317+
# Check if field is still at default in dump
318+
if field_name in type(self).model_fields:
319+
field_info = type(self).model_fields[field_name]
320+
current_value = dump.get(field_name)
321+
322+
# Determine if this is still a default value
323+
is_default = False
324+
if field_info.is_required():
325+
is_default = current_value is None
326+
elif field_info.default is not None:
327+
is_default = current_value == field_info.default
328+
else:
329+
is_default = current_value is None or current_value == field_info.default
330+
331+
# If still at default, try to load from lazy mapping
332+
if is_default:
333+
try:
334+
dump[field_name] = lazy_mapping[field_name]
335+
except KeyError:
336+
# Field not available in this lazy source, keep default
337+
pass
338+
except AttributeError:
339+
# _lazy_sources not set (no lazy sources configured) - return base dump
340+
pass
341+
342+
return dump
343+
226344
@classmethod
227345
def settings_customise_sources(
228346
cls,
@@ -437,6 +555,11 @@ def _settings_build_values(
437555
source_name = source.__name__ if hasattr(source, '__name__') else type(source).__name__
438556
source_state = source()
439557

558+
# Collect lazy mappings from sources for later field access
559+
if hasattr(source, '_lazy_mapping'):
560+
temp_lazy_sources = self._temp_lazy_sources
561+
temp_lazy_sources[source_name] = source._lazy_mapping
562+
440563
if isinstance(source, DefaultSettingsSource):
441564
defaults = source_state
442565

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)