Repository: pydantic/pydantic

Issue:
Providing only one nested value from environment variable
# Feature Request

* OS: **macOS**
* Python version `import sys; print(sys.version)`: **3.6.8 (default, Jun 24 2019, 16:49:13) [GCC 4.2.1 Compatible Apple LLVM 10.0.1 (clang-1001.0.46.4)]**
* Pydantic version `import pydantic; print(pydantic.VERSION)`: **1.0b2**

Hello, great work on the library! I can't find the reason, why environment variables don't work in nested settings. I have a class
```python
class Credentials(pydantic.BaseModel):
    host: str
    user: str
    database: str
    password: pydantic.SecretStr

class Settings(pydantic.BaseSettings):
    db: Credentials

    class Config:
        env_prefix = 'APP_'
```
and I want to specify `password` as an environment variable
```
export APP_DB='{"password": ""}'
```
 but pass other fields in as values in code. Right now `BaseSettings` does simple top-level merging of dictionaries that doesn't consider nested values
```python
def _build_values(self, init_kwargs: Dict[str, Any]) -> Dict[str, Any]:
    return {**self._build_environ(), **init_kwargs}
```
but dictionaries could be merged recursively, so users could provide values for any of their settings in environment variables.

What do you think, would it be useful?


Comments:
Yeah this seems like a good feature request to me.

I've never tried nesting BaseSettings classes but it seems totally reasonable to me and I don't see any reason it shouldn't work like this. (But maybe @samuelcolvin does?)

Could you produce a failing test case or two as a starting point for implementation work?
Sounds good to me. 

Do you want to use dot notation e.g. `db.host`?

Also should this work for dictionaries too?

This isn't required for v1 since it would be entirely backwards compatible. 
It's great to hear!

I think the only logic that needs to change is merging of `self._build_environ()` and `init_kwargs` dictionaries, so I don't see why it wouldn't work for dictionaries.

