Repository: rochacbruno/dynaconf (python)

Issue:
[RFC] Better merging standards
Right now There are some ways for merging existing data structures in Dynaconf

- Using `dynaconf_merge` mark
- Using `__` double underlines for existing dictionaries and nested data
- Using MERGE_ENABLED_FOR_DYNACONF var to merge everything globally

All the 3 existing ways has limitations.

- `dynaconf_merge` is limited because it works only for 1st level vars
- `__` works only for data nested under a dictionary
- MERGE_ENABLED can break stuff in Django config.

# New standards for merging.

> **NOTE** All the above will keep working, we will try to not break backwards compatibility.

Although we are going to explicitly recommend only the new standards on explicit ways for merging that meets:

- Optional Granular control of which data is being merged
- Control of global merge feature per file
- Works also via environment variables
- Merge at any level in the nested structures
- Can merge any data type like dicts and lists

# The merge mark

Historically dynaconf is being using `@marks` for some special environment variables, we will introduce a new one: `@merge` for env vars and improve the existing `dynaconf_merge` for regular files.

## Merging files.

A `settings.yaml` file exists in your project:

```yaml
default:
  name: Bruno
  colors:
    - red
    - green
  data:
    links:
        twitter:  rochacbruno
        site: brunorocha.org
```

which will  add to `settings.__dict__` object data like:

```py
{
  "NAME": "Bruno",
  "COLORS": ["red", "green"],
  "DATA": {"links": {"twitter": "rochacbruno", "site": "brunorocha.org"}}
}
```

In a `settings.local.yaml` we want now to contribute to that existing data we have 2 options

### Merge the whole file

adding a `dynaconf_merge: true` to the root level of the file.

`settings.local.yaml`
```yaml
dynaconf_merge: true
default:
  colors:
    - blue
  data:
    links:
        github: rochacbruno.github.io
```

Then `settings.__dict__`

```py
{
  "NAME": "Bruno",
  "COLORS": ["red", "green", "blue"],
  "DATA": {"links": {"twitter": "rochacbruno", "site": "brunorocha.org", "github": "rochacbruno.github.io"}}
}
```

### Granular control of which variable is being merged

If we want to merge only specific variables we have to enclose data under a `dynaconf_merge` key, for example we want to override everything but we want to merge the colors.

`settings.local.yaml`
```yaml
default:
  name: Other Name
  colors:
    dynaconf_merge:
      - yellow
      - pink
  data:
    links:
        site: other.com
```

if it was `toml`

```toml
[default]
name = "Other Name"

[default.colors]
dynaconf_merge = ["yellow","pink"]

[default.data.links]
site = "other.com"
```

So dynaconf will do pre-processing of every file and for each `dynaconf_merge` it will call the merge pipeline.

Result on `settings.__dict__`
```py
{
  "NAME": "Other Name",  # <-- overwritten
  "COLORS": ["red", "green", "yellow", "pink"],  # <-- merged
  "DATA": {"links": {"site": "other.com"}}  # <-- overwritten
}
```
## Dunder merging

Dunder merging continues to work as a shortcut when the data you want to merge is behind a nested datastructure

```yaml
default:
  data__links__telegram: t.me/rochacbruno  # <-- Calls set('data.links', "t.me/rochacbruno")
```
or `toml`
```toml
[default]
data__links__telegram = "t.me/rochacbruno"  # <-- Calls set('data.links', "t.me/rochacbruno")
```

or `.env`

```bash
export DYNACONF_DATA__links__telegram="t.me/rochacbruno"  # <-- Calls set('data.links', "t.me/rochacbruno")
```

## @merge mark

For envvars the `@merge` will mark as it follows the standard for the other envvar markers.
and it can also be used in regular files

`.yaml`
```yaml
default:
  name: Jon # <-- Overwritten
  colors: "@merge blue"   # <-- Calls .append('blue')
  colors: "@merge ['blue', 'white']" # <-- Calls .extend(['blue', 'white'])
```

`.env`

```bash
export DYNACONf_NAME=Erik   # <-- Overwritten

export DYNACONF_COLORS="@merge ['blue']"   # <-- Calls .extend(['blue'])
export DYNACONF_COLORS="@merge ['blue', 'white']"  # <-- Calls .extend(['blue', 'white'])
export DYNACONF_COLORS="@merge blue"  # <-- Calls .append('blue')
export DYNACONF_COLORS="@merge blue,white"  # <-- Calls .extend('blue,white'.split(','))

export DYNACONF_DATA="@merge {foo='bar'}"  # <-- Calls .update({"foo": "bar"})
export DYNACONF_DATA="@merge foo=bar"  # <-- Calls ['foo'] = "bar"
```

 

