Repository: dynaconf/dynaconf

Issue:
[RFC]  Support computed values.
For https://github.com/SatelliteQE/robottelo/pull/7774/

It is needed to allow some values to be computed at runtime, dynaconf already support lazy values using `@jinja` and `@format` but in this case it will be able to use any callable


Some fields can be computed at runtime and we can provide different API style to define it.

Options listed in comment bellow:
[RFC]  Support computed values.
For https://github.com/SatelliteQE/robottelo/pull/7774/

It is needed to allow some values to be computed at runtime, dynaconf already support lazy values using `@jinja` and `@format` but in this case it will be able to use any callable


Some fields can be computed at runtime and we can provide different API style to define it.

Options listed in comment bellow:


Discussion:
Option 1: explicit dictionary mapping key to callable

```py
from dynaconf import Dynaconf

settings = Dynaconf(
     computed={
        "name": callable_name
     }
)
```
Option 2: A class 

```py

class MyClass:
    def this_is_loaded_at_runtime(self):
        # can access self.settings

settings = Dynaconf(computed=MyClass)

```
Option 3: Stick with tokens just like `@jinja` and `@format`

```py
settings = Dynaconf(
     ...
     lazy_key="@lazy module.callable args k=args"
)
```
Option 4: USe validators to provide default and computation

```py
settings = Dynaconf(
    ...,
    validators=[
        Validator("KEY", lazy=callable, default="foo")
    ]
)

```
Option 5: A complete Schema support #331 

```py

class MySchema(pydantic.BaseModel):
    foo = dynaconf.Lazy(**options)

    def load_foo(self):  
        # do the things


settings = Dynaconf(schema=MySchema)

```

I think option 1 is cleaner and clearer. It also wouldn't require defining a new class for each key requiring a callable.
Option 4 is a good alternative.
Option 1: explicit dictionary mapping key to callable

```py
from dynaconf import Dynaconf

settings = Dynaconf(
     computed={
        "name": callable_name
     }
)
```
Option 2: A class 

```py

class MyClass:
    def this_is_loaded_at_runtime(self):
        # can access self.settings

settings = Dynaconf(computed=MyClass)

```
Option 3: Stick with tokens just like `@jinja` and `@format`

```py
settings = Dynaconf(
     ...
     lazy_key="@lazy module.callable args k=args"
)
```
Option 4: USe validators to provide default and computation

```py
settings = Dynaconf(
    ...,
    validators=[
        Validator("KEY", lazy=callable, default="foo")
    ]
)

```
Option 5: A complete Schema support #331 

```py

class MySchema(pydantic.BaseModel):
    foo = dynaconf.Lazy(**options)

    def load_foo(self):  
        # do the things


settings = Dynaconf(schema=MySchema)

```

I think option 1 is cleaner and clearer. It also wouldn't require defining a new class for each key requiring a callable.
Option 4 is a good alternative.

Resolving patch:
diff --git a/dynaconf/base.py b/dynaconf/base.py
--- a/dynaconf/base.py
+++ b/dynaconf/base.py
@@ -234,7 +234,7 @@ def __init__(self, settings_module=None, **kwargs):  # pragma: no cover
         self._env_cache = {}
         self._loaded_by_loaders = {}
         self._loaders = []
-        self._defaults = {}
+        self._defaults = DynaBox(box_settings=self)
         self.environ = os.environ
         self.SETTINGS_MODULE = None
         self._not_installed_warnings = []
@@ -320,6 +320,20 @@ def values(self):
         """Redirects to store object"""
         return self.store.values()
 
