Repository: dynaconf/dynaconf

Issue:
dynaconf_merge = false supported?
Hello, I would like to know if it is possible to disable merging for some keys of a configs. In our case, we have a bunch of config files (~30) and merging them is the usual behavior. We do this through the `Dynaconf` parameter `merge_enabled`. Now, in a few cases the merging leads to undesired behavior and it would be nice to turn off merging for these specific config keys. Is it possible to do this easily without changing up the default merge behavior and touch all the config files? I tried `dynaconf_merge = false` as a property but this behavior doesn't seem to be supported.

Example:
`1.toml`
```toml
[general]
a = 1

[other]
a = 2
```

`2.toml`
```toml
[general]
b = 1

[other]
dynaconf_merge = false
b = 2
```

`config.py`
```python
from dynaconf import Dynaconf

settings = Dynaconf(
    settings_files=["1.toml", "2.toml"],
    merge_enabled=True,
)

print(settings.to_dict())
```

Output:
```
{'GENERAL': {'b': 1, 'a': 1}, 'OTHER': {'b': 2, 'a': 2}}
```

Expected Output:
```
{'GENERAL': {'b': 1, 'a': 1}, 'OTHER': {'b': 2}}
```

Thanks for your time.


Discussion:
@westphal-jan this should be supported, lets add this to the next version.
Related feature idea (about merging strategies): allow global dict only or list only merging.
https://github.com/dynaconf/dynaconf/issues/644#issuecomment-1496235531

Resolving patch:
diff --git a/dynaconf/base.py b/dynaconf/base.py
--- a/dynaconf/base.py
+++ b/dynaconf/base.py
@@ -886,7 +886,7 @@ def set(
         dotted_lookup=empty,
         is_secret="DeprecatedArgument",  # noqa
         validate=empty,
-        merge=False,
+        merge=empty,
     ):
         """Set a value storing references for the loader
 
@@ -971,7 +971,7 @@ def set(
 
         if existing is not None and existing != value:
             # `dynaconf_merge` used in file root `merge=True`
-            if merge:
+            if merge and merge is not empty:
                 loader_identifier = (
                     loader_identifier._replace(merged=True)
                     if loader_identifier
@@ -989,7 +989,7 @@ def set(
                     # `dynaconf_merge` may be used within the key structure
                     # Or merge_enabled is set to True
                     value, updated_identifier = self._merge_before_set(
-                        existing, value, loader_identifier
+                        existing, value, loader_identifier, context_merge=merge
                     )
                     loader_identifier = updated_identifier
 
@@ -1018,7 +1018,7 @@ def update(
         data=None,
         loader_identifier=None,
         tomlfy=False,
-        merge=False,
+        merge=empty,
         is_secret="DeprecatedArgument",  # noqa
         dotted_lookup=empty,
         validate=empty,
@@ -1070,13 +1070,20 @@ def update(
             self.validators.validate_all()
 
     def _merge_before_set(
-        self, existing, value, identifier: SourceMetadata | None = None
+        self,
+        existing,
+        value,
+        identifier: SourceMetadata | None = None,
+        context_merge=empty,
     ):
         """
         Merge the new value being set with the existing value before set
         Returns the merged value and the updated identifier (for inspecting).
         """
-        global_merge = getattr(self, "MERGE_ENABLED_FOR_DYNACONF", False)
+        # context_merge may come from file_scope or env_scope
+        if context_merge is empty:
+            context_merge = self.get("MERGE_ENABLED_FOR_DYNACONF")
+
         if isinstance(value, dict):
             local_merge = value.pop(
                 "dynaconf_merge", value.pop("dynaconf_merge_unique", None)
@@ -1085,25 +1092,25 @@ def _merge_before_set(
                 # In case `dynaconf_merge:` holds value not boolean - ref #241
                 value = local_merge
 
-            if global_merge or local_merge:
+            if local_merge or (context_merge and local_merge is not False):
                 identifier = (
                     identifier._replace(merged=True) if identifier else None
                 )
                 value = object_merge(existing, 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
+            value = list(value)
+            local_merge = None
+            unique = False
+            if "dynaconf_merge" in value:
+                value.remove("dynaconf_merge")
+                local_merge = True
+            elif "dynaconf_merge_unique" in value:
+                value.remove("dynaconf_merge_unique")
+                local_merge = True
+                unique = True
+
+            if local_merge or (context_merge and local_merge is not False):
                 identifier = (
                     identifier._replace(merged=True) if identifier else None
                 )
diff --git a/dynaconf/loaders/base.py b/dynaconf/loaders/base.py
--- a/dynaconf/loaders/base.py
+++ b/dynaconf/loaders/base.py
@@ -7,6 +7,7 @@
 from dynaconf.utils import build_env_list
 from dynaconf.utils import ensure_a_list
 from dynaconf.utils import upperfy
+from dynaconf.utils.functional import empty
 
 
 class BaseLoader:
@@ -54,7 +55,7 @@ def warn_not_installed(obj, identifier):  # pragma: no cover
             )
         obj._not_installed_warnings.append(identifier)
 
-    def load(self, filename=None, key=None, silent=True):
+    def load(self, filename=None, key=None, silent=True, merge=empty):
         """
         Reads and loads in to `self.obj` a single key or all keys from source
 
