Skip to content

Commit 607f1e8

Browse files
hramezaniRobert Aistleitner
andauthored
Implement proper support for nested complex env values (#22)
Co-authored-by: Robert Aistleitner <[email protected]>
1 parent eb6ed32 commit 607f1e8

File tree

2 files changed

+122
-0
lines changed

2 files changed

+122
-0
lines changed

pydantic_settings/sources.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,35 @@ def _field_is_complex(self, field: FieldInfo) -> Tuple[bool, bool]:
288288

289289
return True, allow_parse_failure
290290

291+
@staticmethod
292+
def next_field(field: Optional[FieldInfo], key: str) -> Optional[FieldInfo]:
293+
"""
294+
Find the field in a sub model by key(env name)
295+
296+
By having the following models:
297+
298+
class SubSubModel(BaseSettings):
299+
dvals: Dict
300+
301+
class SubModel(BaseSettings):
302+
vals: List[str]
303+
sub_sub_model: SubSubModel
304+
305+
class Cfg(BaseSettings):
306+
sub_model: SubModel
307+
308+
Then:
309+
next_field(sub_model, 'vals') Returns the `vals` field of `SubModel` class
310+
next_field(sub_model, 'sub_sub_model') Returns `sub_sub_model` field of `SubModel` class
311+
"""
312+
if not field or origin_is_union(get_origin(field.annotation)):
313+
# no support for Unions of complex BaseSettings fields
314+
return None
315+
elif field.annotation and hasattr(field.annotation, 'model_fields') and field.annotation.model_fields.get(key):
316+
return field.annotation.model_fields[key]
317+
318+
return None
319+
291320
def explode_env_vars(
292321
self, field_name: str, field: FieldInfo, env_vars: Mapping[str, Optional[str]]
293322
) -> Dict[str, Any]:
@@ -307,8 +336,24 @@ def explode_env_vars(
307336
env_name_without_prefix = env_name[self.env_prefix_len :]
308337
_, *keys, last_key = env_name_without_prefix.split(self.env_nested_delimiter)
309338
env_var = result
339+
target_field: Optional[FieldInfo] = field
340+
310341
for key in keys:
342+
target_field = self.next_field(target_field, key)
311343
env_var = env_var.setdefault(key, {})
344+
345+
# get proper field with last_key
346+
target_field = self.next_field(target_field, last_key)
347+
348+
# check if env_val maps to a complex field and if so, parse the env_val
349+
if target_field and env_val:
350+
is_complex, allow_json_failure = self._field_is_complex(target_field)
351+
if is_complex:
352+
try:
353+
env_val = json.loads(env_val)
354+
except ValueError as e:
355+
if not allow_json_failure:
356+
raise e
312357
env_var[last_key] = env_val
313358

314359
return result

tests/test_settings.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1390,3 +1390,80 @@ def settings_customise_sources(
13901390
SettingsError, match='error getting value for field "top" from source "BadCustomSettingsSource"'
13911391
):
13921392
Settings()
1393+
1394+
1395+
def test_nested_env_complex_values(env):
1396+
class SubSubModel(BaseSettings):
1397+
dvals: Dict
1398+
1399+
class SubModel(BaseSettings):
1400+
vals: List[str]
1401+
sub_sub_model: SubSubModel
1402+
1403+
class Cfg(BaseSettings):
1404+
sub_model: SubModel
1405+
1406+
model_config = ConfigDict(env_prefix='cfg_', env_nested_delimiter='__')
1407+
1408+
env.set('cfg_sub_model__vals', '["one", "two"]')
1409+
env.set('cfg_sub_model__sub_sub_model__dvals', '{"three": 4}')
1410+
1411+
assert Cfg().model_dump() == {'sub_model': {'vals': ['one', 'two'], 'sub_sub_model': {'dvals': {'three': 4}}}}
1412+
1413+
env.set('cfg_sub_model__vals', 'invalid')
1414+
with pytest.raises(
1415+
SettingsError, match='error parsing value for field "sub_model" from source "EnvSettingsSource"'
1416+
):
1417+
Cfg()
1418+
1419+
1420+
def test_nested_env_nonexisting_field(env):
1421+
class SubModel(BaseSettings):
1422+
vals: List[str]
1423+
1424+
class Cfg(BaseSettings):
1425+
sub_model: SubModel
1426+
1427+
model_config = ConfigDict(env_prefix='cfg_', env_nested_delimiter='__')
1428+
1429+
env.set('cfg_sub_model__foo_vals', '[]')
1430+
with pytest.raises(ValidationError):
1431+
Cfg()
1432+
1433+
1434+
def test_nested_env_nonexisting_field_deep(env):
1435+
class SubModel(BaseSettings):
1436+
vals: List[str]
1437+
1438+
class Cfg(BaseSettings):
1439+
sub_model: SubModel
1440+
1441+
model_config = ConfigDict(env_prefix='cfg_', env_nested_delimiter='__')
1442+
1443+
env.set('cfg_sub_model__vals__foo__bar__vals', '[]')
1444+
with pytest.raises(ValidationError):
1445+
Cfg()
1446+
1447+
1448+
def test_nested_env_union_complex_values(env):
1449+
class SubModel(BaseSettings):
1450+
vals: Union[List[str], Dict[str, str]]
1451+
1452+
class Cfg(BaseSettings):
1453+
sub_model: SubModel
1454+
1455+
model_config = ConfigDict(env_prefix='cfg_', env_nested_delimiter='__')
1456+
1457+
env.set('cfg_sub_model__vals', '["one", "two"]')
1458+
assert Cfg().model_dump() == {'sub_model': {'vals': ['one', 'two']}}
1459+
1460+
env.set('cfg_sub_model__vals', '{"three": "four"}')
1461+
assert Cfg().model_dump() == {'sub_model': {'vals': {'three': 'four'}}}
1462+
1463+
env.set('cfg_sub_model__vals', 'stringval')
1464+
with pytest.raises(ValidationError):
1465+
Cfg()
1466+
1467+
env.set('cfg_sub_model__vals', '{"invalid": dict}')
1468+
with pytest.raises(ValidationError):
1469+
Cfg()

0 commit comments

Comments
 (0)