Repository: dynaconf/dynaconf

Issue:
[bug] `validate_on_update` causes `AttributeError: 'Settings' object has no attribute 'ENVIRONMENTS_FOR_DYNACONF'`
## Problem

Given this simple setup file:
```python
from dynaconf import Dynaconf, Validator

settings = Dynaconf(
    settings_files=[
        'veryeasyfatt.config.toml',
        '.secrets.toml'
    ],
    merge_enabled=True,         # Merge all found files into one configuration.
    validate_on_update=True,    # Validate the configuration when it is updated.
    validators=[                # Custom validators.
        Validator(
            "files.output.kml",
            default="output.kml",
        ),
    ],
)

# Setup default values for missing settings (see #993)
if str(settings.files.output.kml.strip()) == "":
    settings.files.output.kml = "output.kml"

print(f"KML FILE: '{settings.files.output.kml}'")
```

> NOTE: The actual configuration file is irrelevant, since it **will fail** anyway even if it doesn't actually exists.

When `validate_on_update` is set to `True` it **always fails** with the following stacktrace:

```
  File "d:\Progetti\danea-automation\src\veryeasyfatt\config.py", line 22, in <module>
    if settings.files.output.kml.strip() == "":
  File "D:\Progetti\danea-automation\.venv\lib\site-packages\dynaconf\base.py", line 122, in __getattr__
    self._setup()
  File "D:\Progetti\danea-automation\.venv\lib\site-packages\dynaconf\base.py", line 183, in _setup
    self._wrapped = self._wrapper_class(
  File "D:\Progetti\danea-automation\.venv\lib\site-packages\dynaconf\base.py", line 262, in __init__
    self.set(key, value, loader_identifier="init_kwargs")
  File "D:\Progetti\danea-automation\.venv\lib\site-packages\dynaconf\base.py", line 1018, in set
    self.validators.validate()
  File "D:\Progetti\danea-automation\.venv\lib\site-packages\dynaconf\validator.py", line 492, in validate
    validator.validate(
  File "D:\Progetti\danea-automation\.venv\lib\site-packages\dynaconf\validator.py", line 189, in validate
    self.envs = [settings.current_env]
  File "D:\Progetti\danea-automation\.venv\lib\site-packages\dynaconf\base.py", line 309, in __getattribute__
    return super().__getattribute__(name)
  File "D:\Progetti\danea-automation\.venv\lib\site-packages\dynaconf\base.py", line 746, in current_env
    if self.ENVIRONMENTS_FOR_DYNACONF is False:
  File "D:\Progetti\danea-automation\.venv\lib\site-packages\dynaconf\base.py", line 309, in __getattribute__
    return super().__getattribute__(name)
AttributeError: 'Settings' object has no attribute 'ENVIRONMENTS_FOR_DYNACONF'
```

What can I do to fix this? Is it my fault?


Discussion:
Just to leave my two cents here...

I found that using [**Lazy Validation**](https://www.dynaconf.com/validation/#lazy-validation) (_`settings.validators.validate()`, `settings.validators.validate_all()` or the `validate=True` parameter_) works!

For now i managed to work around the issue just by replacing all calls to `settings.load_file(...)` with `settings.load_file(..., validate=True)`.
@rochacbruno Was this fixed on draft #988 ? I've got the impression you said something about that somewhere.

Resolving patch:
diff --git a/dynaconf/base.py b/dynaconf/base.py
--- a/dynaconf/base.py
+++ b/dynaconf/base.py
@@ -259,7 +259,9 @@ def __init__(self, settings_module=None, **kwargs):  # pragma: no cover
                 loader_identifier="init_settings_module",
             )
         for key, value in kwargs.items():
-            self.set(key, value, loader_identifier="init_kwargs")
+            self.set(
+                key, value, loader_identifier="init_kwargs", validate=False
+            )
         # execute loaders only after setting defaults got from kwargs
         self._defaults = kwargs
 
@@ -605,9 +607,8 @@ def loaded_envs(self, value):
     loaded_namespaces = loaded_envs
 
     @property
-    def loaded_by_loaders(self):
+    def loaded_by_loaders(self):  # pragma: no cover
         """Gets the internal mapping of LOADER -> values"""
-        # return {k.loader:data for k, data in self._loaded_by_loaders}
         return self._loaded_by_loaders
 
     def from_env(self, env="", keep=False, **kwargs):