@@ -112,6 +113,9 @@ def get_source_data(self, files):
     def _envless_load(self, source_data, silent=True, key=None):
         """Load all the keys from each file without env separation"""
         for file_name, file_data in source_data.items():
+            # is there a `dynaconf_merge` on top level of file?
+            file_merge = file_data.get("dynaconf_merge", empty)
+
             # set source metadata
             source_metadata = SourceMetadata(
                 self.identifier, file_name, "default"
@@ -120,6 +124,7 @@ def _envless_load(self, source_data, silent=True, key=None):
             self._set_data_to_obj(
                 file_data,
                 source_metadata,
+                file_merge=file_merge,
                 key=key,
             )
 
@@ -135,12 +140,11 @@ def _load_all_envs(self, source_data, silent=True, key=None):
             }
         """
         for file_name, file_data in source_data.items():
-
             # env name is checked in lower
             file_data = {k.lower(): value for k, value in file_data.items()}
 
             # is there a `dynaconf_merge` on top level of file?
-            file_merge = file_data.get("dynaconf_merge")
+            file_merge = file_data.get("dynaconf_merge", empty)
 
             # is there a flag disabling dotted lookup on file?
             file_dotted_lookup = file_data.get("dynaconf_dotted_lookup")
@@ -151,7 +155,7 @@ def _load_all_envs(self, source_data, silent=True, key=None):
 
                 # set source metadata
                 source_metadata = SourceMetadata(
-                    self.identifier, file_name, env, bool(file_merge)
+                    self.identifier, file_name, env
                 )
 
                 try:
@@ -176,7 +180,7 @@ def _set_data_to_obj(
         self,
         data,
         identifier: SourceMetadata,
-        file_merge=None,
+        file_merge=empty,
         key=False,
         file_dotted_lookup=None,
     ):
@@ -190,7 +194,9 @@ def _set_data_to_obj(
             data = self.obj.filter_strategy(data)
 
         # is there a `dynaconf_merge` inside an `[env]`?
-        file_merge = file_merge or data.pop("DYNACONF_MERGE", False)
+        env_scope_merge = data.pop("DYNACONF_MERGE", None)
+        if env_scope_merge is not None:
+            file_merge = env_scope_merge
 
         # If not passed or passed as None,
         # look for inner [env] value, or default settings.
diff --git a/dynaconf/loaders/py_loader.py b/dynaconf/loaders/py_loader.py
--- a/dynaconf/loaders/py_loader.py
+++ b/dynaconf/loaders/py_loader.py
@@ -14,6 +14,7 @@
 from dynaconf.utils import object_merge
 from dynaconf.utils import upperfy
 from dynaconf.utils.files import find_file
+from dynaconf.utils.functional import empty
 
 
 def load(
@@ -24,7 +25,14 @@ def load(
     key=None,
     validate=False,
 ):
-    """Tries to import a python module"""
+    """
+    Tries to import a python module
+
+    Notes:
+        It doesn't handle environment namespaces explicitly. Eg
+            [default], [development], etc
+        See tests/test_nested_loading.py sample python file
+    """
     mod, loaded_from = get_module(obj, settings_module, silent)
     if not (mod and loaded_from):
         return
@@ -40,9 +48,10 @@ def load(
 def load_from_python_object(
     obj, mod, settings_module, key=None, identifier=None, validate=False
 ):
-    file_merge = getattr(mod, "dynaconf_merge", False) or getattr(
-        mod, "DYNACONF_MERGE", False
-    )
+    file_merge = getattr(mod, "dynaconf_merge", empty)
+    if file_merge is empty:
+        file_merge = getattr(mod, "DYNACONF_MERGE", empty)
+
     for setting in dir(mod):
         # A setting var in a Python file should start with upper case
         # valid: A_value=1, ABC_value=3 A_BBB__default=1


Test patch:
diff --git a/tests_functional/issues/835_926_enable-merge-equal-false/Makefile b/tests_functional/issues/835_926_enable-merge-equal-false/Makefile
new file mode 100644
--- /dev/null
+++ b/tests_functional/issues/835_926_enable-merge-equal-false/Makefile
@@ -0,0 +1,4 @@
+.PHONY: test
+
+test:
+	pytest app_test.py -sv
diff --git a/tests_functional/issues/835_926_enable-merge-equal-false/app_test.py b/tests_functional/issues/835_926_enable-merge-equal-false/app_test.py
new file mode 100644
--- /dev/null
+++ b/tests_functional/issues/835_926_enable-merge-equal-false/app_test.py
@@ -0,0 +1,231 @@
+"""
+Merge misbehaviour:
+    file-scope and local-scope marks doesn't treat False value properly.
+
+https://github.com/dynaconf/dynaconf/issues/835
+https://github.com/dynaconf/dynaconf/issues/926
+"""
+from __future__ import annotations
+
+from textwrap import dedent
+
+import pytest
+
+from dynaconf import Dynaconf
+
+
+def create_file(filename: str, data: str) -> str:
+    """Utility to create files"""
+    with open(filename, "w") as file:
+        file.write(dedent(data))
+    return filename
+
+
+def test_case_835(tmp_path):
+    """
+    When using dynaconf_merge=False inside a structure
+    Should prevent merging of that structure
+    """
+    file_a = create_file(
+        tmp_path / "a.toml",
+        """\
+        [general]
+        a = 1
+
+        [other]
+        a = 2
+        """,
+    )
+
+    file_b = create_file(
+        tmp_path / "b.toml",
+        """\
+        [general]
+        b = 1
+
+        [other]
+        dynaconf_merge = false
+        b = 2
+        """,
+    )
+    settings = Dynaconf(
+        settings_files=[file_a, file_b],
+        merge_enabled=True,
+    )
+
+    assert settings.other.b == 2
+    with pytest.raises(AttributeError):
+        settings.other.a
+
+
+def test_case_926_workaround(tmp_path):
+    """
+    The original example is not a bug because toplevel always merges.
+    But it can be achieved by explicitly deleting old keys.
+    """
+    file_a = create_file(
+        tmp_path / "a.toml",
+        """\
+        [default]
+        DEBUG = true
+        [default.nested]
+        Name="Test"
+        """,
+    )
+
+    file_hooks = create_file(
+        tmp_path / "dynaconf_hooks.py",
+        """\
+        def post(settings):
+            data = {}
+            if settings.DEBUG:
+                data["database_url"] = "sqlite://"
+                data["name"] = "@del"
+            return data
+        """,
+    )
+    settings = Dynaconf(
+        environments=True,
+        settings_files=[file_a, file_hooks],
+    )
+
+    assert settings.database_url == "sqlite://"
+    with pytest.raises(AttributeError):
+        settings.name
+
+
+def test_case_926_local_scope_merge_marks(tmp_path):
+    """
+    When using merge=False on inner structure with global merge_enabled=True
+    Should prevent merging from that level (in dicts)
+    """
+    file_a = create_file(
+        tmp_path / "a.toml",
+        """\
+        [default]
+        DEBUG = true
+        [default.nested]
+        Name="Test"
+        """,
+    )
+
+    file_hooks = create_file(
+        tmp_path / "dynaconf_hooks.py",
+        """\
+        def post(settings):
+            data = {}
+            if settings.DEBUG:
+                data["nested"] = {
+                    "database_url": "sqlite://",
+                    "dynaconf_merge": False
+                }
+            return data
+        """,
+    )
+    settings = Dynaconf(
+        environments=True,
+        settings_files=[file_a, file_hooks],
+    )
+
+    assert settings.nested.database_url == "sqlite://"
+    with pytest.raises(AttributeError):
+        settings.nested.name
+
+
+def test_case_926_file_scope_merge_mark(tmp_path):
+    """
+    When using merge=False on toplevel with global merge_enabled=True
+    Should propagate merge=False to nested structures
+    """
+    file_a = create_file(
+        tmp_path / "a.toml",
+        """\
+        [default]
+        DEBUG = true
+        [default.nested]
+        Name="Test"
+        """,
+    )
+
+    file_hooks = create_file(
+        tmp_path / "dynaconf_hooks.py",
+        """\
+        def post(settings):
+            data = {"dynaconf_merge": False}
+            if settings.DEBUG:
+                data["nested"] = {"database_url": "sqlite://"}
+            return data
+        """,
+    )
+    settings = Dynaconf(
+        environments=True,
+        settings_files=[file_a, file_hooks],
+    )
+
+    assert settings.nested.database_url == "sqlite://"
+    with pytest.raises(AttributeError):
+        settings.nested.name
+
+
+def test_local_scope_merge_false():
+    settings = Dynaconf(
+        environments=True,
+        merge_enabled=True,
+    )
+    settings.set("dicty.name", "foo")
+    settings.set("listy", [1, 2, 3])
+    settings.update(
+        {"dicty": {"database_url": "sqlite://", "dynaconf_merge": False}}
+    )
+    settings.update({"listy": [9999]})
+
+    assert settings.dicty == {"database_url": "sqlite://"}
+    assert settings.listy == [1, 2, 3, 9999]
+
+
+def test_file_scope_merge_false(tmp_path):
+    """
+    Similar to when using a merge_enabled=true in the toplevel of a file,
+    using merge_enabled=false should prevent all structures of that scope from
+    merging (with the exception of the toplevel itself), even if global
+    merge_enabled=True.
+    """
+    # file-scope dynaconf_merge=True, merge_enabled=False
+    file_a = create_file(
+        tmp_path / "a.toml",
+        """\
+        dicty={name="foo"}
+        listy=[1, 2, 3]
+        """,
+    )
+    file_b = create_file(
+        tmp_path / "b.toml",
+        """\
+        dynaconf_merge=true
+        dicty={database_url="sqlite://"}
+        listy=[9999]
+        """,
+    )
+    settings = Dynaconf(merge_enabled=False, settings_file=[file_a, file_b])
+    assert settings.dicty == {"database_url": "sqlite://", "name": "foo"}
+    assert settings.listy == [1, 2, 3, 9999]
+
+    # file-scope dynaconf_merge=False, merge_enabled=True
+    file_c = create_file(
+        tmp_path / "c.toml",
+        """\
+        dicty={name="foo"}
+        listy=[1, 2, 3]
+        """,
+    )
+    file_d = create_file(
+        tmp_path / "d.toml",
+        """\
+        dynaconf_merge=false
+        dicty={database_url="sqlite://"}
+        listy=[9999]
+        """,
+    )
+    settings = Dynaconf(merge_enabled=True, settings_file=[file_c, file_d])
+    assert settings.dicty == {"database_url": "sqlite://"}
+    assert settings.listy == [9999]