diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 809af620..bbc9e7b1 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -144,6 +144,65 @@ def _extract_field_info(self, field: FieldInfo, field_name: str) -> List[Tuple[s return field_info + def _replace_field_names_case_insensitively(self, field: FieldInfo, field_values: Dict[str, Any]) -> Dict[str, Any]: + """ + Replace field names in values dict by looking in models fields insensitively. + + By having the following models: + + ```py + class SubSubSub(BaseModel): + VaL3: str + + class SubSub(BaseModel): + Val2: str + SUB_sub_SuB: SubSubSub + + class Sub(BaseModel): + VAL1: str + SUB_sub: SubSub + + class Settings(BaseSettings): + nested: Sub + + model_config = ConfigDict(env_nested_delimiter='__') + ``` + + Then: + _replace_field_names_case_insensitively( + field, + {"val1": "v1", "sub_SUB": {"VAL2": "v2", "sub_SUB_sUb": {"vAl3": "v3"}}} + ) + Returns {'VAL1': 'v1', 'SUB_sub': {'Val2': 'v2', 'SUB_sub_SuB': {'VaL3': 'v3'}}} + """ + values: Dict[str, Any] = {} + + for name, value in field_values.items(): + sub_model_field: Optional[FieldInfo] = None + + # This is here to make mypy happy + # Item "None" of "Optional[Type[Any]]" has no attribute "model_fields" + if not field.annotation or not hasattr(field.annotation, 'model_fields'): + values[name] = value + continue + + # Find field in sub model by looking in fields case insensitively + for sub_model_field_name, f in field.annotation.model_fields.items(): + if not f.validation_alias and sub_model_field_name.lower() == name.lower(): + sub_model_field = f + break + + if not sub_model_field: + values[name] = value + continue + + if lenient_issubclass(sub_model_field.annotation, BaseModel): + values[sub_model_field_name] = self._replace_field_names_case_insensitively(sub_model_field, value) + else: + values[sub_model_field_name] = value + + return values + def __call__(self) -> Dict[str, Any]: d: Dict[str, Any] = {} @@ -163,7 +222,10 @@ def __call__(self) -> Dict[str, Any]: ) from e if field_value is not None: - d[field_key] = field_value + if not self.config.get('case_sensitive', False) and lenient_issubclass(field.annotation, BaseModel): + d[field_key] = self._replace_field_names_case_insensitively(field, field_value) + else: + d[field_key] = field_value return d @@ -300,6 +362,7 @@ def next_field(field: Optional[FieldInfo], key: str) -> Optional[FieldInfo]: By having the following models: + ```py class SubSubModel(BaseSettings): dvals: Dict @@ -309,6 +372,7 @@ class SubModel(BaseSettings): class Cfg(BaseSettings): sub_model: SubModel + ``` Then: next_field(sub_model, 'vals') Returns the `vals` field of `SubModel` class diff --git a/tests/test_settings.py b/tests/test_settings.py index 1c1b38d5..dbc3ff52 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1486,3 +1486,29 @@ class Cfg(BaseSettings): env.set('cfg_sub_model__vals', '{"invalid": dict}') with pytest.raises(ValidationError): Cfg() + + +def test_nested_model_case_insensitive(env): + class SubSubSub(BaseModel): + VaL3: str + val4: str = Field(validation_alias='VAL4') + + class SubSub(BaseModel): + Val2: str + SUB_sub_SuB: SubSubSub + + class Sub(BaseModel): + VAL1: str + SUB_sub: SubSub + + class Settings(BaseSettings): + nested: Sub + + model_config = ConfigDict(env_nested_delimiter='__') + + env.set('nested', '{"val1": "v1", "sub_SUB": {"VAL2": "v2", "sub_SUB_sUb": {"vAl3": "v3", "VAL4": "v4"}}}') + s = Settings() + assert s.nested.VAL1 == 'v1' + assert s.nested.SUB_sub.Val2 == 'v2' + assert s.nested.SUB_sub.SUB_sub_SuB.VaL3 == 'v3' + assert s.nested.SUB_sub.SUB_sub_SuB.val4 == 'v4'