Should I start a merge request with the changes and tests or would you like to tackle this one yourselves?
@idmitrievsky It would be great if you could start a pull request for this. Your approach to the logic sounds right to me (though admittedly there could be some edge cases I'm not considering).

Patch:
diff --git a/pydantic/env_settings.py b/pydantic/env_settings.py
--- a/pydantic/env_settings.py
+++ b/pydantic/env_settings.py
@@ -5,6 +5,7 @@
 from .fields import ModelField
 from .main import BaseModel, Extra
 from .typing import display_as_type
+from .utils import deep_update
 
 
 class SettingsError(ValueError):
@@ -24,7 +25,7 @@ def __init__(__pydantic_self__, **values: Any) -> None:
         super().__init__(**__pydantic_self__._build_values(values))
 
     def _build_values(self, init_kwargs: Dict[str, Any]) -> Dict[str, Any]:
-        return {**self._build_environ(), **init_kwargs}
+        return deep_update(self._build_environ(), init_kwargs)
 
     def _build_environ(self) -> Dict[str, Optional[str]]:
         """
diff --git a/pydantic/utils.py b/pydantic/utils.py
--- a/pydantic/utils.py
+++ b/pydantic/utils.py
@@ -5,6 +5,7 @@
     TYPE_CHECKING,
     Any,
     Callable,
+    Dict,
     Generator,
     Iterator,
     List,
@@ -12,6 +13,7 @@
     Set,
     Tuple,
     Type,
+    TypeVar,
     Union,
     no_type_check,
 )
@@ -23,11 +25,12 @@
 except ImportError:
     Literal = None  # type: ignore
 
-
 if TYPE_CHECKING:
     from .main import BaseModel  # noqa: F401
     from .typing import SetIntStr, DictIntStrAny, IntStr, ReprArgs  # noqa: F401
 
+KeyType = TypeVar('KeyType')
+
 
 def import_string(dotted_path: str) -> Any:
     """
@@ -98,6 +101,16 @@ def in_ipython() -> bool:
         return True
 
 
+def deep_update(mapping: Dict[KeyType, Any], updating_mapping: Dict[KeyType, Any]) -> Dict[KeyType, Any]:
+    updated_mapping = mapping.copy()
+    for k, v in updating_mapping.items():
+        if k in mapping and isinstance(mapping[k], dict) and isinstance(v, dict):
+            updated_mapping[k] = deep_update(mapping[k], v)
+        else:
+            updated_mapping[k] = v
+    return updated_mapping
+
+
 def almost_equal_floats(value_1: float, value_2: float, *, delta: float = 1e-8) -> bool:
     """
     Return True if two floats are almost equal


Test patch:
diff --git a/tests/test_settings.py b/tests/test_settings.py
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -1,5 +1,5 @@
 import os
-from typing import List, Set
+from typing import Dict, List, Set
 
 import pytest
 
@@ -48,6 +48,32 @@ class Config:
     assert s.apple == 'has_prefix'
 
 
+def test_nested_env_with_basemodel(env):
+    class TopValue(BaseModel):
+        apple: str
+        banana: str
+
+    class Settings(BaseSettings):
+        top: TopValue
+
+    with pytest.raises(ValidationError):
+        Settings()
+    env.set('top', '{"banana": "secret_value"}')
+    s = Settings(top={'apple': 'value'})
+    assert s.top == {'apple': 'value', 'banana': 'secret_value'}
+
+
+def test_nested_env_with_dict(env):
+    class Settings(BaseSettings):
+        top: Dict[str, str]
+
+    with pytest.raises(ValidationError):
+        Settings()
+    env.set('top', '{"banana": "secret_value"}')
+    s = Settings(top={'apple': 'value'})
+    assert s.top == {'apple': 'value', 'banana': 'secret_value'}
+
+
 class DateModel(BaseModel):
     pips: bool = False
 
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -8,7 +8,7 @@
 from pydantic import BaseModel
 from pydantic.color import Color
 from pydantic.typing import display_as_type, is_new_type, new_type_supertype
-from pydantic.utils import ValueItems, import_string, lenient_issubclass, truncate
+from pydantic.utils import ValueItems, deep_update, import_string, lenient_issubclass, truncate
 
 try:
     import devtools
@@ -222,3 +222,43 @@ class Model(BaseModel):
         '    ],\n'
         ')'
     )
+
+
+@pytest.mark.parametrize(
+    'mapping, updating_mapping, expected_mapping, msg',
+    [
+        (
+            {'key': {'inner_key': 0}},
+            {'other_key': 1},
+            {'key': {'inner_key': 0}, 'other_key': 1},
+            'extra keys are inserted',
+        ),
+        (
+            {'key': {'inner_key': 0}, 'other_key': 1},
+            {'key': [1, 2, 3]},
+            {'key': [1, 2, 3], 'other_key': 1},
+            'values that can not be merged are updated',
+        ),
+        (
+            {'key': {'inner_key': 0}},
+            {'key': {'other_key': 1}},
+            {'key': {'inner_key': 0, 'other_key': 1}},
+            'values that have corresponding keys are merged',
+        ),
+        (
+            {'key': {'inner_key': {'deep_key': 0}}},
+            {'key': {'inner_key': {'other_deep_key': 1}}},
+            {'key': {'inner_key': {'deep_key': 0, 'other_deep_key': 1}}},
+            'deeply nested values that have corresponding keys are merged',
+        ),
+    ],
+)
+def test_deep_update(mapping, updating_mapping, expected_mapping, msg):
+    assert deep_update(mapping, updating_mapping) == expected_mapping, msg
+
+
+def test_deep_update_is_not_mutating():
+    mapping = {'key': {'inner_key': {'deep_key': 1}}}
+    updated_mapping = deep_update(mapping, {'key': {'inner_key': {'other_deep_key': 1}}})
+    assert updated_mapping == {'key': {'inner_key': {'deep_key': 1, 'other_deep_key': 1}}}
+    assert mapping == {'key': {'inner_key': {'deep_key': 1}}}