Repository: rochacbruno/dynaconf
Pull request #139 (2019-03-27 21:18:45)

Add dynaconf_merge fucntionality for dict and list settings.
## Merging existing values

If your settings has existing variables of types `list` ot `dict` and you want to `merge` instead of `override` then the `dynaconf_merge` and `dynaconf_merge_unique` stanzas can mark that variable as a candidate for merging.

Examples:

For a **dict** value:

Your main settings file (e.g `settings.toml`) has an existing `DATABASE` dict setting on `[default]` env.

Now you want to contribute to the same `DATABASE` key by addind new keys, so you can use `dynaconf_merge` at the end of your dict:

In specific `[envs]`

```toml
[default]
database = {host="server.com", user="default"}

[development]
database = {user="dev_user", dynaconf_merge=true}

[production]
database = {user="prod_user", dynaconf_merge=true}
```

In an environment variable:

```bash
# Toml formatted envvar
export DYNACONF_DATABASE='{password=1234, dynaconf_merge=true}'
```

Or in an additional file (e.g `settings.yaml, .secrets.yaml, etc`):

```yaml
default:
  database:
    password: 1234
    dynaconf_merge: true
```

The `dynaconf_merge` token will mark that object to be merged with existing values (of course `dynaconf_merge` key will not be added to the final settings it is jsut a mark)

The end result will be on `[development]` env:

```python
settings.DATABASE == {'host': 'server.com', 'user': 'dev_user', 'password': 1234}
```

The same can be applied to **lists**:

`settings.toml`
```toml
[default]
plugins = ["core"]

[development]
plugins = ["debug_toolbar", "dynaconf_merge"]
```

And in environment variable

```bash
export DYNACONF_PLUGINS='["ci_plugin", "dynaconf_merge"]'
```

Then the end result on `[development]` is:

```python
settings.PLUGINS == ["ci_plugin", "debug_toolbar", "core"]
```

### Avoiding duplications on lists

The `dynaconf_merge_unique` is the token for when you want to avoid duplications in a list.

Example:

```toml
[default]
scripts = ['install.sh', 'deploy.sh']

[development]
scripts = ['dev.sh', 'test.sh', 'deploy.sh', 'dynaconf_merge_unique']
```

```bash
export DYNACONF_SCRIPTS='["deploy.sh", "run.sh", "dynaconf_merge_unique"]'
```

The end result for `[development]` will be:

```python
settings.SCRIPTS == ['install.sh', 'dev.sh', 'test.sh', 'deploy.sh', 'run.sh']
```

> Note that `deploy.sh` is set 3 times but it is not repeated in the final settings.

### Known caveats

The **dynaconf_merge** functionality works only for the first level keys, it will not merge subdicts or nested lists (yet).

## More examples