Pull request description:
Fix #241 new merge standards
Adds `dynaconf_merge` and `@merge` for better merge standards. ref #241 

Patch:
diff --git a/Makefile b/Makefile
index b2738bc..f95bcd6 100644
--- a/Makefile
+++ b/Makefile
@@ -15,7 +15,8 @@ test_examples:
 	cd example/app;pwd;python app.py
 	cd example/dunder;pwd;python app.py
 	cd example/app_with_dotenv;pwd;python app.py
-	cd example/merge_configs;pwd;python app.py
+	cd example/merge_enabled;pwd;python app.py
+	cd example/new_merge;pwd;python app.py
 	cd example/dynaconf_merge;pwd;python app.py
 	cd example/multiple_sources;pwd;python app.py
 	cd example/multiple_folders;pwd;python app.py
@@ -51,7 +52,7 @@ test_examples:
 	python example/compat.py
 	python example/app/app.py
 	python example/app_with_dotenv/app.py
-	python example/merge_configs/app.py
+	python example/merge_enabled/app.py
 	python example/dynaconf_merge/app.py
 	python example/multiple_sources/app.py
 	python example/multiple_folders/app.py
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index d41c5a6..5f0090e 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -268,7 +268,8 @@ jobs:
       pushd example\ & cd & python compat.py & popd
       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\merge_enabled & cd & python app.py & popd
+      pushd example\new_merge & 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
diff --git a/docs/guides/environment_variables.md b/docs/guides/environment_variables.md
index 2b9d907..5c92425 100644
--- a/docs/guides/environment_variables.md
+++ b/docs/guides/environment_variables.md
@@ -223,11 +223,32 @@ database = {host="server.com", user="default"}
 database = {user="dev_user", dynaconf_merge=true}
 ```
 
-In an environment variable:
+or
+
+> New in **2.2.0**
+
+```cfg
+[default]
+database = {host="server.com", user="default"}
+
+[development.database]
+dynaconf_merge = {user="dev_user"}
+```
+
+In an environment variable use `@merge` token:
+
+> **New in 2.2.0**
+
+```bash
+# Toml formatted envvar
+export DYNACONF_DATABASE='@merge {password=1234}'
+```
+
+or `dunder` (recommended)
 
 ```bash
 # Toml formatted envvar
-export DYNACONF_DATABASE='{password=1234, dynaconf_merge=true}'
+export DYNACONF_DATABASE__PASSWORD=1234
 ```
 
 The end result will be on `[development]` env:
diff --git a/docs/guides/usage.md b/docs/guides/usage.md
index cfbec65..0464c6b 100644
--- a/docs/guides/usage.md
+++ b/docs/guides/usage.md
@@ -202,6 +202,8 @@ The order can be changed by overriding the `SETTINGS_FILE_FOR_DYNACONF` the `COR
 
 ## Local configuration files and merging to existing data
 
+> New in **2.2.0**
+
 This feature is useful for maintaining a shared set of config files for a team, while still allowing for local configuration.
 
 Any file matched by the glob `*.local.*` will be read at the end of file loading order. So it is possible to have local settings files that are for example not committed to the version controlled repository. (e:g add `**/*.local*` to your `.gitignore`)
@@ -220,7 +222,7 @@ parameters = {enabled=true, number=42}
 [default]
 password = 1234
 
-# settings.local.yaml  # <-- 3rd loaded  (overrides previous existing vars)
+# settings.local.toml  # <-- 3rd loaded  (overrides previous existing vars)
 [default]
 colors = ["pink"]
 parameters = {enabled=false}
@@ -237,12 +239,44 @@ settings.PASSWORD == 9999
 
 For each loaded file dynaconf will `override` previous existing keys so if you want to `append` new values to existing variables you can use 3 strategies.
 
+### Mark the local file to be entirely merged
+
+> New in **2.2.0**
+
+```ini
+# settings.local.toml
+dynaconf_merge = true
+[default]
+colors = ["pink"]
+parameters = {enabled=false}
+```
+
+By adding `dynaconf_merge` to the top root of the file mark entire file to be merged.
+
+And then the values will be updated in to existing data structures.
+
+```python
+settings.COLORS == ["pink", "green", "blue"]
+settings.PARAMETERS == {"enabled": False, "number": 42}
+settings.PASSWORD == 9999
+```
+
+You can also mark a single `env` like `[development]` to be merged.
+
+```ini
+# settings.local.toml
+[development]
+dynaconf_merge = true
+colors = ["pink"]
+parameters = {enabled=false}
+```
+
 ### dynaconf merge token
 
 ```ini
-# settings.local.yaml
+# settings.local.toml
 [default]
-names = ["Karla", "dynaconf_merge"]
+colors = ["pink", "dynaconf_merge"]
 parameters = {enabled=false, dynaconf_merge=true}
 ```
 
