Repository: dynaconf/dynaconf
Pull request #1197 (2024-11-16 02:28:58)

fix: lazy validator default value would evalute early
fixes: #1196


Patch:
diff --git a/dynaconf/utils/parse_conf.py b/dynaconf/utils/parse_conf.py
index dae8f74..5d7ef87 100644
--- a/dynaconf/utils/parse_conf.py
+++ b/dynaconf/utils/parse_conf.py
@@ -219,8 +219,14 @@ class Lazy:
         self, value=empty, formatter=Formatters.python_formatter, casting=None
     ):
         self.value = value
-        self.formatter = formatter
         self.casting = casting
+        # Sometimes a simple function is passed to the formatter.
+        # but on evaluation-time, we may need to access `formatter.token`
+        # so we are wrapping the fn to comply with this interface.
+        if isinstance(formatter, BaseFormatter):
+            self.formatter = formatter
+        else:
+            self.formatter = BaseFormatter(formatter, "lambda")
 
     @property
     def context(self):
diff --git a/dynaconf/validator.py b/dynaconf/validator.py
index f5ceb3d..15d7290 100644
--- a/dynaconf/validator.py
+++ b/dynaconf/validator.py
@@ -14,6 +14,7 @@ from typing import TYPE_CHECKING
 from dynaconf import validator_conditions
 from dynaconf.utils import ensure_a_list
 from dynaconf.utils.functional import empty
+from dynaconf.utils.parse_conf import Lazy
 
 if TYPE_CHECKING:
     from dynaconf.base import LazySettings  # noqa: F401
@@ -139,6 +140,22 @@ class Validator:
         if condition is not None and not callable(condition):
             raise TypeError("condition must be callable")
 
+        # in the case that:
+        # * default is a Lazy object AND
+        # * there isnt any validate operation to perform (that would require knowing the lazy value)
+        # Then we shouldnt trigger the Lazy evaluation
+        self.should_call_lazy = not all(
+            (
+                default,
+                isinstance(default, Lazy),
+                not must_exist,
+                not required,
+                not cast,
+                not items_validators,
+                not operations,
+            )
+        )
+
         self.names = names
         self.must_exist = must_exist if must_exist is not None else required
         self.condition = condition
@@ -332,11 +349,10 @@ class Validator:
                 continue
 
             if self.default is not empty:
-                default_value = (
-                    self.default(settings, self)
-                    if callable(self.default)
-                    else self.default
-                )
+                if callable(self.default) and self.should_call_lazy:
+                    default_value = self.default(settings, self)
+                else:
+                    default_value = self.default
             else:
                 default_value = empty
 
@@ -388,7 +404,8 @@ class Validator:
             # however we need to cast it
             # so we call .set again
             # NOTE: we must stop mutating settings here
-            value = self.cast(settings.get(name))
+            if self.should_call_lazy:
+                value = self.cast(settings.get(name))
             if _set := getattr(settings, "set", None):
                 # Settings is Dynaconf
                 _set(name, value, validate=False)


Test patch:
diff --git a/tests_functional/issues/1196-early-trigger-on-validation/app.py b/tests_functional/issues/1196-early-trigger-on-validation/app.py
new file mode 100644
index 0000000..ef33ce1
--- /dev/null
+++ b/tests_functional/issues/1196-early-trigger-on-validation/app.py
@@ -0,0 +1,34 @@
+from dynaconf import Dynaconf
+from dynaconf import Validator
+from dynaconf.utils.functional import empty
+from dynaconf.utils.parse_conf import Lazy
+
+
+def test_lazy_error(tmp_path):
+    def my_lazy_function(value, **context):
+        """
+        value: Default value passed to the validator, defaults to `empty`
+        context: A dictionary containing
+                env: All the environment variables
+                this: The settings instance
+        """
+        raise RuntimeError("Should never be hit.")
+        return "When the first value is accessed, then the my_lazy_function will be called"
+
+    settings = Dynaconf(
+        validators=[
+            Validator(
+                "MYSQL_HOST",
+                eq="development.com",
+                default="development.com",
+            ),
+            Validator(
+                "MYSQL_HOST",
+                ne="development.com",
+                env="PRODUCTION",
+                default="development.comp",
+            ),
+            Validator("FOO", default=Lazy(empty, formatter=my_lazy_function)),
+        ]
+    )
+    assert settings.MYSQL_HOST