@@ -939,6 +940,7 @@ def set(
         if dotted_lookup is empty:
             dotted_lookup = self.get("DOTTED_LOOKUP_FOR_DYNACONF")
         nested_sep = self.get("NESTED_SEPARATOR_FOR_DYNACONF")
+
         if isinstance(key, str):
             if nested_sep and nested_sep in key:
                 key = key.replace(nested_sep, ".")  # FOO__bar -> FOO.bar
@@ -985,7 +987,8 @@ def set(
             else:
                 parsed = parsed.unwrap()
 
-        if existing is not None and existing != parsed:
+        should_merge = existing is not None and existing != parsed
+        if should_merge:
             # `dynaconf_merge` used in file root `merge=True`
             if merge and merge is not empty:
                 source_metadata = source_metadata._replace(merged=True)
diff --git a/dynaconf/loaders/__init__.py b/dynaconf/loaders/__init__.py
--- a/dynaconf/loaders/__init__.py
+++ b/dynaconf/loaders/__init__.py
@@ -25,8 +25,14 @@
 
 
 def default_loader(obj, defaults=None):
-    """Loads default settings and check if there are overridings
-    exported as environment variables"""
+    """Initial loader for the initialization process.
+
+    Steps:
+    - Load default settings (from static module) + kwargs overrides (togheter)
+    - Load envvar overrides
+    """
+    # LOAD DEFAULT STATIC + KWARGS OVERRIDES
+
     defaults = defaults or {}
     default_settings_values = {
         key: value
@@ -41,7 +47,14 @@ def default_loader(obj, defaults=None):
     for key in all_keys:
         if not obj.exists(key):
             value = defaults.get(key, default_settings_values.get(key))
-            obj.set(key, value, loader_identifier="default_settings")
+            obj.set(
+                key,
+                value,
+                loader_identifier="default_settings",
+                validate=False,
+            )
+
+    # LOAD ENVVAR OVERRIDES
 
     # start dotenv to get default env vars from there
     # check overrides in env vars
diff --git a/dynaconf/utils/__init__.py b/dynaconf/utils/__init__.py
--- a/dynaconf/utils/__init__.py
+++ b/dynaconf/utils/__init__.py
@@ -79,27 +79,30 @@ def safe_items(data):
             else:
                 return data.items()
 
-        for old_key, value in safe_items(old):
-
-            # This is for when the dict exists internally
-            # but the new value on the end of full path is the same
-            if (
-                existing_value is not None
-                and old_key.lower() == full_path[-1].lower()
-                and existing_value is value
-            ):
-                # Here Be The Dragons
-                # This comparison needs to be smarter
-                continue
-
-            if old_key not in new:
-                new[old_key] = value
-            else:
-                object_merge(
-                    value,
-                    new[old_key],
-                    full_path=full_path[1:] if full_path else None,
-                )
+        # local mark may set dynaconf_merge=False
+        should_merge = new.pop("dynaconf_merge", True)
+        if should_merge:
+            for old_key, value in safe_items(old):
+
+                # This is for when the dict exists internally
+                # but the new value on the end of full path is the same
+                if (
+                    existing_value is not None
+                    and old_key.lower() == full_path[-1].lower()
+                    and existing_value is value
+                ):
+                    # Here Be The Dragons
+                    # This comparison needs to be smarter
+                    continue
+
+                if old_key not in new:
+                    new[old_key] = value
+                else:
+                    object_merge(
+                        value,
+                        new[old_key],
+                        full_path=full_path[1:] if full_path else None,
+                    )
 
         handle_metavalues(old, new)
 


Test patch:
diff --git a/tests/test_base.py b/tests/test_base.py
--- a/tests/test_base.py
+++ b/tests/test_base.py
@@ -267,6 +267,37 @@ def test_global_set_merge(settings):
     }
 
 
+def test_global_set_merge_false():
+    # data
+    list_old = [1, 2, 3]
+    list_new = [4, 5, 6]
+    list_expected = [4, 5, 6]
+    dict_old = {"a": {"b": "B"}}
+    dict_new = {"a": {"c": "C"}}
+    dict_expected = {"a": {"c": "C"}}
+
+    # environment = False
+    settings_a = Dynaconf(
+        environments=False,
+        merge_enabled=False,
+    )
+    settings_a.set("default", {"dicty": dict_old, "listy": list_old})
+    settings_a.set("default", {"dicty": dict_new, "listy": list_new})
+
+    assert settings_a.default.dicty == dict_expected
+    assert settings_a.default.listy == list_expected
+
+    # environment = True
+    settings_b = Dynaconf(
+        environments=True,
+        merge_enabled=False,
+    )
+    settings_b.set("test", {"dicty": dict_old, "listy": list_old})
+    settings_b.set("test", {"dicty": dict_new, "listy": list_new})
+    assert settings_b.test.dicty == dict_expected
+    assert settings_b.test.listy == list_expected
+
+
 def test_global_merge_shortcut(settings):
     settings.set("MERGE_ENABLED_FOR_DYNACONF", True)
     settings.set("MERGE_KEY", ["item1"])
@@ -334,6 +365,33 @@ def test_local_set_merge_list_unique(settings):
     assert settings.SCRIPTS == ["install.sh", "dev.sh", "test.sh", "deploy.sh"]
 
 
+def test_local_set_merge_false_dict():
+    # environment = False
+    settings_a = Dynaconf(
+        environments=False,
+        merge_enabled=True,
+    )
+    dict_old = {"a": {"b": {"key_old": "from_old"}}}
+    dict_new = {"a": {"b": {"key_new": "from_new", "dynaconf_merge": False}}}
+    settings_a.set("default", {"dicty": dict_old, "listy": [1, 2, 3]})
+    settings_a.set("default", {"dicty": dict_new, "listy": [9999]})
+
+    assert settings_a.default.dicty.a.b == {"key_new": "from_new"}
+    assert settings_a.default.listy == [1, 2, 3, 9999]
+
+    # environment = True
+    settings_b = Dynaconf(
+        environments=True,
+        merge_enabled=True,
+    )
+    dict_old = {"a": {"b": {"key_old": "from_old"}}}
+    dict_new = {"a": {"b": {"key_new": "from_new", "dynaconf_merge": False}}}
+    settings_b.set("test", {"dicty": dict_old, "listy": [1, 2, 3]})
+    settings_b.set("test", {"dicty": dict_new, "listy": [9999]})
+    assert settings_b.test.dicty.a.b == {"key_new": "from_new"}
+    assert settings_b.test.listy == [1, 2, 3, 9999]
+
+
 def test_set_explicit_merge_token(tmpdir):
     data = {
         "a_list": [1, 2],
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
--- 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
@@ -229,3 +229,63 @@ def test_file_scope_merge_false(tmp_path):
     settings = Dynaconf(merge_enabled=True, settings_file=[file_c, file_d])
     assert settings.dicty == {"database_url": "sqlite://"}
     assert settings.listy == [9999]
+
+
+def test_local_scope_merge_false_noenv_files(tmp_path):
+
+    file_a = create_file(
+        tmp_path / "a.toml",
+        """\
+            [default]
+            dicty = {name= 'foo'}
+            listy = [1,2,3]
+            """,
+    )
+
+    file_b = create_file(
+        tmp_path / "b.toml",
+        """\
+            [default]
+            dicty = {"database_url"= 'sqlite://', "dynaconf_merge"=false}
+            listy = [9999]
+            """,
+    )
+
+    settings = Dynaconf(
+        settings_files=[file_a, file_b],
+        environments=False,
+        merge_enabled=True,
+    )
+
+    assert settings.default.dicty == {"database_url": "sqlite://"}
+    assert settings.default.listy == [1, 2, 3, 9999]
+
+
+def test_local_scope_merge_false_env_nested(tmp_path):
+
+    file_a = create_file(
+        tmp_path / "a.toml",
+        """\
+        [default.test]
+        dicty = {name= 'foo'}
+        listy = [1,2,3]
+        """,
+    )
+
+    file_b = create_file(
+        tmp_path / "b.toml",
+        """\
+        [default.test]
+        dicty = {"database_url"= 'sqlite://', "dynaconf_merge"=false}
+        listy = [9999]
+        """,
+    )
+
+    settings = Dynaconf(
+        settings_files=[file_a, file_b],
+        environments=True,
+        merge_enabled=True,
+    )
+
+    assert settings.test.dicty == {"database_url": "sqlite://"}
+    assert settings.test.listy == [1, 2, 3, 9999]
diff --git a/tests_functional/issues/994_validate_on_update_fix/Makefile b/tests_functional/issues/994_validate_on_update_fix/Makefile
new file mode 100644
--- /dev/null
+++ b/tests_functional/issues/994_validate_on_update_fix/Makefile
@@ -0,0 +1,4 @@
+.PHONY: test
+
+test:
+	pytest -sv app_test.py
diff --git a/tests_functional/issues/994_validate_on_update_fix/app_test.py b/tests_functional/issues/994_validate_on_update_fix/app_test.py
new file mode 100644
--- /dev/null
+++ b/tests_functional/issues/994_validate_on_update_fix/app_test.py
@@ -0,0 +1,41 @@
+"""
+#994 (https://github.com/dynaconf/dynaconf/issues/994)
+
+
+"""
+from __future__ import annotations
+
+from dynaconf import Dynaconf
+from dynaconf import Validator
+
+
+def test_994():
+    settings = Dynaconf(
+        settings_files=["veryeasyfatt.config.toml", ".secrets.toml"],
+        # Merge all found files into one configuration.
+        merge_enabled=True,
+        # Validate the configuration when it is updated.
+        validate_on_update=True,
+        validators=[  # Custom validators.
+            Validator(
+                "files.output.kml",
+                default="output.kml",
+            ),
+        ],
+    )
+
+    # Setup default values for missing settings (see #993)
+    if str(settings.files.output.kml.strip()) == "":
+        settings.files.output.kml = "output.kml"
+
+    assert settings.files.output.kml
+
+
+def test_minimal():
+    settings = Dynaconf(
+        merge_enabled=True,
+        validate_on_update=True,
+        validators=[Validator("foo", must_exist=True, default="none")],
+    )
+    settings.set("foo", "bar")
+    assert settings