Repository: dynaconf/dynaconf

Issue:
[RFC] Allow for validation of only specific nested sections
There is currently no way to specify what section(s) of a settings object are to be validated during a validate call. This can cause issues when validators are added, but the values they are validating are not.

Example: I have two larger sub-sections of a settings object, where the values for the second have been changed to be invalid. I do not want the second section's validators to trigger when I'm validating the first as that raises a validation error.
```python
# section 1
settings.SECTION1

#section 2
settings.SECTION2

settings.SECTION2.some_key = "bad value"

settings.validators.validate()  # raises a validation error
```

Ideally, I would like to be able to specify which sections of the settings object should be subject to validation when calling validate.

Example:
```python
# only validate SECTION1 settings, so SECTION2 validation errors don't interfere
settings.validators.validate(settings.SECTION1) 
```

Optional: Add `only` and/or `exclude` arguments to the validate method.

Example:
```python
settings.validators.validate(only=[settings.SECTION1, settings.SECTION3])

settings.validators.validate(exclude=settings.SECTION2)
```


Discussion:

Resolving patch:
diff --git a/dynaconf/base.py b/dynaconf/base.py
--- a/dynaconf/base.py
+++ b/dynaconf/base.py
@@ -201,6 +201,8 @@ def __init__(self, settings_module=None, **kwargs):  # pragma: no cover
         self.environ = os.environ
         self.SETTINGS_MODULE = None
         self._not_installed_warnings = []