Take a look at the [example](https://github.com/rochacbruno/dynaconf/tree/master/example) folder to see some examples of use with different file formats and features.

rochacbruno: @bmbouter @dkliban @psav @rsnyman 

Now I got a better implementation, will help if you can give your inputs.
codecov[bot]: # [Codecov](https://codecov.io/gh/rochacbruno/dynaconf/pull/139?src=pr&el=h1) Report
> Merging [#139](https://codecov.io/gh/rochacbruno/dynaconf/pull/139?src=pr&el=desc) into [master](https://codecov.io/gh/rochacbruno/dynaconf/commit/d08284fa050d71647db55c56f6c9a218494f90bb?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/rochacbruno/dynaconf/pull/139/graphs/tree.svg?width=650&token=GXszVzJcqW&height=150&src=pr)](https://codecov.io/gh/rochacbruno/dynaconf/pull/139?src=pr&el=tree)

```diff
@@          Coverage Diff          @@
##           master   #139   +/-   ##
=====================================
  Coverage     100%   100%           
=====================================
  Files          17     17           
  Lines         617    621    +4     
=====================================
+ Hits          617    621    +4
```


| [Impacted Files](https://codecov.io/gh/rochacbruno/dynaconf/pull/139?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [dynaconf/loaders/base.py](https://codecov.io/gh/rochacbruno/dynaconf/pull/139/diff?src=pr&el=tree#diff-ZHluYWNvbmYvbG9hZGVycy9iYXNlLnB5) | `100% <100%> (ø)` | :arrow_up: |
| [dynaconf/utils/\_\_init\_\_.py](https://codecov.io/gh/rochacbruno/dynaconf/pull/139/diff?src=pr&el=tree#diff-ZHluYWNvbmYvdXRpbHMvX19pbml0X18ucHk=) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/rochacbruno/dynaconf/pull/139?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/rochacbruno/dynaconf/pull/139?src=pr&el=footer). Last update [d08284f...8995bac](https://codecov.io/gh/rochacbruno/dynaconf/pull/139?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

rsnyman: Looks good to me.

Patch:
diff --git a/Makefile b/Makefile
index ff0bf3a..44d44ee 100644
--- a/Makefile
+++ b/Makefile
@@ -8,6 +8,7 @@ test_examples:
 	@cd example/app;pwd;python app.py | grep -c app
 	@cd example/app_with_dotenv;pwd;python app.py | grep -c app_with_dotenv
 	@cd example/merge_configs;pwd;python app.py | grep -c merge_configs
+	@cd example/dynaconf_merge;pwd;python app.py
 	@cd example/multiple_sources;pwd;python app.py | grep -c multiple_sources
 	@cd example/multiple_folders;pwd;python app.py | grep -c var2_prod
 	@cd example/toml_example/;pwd;python app.py | grep -c toml_example
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index b30302c..ac3c55a 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -19,7 +19,7 @@ jobs:
         python.version: '3.6'
       Python37:
         python.version: '3.7'
-    maxParallel: 15
+    maxParallel: 4
 
   steps:
   - task: UsePythonVersion@0
@@ -49,7 +49,7 @@ jobs:
         python.version: '3.6'
       Python37:
         python.version: '3.7'
-    maxParallel: 15
+    maxParallel: 4
 
   steps:
   - task: UsePythonVersion@0
@@ -70,7 +70,7 @@ jobs:
   - task: PublishTestResults@2
     inputs:
       testResultsFiles: '**/test-results.xml'
-      testRunTitle: 'Python $(python.version)'
+      testRunTitle: 'LinuxUnit Python $(python.version)'
     condition: succeededOrFailed()
 
 - job: 'LinuxFunctional'
@@ -82,7 +82,7 @@ jobs:
         python.version: '3.6'
       Python37:
         python.version: '3.7'
-    maxParallel: 15
+    maxParallel: 4
 
   steps:
   - task: UsePythonVersion@0
@@ -103,11 +103,9 @@ jobs:
     vmImage: 'Ubuntu-16.04'
   strategy:
     matrix:
-      Python36:
-        python.version: '3.6'
       Python37:
         python.version: '3.7'
-    maxParallel: 15
+    maxParallel: 4
 
   steps:
   - task: UsePythonVersion@0
@@ -135,11 +133,9 @@ jobs:
     vmImage: 'Ubuntu-16.04'
   strategy:
     matrix:
-      Python36:
-        python.version: '3.6'
       Python37:
         python.version: '3.7'
-    maxParallel: 15
+    maxParallel: 4
 
   steps:
   - task: UsePythonVersion@0
@@ -181,7 +177,7 @@ jobs:
   - task: PublishTestResults@2
     inputs:
       testResultsFiles: '**/test-results.xml'
-      testRunTitle: 'Python $(python.version)'
+      testRunTitle: 'WindowsUnit Python $(python.version)'
     condition: succeededOrFailed()
 
 - job: 'WindowsInstall'
@@ -233,6 +229,7 @@ jobs:
       pushd example\app & cd & python app.py  & popd
       pushd example\app_with_dotenv & cd & python app.py & popd
       pushd example\merge_configs & cd & python app.py & popd
+      pushd example\dynaconf_merge & cd & python app.py & popd
       pushd example\multiple_sources & cd & python app.py & popd
       pushd example\multiple_folders & cd & python app.py & popd
       pushd example\toml_example\ & cd & python app.py & popd
@@ -250,6 +247,7 @@ jobs:
       pushd example\envs & cd & python app.py & popd
       pushd example\custom_loader & cd & python app.py & popd
       pushd example\get_fresh & cd & python app.py & popd
+      pushd example\includes & cd & python app.py & popd
       pushd example\jenkins_secrets_file & cd & python app.py & popd
       pushd example\specific_settings_modules & cd & python app.py & popd
       pushd example\django_example\ & cd & python manage.py test & popd
diff --git a/docs/guides/environment_variables.md b/docs/guides/environment_variables.md
index 3a1ad32..d69d0c7 100644
--- a/docs/guides/environment_variables.md
+++ b/docs/guides/environment_variables.md
@@ -56,6 +56,39 @@ DYNACONF_ARRAY='@json [42, 3.14, "hello", true, ["otherarray"], {"foo": "bar"}]'
 
 > **NOTE**: Older versions of Dynaconf used the `@casting` prefixes for env vars like `export DYNACONF_INTEGER='@int 123'` still works but this casting is deprecated in favor of using TOML syntax described above. To disable the `@casting` do `export AUTO_CAST_FOR_DYNACONF=false`
 
+### Merging exported variables with existing data
+
+To merge exported variables there is the **dynaconf_merge** tokens, example:
+
+Your main settings file (e.g `settings.toml`) has an existing `DATABASE` dict setting on `[default]` env.
+
+Now you want to contribute to the same `DATABASE` key by addind new keys, so you can use `dynaconf_merge` at the end of your dict:
+
+In specific `[envs]`
+
+```toml
+[default]
+database = {host="server.com", user="default"}
+
+[development]
+database = {user="dev_user", dynaconf_merge=true}
+```
+
+In an environment variable:
+
+```bash
+# Toml formatted envvar
+export DYNACONF_DATABASE='{password=1234, dynaconf_merge=true}'
+```
+
+The end result will be on `[development]` env:
+
+```python
+settings.DATABASE == {'host': 'server.com', 'user': 'dev_user', 'password': 1234}
+```
+
+Read more in [Getting Started Guide](usage.html)
+
 ### The global prefix
 
 The **DYNACONF_{param}** prefix is set by **GLOBAL_ENV_FOR_DYNACONF** and serves only to be used in environment variables to override config values.
diff --git a/docs/guides/usage.md b/docs/guides/usage.md
index 936ccf6..7f93e4a 100644
--- a/docs/guides/usage.md
+++ b/docs/guides/usage.md
@@ -235,4 +235,107 @@ A settings file can include a `dynaconf_include` stanza, whose exact
   Currently, only a single level of includes is permitted to keep things
   simple and straightforward.
 
-Take a look at the [example](https://github.com/rochacbruno/dynaconf/tree/master/example) folder to see some examples of use with different file formats.
+## Merging existing values
+
+If your settings has existing variables of types `list` ot `dict` and you want to `merge` instead of `override` then 
+the `dynaconf_merge` and `dynaconf_merge_unique` stanzas can mark that variable as a candidate for merging.
+
+For **dict** value:
+
+Your main settings file (e.g `settings.toml`) has an existing `DATABASE` dict setting on `[default]` env.
+
+Now you want to contribute to the same `DATABASE` key by addind new keys, so you can use `dynaconf_merge` at the end of your dict:
+
+In specific `[envs]`
+
+```toml
+[default]
+database = {host="server.com", user="default"}
+
+[development]
+database = {user="dev_user", dynaconf_merge=true}
+
+[production]
+database = {user="prod_user", dynaconf_merge=true}
+```
+
+In an environment variable:
+
+```bash
+# Toml formatted envvar
+export DYNACONF_DATABASE='{password=1234, dynaconf_merge=true}'
+```
+
+Or in an additional file (e.g `settings.yaml, .secrets.yaml, etc`):
+
+```yaml
+default:
+  database:
+    password: 1234
+    dynaconf_merge: true
+```
+
+The `dynaconf_merge` token will mark that object to be merged with existing values (of course `dynaconf_merge` key will not be added to the final settings it is jsut a mark)
+
+The end result will be on `[development]` env:
+
+```python
+settings.DATABASE == {'host': 'server.com', 'user': 'dev_user', 'password': 1234}
+```
+
+The same can be applied to **lists**:
+
+`settings.toml`
+```toml
+[default]
+plugins = ["core"]
+
+[development]
+plugins = ["debug_toolbar", "dynaconf_merge"]
+```
+
+And in environment variable
+
+```bash
+export DYNACONF_PLUGINS='["ci_plugin", "dynaconf_merge"]'
+```
+
+Then the end result on `[development]` is:
+
+```python
+settings.PLUGINS == ["ci_plugin", "debug_toolbar", "core"]
+```
+
+### Avoiding duplications on lists
+
+The `dynaconf_merge_unique` is the token for when you want to avoid duplications in a list.
+
+Example:
+
+```toml
+[default]
+scripts = ['install.sh', 'deploy.sh']
+
+[development]
+scripts = ['dev.sh', 'test.sh', 'deploy.sh', 'dynaconf_merge_unique']
+```
+
+```bash
+export DYNACONF_SCRIPTS='["deploy.sh", "run.sh", "dynaconf_merge_unique"]'
+```
+
+The end result for `[development]` will be:
+
+```python
+settings.SCRIPTS == ['install.sh', 'dev.sh', 'test.sh', 'deploy.sh', 'run.sh']
+```
+
+> Note that `deploy.sh` is set 3 times but it is not repeated in the final settings.
+
+### Known caveats
+
+The **dynaconf_merge** functionality works only for the first level keys, it will not merge subdicts or nested lists (yet).
+
+## More examples
+
+Take a look at the [example](https://github.com/rochacbruno/dynaconf/tree/master/example) folder to see some examples of use with different file formats and features.
diff --git a/dynaconf/base.py b/dynaconf/base.py
index b730023..94a7f11 100644
--- a/dynaconf/base.py
+++ b/dynaconf/base.py
@@ -530,13 +530,14 @@ class Settings(object):
         self.update(data=data, **kwargs)
 
     def set(self, key, value, loader_identifier=None, tomlfy=False,
-            dotted_lookup=True):
+            dotted_lookup=True, is_secret=False):
         """Set a value storing references for the loader
 
         :param key: The key to store
         :param value: The value to store
         :param loader_identifier: Optional loader name e.g: toml, yaml etc.
         :param tomlfy: Bool define if value is parsed by toml (defaults False)
+        :param is_secret: Bool define if secret values is hidden on logs.
         """
 
         if '.' in key and dotted_lookup is True:
@@ -548,10 +549,9 @@ class Settings(object):
 
         value = parse_conf_data(value, tomlfy=tomlfy)
         key = key.strip().upper()
-
-        if getattr(self, 'MERGE_ENABLED_FOR_DYNACONF', False):
-            attr = getattr(self, key, None)
-            object_merge(attr, value)
+        existing = getattr(self, key, None)
+        if existing is not None and existing != value:
+            value = self._merge_before_set(key, existing, value, is_secret)
 
         if isinstance(value, dict):
             value = DynaBox(value, box_it_up=True)
@@ -571,7 +571,7 @@ class Settings(object):
             self._defaults[key] = value
 
     def update(self, data=None, loader_identifier=None,
-               tomlfy=False, **kwargs):
+               tomlfy=False, is_secret=False, **kwargs):
         """
         Update values in the current settings object without saving in stores::
 
@@ -594,7 +594,72 @@ class Settings(object):
         data.update(kwargs)
         for key, value in data.items():
             self.set(key, value, loader_identifier=loader_identifier,
-                     tomlfy=tomlfy)
+                     tomlfy=tomlfy, is_secret=is_secret)
+
+    def _merge_before_set(self, key, existing, value, is_secret):
+        """Merge the new value being set with the existing value before set"""
+
+        def _log_before_merging(_value):
+            self.logger.debug(
+                'Merging existing %s: %s with new: %s',
+                key,
+                existing,
+                _value
+            )
+
+        def _log_after_merge(_value):
+            self.logger.debug(
+                '%s merged to %s',
+                key,
+                _value
+            )
+
+        global_merge = getattr(self, 'MERGE_ENABLED_FOR_DYNACONF', False)
+
+        if isinstance(value, dict):
+            local_merge = value.pop(
+                'dynaconf_merge',
+                value.pop('dynaconf_merge_unique', None)
+            )
+            if global_merge or local_merge:
+                safe_value = {k: '***' for k in value} if is_secret else value
+                _log_before_merging(safe_value)
+                object_merge(existing, value)
+                safe_value = {
+                    k: ('***' if k in safe_value else v)
+                    for k, v
+                    in value.items()
+                } if is_secret else value
+                _log_after_merge(safe_value)
+
+        if isinstance(value, (list, tuple)):
+            local_merge = (
+                'dynaconf_merge' in value or 'dynaconf_merge_unique' in value
+            )
+            if global_merge or local_merge:
+                value = list(value)
+                unique = False
+
+                if local_merge:
+                    try:
+                        value.remove('dynaconf_merge')
+                    except ValueError:  # EAFP
+                        value.remove('dynaconf_merge_unique')
+                        unique = True
+
+                original = set(value)
+                _log_before_merging(
+                    ['***' for item in value] if is_secret else value
+                )
+                object_merge(existing, value, unique=unique)
+                safe_value = [
+                    '***' if item in original else item
+                    for item
+                    in value
+                ] if is_secret else value
+                _log_after_merge(safe_value)
+
+        return value
 
     @property
     def loaders(self):  # pragma: no cover
diff --git a/dynaconf/loaders/base.py b/dynaconf/loaders/base.py
index 8bc2c87..d2139b4 100644
--- a/dynaconf/loaders/base.py
+++ b/dynaconf/loaders/base.py
@@ -1,5 +1,6 @@
 # coding: utf-8
 import io
+import os
 from dynaconf.utils.files import find_file
 from dynaconf.utils import raw_logger
 
@@ -144,16 +145,25 @@ class BaseLoader(object):
                 if key:
                     key = key.upper()
 
-                if not key:
-                    self.obj.update(data, loader_identifier=identifier)
-                elif key in data:
-                    self.obj.set(key, data.get(key),
-                                 loader_identifier=identifier)
+                is_secret = 'secret' in source_file
 
                 self.obj.logger.debug(
-                    '{}_loader: {}: {}'.format(
+                    '{}_loader: {}[{}]{}'.format(
                         self.identifier,
+                        os.path.split(source_file)[-1],
                         env.lower(),
-                        list(data.keys()) if 'secret' in source_file else data
+                        list(data.keys()) if is_secret else data
                     )
                 )
+
+                if not key:
+                    self.obj.update(
+                        data, loader_identifier=identifier, is_secret=is_secret
+                    )
+                elif key in data:
+                    self.obj.set(
+                        key,
+                        data.get(key),
+                        loader_identifier=identifier,
+                        is_secret=is_secret
+                    )
diff --git a/dynaconf/utils/__init__.py b/dynaconf/utils/__init__.py
index 87b4c88..28115d5 100644
--- a/dynaconf/utils/__init__.py
+++ b/dynaconf/utils/__init__.py
@@ -16,14 +16,18 @@ if os.name == 'nt':  # pragma: no cover
     BANNER = "DYNACONF"
 
 
-def object_merge(old, new):
+def object_merge(old, new, unique=False):
     """
-    Recursively merge two data structures
+    Recursively merge two data structures.
+
+    :param unique: When set to True existing list items are not set.
     """
     if isinstance(old, list) and isinstance(new, list):
         if old == new:
             return
         for item in old[::-1]:
+            if unique and item in new:
+                continue
             new.insert(0, item)
     if isinstance(old, dict) and isinstance(new, dict):
         for key, value in old.items():
diff --git a/example/dynaconf_merge/.env b/example/dynaconf_merge/.env
new file mode 100644
index 0000000..cf4df8e
--- /dev/null
+++ b/example/dynaconf_merge/.env
@@ -0,0 +1,1 @@
+# DYNACONF_DATABASE="{host='gotcha.com', dynaconf_merge=true}"
diff --git a/example/dynaconf_merge/.secrets.toml b/example/dynaconf_merge/.secrets.toml
new file mode 100644
index 0000000..83bcbd9
--- /dev/null
+++ b/example/dynaconf_merge/.secrets.toml
@@ -0,0 +1,7 @@
+[development]
+database = {passwd='1234', dynaconf_merge=true}
+plugins = ['secret', 'dynaconf_merge']
+scripts = ['encrypt.sh', 'dynaconf_merge_unique']
+
+[production]
+database = {passwd='666', dynaconf_merge=true}
diff --git a/example/dynaconf_merge/app.py b/example/dynaconf_merge/app.py
new file mode 100644
index 0000000..f443301
--- /dev/null
+++ b/example/dynaconf_merge/app.py
@@ -0,0 +1,13 @@
+from dynaconf import settings
+
+assert settings.DATABASE == {
+    'host': 'dev.server.com',
+    'user': 'dev',
+    'port': 666,
+    'passwd': '1234'
+}, settings.DATABASE
+
+
+assert settings.PLUGINS == [
+    'core', 'debug_toolbar', 'secret'
+], settings.PLUGINS
diff --git a/example/dynaconf_merge/settings.toml b/example/dynaconf_merge/settings.toml
new file mode 100644
index 0000000..ca53a1f
--- /dev/null
+++ b/example/dynaconf_merge/settings.toml
@@ -0,0 +1,12 @@
+[default]
+database = {host='server.com', port=666}
+plugins = ['core']
+scripts = ['install.sh', 'deploy.sh']
+
+[development]
+database = {host='dev.server.com', user='dev', dynaconf_merge=true}
+plugins = ['debug_toolbar', 'dynaconf_merge']
+scripts = ['dev.sh', 'test.sh', 'deploy.sh', 'dynaconf_merge_unique']
+
+[production]
+database = {user='prod', dynaconf_merge=true}


Test patch:
diff --git a/tests/test_base.py b/tests/test_base.py
index de091d0..7b3a00a 100644
--- a/tests/test_base.py
+++ b/tests/test_base.py
@@ -177,7 +177,7 @@ def test_set(settings):
     assert settings.get("BAZ") == "bar"
 
 
-def test_set_merge(settings):
+def test_global_set_merge(settings):
     settings.set("MERGE_ENABLED_FOR_DYNACONF", True)
     settings.set("MERGE_KEY", {
         "items": [{"name": "item 1"}, {"name": "item 2"}]
@@ -192,12 +192,50 @@ def test_set_merge(settings):
         ]
     }
 
-def test_merge_shortcut(settings):
+
+def test_global_merge_shortcut(settings):
     settings.set("MERGE_ENABLED_FOR_DYNACONF", True)
     settings.set("MERGE_KEY", ["item1"])
     settings.set("MERGE_KEY", ["item1"])
     assert settings.MERGE_KEY == ["item1"]
 
+
+def test_local_set_merge_dict(settings):
+    settings.set("DATABASE", {"host": "localhost", "port": 666})
+    # calling twice  does not change anything
+    settings.set("DATABASE", {"host": "localhost", "port": 666})
+    assert settings.DATABASE == {"host": "localhost", "port": 666}
+
+    settings.set(
+        "DATABASE",
+        {"host": "new", "user": "admin", "dynaconf_merge": True}
+    )
+    assert settings.DATABASE == {'host': 'new', 'port': 666, 'user': 'admin'}
+    assert settings.DATABASE.HOST == 'new'
+    assert settings.DATABASE.user == 'admin'
+
+
+def test_local_set_merge_list(settings):
+    settings.set("PLUGINS", ["core"])
+    settings.set("PLUGINS", ["core"])
+    assert settings.PLUGINS == ["core"]
+
+    settings.set("PLUGINS", ["debug_toolbar", "dynaconf_merge"])
+    assert settings.PLUGINS == ["core", "debug_toolbar"]
+
+
+def test_local_set_merge_list_unique(settings):
+    settings.set("SCRIPTS", ['install.sh', 'deploy.sh'])
+    settings.set("SCRIPTS", ['install.sh', 'deploy.sh'])
+    assert settings.SCRIPTS == ['install.sh', 'deploy.sh']
+
+    settings.set(
+        "SCRIPTS",
+        ['dev.sh', 'test.sh', 'deploy.sh', 'dynaconf_merge_unique']
+    )
+    assert settings.SCRIPTS == ['install.sh', 'dev.sh', 'test.sh', 'deploy.sh']
+
+
 def test_exists(settings):
     settings.set('BOOK', 'TAOCP')
     assert settings.exists('BOOK') is True
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 7c39abd..b70a696 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -4,7 +4,7 @@ import os
 import pytest
 import tempfile
 from dynaconf import default_settings
-from dynaconf.utils import missing, Missing
+from dynaconf.utils import missing, Missing, object_merge
 from dynaconf.utils.parse_conf import unparse_conf_data, parse_conf_data
 from dynaconf.utils.files import find_file
 
@@ -109,3 +109,33 @@ def test_missing_sentinel():
     assert bool(missing) is False
 
     assert str(missing) == '<dynaconf.missing>'
+
+
+def test_merge_existing_list():
+    existing = ['bruno', 'karla']
+    object_merge(existing, existing)
+    # calling twice the same object does not duplicate
+    assert existing == ['bruno', 'karla']
+
+    new = ['erik', 'bruno']
+    object_merge(existing, new)
+    assert new == ['bruno', 'karla', 'erik', 'bruno']
+
+
+def test_merge_existing_list_unique():
+    existing = ['bruno', 'karla']
+    new = ['erik', 'bruno']
+    object_merge(existing, new, unique=True)
+    assert new == ['karla', 'erik', 'bruno']
+
+
+def test_merge_existing_dict():
+    existing = {'host': 'localhost', 'port': 666}
+    new = {'user': 'admin'}
+
+    # calling with same data has no effect
+    object_merge(existing, existing)
+    assert existing == {'host': 'localhost', 'port': 666}
+
+    object_merge(existing, new)
+    assert new == {'host': 'localhost', 'port': 666, 'user': 'admin'}