@@ -256,9 +290,22 @@ settings.PARAMETERS == {"enabled": False, "number": 42}
 settings.PASSWORD == 9999
 ```
 
+> New in **2.2.0**
+
+And it also works having `dynaconf_merge` as dict keys holding the value to be merged.
+
+```ini
+# settings.local.toml
+[default.colors]
+dynaconf_merge = ["pink"]  # <-- value ["pink"] will be merged in to existing colors
+
+[default.parameters]
+dynaconf_merge = {enabled=false}
+```
+
 ### Dunder merging for nested structures
 
-For nested structures the recommendation is to use dunder mergin because it it easier to read and also it has no limitations in terms of nesting levels.
+For nested structures the recommendation is to use dunder merging because it it easier to read and also it has no limitations in terms of nesting levels.
 
 ```ini
 # settings.local.yaml
@@ -462,7 +509,7 @@ 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:
+Now you want to contribute to the same `DATABASE` key by adding new keys, so you can use `dynaconf_merge` at the end of your dict:
 
 In specific `[envs]`
 
@@ -477,14 +524,36 @@ database = {user="dev_user", dynaconf_merge=true}
 database = {user="prod_user", dynaconf_merge=true}
 ```
 
+also allowed the alternative short format
+
+```cfg
+[default]
+database = {host="server.com", user="default"}
+
+[development.database]
+dynaconf_merge = {user="dev_user"}
+
+[production.database]
+dynaconf_merge = {user="prod_user"}
+```
+
 In an environment variable:
 
+Using `@merge` mark
+
 ```bash
 # Toml formatted envvar
-export DYNACONF_DATABASE='{password=1234, dynaconf_merge=true}'
+export DYNACONF_DATABASE='@merge {password=1234}'
 ```
 
-It is also possible to use nested  `dunder` traversal like:
+or `@merge` mark short format
+
+```bash
+# Toml formatted envvar
+export DYNACONF_DATABASE='@merge password=1234'
+```
+
+It is also possible to use nested `dunder` traversal like:
 
 ```bash
 export DYNACONF_DATABASE__password=1234