+    def setdefault(self, item, default):
+        """Returns value if exists or set it as the given default"""
+        value = self.get(item, empty)
+        if value is empty and default is not empty:
+            self.set(
+                item,
+                default,
+                loader_identifier="setdefault",
+                tomlfy=True,
+                dotted_lookup=True,
+            )
+            return default
+        return value
+
     def as_dict(self, env=None, internal=False):
         """Returns a dictionary with set key and values.
 
diff --git a/dynaconf/validator.py b/dynaconf/validator.py
--- a/dynaconf/validator.py
+++ b/dynaconf/validator.py
@@ -52,9 +52,9 @@ class Validator(object):
         # When the very first thing to be performed when passed.
         # if no env is passed to `when` it is inherited
 
-    `must_exist` is `exists` requirement. (executed after when)::
+    `must_exist` is alias to `required` requirement. (executed after when)::
 
-       settings.exists(value)
+       settings.get(value, empty) returns non empty
 
     condition is a callable to be executed and return boolean::
 
@@ -80,11 +80,13 @@ def __init__(
         self,
         *names,
         must_exist=None,
+        required=None,  # this is alias for `must_exist`
         condition=None,
         when=None,
         env=None,
         messages=None,
         cast=None,
+        default=empty,  # Literal value or a callable
         **operations
     ):
         # Copy immutable MappingProxyType as a mutable dict
@@ -99,11 +101,12 @@ def __init__(
             raise TypeError("condition must be callable")
 
         self.names = names
-        self.must_exist = must_exist
+        self.must_exist = must_exist if must_exist is not None else required
         self.condition = condition
         self.when = when
         self.cast = cast or (lambda value: value)
         self.operations = operations
+        self.default = default
 
         if isinstance(env, str):
             self.envs = [env]
@@ -169,24 +172,31 @@ def validate(self, settings):
     def _validate_items(self, settings, env=None):
         env = env or settings.current_env
         for name in self.names:
-            exists = settings.exists(name)
+            if self.default is not empty:
+                default_value = (
+                    self.default(settings, self)
+                    if callable(self.default)
+                    else self.default
+                )
+            else:
+                default_value = empty
+
+            value = self.cast(settings.setdefault(name, default_value))
 
             # is name required but not exists?
-            if self.must_exist is True and not exists:
+            if self.must_exist is True and value is empty:
                 raise ValidationError(
                     self.messages["must_exist_true"].format(name=name, env=env)
                 )
-            elif self.must_exist is False and exists:
+            elif self.must_exist is False and value is not empty:
                 raise ValidationError(
                     self.messages["must_exist_false"].format(
                         name=name, env=env
                     )
                 )
-            elif self.must_exist in (False, None) and not exists:
+            elif self.must_exist in (False, None) and value is empty:
                 return
 
-            value = self.cast(settings.get(name, empty))
-
             # is there a callable condition?
             if self.condition is not None:
                 if not self.condition(value):
diff --git a/example/issues/377_default_validators/app.py b/example/issues/377_default_validators/app.py
new file mode 100644
--- /dev/null
+++ b/example/issues/377_default_validators/app.py
@@ -0,0 +1,42 @@
+from dynaconf import Dynaconf
+from dynaconf import Validator
+
+
+settings = Dynaconf(
+    validators=[Validator("COMPUTED", default=lambda st, va: "I am computed")]
+)
+
+
+assert settings.COMPUTED == "I am computed"
+
+settings2 = Dynaconf(
+    settings_file="settings.toml",
+    environments=True,
+    validators=[
+        Validator(
+            "FOO",
+            env="production",
+            default=lambda st, va: "FOO is computed in prod",
+        ),
+        Validator("BAR", env="production", default="@format {this.foo}/BAR"),
+        Validator(
+            "ZAZ", env="production", default="@jinja {{this.get('foo')}}/ZAZ"
+        ),
+        Validator("FOO", env="development", required=True, eq="dev foo"),
+    ],
+)
+
+assert settings2.FOO == "dev foo"
+assert settings2.get("FOO") == "dev foo"
+assert settings2["FOO"] == "dev foo"
+assert settings2.foo == "dev foo"
+assert settings2("foo") == "dev foo"
+
+assert settings2.from_env("production").FOO == "FOO is computed in prod"
+assert settings2.from_env("production").foo == "FOO is computed in prod"
+assert settings2.from_env("production")["FOO"] == "FOO is computed in prod"
+assert settings2.from_env("production")("FOO") == "FOO is computed in prod"
+assert settings2.from_env("production").get("FOO") == "FOO is computed in prod"
+
+assert settings2.from_env("production").BAR == "FOO is computed in prod/BAR"
+assert settings2.from_env("production").ZAZ == "FOO is computed in prod/ZAZ"


Test patch:
diff --git a/tests/test_validators.py b/tests/test_validators.py
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -427,3 +427,25 @@ def test_cast_before_validate(tmpdir):
     )
     assert settings.name == "Bruno"
     assert settings.colors == ["red", "green", "blue"]
+
+
+def test_validator_can_provide_default(tmpdir):
+    tmpfile = tmpdir.join("settings.toml")
+    TOML = """
+    name = 'Bruno'
+    colors = ['red', 'green', 'blue']
+    """
+    tmpfile.write(TOML)
+    settings = LazySettings(
+        settings_file=str(tmpfile),
+        validators=[
+            Validator("name", required=True),
+            Validator("FOO", default="BAR"),
+            Validator("COMPUTED", default=lambda st, va: "I am computed"),
+        ],
+    )
+    assert settings.name == "Bruno"
+    assert settings.colors == ["red", "green", "blue"]
+
+    assert settings.FOO == "BAR"
+    assert settings.COMPUTED == "I am computed"