Repository: rochacbruno/dynaconf (python)

Issue:
RFC: Allow dot notation for .set method
Dot traversal access has been implemented by @jperras in #93 

Now it is possible to read values like `settings['DICT.KEY.NESTED.OTHER']` 

But the set method does not have this functionality.

```py
settings.set('DICT.KEY.NESTED.OTHER', 1)
{
   'DICT.KEY.NESTED.OTHER':  1
}
```

But what we want is

```py
settings.set('DICT.KEY.NESTED.OTHER', 1)
{
   'DICT': {
     'KEY': {
        'NESTED': {
            'OTHER':  1
        }
     }
  }
}
```

Unless we explitly pass `dotted_lookup=False`

- [ ] Keep backwards compatibility
- [ ] Add unit tests
- [ ] Add a use case in `examples/`
- [ ] opt-out via explicit param `.set(x, y, dotted_lookup=False)`
- [ ] opt-out globally via `NESTED_SET_ENABLED_FOR_DYNACONF=False`
- [ ] work well with the new MERGE feature that was included #88 





Pull request description:
Allow dotted-path based setting of configuration key/value pairs.
## Summary

To mirror the functionality added in #93, the ability to set nested structures has been added in this PR.

## Examples

You can set a value with an arbitrary number of nested keys, separated by dots:

```python
settings.set('nested_1.nested_2.nested_3.nested_4', 'secret')
```

And accessing the keys/values with dotted-path lookup behaves as expected:

```python
print(settings.NESTED_1.NESTED_2.NESTED_3.to_dict())
```

If for some reason you didn't want to have a key parsed into nested structures delimited by dots and just wanted a key of "foo.bar", you can disable the parsing with:

```python
settings.set('nested_1.nested_2.nested_3.nested_4',
                    'secret',
                    dotted_lookup=False)
```

And accessing keys that don't exist will raise `KeyError`:

```python
# Raises KeyError due to NESTED_5 not existing
settings.NESTED_1.NESTED_5
```

Tests have been added, and it functions with the merge functionality introduced in #88.

## References

Closes #96.

Patch:
diff --git a/dynaconf/base.py b/dynaconf/base.py
index 00e125c..fd48b1e 100644
--- a/dynaconf/base.py
+++ b/dynaconf/base.py
@@ -514,7 +514,22 @@ class Settings(object):
         for key in keys:
             self.unset(key)
 
-    def set(self, key, value, loader_identifier=None, tomlfy=False):
+    def _dotted_set(self, dotted_key, value, tomlfy=False, **kwargs):
+
+        data = DynaBox(default_box=True)
+        tree = data
+        split_keys = dotted_key.split('.')
+
+        for k in split_keys[:-1]:
+            tree = tree.setdefault(k, {})
+
+        value = parse_conf_data(value, tomlfy=tomlfy)
+        tree[split_keys[-1]] = value
+
+        self.update(data=data, **kwargs)
+
+    def set(self, key, value, loader_identifier=None, tomlfy=False,
+            dotted_lookup=True):
         """Set a value storing references for the loader
 
         :param key: The key to store
@@ -522,6 +537,14 @@ class Settings(object):
         :param loader_identifier: Optional loader name e.g: toml, yaml etc.
         :param tomlfy: Bool define if value is parsed by toml (defaults False)
         """
+
+        if '.' in key and dotted_lookup is True:
+            return self._dotted_set(
+                key,
+                value,
+                loader_identifier=loader_identifier,
+                tomlfy=tomlfy)
+
         value = parse_conf_data(value, tomlfy=tomlfy)
         key = key.strip().upper()
 
diff --git a/example/full_example.py b/example/full_example.py
index b601350..4578f22 100644
--- a/example/full_example.py
+++ b/example/full_example.py
@@ -58,3 +58,6 @@ print("\nDotted path lookup value :")
 print(settings('ADICT.KEY'))
 
 print(settings.WORKS)
+
+print("\nDotted path set value :")
+print(settings.set('ONE.TWO', 'value'))


Test patch:
diff --git a/tests/test_base.py b/tests/test_base.py
index a0ede2f..2453394 100644
--- a/tests/test_base.py
+++ b/tests/test_base.py
@@ -201,7 +201,8 @@ def test_dotted_traversal_access(settings):
             'PASSWORD': 'secret',
             'SSL': {'CONTEXT': 'SECURE'},
             'DOTTED.KEY': True
-        }
+        },
+        dotted_lookup=False
     )
     assert settings.get('PARAMS') == {
         'PASSWORD': 'secret',
@@ -237,7 +238,7 @@ def test_dotted_traversal_access(settings):
     assert settings('PARAMS.DOTTED.KEY') is None
     assert settings('PARAMS').get('DOTTED.KEY') is True
 
-    settings.set('DOTTED.KEY', True)
+    settings.set('DOTTED.KEY', True, dotted_lookup=False)
     assert settings('DOTTED.KEY', dotted_lookup=False) is True
 
     settings.set(
@@ -256,3 +257,47 @@ def test_dotted_traversal_access(settings):
     assert settings('NESTED_1.nested_2.nested_3.nested_4') is True
     # First key is always transformed to upper()
     assert settings('nested_1.nested_2.nested_3.nested_4') is True
+
+
+def test_dotted_set(settings):
+
+    settings.set('nested_1.nested_2.nested_3.nested_4', 'secret')
+
+    assert settings.NESTED_1.NESTED_2.NESTED_3.NESTED_4 == 'secret'
+    assert settings.NESTED_1.NESTED_2.NESTED_3.to_dict() == {'nested_4': 'secret'}
+    assert settings.NESTED_1.NESTED_2.to_dict() == {
+        'nested_3': {
+            'nested_4': 'secret'
+        }
+    }
+
+    assert settings.get('nested_1').to_dict() == {
+        'nested_2': {
+            'nested_3': {
+                'nested_4': 'secret'
+            }
+        }
+    }
+
+    with pytest.raises(KeyError):
+        settings.NESTED_1.NESTED_5
+
+
+def test_dotted_set_with_merge(settings):
+    settings.set("MERGE_ENABLED_FOR_DYNACONF", True)
+
+    settings.set("MERGE.KEY", {
+        "items": [{"name": "item 1"}, {"name": "item 2"}]
+    })
+    settings.set("MERGE.KEY", {
+        "items": [{"name": "item 3"}, {"name": "item 4"}]
+    })
+
+    assert settings.MERGE.KEY == {
+        'items': [
+            {'name': 'item 1'},
+            {'name': 'item 2'},
+            {'name': 'item 3'},
+            {'name': 'item 4'},
+        ]
+    }