@@ -503,6 +572,13 @@ DATABASE = {'password': 1234, 'user': 'admin', 'ARGS': {'timeout': 30, 'retries'
 
 > **IMPORTANT** lower case keys are respected only on *nix systems, unfortunately Windows environment variables are case insensitive and Python reads it as all upper cases, that means that if you are running on Windows the dictionary can have only upper case keys.
 
+You can also export a toml dictionary.
+
+```bash
+# Toml formatted envvar
+export DYNACONF_DATABASE='{password=1234, dynaconf_merge=true}'
+```
+
 Or in an additional file (e.g `settings.yaml, .secrets.yaml, etc`) by using `dynaconf_merge` token:
 
 ```yaml
@@ -512,6 +588,15 @@ default:
     dynaconf_merge: true
 ```
 
+or
+
+```yaml
+default:
+  database:
+    dynaconf_merge:
+      password: 1234
+```
+
 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 just a mark)
 
 The end result will be on `[development]` env:
@@ -532,8 +617,38 @@ plugins = ["core"]
 plugins = ["debug_toolbar", "dynaconf_merge"]
 ```
 
+or
+
+```cfg
+[default]
+plugins = ["core"]
+
+[development.plugins]
+dynaconf_merge = ["debug_toolbar"]
+```
+
 And in environment variable
 
+using `@merge` token
+
+```bash
+export DYNACONF_PLUGINS='@merge ["ci_plugin"]'
+```
+
+or short version
+
+```bash
+export DYNACONF_PLUGINS='@merge ci_plugin'
+```
+
+comma separated values also supported:
+
+```bash
+export DYNACONF_PLUGINS='@merge ci_plugin,other_plugin'
+```
+
+or explicitly
+
 ```bash
 export DYNACONF_PLUGINS='["ci_plugin", "dynaconf_merge"]'
 ```
@@ -544,6 +659,16 @@ Then the end result on `[development]` is:
 settings.PLUGINS == ["ci_plugin", "debug_toolbar", "core"]
 ```
 
+If your value is a dictionary:
+
+```bash
+export DYNACONF_DATA="@merge {foo='bar'}"
+
+# or the short
+
+export DYNACONF_DATA="@merge foo=bar"
+```
+
 ### Avoiding duplications on lists
 
 The `dynaconf_merge_unique` is the token for when you want to avoid duplications in a list.
@@ -572,8 +697,9 @@ settings.SCRIPTS == ['install.sh', 'dev.sh', 'test.sh', 'deploy.sh', 'run.sh']
 
 ### Known caveats
 
-The **dynaconf_merge** functionality works only for the first level keys, it will not merge subdicts or nested lists (yet).
+The **dynaconf_merge** and **@merge** functionalities works only for the first level keys, it will not merge subdicts or nested lists (yet).
 
+For deeper nested objects use [dunder merge](environment_variables.html#nested-keys-in-dictionaries-via-environment-variables).
 
 ### Global merge
 
diff --git a/dynaconf/base.py b/dynaconf/base.py
index 502edcb..1b6c885 100644
--- a/dynaconf/base.py
+++ b/dynaconf/base.py
@@ -623,22 +623,22 @@ class Settings(object):
         for key in keys:
             self.unset(key, force=force)
 
-    def _dotted_set(
-        self, dotted_key, value, tomlfy=False, merge=True, **kwargs
-    ):
+    def _dotted_set(self, dotted_key, value, tomlfy=False, **kwargs):
         """Sets dotted keys as nested dictionaries.
 
+        Dotted set will always merge existing data, to avoid merging the
+        @reset token should be used.
+
         Arguments:
             dotted_key {str} -- A traversal name e.g: foo.bar.zaz
             value {Any} -- The value to set to the nested value.
 
         Keyword Arguments:
             tomlfy {bool} -- Perform toml parsing (default: {False})
-            merge {bool} -- Merge existing dictionaries (default: {True})
         """
 
         split_keys = dotted_key.split(".")
-        existing_data = self.get(split_keys[0], {}) if merge else {}
+        existing_data = self.get(split_keys[0], {})
         new_data = DynaBox(default_box=True)
 
         tree = new_data
@@ -648,7 +648,7 @@ class Settings(object):
         value = parse_conf_data(value, tomlfy=tomlfy)
         tree[split_keys[-1]] = value
 
-        if existing_data and merge:
+        if existing_data:
             object_merge({split_keys[0]: existing_data}, new_data)
 
         self.update(data=new_data, tomlfy=tomlfy, **kwargs)
@@ -661,7 +661,7 @@ class Settings(object):
         tomlfy=False,
         dotted_lookup=True,
         is_secret=False,
-        merge=True,
+        merge=False,
     ):
         """Set a value storing references for the loader
 
@@ -670,7 +670,7 @@ 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)
         :param is_secret: Bool define if secret values is hidden on logs.
-        :param merge: Bool define if existing nested/dotted will be merged.
+        :param merge: Bool define if existing nested data will be merged.
         """
 
         nested_sep = self.get("NESTED_SEPARATOR_FOR_DYNACONF")
@@ -680,11 +680,7 @@ class Settings(object):
 
         if "." in key and dotted_lookup is True:
             return self._dotted_set(
-                key,
-                value,
-                loader_identifier=loader_identifier,
-                tomlfy=tomlfy,
-                merge=merge,
+                key, value, loader_identifier=loader_identifier, tomlfy=tomlfy
             )
 
         value = parse_conf_data(value, tomlfy=tomlfy)
@@ -699,8 +695,36 @@ class Settings(object):
             # just in case someone use a `@reset` in a first level var.
             value = value.value
 
+        if getattr(value, "dynaconf_merge", False):
+            # just in case someone use a `@merge` in a first level var
+
+            if isinstance(value.value, (int, float, bool)):
+                # @merge 1, @merge 1.1, @merge False
+                value.value = [value.value]
+            elif isinstance(value.value, str):
+                if "=" in value.value:
+                    # @merge foo=bar
+                    k, v = value.value.split("=")
+                    value.value = {k: v}
+                elif "," in value.value:
+                    # @merge foo,bar
+                    value.value = value.value.split(",")
+                else:
+                    # @merge foo
+                    value.value = [value.value]
+
+            if existing:
+                object_merge(existing, value.value)
+
+            value = value.value
+
         if existing is not None and existing != value:
-            value = self._merge_before_set(key, existing, value, is_secret)
+            # `dynaconf_merge` used in file root `merge=True`
+            if merge:
+                object_merge(existing, value)
+            else:
+                # `dynaconf_merge` may be used within the key structure
+                value = self._merge_before_set(key, existing, value, is_secret)
 
         if isinstance(value, dict):
             value = DynaBox(value, box_it_up=True)
@@ -725,6 +749,7 @@ class Settings(object):
         loader_identifier=None,
         tomlfy=False,
         is_secret=False,
+        merge=False,
         **kwargs
     ):
         """
@@ -742,6 +767,7 @@ class Settings(object):
         :param data: Data to be updated
         :param loader_identifier: Only to be used by custom loaders
         :param tomlfy: Bool define if value is parsed by toml (defaults False)
+        :param merge: Bool define if existing nested data will be merged.
         :param kwargs: extra values to update
         :return: None
         """