+        self._validate_only = kwargs.pop("validate_only", None)
+        self._validate_exclude = kwargs.pop("validate_exclude", None)
 
         self.validators = ValidatorList(
             self, validators=kwargs.pop("validators", None)
@@ -215,7 +217,9 @@ def __init__(self, settings_module=None, **kwargs):  # pragma: no cover
         self._defaults = kwargs
         self.execute_loaders()
 
-        self.validators.validate()
+        self.validators.validate(
+            only=self._validate_only, exclude=self._validate_exclude
+        )
 
     def __call__(self, *args, **kwargs):
         """Allow direct call of `settings('val')`
@@ -1174,5 +1178,7 @@ def is_overridden(self, setting):  # noqa
         "environ",
         "SETTINGS_MODULE",
         "validators",
+        "_validate_only",
+        "_validate_exclude",
     ]
 )
diff --git a/dynaconf/validator.py b/dynaconf/validator.py
--- a/dynaconf/validator.py
+++ b/dynaconf/validator.py
@@ -2,6 +2,7 @@
 from types import MappingProxyType
 
 from dynaconf import validator_conditions  # noqa
+from dynaconf.utils import ensure_a_list
 from dynaconf.utils.functional import empty
 
 
@@ -147,8 +148,16 @@ def __eq__(self, other):
 
         return False
 
-    def validate(self, settings):
+    def validate(self, settings, only=None, exclude=None):
         """Raise ValidationError if invalid"""
+        # If only or exclude are not set, this value always passes startswith
+        only = ensure_a_list(only or [""])
+        if only and not isinstance(only[0], str):
+            raise ValueError("'only' must be a string or list of strings.")
+
+        exclude = ensure_a_list(exclude)
+        if exclude and not isinstance(exclude[0], str):
+            raise ValueError("'exclude' must be a string or list of strings.")
 
         if self.envs is None:
             self.envs = [settings.current_env]
@@ -159,7 +168,7 @@ def validate(self, settings):
                 if self.when.envs is None:
                     self.when.envs = self.envs
 
-                self.when.validate(settings)
+                self.when.validate(settings, only=only, exclude=exclude)
             except ValidationError:
                 # if when is invalid, return canceling validation flow
                 return
@@ -169,15 +178,25 @@ def validate(self, settings):
             len(self.envs) == 1
             and self.envs[0].upper() == settings.current_env.upper()
         ):
-            self._validate_items(settings, settings.current_env)
+            self._validate_items(
+                settings, settings.current_env, only=only, exclude=exclude
+            )
             return
 
         for env in self.envs:
-            self._validate_items(settings.from_env(env))
+            self._validate_items(
+                settings.from_env(env), only=only, exclude=exclude
+            )
 
-    def _validate_items(self, settings, env=None):
+    def _validate_items(self, settings, env=None, only=None, exclude=None):
         env = env or settings.current_env
         for name in self.names:
+            # Skip if only is set and name isn't in the only list
+            if not any(name.startswith(sub) for sub in only):
+                continue
+            # Skip if exclude is set and name is in the exclude list
+            if any(name.startswith(sub) for sub in exclude):
+                continue
             if self.default is not empty:
                 default_value = (
                     self.default(settings, self)
@@ -244,7 +263,7 @@ def __init__(self, validator_a, validator_b, *args, **kwargs):
                 )
                 setattr(self, attr, value)
 
-    def validate(self, settings):  # pragma: no cover
+    def validate(self, settings, only=None, exclude=None):  # pragma: no cover
         raise NotImplementedError(
             "subclasses OrValidator or AndValidator implements this method"
         )
@@ -253,12 +272,12 @@ def validate(self, settings):  # pragma: no cover
 class OrValidator(CombinedValidator):
     """Evaluates on Validator() | Validator()"""
 
-    def validate(self, settings):
+    def validate(self, settings, only=None, exclude=None):
         """Ensure at least one of the validators are valid"""
         errors = []
         for validator in self.validators:
             try:
-                validator.validate(settings)
+                validator.validate(settings, only=only, exclude=exclude)
             except ValidationError as e:
                 errors.append(e)
                 continue
@@ -278,12 +297,12 @@ def validate(self, settings):
 class AndValidator(CombinedValidator):
     """Evaluates on Validator() & Validator()"""
 
-    def validate(self, settings):
+    def validate(self, settings, only=None, exclude=None):
         """Ensure both the validators are valid"""
         errors = []
         for validator in self.validators:
             try:
-                validator.validate(settings)
+                validator.validate(settings, only=only, exclude=exclude)
             except ValidationError as e:
                 errors.append(e)
                 continue
@@ -303,6 +322,8 @@ class ValidatorList(list):
     def __init__(self, settings, validators=None, *args, **kwargs):
         if isinstance(validators, (list, tuple)):
             args = list(args) + list(validators)
+        self._only = kwargs.pop("validate_only", None)
+        self._exclude = kwargs.pop("validate_exclude", None)
         super(ValidatorList, self).__init__(args, **kwargs)
         self.settings = settings
 
@@ -313,6 +334,8 @@ def register(self, *args, **kwargs):
             if validator and validator not in self:
                 self.append(validator)
 
-    def validate(self):
+    def validate(self, only=None, exclude=None):
+        # only = only or self._only
+        # exclude = exclude or self._exclude
         for validator in self:
-            validator.validate(self.settings)
+            validator.validate(self.settings, only=only, exclude=exclude)
diff --git a/example/validators/with_python/conf.py b/example/validators/with_python/conf.py
--- a/example/validators/with_python/conf.py
+++ b/example/validators/with_python/conf.py
@@ -45,6 +45,8 @@
     Validator(
         "A_DICT.NESTED_1.NESTED_2.NESTED_3.NESTED_4", must_exist=True, eq=1
     ),
+    Validator("A_DICT.NESTED_1.NOT.YET.LOADED", must_exist=True, eq=1),
 )
 
-settings.validators.validate()
+# Validate settings except those that we don't want to validate yet
+settings.validators.validate(exclude="A_DICT.NESTED_1.NOT")


Test patch:
diff --git a/tests/test_validators.py b/tests/test_validators.py
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -47,6 +47,56 @@
 TESTVALUE = "value"
 """
 
+YAML = """
+server:
+    hostname: "localhost"
+    port: 22
+    users:
+      - "Bruno"
+      - "Lula"
+app:
+    name: "testname"
+    path: "/tmp/app_startup"
+    args:
+        arg1: "a"
+        arg2: "b"
+        arg3: "c"
+"""
+
+
+@pytest.fixture
+def yaml_validators_good():
+    return [
+        Validator(
+            "server.hostname", "server.port", "server.users", must_exist=True
+        ),
+        Validator(
+            "app.name",
+            "app.path",
+            "app.args.arg1",
+            "app.args.arg2",
+            "app.args.arg3",
+            must_exist=True,
+        ),
+    ]
+
+
+@pytest.fixture
+def yaml_validators_bad():
+    return [
+        Validator("missing.value", must_exist=True),
+        Validator("app.missing", must_exist=True),
+        Validator("app.args.missing", must_exist=True),
+    ]
+
+
+@pytest.fixture
+def yaml_validators_mixed(yaml_validators_good, yaml_validators_bad):
+    mixed = []
+    mixed.extend(yaml_validators_good)
+    mixed.extend(yaml_validators_bad)
+    return mixed
+
 
 def test_validators_on_init(tmpdir):
     TOML = """
@@ -483,3 +533,72 @@ def test_validator_can_provide_default(tmpdir):
 
     assert settings.FOO == "BAR"
     assert settings.COMPUTED == "I am computed"
+
+
+def test_validator_init_exclude(tmpdir, yaml_validators_mixed):
+    tmpfile = tmpdir.join("settings.yaml")
+    tmpfile.write(YAML)
+    settings = LazySettings(
+        settings_file=str(tmpfile),
+        validators=yaml_validators_mixed,
+        validate_exclude=["missing", "app.missing", "app.args.missing"],
+    )
+    assert settings.server.hostname == "localhost"
+
+
+def test_validator_init_only(tmpdir, yaml_validators_mixed):
+    tmpfile = tmpdir.join("settings.yaml")
+    tmpfile.write(YAML)
+    settings = LazySettings(
+        settings_file=str(tmpfile),
+        validators=yaml_validators_mixed,
+        validate_only=["server"],
+    )
+    assert settings.server.hostname == "localhost"
+
+
+def test_validator_init_mixed(tmpdir, yaml_validators_mixed):
+    tmpfile = tmpdir.join("settings.yaml")
+    tmpfile.write(YAML)
+    settings = LazySettings(
+        settings_file=str(tmpfile),
+        validators=yaml_validators_mixed,
+        validate_only=["server", "app"],
+        validate_exclude=["app.missing", "app.args.missing"],
+    )
+    assert settings.server.hostname == "localhost"
+
+
+def test_validator_only_post_register(
+    tmpdir, yaml_validators_good, yaml_validators_bad
+):
+    tmpfile = tmpdir.join("settings.yaml")
+    tmpfile.write(YAML)
+    settings = LazySettings(
+        settings_file=str(tmpfile),
+        validators=yaml_validators_good,
+        validate_only=["server"],
+    )
+    assert settings.server.hostname == "localhost"
+    settings.validators.register(*yaml_validators_bad)
+    # call validation only on the server section
+    settings.validators.validate(only=["server"])
+
+
+def test_validator_exclude_post_register(
+    tmpdir, yaml_validators_good, yaml_validators_bad
+):
+    tmpfile = tmpdir.join("settings.yaml")
+    tmpfile.write(YAML)
+    settings = LazySettings(
+        settings_file=str(tmpfile),
+        validators=yaml_validators_good,
+        validate_only=["server", "app.path"],
+    )
+    assert settings.server.hostname == "localhost"
+    settings.validators.register(*yaml_validators_bad)
+    # call validation only on the server section
+    settings.validators.validate(
+        exclude=["missing", "app.missing", "app.args.missing"]
+    )
+    settings.app.path = "/tmp/app_startup"