Skip to content

Commit 6b688c2

Browse files
committed
test: add tests for lazy load
1 parent 8419aaa commit 6b688c2

9 files changed

+1484
-0
lines changed

tests/test_lazy_loading.py

Lines changed: 753 additions & 0 deletions
Large diffs are not rendered by default.

tests/test_lazy_mapping.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
"""
2+
Test pydantic_settings LazyMapping core functionality.
3+
4+
LazyMapping defers field value resolution until the fields are accessed,
5+
rather than eagerly evaluating all fields during settings initialization.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from unittest.mock import MagicMock, patch
11+
12+
import pytest
13+
14+
from pydantic_settings import PydanticBaseSettingsSource
15+
from pydantic_settings.sources.lazy import LazyMapping
16+
17+
18+
class TestLazyMapping:
19+
"""Test LazyMapping class behavior."""
20+
21+
def test_lazy_mapping_init(self):
22+
"""Test LazyMapping initialization."""
23+
source = MagicMock(spec=PydanticBaseSettingsSource)
24+
mapping = LazyMapping(source)
25+
assert mapping._source is source
26+
assert mapping._cached_values == {}
27+
28+
def test_lazy_mapping_getitem_with_caching(self):
29+
"""Test LazyMapping caches values after first access."""
30+
source = MagicMock()
31+
32+
# Mock settings class with a field
33+
settings_cls = MagicMock()
34+
field_info = MagicMock()
35+
settings_cls.model_fields = {'test_field': field_info}
36+
source.settings_cls = settings_cls
37+
38+
# Mock the field resolution
39+
source._get_resolved_field_value.return_value = ('test-value', None, False)
40+
source.prepare_field_value.return_value = 'prepared-value'
41+
42+
mapping = LazyMapping(source)
43+
44+
# First access should call the resolution methods
45+
value1 = mapping['test_field']
46+
assert value1 == 'prepared-value'
47+
assert source._get_resolved_field_value.call_count == 1
48+
49+
# Second access should use cached value
50+
value2 = mapping['test_field']
51+
assert value2 == 'prepared-value'
52+
assert source._get_resolved_field_value.call_count == 1 # Not called again
53+
54+
def test_lazy_mapping_getitem_key_not_found(self):
55+
"""Test LazyMapping raises KeyError for missing keys."""
56+
source = MagicMock()
57+
settings_cls = MagicMock()
58+
settings_cls.model_fields = {}
59+
source.settings_cls = settings_cls
60+
61+
mapping = LazyMapping(source)
62+
63+
with pytest.raises(KeyError):
64+
_ = mapping['nonexistent']
65+
66+
def test_lazy_mapping_iter(self):
67+
"""Test LazyMapping iteration returns all field names."""
68+
source = MagicMock()
69+
70+
# Mock settings class with multiple fields
71+
field_info1 = MagicMock()
72+
field_info2 = MagicMock()
73+
settings_cls = MagicMock()
74+
settings_cls.model_fields = {'field1': field_info1, 'field2': field_info2}
75+
source.settings_cls = settings_cls
76+
77+
# Mock alias names function
78+
with patch('pydantic_settings.sources.lazy._get_alias_names') as mock_alias:
79+
mock_alias.side_effect = [(['alias1'], None), (['alias2'], None)]
80+
81+
mapping = LazyMapping(source)
82+
keys = list(mapping)
83+
84+
assert 'alias1' in keys
85+
assert 'field1' in keys or 'alias2' in keys
86+
assert 'field2' in keys or 'alias2' in keys
87+
88+
def test_lazy_mapping_len(self):
89+
"""Test LazyMapping returns correct length."""
90+
source = MagicMock(spec=PydanticBaseSettingsSource)
91+
settings_cls = MagicMock()
92+
settings_cls.model_fields = {'field1': MagicMock(), 'field2': MagicMock(), 'field3': MagicMock()}
93+
source.settings_cls = settings_cls
94+
95+
mapping = LazyMapping(source)
96+
assert len(mapping) == 3
97+
98+
def test_lazy_mapping_copy(self):
99+
"""Test LazyMapping.copy() preserves cached values."""
100+
source = MagicMock(spec=PydanticBaseSettingsSource)
101+
settings_cls = MagicMock()
102+
settings_cls.model_fields = {'field1': MagicMock()}
103+
source.settings_cls = settings_cls
104+
105+
mapping = LazyMapping(source)
106+
mapping._cached_values['field1'] = 'cached-value'
107+
108+
copied = mapping.copy()
109+
110+
assert isinstance(copied, LazyMapping)
111+
assert copied._source is source
112+
assert copied._cached_values == {'field1': 'cached-value'}
113+
# Ensure it's a shallow copy of the dict
114+
assert copied._cached_values is not mapping._cached_values
115+
116+
def test_lazy_mapping_items(self):
117+
"""Test LazyMapping.items() yields accessible key-value pairs."""
118+
source = MagicMock()
119+
120+
field_info = MagicMock()
121+
settings_cls = MagicMock()
122+
settings_cls.model_fields = {'test_field': field_info}
123+
source.settings_cls = settings_cls
124+
125+
source._get_resolved_field_value.return_value = ('value', None, False)
126+
source.prepare_field_value.return_value = 'prepared-value'
127+
128+
with patch('pydantic_settings.sources.lazy._get_alias_names') as mock_alias:
129+
mock_alias.return_value = (['test_field'], None)
130+
131+
mapping = LazyMapping(source)
132+
items = dict(mapping.items())
133+
134+
assert 'test_field' in items
135+
assert items['test_field'] == 'prepared-value'
136+
137+
138+
class TestLazyLoadingWithDeepUpdate:
139+
"""Test LazyMapping behavior with deep_update to ensure lazy behavior is preserved."""
140+
141+
def test_lazy_mapping_deep_update_preserves_lazy_behavior(self):
142+
"""Test that deep_update with LazyMapping copy() preserves lazy behavior."""
143+
from pydantic._internal._utils import deep_update
144+
145+
source = MagicMock()
146+
settings_cls = MagicMock()
147+
field_info = MagicMock()
148+
settings_cls.model_fields = {'field1': field_info, 'field2': field_info}
149+
source.settings_cls = settings_cls
150+
151+
source._get_resolved_field_value.return_value = ('value', None, False)
152+
source.prepare_field_value.side_effect = lambda fname, fi, v, _: f'prepared-{fname}'
153+
154+
# Create LazyMapping
155+
mapping2 = LazyMapping(source)
156+
157+
# Simulate deep_update behavior
158+
result = deep_update({'field1': 'eager-value'}, mapping2.copy())
159+
160+
# The result should contain the lazily evaluated value from mapping2
161+
# and the copy() should have returned a LazyMapping, not a regular dict
162+
assert isinstance(result.get('field1'), str)
163+
164+
def test_lazy_mapping_copy_maintains_source_reference(self):
165+
"""Test that LazyMapping.copy() maintains reference to the same source."""
166+
source = MagicMock()
167+
settings_cls = MagicMock()
168+
settings_cls.model_fields = {}
169+
source.settings_cls = settings_cls
170+
171+
mapping = LazyMapping(source)
172+
copied = mapping.copy()
173+
174+
# Both should reference the same source object
175+
assert mapping._source is copied._source
176+
177+
178+
class TestLazyLoadingWithConfigFiles:
179+
"""Test lazy loading compatibility with config file sources."""
180+
181+
def test_lazy_mapping_with_dict_like_interface(self):
182+
"""Test that LazyMapping implements proper dict-like interface."""
183+
source = MagicMock()
184+
field_info = MagicMock()
185+
settings_cls = MagicMock()
186+
settings_cls.model_fields = {'field1': field_info}
187+
source.settings_cls = settings_cls
188+
189+
source._get_resolved_field_value.return_value = ('test-value', None, False)
190+
source.prepare_field_value.return_value = 'prepared-value'
191+
192+
mapping = LazyMapping(source)
193+
194+
# Test Mapping interface methods
195+
assert 'field1' in list(mapping)
196+
assert len(mapping) == 1
197+
assert mapping['field1'] == 'prepared-value'
198+
199+
def test_lazy_mapping_error_on_missing_field(self):
200+
"""Test LazyMapping raises SettingsError for missing fields during resolution."""
201+
from pydantic_settings.exceptions import SettingsError
202+
203+
source = MagicMock()
204+
field_info = MagicMock()
205+
settings_cls = MagicMock()
206+
settings_cls.model_fields = {'field1': field_info}
207+
source.settings_cls = settings_cls
208+
source.__class__.__name__ = 'MockSource'
209+
210+
# Simulate an error during field value resolution
211+
source._get_resolved_field_value.side_effect = ValueError('Connection failed')
212+
213+
mapping = LazyMapping(source)
214+
215+
# Accessing the field should raise SettingsError (not ValueError)
216+
with pytest.raises(SettingsError, match='error getting value for field "field1"'):
217+
_ = mapping['field1']

tests/test_settings.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from pathlib import Path
1111
from typing import Annotated, Any, Generic, Literal, TypeVar
1212
from unittest import mock
13+
from unittest.mock import patch
1314

1415
import pytest
1516
from annotated_types import MinLen
@@ -50,6 +51,7 @@
5051
SettingsError,
5152
)
5253
from pydantic_settings.sources import DefaultSettingsSource
54+
from pydantic_settings.sources.lazy import LazyMapping
5355

5456
try:
5557
import dotenv
@@ -3264,3 +3266,133 @@ class StrictSettings(BaseSettings, env_nested_delimiter='__', strict=True):
32643266
'my_int': 1,
32653267
},
32663268
}
3269+
3270+
3271+
class TestEnvSettingsSourceLazyLoading:
3272+
"""Unit tests for lazy_load behavior in EnvSettingsSource."""
3273+
3274+
def test_returns_lazy_mapping_stored_when_lazy_load_true(self):
3275+
"""Test EnvSettingsSource stores LazyMapping when lazy_load=True."""
3276+
3277+
class TestSettings(BaseSettings):
3278+
field: str = 'default'
3279+
3280+
source = EnvSettingsSource(TestSettings, lazy_load=True)
3281+
result = source()
3282+
# When lazy_load=True, __call__ returns empty dict
3283+
assert isinstance(result, dict)
3284+
assert len(result) == 0
3285+
# But LazyMapping is stored on the source for later retrieval
3286+
assert hasattr(source, '_lazy_mapping')
3287+
assert isinstance(source._lazy_mapping, LazyMapping)
3288+
3289+
def test_returns_dict_when_lazy_load_false(self):
3290+
"""Test EnvSettingsSource returns dict when lazy_load=False."""
3291+
3292+
class TestSettings(BaseSettings):
3293+
field: str = 'default'
3294+
3295+
source = EnvSettingsSource(TestSettings, lazy_load=False)
3296+
result = source()
3297+
assert isinstance(result, dict)
3298+
# When lazy_load=False, there should be no _lazy_mapping
3299+
assert not hasattr(source, '_lazy_mapping') or source._lazy_mapping is None
3300+
3301+
def test_lazy_mapping_defers_resolution(self):
3302+
"""Test LazyMapping defers field value resolution."""
3303+
3304+
class TestSettings(BaseSettings):
3305+
test_field: str = 'default'
3306+
3307+
source = EnvSettingsSource(TestSettings, lazy_load=True)
3308+
3309+
# Mock the internal resolution method
3310+
with patch.object(source, '_get_resolved_field_value', wraps=source._get_resolved_field_value) as mock_resolve:
3311+
source()
3312+
# Resolution should NOT be called during source()
3313+
assert mock_resolve.call_count == 0
3314+
3315+
# Now access a field through LazyMapping stored on source
3316+
lazy_mapping = source._lazy_mapping
3317+
try:
3318+
_ = lazy_mapping['test_field']
3319+
# Resolution SHOULD be called on field access
3320+
assert mock_resolve.call_count > 0
3321+
except KeyError:
3322+
# Field not found is ok, point is resolution was attempted
3323+
assert mock_resolve.call_count > 0
3324+
3325+
3326+
def test_env_source_accepts_lazy_load():
3327+
"""Test EnvSettingsSource accepts lazy_load parameter."""
3328+
3329+
class TestSettings(BaseSettings):
3330+
field: str = 'default'
3331+
3332+
# Should not raise any error
3333+
source = EnvSettingsSource(TestSettings, lazy_load=True)
3334+
assert hasattr(source, 'lazy_load')
3335+
3336+
3337+
class TestDotEnvSettingsSourceLazyLoading:
3338+
"""Unit tests for lazy_load behavior in DotEnvSettingsSource."""
3339+
3340+
def test_returns_lazy_mapping_when_lazy_load_true(self, tmp_path):
3341+
"""Test DotEnvSettingsSource stores LazyMapping when lazy_load=True."""
3342+
env_file = tmp_path / '.env'
3343+
env_file.write_text('TEST_FIELD=value\n')
3344+
3345+
class TestSettings(BaseSettings):
3346+
test_field: str = 'default'
3347+
3348+
source = DotEnvSettingsSource(TestSettings, env_file=env_file, lazy_load=True)
3349+
result = source()
3350+
assert isinstance(result, dict)
3351+
assert len(result) == 0
3352+
assert hasattr(source, '_lazy_mapping')
3353+
assert isinstance(source._lazy_mapping, LazyMapping)
3354+
3355+
def test_returns_dict_when_lazy_load_false(self, tmp_path):
3356+
"""Test DotEnvSettingsSource returns dict when lazy_load=False."""
3357+
env_file = tmp_path / '.env'
3358+
env_file.write_text('TEST_FIELD=value\n')
3359+
3360+
class TestSettings(BaseSettings):
3361+
test_field: str = 'default'
3362+
3363+
source = DotEnvSettingsSource(TestSettings, env_file=env_file, lazy_load=False)
3364+
result = source()
3365+
assert isinstance(result, dict)
3366+
assert not hasattr(source, '_lazy_mapping') or source._lazy_mapping is None
3367+
3368+
def test_lazy_mapping_defers_resolution(self, tmp_path):
3369+
"""Test DotEnvSettingsSource LazyMapping defers resolution."""
3370+
env_file = tmp_path / '.env'
3371+
env_file.write_text('TEST_FIELD=test_value\n')
3372+
3373+
class TestSettings(BaseSettings):
3374+
test_field: str = 'default'
3375+
3376+
source = DotEnvSettingsSource(TestSettings, env_file=env_file, lazy_load=True)
3377+
3378+
with patch.object(source, '_get_resolved_field_value', wraps=source._get_resolved_field_value) as mock_resolve:
3379+
source()
3380+
assert mock_resolve.call_count == 0
3381+
3382+
lazy_mapping = source._lazy_mapping
3383+
try:
3384+
_ = lazy_mapping['test_field']
3385+
assert mock_resolve.call_count > 0
3386+
except KeyError:
3387+
assert mock_resolve.call_count > 0
3388+
3389+
3390+
def test_dotenv_source_accepts_lazy_load():
3391+
"""Test DotEnvSettingsSource accepts lazy_load parameter."""
3392+
3393+
class TestSettings(BaseSettings):
3394+
field: str = 'default'
3395+
3396+
# Should not raise any error
3397+
source = DotEnvSettingsSource(TestSettings, lazy_load=True)
3398+
assert hasattr(source, 'lazy_load')

tests/test_source_aws_secrets_manager.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,12 @@ def settings_customise_sources(
158158

159159
assert settings.sql_server_user == 'test-user'
160160
assert settings.sql_server.password == 'test-password'
161+
162+
163+
@pytest.mark.skipif(not aws_secrets_manager, reason='pydantic-settings[aws-secrets-manager] is not installed')
164+
def test_aws_secrets_source_accepts_lazy_load():
165+
"""Test AWSSecretsManagerSettingsSource accepts lazy_load parameter."""
166+
import inspect
167+
168+
sig = inspect.signature(AWSSecretsManagerSettingsSource.__init__)
169+
assert 'lazy_load' in sig.parameters

0 commit comments

Comments
 (0)