@@ -754,6 +780,7 @@ class Settings(object):
                 loader_identifier=loader_identifier,
                 tomlfy=tomlfy,
                 is_secret=is_secret,
+                merge=merge,
             )
 
     def _merge_before_set(self, key, existing, value, is_secret):
@@ -773,6 +800,10 @@ class Settings(object):
             local_merge = value.pop(
                 "dynaconf_merge", value.pop("dynaconf_merge_unique", None)
             )
+            if local_merge not in (True, False, None) and not value:
+                # In case `dynaconf_merge:` holds value not boolean - ref #241
+                value = local_merge
+
             if global_merge or local_merge:
                 safe_value = {k: "***" for k in value} if is_secret else value
                 _log_before_merging(safe_value)
diff --git a/dynaconf/loaders/base.py b/dynaconf/loaders/base.py
index f08ba76..a9073fc 100644
--- a/dynaconf/loaders/base.py
+++ b/dynaconf/loaders/base.py
@@ -104,6 +104,9 @@ class BaseLoader(object):
                 k.lower(): value for k, value in source_data.items()
             }
 
+            # is there a `dynaconf_merge` on top level of file?
+            file_merge = source_data.get("dynaconf_merge")
+
             # all lower case for comparison
             base_envs = [
                 # DYNACONF or MYPROGRAM
@@ -157,9 +160,15 @@ class BaseLoader(object):
                     )
                 )
 
+                # is there a `dynaconf_merge` inside an `[env]`?
+                file_merge = file_merge or data.pop("DYNACONF_MERGE", False)
+
                 if not key:
                     self.obj.update(
-                        data, loader_identifier=identifier, is_secret=is_secret
+                        data,
+                        loader_identifier=identifier,
+                        is_secret=is_secret,
+                        merge=file_merge,
                     )
                 elif key in data:
                     self.obj.set(
@@ -167,4 +176,5 @@ class BaseLoader(object):
                         data.get(key),
                         loader_identifier=identifier,
                         is_secret=is_secret,
+                        merge=file_merge,
                     )
diff --git a/dynaconf/loaders/py_loader.py b/dynaconf/loaders/py_loader.py
index 261c4f2..bc16a8d 100644
--- a/dynaconf/loaders/py_loader.py
+++ b/dynaconf/loaders/py_loader.py
@@ -32,6 +32,9 @@ def load(obj, settings_module, identifier="py", silent=False, key=None):
 def load_from_python_object(
     obj, mod, settings_module, key=None, identifier=None
 ):
+    file_merge = getattr(mod, "dynaconf_merge", False) or getattr(
+        mod, "DYNACONF_MERGE", False
+    )
     for setting in dir(mod):
         # at least 3 first chars should be upper to be considered a setting var
         if setting[:3].isupper():
@@ -43,7 +46,12 @@ def load_from_python_object(
                     "*****" if "secret" in settings_module else setting_value,
                     identifier,
                 )
-                obj.set(setting, setting_value, loader_identifier=identifier)
+                obj.set(
+                    setting,
+                    setting_value,
+                    loader_identifier=identifier,
+                    merge=file_merge,
+                )
     obj._loaded_files.append(mod.__file__)
 
 
diff --git a/dynaconf/utils/parse_conf.py b/dynaconf/utils/parse_conf.py
index b118c61..d9fc4c5 100644
--- a/dynaconf/utils/parse_conf.py
+++ b/dynaconf/utils/parse_conf.py
@@ -36,6 +36,12 @@ class Del(MetaValue):
     dynaconf_del = True
 
 
