From 112b07ef9036d826c234ed92b9b19c8370ac0b52 Mon Sep 17 00:00:00 2001 From: Albert DeFusco Date: Wed, 19 Feb 2025 14:05:43 -0500 Subject: [PATCH 01/15] optionally gather line_errors from each source --- pydantic_settings/main.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index 9a139d1e..1b1a22f1 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -2,6 +2,7 @@ import asyncio import inspect +import json import threading from argparse import Namespace from types import SimpleNamespace @@ -13,6 +14,7 @@ from pydantic._internal._utils import deep_update, is_model_class from pydantic.dataclasses import is_pydantic_dataclass from pydantic.main import BaseModel +from pydantic_core import ValidationError, InitErrorDetails from .sources import ( ENV_FILE_SENTINEL, @@ -32,7 +34,6 @@ T = TypeVar('T') - class SettingsConfigDict(ConfigDict, total=False): case_sensitive: bool nested_model_default_partial_update: bool | None @@ -58,6 +59,7 @@ class SettingsConfigDict(ConfigDict, total=False): cli_ignore_unknown_args: bool | None cli_kebab_case: bool | None secrets_dir: PathType | None + validate_each_source: bool | None json_file: PathType | None json_file_encoding: str | None yaml_file: PathType | None @@ -257,6 +259,7 @@ def _settings_build_values( _cli_ignore_unknown_args: bool | None = None, _cli_kebab_case: bool | None = None, _secrets_dir: PathType | None = None, + _validate_each_source: bool | None = None ) -> dict[str, Any]: # Determine settings config values case_sensitive = _case_sensitive if _case_sensitive is not None else self.model_config.get('case_sensitive') @@ -332,6 +335,8 @@ def _settings_build_values( secrets_dir = _secrets_dir if _secrets_dir is not None else self.model_config.get('secrets_dir') + validate_each_source = _validate_each_source if _validate_each_source is not None else self.model_config.get("validate_each_source") + # Configure built-in sources default_settings = DefaultSettingsSource( self.__class__, nested_model_default_partial_update=nested_model_default_partial_update @@ -400,6 +405,7 @@ def _settings_build_values( if sources: state: dict[str, Any] = {} states: dict[str, dict[str, Any]] = {} + all_line_errors: list[dict[str, Any]] = [] for source in sources: if isinstance(source, PydanticBaseSettingsSource): source._set_current_state(state) @@ -410,6 +416,26 @@ def _settings_build_values( states[source_name] = source_state state = deep_update(source_state, state) + + if validate_each_source: + if not source_state: + continue + try: + _ = super(BaseSettings, self).__init__(**source_state) + except ValidationError as e: + line_errors = json.loads(e.json()) + for line in line_errors: + ctx = line.get("ctx", {}) + ctx = {"source": source_name} + line['ctx'] = ctx + all_line_errors.extend(line_errors) + + if all_line_errors and validate_each_source: + raise ValidationError.from_exception_data( + title=self.__class__.__name__, + line_errors=[InitErrorDetails(**l) for l in all_line_errors], + input_type="python" + ) return state else: # no one should mean to do this, but I think returning an empty dict is marginally preferable From 12d5537078f0e164b0ac63acbe6ccd26063eabf6 Mon Sep 17 00:00:00 2001 From: Albert DeFusco Date: Wed, 19 Feb 2025 14:06:22 -0500 Subject: [PATCH 02/15] test source included in line_error context --- tests/test_settings.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/test_settings.py b/tests/test_settings.py index ba793a8e..917693b0 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -44,7 +44,7 @@ NoDecode, PydanticBaseSettingsSource, SecretsSettingsSource, - SettingsConfigDict, + SettingsConfigDict ) from pydantic_settings.sources import DefaultSettingsSource, SettingsError @@ -1103,6 +1103,30 @@ class Settings(BaseSettings): ] +def test_env_file_with_env_prefix_invalid_with_sources(tmp_path): + p = tmp_path / '.env' + p.write_text(prefix_test_env_invalid_file) + + class Settings(BaseSettings): + a: str + b: str + c: str + + model_config = SettingsConfigDict(env_file=p, env_prefix='prefix_', validate_each_source=True) + + with pytest.raises(ValidationError) as exc_info: + Settings() + assert exc_info.value.errors(include_url=False) == [ + { + 'type': 'extra_forbidden', + 'loc': ('f',), + 'msg': 'Extra inputs are not permitted', + 'input': 'random value', + 'ctx': {'source': 'DotEnvSettingsSource'} + } + ] + + def test_ignore_env_file_with_env_prefix_invalid(tmp_path): p = tmp_path / '.env' p.write_text(prefix_test_env_invalid_file) From fcc2329b135ee028490ebe258f350b96ad770da6 Mon Sep 17 00:00:00 2001 From: Albert DeFusco Date: Wed, 19 Feb 2025 14:15:25 -0500 Subject: [PATCH 03/15] fix appending source to context --- pydantic_settings/main.py | 4 ++-- tests/test_settings.py | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index 1b1a22f1..974882c3 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -426,14 +426,14 @@ def _settings_build_values( line_errors = json.loads(e.json()) for line in line_errors: ctx = line.get("ctx", {}) - ctx = {"source": source_name} + ctx["source"] = source_name line['ctx'] = ctx all_line_errors.extend(line_errors) if all_line_errors and validate_each_source: raise ValidationError.from_exception_data( title=self.__class__.__name__, - line_errors=[InitErrorDetails(**l) for l in all_line_errors], + line_errors=[InitErrorDetails(**l) for l in all_line_errors], input_type="python" ) return state diff --git a/tests/test_settings.py b/tests/test_settings.py index 917693b0..1a850cd7 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -472,6 +472,28 @@ class AnnotatedComplexSettings(BaseSettings): ] +def test_annotated_list_with_error_source(env): + class AnnotatedComplexSettings(BaseSettings, validate_each_source=True): + apples: Annotated[List[str], MinLen(2)] = [] + + env.set('apples', '["russet", "granny smith"]') + s = AnnotatedComplexSettings() + assert s.apples == ['russet', 'granny smith'] + + env.set('apples', '["russet"]') + with pytest.raises(ValidationError) as exc_info: + AnnotatedComplexSettings() + assert exc_info.value.errors(include_url=False) == [ + { + 'ctx': {'actual_length': 1, 'field_type': 'List', 'min_length': 2, 'source': 'EnvSettingsSource'}, + 'input': ['russet'], + 'loc': ('apples',), + 'msg': 'List should have at least 2 items after validation, not 1', + 'type': 'too_short', + } + ] + + def test_set_dict_model(env): env.set('bananas', '[1, 2, 3, 3]') env.set('CARROTS', '{"a": null, "b": 4}') From 38e94de216b8a7d093c3d93ed8b500131926562c Mon Sep 17 00:00:00 2001 From: Albert DeFusco Date: Wed, 19 Feb 2025 14:40:21 -0500 Subject: [PATCH 04/15] validate for missing using full state --- pydantic_settings/main.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index 974882c3..c18bb9b0 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -405,7 +405,7 @@ def _settings_build_values( if sources: state: dict[str, Any] = {} states: dict[str, dict[str, Any]] = {} - all_line_errors: list[dict[str, Any]] = [] + all_line_errors: list[InitErrorDetails] = [] for source in sources: if isinstance(source, PydanticBaseSettingsSource): source._set_current_state(state) @@ -425,16 +425,29 @@ def _settings_build_values( except ValidationError as e: line_errors = json.loads(e.json()) for line in line_errors: + if line.get("type", "") == "missing": + continue ctx = line.get("ctx", {}) ctx["source"] = source_name line['ctx'] = ctx - all_line_errors.extend(line_errors) + details = InitErrorDetails(**line) + all_line_errors.append(details) + + if validate_each_source: + try: + _ = super(BaseSettings, self).__init__(**state) + except ValidationError as e: + line_errors = json.loads(e.json()) + for line in line_errors: + if line.get("type", "") != "missing": + continue + details = InitErrorDetails(**line) + all_line_errors.append(details) if all_line_errors and validate_each_source: raise ValidationError.from_exception_data( title=self.__class__.__name__, - line_errors=[InitErrorDetails(**l) for l in all_line_errors], - input_type="python" + line_errors=all_line_errors ) return state else: From 6232d4d58a453542c6d873e305ddde1eb6fe9b86 Mon Sep 17 00:00:00 2001 From: Albert DeFusco Date: Wed, 19 Feb 2025 14:40:39 -0500 Subject: [PATCH 05/15] test multi-source validation and include missing fields --- tests/test_multi_source.py | 87 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/test_multi_source.py diff --git a/tests/test_multi_source.py b/tests/test_multi_source.py new file mode 100644 index 00000000..616cbe09 --- /dev/null +++ b/tests/test_multi_source.py @@ -0,0 +1,87 @@ +""" +Integration tests with multiple sources +""" + +from typing import Tuple, Type, Union + +from pydantic import BaseModel, ValidationError +import pytest + +from pydantic_settings import ( + BaseSettings, + JsonConfigSettingsSource, + PydanticBaseSettingsSource, + SettingsConfigDict, +) + +def test_line_errors_from_source(monkeypatch, tmp_path): + monkeypatch.setenv("SETTINGS_NESTED__NESTED_FIELD", "a") + p = tmp_path / 'settings.json' + p.write_text( + """ + {"foobar": 0, "null_field": null} + """ + ) + + class Nested(BaseModel): + nested_field: int + + class Settings(BaseSettings): + model_config = SettingsConfigDict( + json_file=p, + env_prefix="SETTINGS_", + env_nested_delimiter="__", + validate_each_source=True + ) + foobar: str + nested: Nested + null_field: Union[str, None] + extra: bool + + @classmethod + def settings_customise_sources( + cls, + settings_cls: Type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> Tuple[PydanticBaseSettingsSource, ...]: + return ( + JsonConfigSettingsSource(settings_cls), + env_settings, + init_settings + ) + + with pytest.raises(ValidationError) as exc_info: + _ = Settings(null_field=0) + + assert exc_info.value.errors(include_url=False) == [ + { + 'ctx': {'source': 'JsonConfigSettingsSource'}, + 'input': 0, + 'loc': ('foobar',), + 'msg': 'Input should be a valid string', + 'type': 'string_type', + }, + { + 'ctx': {'source': 'EnvSettingsSource'}, + 'input': 'a', + 'loc': ('nested', 'nested_field'), + 'msg': 'Input should be a valid integer, unable to parse string as an integer', + 'type': 'int_parsing' + }, + { + 'ctx': {'source': 'InitSettingsSource'}, + 'input': 0, + 'loc': ('null_field',), + 'msg': 'Input should be a valid string', + 'type': 'string_type' + }, + { + 'input': {'foobar': 0, 'nested': {'nested_field': 'a'}, 'null_field': None}, + 'loc': ('extra',), + 'msg': 'Field required', + 'type': 'missing' + } + ] From 590525f04c8e7ecbc8097fc6ebd42a1e2017159c Mon Sep 17 00:00:00 2001 From: Albert DeFusco Date: Wed, 19 Feb 2025 15:16:57 -0500 Subject: [PATCH 06/15] prepend source to loc field --- pydantic_settings/main.py | 4 +--- tests/test_multi_source.py | 9 +++------ tests/test_settings.py | 9 ++++----- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index c18bb9b0..774d3e6a 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -427,9 +427,7 @@ def _settings_build_values( for line in line_errors: if line.get("type", "") == "missing": continue - ctx = line.get("ctx", {}) - ctx["source"] = source_name - line['ctx'] = ctx + line['loc'] = [source_name] + line['loc'] details = InitErrorDetails(**line) all_line_errors.append(details) diff --git a/tests/test_multi_source.py b/tests/test_multi_source.py index 616cbe09..ba796422 100644 --- a/tests/test_multi_source.py +++ b/tests/test_multi_source.py @@ -58,23 +58,20 @@ def settings_customise_sources( assert exc_info.value.errors(include_url=False) == [ { - 'ctx': {'source': 'JsonConfigSettingsSource'}, 'input': 0, - 'loc': ('foobar',), + 'loc': ('JsonConfigSettingsSource', 'foobar',), 'msg': 'Input should be a valid string', 'type': 'string_type', }, { - 'ctx': {'source': 'EnvSettingsSource'}, 'input': 'a', - 'loc': ('nested', 'nested_field'), + 'loc': ('EnvSettingsSource', 'nested', 'nested_field'), 'msg': 'Input should be a valid integer, unable to parse string as an integer', 'type': 'int_parsing' }, { - 'ctx': {'source': 'InitSettingsSource'}, 'input': 0, - 'loc': ('null_field',), + 'loc': ('InitSettingsSource', 'null_field',), 'msg': 'Input should be a valid string', 'type': 'string_type' }, diff --git a/tests/test_settings.py b/tests/test_settings.py index 1a850cd7..6c921e83 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -485,9 +485,9 @@ class AnnotatedComplexSettings(BaseSettings, validate_each_source=True): AnnotatedComplexSettings() assert exc_info.value.errors(include_url=False) == [ { - 'ctx': {'actual_length': 1, 'field_type': 'List', 'min_length': 2, 'source': 'EnvSettingsSource'}, + 'ctx': {'actual_length': 1, 'field_type': 'List', 'min_length': 2}, 'input': ['russet'], - 'loc': ('apples',), + 'loc': ('EnvSettingsSource', 'apples',), 'msg': 'List should have at least 2 items after validation, not 1', 'type': 'too_short', } @@ -1141,10 +1141,9 @@ class Settings(BaseSettings): assert exc_info.value.errors(include_url=False) == [ { 'type': 'extra_forbidden', - 'loc': ('f',), + 'loc': ('DotEnvSettingsSource', 'f',), 'msg': 'Extra inputs are not permitted', - 'input': 'random value', - 'ctx': {'source': 'DotEnvSettingsSource'} + 'input': 'random value' } ] From 5497ab2857784deb39c4465f0f96088c3907f071 Mon Sep 17 00:00:00 2001 From: Albert DeFusco Date: Wed, 19 Feb 2025 15:21:30 -0500 Subject: [PATCH 07/15] correction, let's keep source in ctx --- pydantic_settings/main.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index 774d3e6a..e6f9a83b 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -428,6 +428,9 @@ def _settings_build_values( if line.get("type", "") == "missing": continue line['loc'] = [source_name] + line['loc'] + ctx = line.get("ctx", {}) + ctx["source"] = source_name + line['ctx'] = ctx details = InitErrorDetails(**line) all_line_errors.append(details) From f90635271fe14331e3c6080ce210e96157104d32 Mon Sep 17 00:00:00 2001 From: Albert DeFusco Date: Wed, 19 Feb 2025 15:23:25 -0500 Subject: [PATCH 08/15] and the tests --- tests/test_multi_source.py | 3 +++ tests/test_settings.py | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_multi_source.py b/tests/test_multi_source.py index ba796422..9e47b8cb 100644 --- a/tests/test_multi_source.py +++ b/tests/test_multi_source.py @@ -58,18 +58,21 @@ def settings_customise_sources( assert exc_info.value.errors(include_url=False) == [ { + 'ctx': {'source': 'JsonConfigSettingsSource'}, 'input': 0, 'loc': ('JsonConfigSettingsSource', 'foobar',), 'msg': 'Input should be a valid string', 'type': 'string_type', }, { + 'ctx': {'source': 'EnvSettingsSource'}, 'input': 'a', 'loc': ('EnvSettingsSource', 'nested', 'nested_field'), 'msg': 'Input should be a valid integer, unable to parse string as an integer', 'type': 'int_parsing' }, { + 'ctx': {'source': 'InitSettingsSource'}, 'input': 0, 'loc': ('InitSettingsSource', 'null_field',), 'msg': 'Input should be a valid string', diff --git a/tests/test_settings.py b/tests/test_settings.py index 6c921e83..c5620c3c 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -485,7 +485,7 @@ class AnnotatedComplexSettings(BaseSettings, validate_each_source=True): AnnotatedComplexSettings() assert exc_info.value.errors(include_url=False) == [ { - 'ctx': {'actual_length': 1, 'field_type': 'List', 'min_length': 2}, + 'ctx': {'actual_length': 1, 'field_type': 'List', 'min_length': 2, 'source': 'EnvSettingsSource'}, 'input': ['russet'], 'loc': ('EnvSettingsSource', 'apples',), 'msg': 'List should have at least 2 items after validation, not 1', @@ -1143,7 +1143,8 @@ class Settings(BaseSettings): 'type': 'extra_forbidden', 'loc': ('DotEnvSettingsSource', 'f',), 'msg': 'Extra inputs are not permitted', - 'input': 'random value' + 'input': 'random value', + 'ctx': {'source': 'DotEnvSettingsSource'} } ] From 8a5e773f2873a79b11f7914edf07f5d4f5e2061a Mon Sep 17 00:00:00 2001 From: Albert DeFusco Date: Wed, 19 Feb 2025 15:31:56 -0500 Subject: [PATCH 09/15] fix super --- pydantic_settings/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index e6f9a83b..ca106fe6 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -421,7 +421,7 @@ def _settings_build_values( if not source_state: continue try: - _ = super(BaseSettings, self).__init__(**source_state) + _ = super().__init__(**source_state) except ValidationError as e: line_errors = json.loads(e.json()) for line in line_errors: @@ -436,7 +436,7 @@ def _settings_build_values( if validate_each_source: try: - _ = super(BaseSettings, self).__init__(**state) + _ = super().__init__(**state) except ValidationError as e: line_errors = json.loads(e.json()) for line in line_errors: From 063b71d9e5704d88746f12f880da11c500eec8e5 Mon Sep 17 00:00:00 2001 From: Albert DeFusco Date: Wed, 19 Feb 2025 15:34:42 -0500 Subject: [PATCH 10/15] unnecessary use of initerrordetails --- pydantic_settings/main.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index ca106fe6..2aa17d45 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -431,8 +431,7 @@ def _settings_build_values( ctx = line.get("ctx", {}) ctx["source"] = source_name line['ctx'] = ctx - details = InitErrorDetails(**line) - all_line_errors.append(details) + all_line_errors.append(line) if validate_each_source: try: @@ -442,8 +441,7 @@ def _settings_build_values( for line in line_errors: if line.get("type", "") != "missing": continue - details = InitErrorDetails(**line) - all_line_errors.append(details) + all_line_errors.append(line) if all_line_errors and validate_each_source: raise ValidationError.from_exception_data( From 8cf446231424ea25e8eede22c169d1b259ac0a39 Mon Sep 17 00:00:00 2001 From: Albert DeFusco Date: Wed, 19 Feb 2025 15:36:53 -0500 Subject: [PATCH 11/15] single quotes --- pydantic_settings/main.py | 8 ++++---- tests/test_multi_source.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index 2aa17d45..b37cb288 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -425,11 +425,11 @@ def _settings_build_values( except ValidationError as e: line_errors = json.loads(e.json()) for line in line_errors: - if line.get("type", "") == "missing": + if line.get("type", "") == 'missing': continue line['loc'] = [source_name] + line['loc'] - ctx = line.get("ctx", {}) - ctx["source"] = source_name + ctx = line.get('ctx', {}) + ctx['source'] = source_name line['ctx'] = ctx all_line_errors.append(line) @@ -439,7 +439,7 @@ def _settings_build_values( except ValidationError as e: line_errors = json.loads(e.json()) for line in line_errors: - if line.get("type", "") != "missing": + if line.get('type', '') != 'missing': continue all_line_errors.append(line) diff --git a/tests/test_multi_source.py b/tests/test_multi_source.py index 9e47b8cb..f8a60f24 100644 --- a/tests/test_multi_source.py +++ b/tests/test_multi_source.py @@ -15,7 +15,7 @@ ) def test_line_errors_from_source(monkeypatch, tmp_path): - monkeypatch.setenv("SETTINGS_NESTED__NESTED_FIELD", "a") + monkeypatch.setenv('SETTINGS_NESTED__NESTED_FIELD', 'a') p = tmp_path / 'settings.json' p.write_text( """ @@ -29,8 +29,8 @@ class Nested(BaseModel): class Settings(BaseSettings): model_config = SettingsConfigDict( json_file=p, - env_prefix="SETTINGS_", - env_nested_delimiter="__", + env_prefix='SETTINGS_', + env_nested_delimiter='__', validate_each_source=True ) foobar: str From e9467af90f87773469c6a4d8afe862efa4b4a1e2 Mon Sep 17 00:00:00 2001 From: Albert DeFusco Date: Wed, 19 Feb 2025 15:37:27 -0500 Subject: [PATCH 12/15] one more --- pydantic_settings/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index b37cb288..b66cc11e 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -335,7 +335,7 @@ def _settings_build_values( secrets_dir = _secrets_dir if _secrets_dir is not None else self.model_config.get('secrets_dir') - validate_each_source = _validate_each_source if _validate_each_source is not None else self.model_config.get("validate_each_source") + validate_each_source = _validate_each_source if _validate_each_source is not None else self.model_config.get('validate_each_source') # Configure built-in sources default_settings = DefaultSettingsSource( From 577ca8e9129754026483fea38e75c2c5dd4b15a5 Mon Sep 17 00:00:00 2001 From: Albert DeFusco Date: Thu, 20 Feb 2025 09:30:32 -0500 Subject: [PATCH 13/15] lint and reduce complexity --- pydantic_settings/main.py | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index b66cc11e..fa6d0735 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -14,7 +14,7 @@ from pydantic._internal._utils import deep_update, is_model_class from pydantic.dataclasses import is_pydantic_dataclass from pydantic.main import BaseModel -from pydantic_core import ValidationError, InitErrorDetails +from pydantic_core import InitErrorDetails, ValidationError from .sources import ( ENV_FILE_SENTINEL, @@ -417,15 +417,13 @@ def _settings_build_values( states[source_name] = source_state state = deep_update(source_state, state) - if validate_each_source: - if not source_state: - continue + if source_state and validate_each_source: try: _ = super().__init__(**source_state) except ValidationError as e: line_errors = json.loads(e.json()) for line in line_errors: - if line.get("type", "") == 'missing': + if line.get('type', '') == 'missing': continue line['loc'] = [source_name] + line['loc'] ctx = line.get('ctx', {}) @@ -438,16 +436,14 @@ def _settings_build_values( _ = super().__init__(**state) except ValidationError as e: line_errors = json.loads(e.json()) - for line in line_errors: - if line.get('type', '') != 'missing': - continue - all_line_errors.append(line) - - if all_line_errors and validate_each_source: - raise ValidationError.from_exception_data( - title=self.__class__.__name__, - line_errors=all_line_errors - ) + all_line_errors.extend([line for line in line_errors if line.get('type', '') == 'missing']) + + if all_line_errors: + raise ValidationError.from_exception_data( + title=self.__class__.__name__, + line_errors=all_line_errors + ) + return state else: # no one should mean to do this, but I think returning an empty dict is marginally preferable From 4d13cd7e108515144ac2a89104a59e1dcb262558 Mon Sep 17 00:00:00 2001 From: Albert DeFusco Date: Thu, 20 Feb 2025 09:31:34 -0500 Subject: [PATCH 14/15] lint tests --- tests/test_multi_source.py | 3 ++- tests/test_settings.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_multi_source.py b/tests/test_multi_source.py index f8a60f24..49a670bd 100644 --- a/tests/test_multi_source.py +++ b/tests/test_multi_source.py @@ -4,8 +4,8 @@ from typing import Tuple, Type, Union -from pydantic import BaseModel, ValidationError import pytest +from pydantic import BaseModel, ValidationError from pydantic_settings import ( BaseSettings, @@ -14,6 +14,7 @@ SettingsConfigDict, ) + def test_line_errors_from_source(monkeypatch, tmp_path): monkeypatch.setenv('SETTINGS_NESTED__NESTED_FIELD', 'a') p = tmp_path / 'settings.json' diff --git a/tests/test_settings.py b/tests/test_settings.py index c5620c3c..301c09ef 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -44,7 +44,7 @@ NoDecode, PydanticBaseSettingsSource, SecretsSettingsSource, - SettingsConfigDict + SettingsConfigDict, ) from pydantic_settings.sources import DefaultSettingsSource, SettingsError From 9842e13f4c339e6289d032ead4528fcb12567aa9 Mon Sep 17 00:00:00 2001 From: Albert DeFusco Date: Thu, 20 Feb 2025 09:35:28 -0500 Subject: [PATCH 15/15] formatting with ruff --- pydantic_settings/main.py | 12 ++++++++---- pydantic_settings/sources.py | 8 +++++--- tests/test_multi_source.py | 29 ++++++++++++++--------------- tests/test_settings.py | 15 ++++++++++----- 4 files changed, 37 insertions(+), 27 deletions(-) diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index fa6d0735..8c716cc8 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -34,6 +34,7 @@ T = TypeVar('T') + class SettingsConfigDict(ConfigDict, total=False): case_sensitive: bool nested_model_default_partial_update: bool | None @@ -259,7 +260,7 @@ def _settings_build_values( _cli_ignore_unknown_args: bool | None = None, _cli_kebab_case: bool | None = None, _secrets_dir: PathType | None = None, - _validate_each_source: bool | None = None + _validate_each_source: bool | None = None, ) -> dict[str, Any]: # Determine settings config values case_sensitive = _case_sensitive if _case_sensitive is not None else self.model_config.get('case_sensitive') @@ -335,7 +336,11 @@ def _settings_build_values( secrets_dir = _secrets_dir if _secrets_dir is not None else self.model_config.get('secrets_dir') - validate_each_source = _validate_each_source if _validate_each_source is not None else self.model_config.get('validate_each_source') + validate_each_source = ( + _validate_each_source + if _validate_each_source is not None + else self.model_config.get('validate_each_source') + ) # Configure built-in sources default_settings = DefaultSettingsSource( @@ -440,8 +445,7 @@ def _settings_build_values( if all_line_errors: raise ValidationError.from_exception_data( - title=self.__class__.__name__, - line_errors=all_line_errors + title=self.__class__.__name__, line_errors=all_line_errors ) return state diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index bd4f4315..c929fe86 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -1785,11 +1785,13 @@ def _add_parser_args( if isinstance(group, dict): group = self._add_group(parser, **group) added_args += list(arg_names) - self._add_argument(group, *(f'{flag_prefix[:len(name)]}{name}' for name in arg_names), **kwargs) + self._add_argument( + group, *(f'{flag_prefix[: len(name)]}{name}' for name in arg_names), **kwargs + ) else: added_args += list(arg_names) self._add_argument( - parser, *(f'{flag_prefix[:len(name)]}{name}' for name in arg_names), **kwargs + parser, *(f'{flag_prefix[: len(name)]}{name}' for name in arg_names), **kwargs ) self._add_parser_alias_paths(parser, alias_path_args, added_args, arg_prefix, subcommand_prefix, group) @@ -2246,7 +2248,7 @@ def _load_env_vars(self) -> Mapping[str, Optional[str]]: return AzureKeyVaultMapping(secret_client) def __repr__(self) -> str: - return f'{self.__class__.__name__}(url={self._url!r}, ' f'env_nested_delimiter={self.env_nested_delimiter!r})' + return f'{self.__class__.__name__}(url={self._url!r}, env_nested_delimiter={self.env_nested_delimiter!r})' def _get_env_var_key(key: str, case_sensitive: bool = False) -> str: diff --git a/tests/test_multi_source.py b/tests/test_multi_source.py index 49a670bd..5e2a800c 100644 --- a/tests/test_multi_source.py +++ b/tests/test_multi_source.py @@ -29,10 +29,7 @@ class Nested(BaseModel): class Settings(BaseSettings): model_config = SettingsConfigDict( - json_file=p, - env_prefix='SETTINGS_', - env_nested_delimiter='__', - validate_each_source=True + json_file=p, env_prefix='SETTINGS_', env_nested_delimiter='__', validate_each_source=True ) foobar: str nested: Nested @@ -48,11 +45,7 @@ def settings_customise_sources( dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: - return ( - JsonConfigSettingsSource(settings_cls), - env_settings, - init_settings - ) + return (JsonConfigSettingsSource(settings_cls), env_settings, init_settings) with pytest.raises(ValidationError) as exc_info: _ = Settings(null_field=0) @@ -61,7 +54,10 @@ def settings_customise_sources( { 'ctx': {'source': 'JsonConfigSettingsSource'}, 'input': 0, - 'loc': ('JsonConfigSettingsSource', 'foobar',), + 'loc': ( + 'JsonConfigSettingsSource', + 'foobar', + ), 'msg': 'Input should be a valid string', 'type': 'string_type', }, @@ -70,19 +66,22 @@ def settings_customise_sources( 'input': 'a', 'loc': ('EnvSettingsSource', 'nested', 'nested_field'), 'msg': 'Input should be a valid integer, unable to parse string as an integer', - 'type': 'int_parsing' + 'type': 'int_parsing', }, { 'ctx': {'source': 'InitSettingsSource'}, 'input': 0, - 'loc': ('InitSettingsSource', 'null_field',), + 'loc': ( + 'InitSettingsSource', + 'null_field', + ), 'msg': 'Input should be a valid string', - 'type': 'string_type' + 'type': 'string_type', }, { 'input': {'foobar': 0, 'nested': {'nested_field': 'a'}, 'null_field': None}, 'loc': ('extra',), 'msg': 'Field required', - 'type': 'missing' - } + 'type': 'missing', + }, ] diff --git a/tests/test_settings.py b/tests/test_settings.py index 301c09ef..190be2f9 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -487,7 +487,10 @@ class AnnotatedComplexSettings(BaseSettings, validate_each_source=True): { 'ctx': {'actual_length': 1, 'field_type': 'List', 'min_length': 2, 'source': 'EnvSettingsSource'}, 'input': ['russet'], - 'loc': ('EnvSettingsSource', 'apples',), + 'loc': ( + 'EnvSettingsSource', + 'apples', + ), 'msg': 'List should have at least 2 items after validation, not 1', 'type': 'too_short', } @@ -1141,10 +1144,13 @@ class Settings(BaseSettings): assert exc_info.value.errors(include_url=False) == [ { 'type': 'extra_forbidden', - 'loc': ('DotEnvSettingsSource', 'f',), + 'loc': ( + 'DotEnvSettingsSource', + 'f', + ), 'msg': 'Extra inputs are not permitted', 'input': 'random value', - 'ctx': {'source': 'DotEnvSettingsSource'} + 'ctx': {'source': 'DotEnvSettingsSource'}, } ] @@ -1925,8 +1931,7 @@ def test_builtins_settings_source_repr(): == "EnvSettingsSource(env_nested_delimiter='__', env_prefix_len=0)" ) assert repr(DotEnvSettingsSource(BaseSettings, env_file='.env', env_file_encoding='utf-8')) == ( - "DotEnvSettingsSource(env_file='.env', env_file_encoding='utf-8', " - 'env_nested_delimiter=None, env_prefix_len=0)' + "DotEnvSettingsSource(env_file='.env', env_file_encoding='utf-8', env_nested_delimiter=None, env_prefix_len=0)" ) assert ( repr(SecretsSettingsSource(BaseSettings, secrets_dir='/secrets'))