+class Merge(MetaValue):
+    """Triggers an existing key to be merged"""
+
+    dynaconf_merge = True
+
+
 converters = {
     "@int": int,
     "@float": float,
@@ -46,6 +52,7 @@ converters = {
     # Meta Values to trigger pre assignment actions
     "@reset": lambda value: Reset(value),
     "@del": lambda value: Del(value),
+    "@merge": lambda value: Merge(value),
     # Special markers to be used as placeholders e.g: in prefilled forms
     # will always return None when evaluated
     "@note": lambda value: None,
diff --git a/example/merge_configs/.env b/example/merge_enabled/.env
similarity index 100%
rename from example/merge_configs/.env
rename to example/merge_enabled/.env
diff --git a/example/merge_configs/app.py b/example/merge_enabled/app.py
similarity index 100%
rename from example/merge_configs/app.py
rename to example/merge_enabled/app.py
diff --git a/example/merge_configs/development_settings.py b/example/merge_enabled/development_settings.py
similarity index 100%
rename from example/merge_configs/development_settings.py
rename to example/merge_enabled/development_settings.py
diff --git a/example/merge_configs/global_settings.py b/example/merge_enabled/global_settings.py
similarity index 100%
rename from example/merge_configs/global_settings.py
rename to example/merge_enabled/global_settings.py
diff --git a/example/merge_configs/production_settings.py b/example/merge_enabled/production_settings.py
similarity index 100%
rename from example/merge_configs/production_settings.py
rename to example/merge_enabled/production_settings.py
diff --git a/example/merge_configs/settings.conf b/example/merge_enabled/settings.conf
similarity index 100%
rename from example/merge_configs/settings.conf
rename to example/merge_enabled/settings.conf
diff --git a/example/merge_configs/settings.ini b/example/merge_enabled/settings.ini
similarity index 100%
rename from example/merge_configs/settings.ini
rename to example/merge_enabled/settings.ini
diff --git a/example/merge_configs/settings.json b/example/merge_enabled/settings.json
similarity index 100%
rename from example/merge_configs/settings.json
rename to example/merge_enabled/settings.json
diff --git a/example/merge_configs/settings.properties b/example/merge_enabled/settings.properties
similarity index 100%
rename from example/merge_configs/settings.properties
rename to example/merge_enabled/settings.properties
diff --git a/example/merge_configs/settings.py b/example/merge_enabled/settings.py
similarity index 100%
rename from example/merge_configs/settings.py
rename to example/merge_enabled/settings.py
diff --git a/example/merge_configs/settings.tml b/example/merge_enabled/settings.tml
similarity index 100%
rename from example/merge_configs/settings.tml
rename to example/merge_enabled/settings.tml
diff --git a/example/merge_configs/settings.toml b/example/merge_enabled/settings.toml
similarity index 100%
rename from example/merge_configs/settings.toml
rename to example/merge_enabled/settings.toml
diff --git a/example/merge_configs/settings.yaml b/example/merge_enabled/settings.yaml
similarity index 100%
rename from example/merge_configs/settings.yaml
rename to example/merge_enabled/settings.yaml
diff --git a/example/merge_configs/settings.yml b/example/merge_enabled/settings.yml
similarity index 100%
rename from example/merge_configs/settings.yml
rename to example/merge_enabled/settings.yml
diff --git a/example/new_merge/.env b/example/new_merge/.env
new file mode 100644
index 0000000..cc0fa99
--- /dev/null
+++ b/example/new_merge/.env
@@ -0,0 +1,1 @@
+DYNACONF_FILES='@merge [".env"]'
diff --git a/example/new_merge/app.py b/example/new_merge/app.py
new file mode 100644
index 0000000..193979b
--- /dev/null
+++ b/example/new_merge/app.py
@@ -0,0 +1,26 @@
+from dynaconf import settings
+
+assert settings.PY_MERGE_LIST == [1, 2], settings.PY_MERGE_LIST
+assert settings.PY_MERGE_DICT == {"a": 1, "b": 2}, settings.PY_MERGE_DICT
+
+for filename in [
+    "settings.py",
+    "settings.local.py",
+    "settings.toml",
+    "settings.local.toml",
+    "settings.yaml",
+    "settings.local.yaml",
+    ".env",
+]:
+    assert filename in settings.FILES, settings.FILES
+
+
+assert settings.PERSON.name == "Bruno", settings.PERSON
+assert settings.PERSON.age == 36, settings.PERSON
+assert settings.PERSON.city == "Guarulhos", settings.PERSON
+assert settings.PERSON.country == "Brasil", settings.PERSON
+assert settings.PERSON.email == "bruno@example.com", settings.PERSON
+assert settings.PERSON.phone == "123-456", settings.PERSON
+
+print(settings.FILES)
+print(settings.PERSON)
diff --git a/example/new_merge/settings.local.py b/example/new_merge/settings.local.py
new file mode 100644
index 0000000..0eddf59
--- /dev/null
+++ b/example/new_merge/settings.local.py
@@ -0,0 +1,5 @@
+DYNACONF_MERGE = True
+FILES = ["settings.local.py"]
+PERSON = {"country": "Brasil"}
+PY_MERGE_LIST = [2]
+PY_MERGE_DICT = {"b": 2}
diff --git a/example/new_merge/settings.local.toml b/example/new_merge/settings.local.toml
new file mode 100644
index 0000000..715749a
--- /dev/null
+++ b/example/new_merge/settings.local.toml
@@ -0,0 +1,7 @@
+dynaconf_merge = true
+
+[default]
+files = ['settings.local.toml']
+
+[default.person]
+email = "bruno@example.com"
diff --git a/example/new_merge/settings.local.yaml b/example/new_merge/settings.local.yaml
new file mode 100644
index 0000000..2860d2f
--- /dev/null
+++ b/example/new_merge/settings.local.yaml
@@ -0,0 +1,6 @@
+default:
+  dynaconf_merge: true
+  files:
+    - settings.local.yaml
+  person:
+    phone: 123-456
diff --git a/example/new_merge/settings.py b/example/new_merge/settings.py
new file mode 100644
index 0000000..29e327f
--- /dev/null
+++ b/example/new_merge/settings.py
@@ -0,0 +1,4 @@
+FILES = ["settings.py"]
+PERSON = {"name": "Bruno"}
+PY_MERGE_LIST = [1]
+PY_MERGE_DICT = {"a": 1}
diff --git a/example/new_merge/settings.toml b/example/new_merge/settings.toml
new file mode 100644
index 0000000..f098e4c
--- /dev/null
+++ b/example/new_merge/settings.toml
@@ -0,0 +1,7 @@
+dynaconf_merge = true
+
+[default]
+files = ['settings.toml']
+
+[default.person]
+age = 36
diff --git a/example/new_merge/settings.yaml b/example/new_merge/settings.yaml
new file mode 100644
index 0000000..09b97b1
--- /dev/null
+++ b/example/new_merge/settings.yaml
@@ -0,0 +1,6 @@
+dynaconf_merge: true
+default:
+  files:
+    - settings.yaml
+  person:
+    city: Guarulhos


Test patch:
diff --git a/tests/test_base.py b/tests/test_base.py
index 28bfaaa..c2791e4 100644
--- a/tests/test_base.py
+++ b/tests/test_base.py
@@ -280,6 +280,193 @@ def test_local_set_merge_list_unique(settings):
     assert settings.SCRIPTS == ["install.sh", "dev.sh", "test.sh", "deploy.sh"]
 
 
+def test_set_explicit_merge_token(tmpdir):
+    data = {
+        "default": {
+            "a_list": [1, 2],
+            "b_list": [1],
+            "a_dict": {"name": "Bruno"},
+        }
+    }
+    toml_loader.write(str(tmpdir.join("settings.toml")), data, merge=False)
+    settings = LazySettings()
+    assert settings.A_LIST == [1, 2]
+    assert settings.B_LIST == [1]
+    assert settings.A_DICT == {"name": "Bruno"}
+    assert settings.A_DICT.name == "Bruno"
+
+    settings.set("a_list", [3], merge=True)
+    assert settings.A_LIST == [1, 2, 3]
+
+    settings.set("b_list", "@merge [2]")
+    assert settings.B_LIST == [1, 2]
+
+    settings.set("b_list", "@merge [3, 4]")
+    assert settings.B_LIST == [1, 2, 3, 4]
+
+    settings.set("b_list", "@merge 5")
+    assert settings.B_LIST == [1, 2, 3, 4, 5]
+
+    settings.set("b_list", "@merge 6.6")
+    assert settings.B_LIST == [1, 2, 3, 4, 5, 6.6]
+
+    settings.set("b_list", "@merge false")
+    assert settings.B_LIST == [1, 2, 3, 4, 5, 6.6, False]
+
+    settings.set("b_list", "@merge foo,bar")
+    assert settings.B_LIST == [1, 2, 3, 4, 5, 6.6, False, "foo", "bar"]
+
+    settings.set("b_list", "@merge zaz")
+    assert settings.B_LIST == [1, 2, 3, 4, 5, 6.6, False, "foo", "bar", "zaz"]
+
+    settings.set("a_dict", "@merge {city='Guarulhos'}")
+    assert settings.A_DICT.name == "Bruno"
+    assert settings.A_DICT.city == "Guarulhos"
+
+    settings.set("a_dict", "@merge country=Brasil")
+    assert settings.A_DICT.name == "Bruno"
+    assert settings.A_DICT.city == "Guarulhos"
+    assert settings.A_DICT.country == "Brasil"
+
+    settings.set("new_key", "@merge foo=bar")
+    assert settings.NEW_KEY == {"foo": "bar"}
+
+
+def test_set_new_merge_issue_241_1(tmpdir):
+    data = {
+        "default": {
+            "name": "Bruno",
+            "colors": ["red", "green"],
+            "data": {
+                "links": {"twitter": "rochacbruno", "site": "brunorocha.org"}
+            },
+        }
+    }
+    toml_loader.write(str(tmpdir.join("settings.toml")), data, merge=False)
+    settings = LazySettings()
+    assert settings.NAME == "Bruno"
+    assert settings.COLORS == ["red", "green"]
+    assert settings.DATA.links == {
+        "twitter": "rochacbruno",
+        "site": "brunorocha.org",
+    }
+
+
+def test_set_new_merge_issue_241_2(tmpdir):
+    data = {
+        "default": {
+            "name": "Bruno",
+            "colors": ["red", "green"],
+            "data": {
+                "links": {"twitter": "rochacbruno", "site": "brunorocha.org"}
+            },
+        }
+    }
+    toml_loader.write(str(tmpdir.join("settings.toml")), data, merge=False)
+
+    data = {
+        "dynaconf_merge": True,
+        "default": {
+            "colors": ["blue"],
+            "data": {"links": {"github": "rochacbruno.github.io"}},
+        },
+    }
+    toml_loader.write(
+        str(tmpdir.join("settings.local.toml")), data, merge=False
+    )
+
+    settings = LazySettings()
+    assert settings.NAME == "Bruno"
+    assert settings.COLORS == ["red", "green", "blue"]
+    assert settings.DATA.links == {
+        "twitter": "rochacbruno",
+        "site": "brunorocha.org",
+        "github": "rochacbruno.github.io",
+    }
+
+
+def test_set_new_merge_issue_241_3(tmpdir):
+    data = {
+        "default": {
+            "name": "Bruno",
+            "colors": ["red", "green"],
+            "data": {
+                "links": {"twitter": "rochacbruno", "site": "brunorocha.org"}
+            },
+        }
+    }
+    toml_loader.write(str(tmpdir.join("settings.toml")), data, merge=False)
+
+    data = {
+        "default": {
+            "name": "Tommy Shelby",
+            "colors": {"dynaconf_merge": ["yellow", "pink"]},
+            "data": {"links": {"site": "pb.com"}},
+        }
+    }
+    toml_loader.write(
+        str(tmpdir.join("settings.local.toml")), data, merge=False
+    )
+
+    settings = LazySettings()
+    assert settings.NAME == "Tommy Shelby"
+    assert settings.COLORS == ["red", "green", "yellow", "pink"]
+    assert settings.DATA.links == {"site": "pb.com"}
+
+
+def test_set_new_merge_issue_241_4(tmpdir):
+    data = {
+        "default": {
+            "name": "Bruno",
+            "colors": ["red", "green"],
+            "data": {
+                "links": {"twitter": "rochacbruno", "site": "brunorocha.org"}
+            },
+        }
+    }
+    toml_loader.write(str(tmpdir.join("settings.toml")), data, merge=False)
+
+    data = {"default": {"data__links__telegram": "t.me/rochacbruno"}}
+    toml_loader.write(
+        str(tmpdir.join("settings.local.toml")), data, merge=False
+    )
+
+    settings = LazySettings()
+    assert settings.NAME == "Bruno"
+    assert settings.COLORS == ["red", "green"]
+    assert settings.DATA.links == {
+        "twitter": "rochacbruno",
+        "site": "brunorocha.org",
+        "telegram": "t.me/rochacbruno",
+    }
+
+
+def test_set_new_merge_issue_241_5(tmpdir):
+    data = {
+        "default": {
+            "name": "Bruno",
+            "colors": ["red", "green"],
+            "data": {
+                "links": {"twitter": "rochacbruno", "site": "brunorocha.org"}
+            },
+        }
+    }
+    toml_loader.write(str(tmpdir.join("settings.toml")), data, merge=False)
+
+    data = {"default": {"colors": "@merge ['blue']"}}
+    toml_loader.write(
+        str(tmpdir.join("settings.local.toml")), data, merge=False
+    )
+
+    settings = LazySettings()
+    assert settings.NAME == "Bruno"
+    assert settings.COLORS == ["red", "green", "blue"]
+    assert settings.DATA.links == {
+        "twitter": "rochacbruno",
+        "site": "brunorocha.org",
+    }
+
+
 def test_exists(settings):
     settings.set("BOOK", "TAOCP")
     assert settings.exists("BOOK") is True
@@ -375,20 +562,6 @@ def test_dotted_set(settings):
         "nested_2": {"nested_3": {"nested_4": "Updated Secret"}},
         "nested_2_0": "Hello",
     }
-    settings.set(
-        "nested_1.nested_2.nested_3.nested_4", "New Stuff", merge=False
-    )
-    assert settings.NESTED_1.NESTED_2.NESTED_3.NESTED_4 == "New Stuff"
-    assert settings.NESTED_1.NESTED_2.NESTED_3 == {"nested_4": "New Stuff"}
-    assert settings.NESTED_1.NESTED_2 == {
-        "nested_3": {"nested_4": "New Stuff"}
-    }
-    assert settings.NESTED_1 == {
-        "nested_2": {"nested_3": {"nested_4": "New Stuff"}}
-    }
-    assert settings.get("nested_1").to_dict() == {
-        "nested_2": {"nested_3": {"nested_4": "New Stuff"}}
-    }
 
 
 def test_dotted_set_with_merge(settings):