Repository: rochacbruno/dynaconf (python)

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)
```

Pull request description:
Add the ability for selective validation
This change introduces the ability to control which sections of a
settings object are subject to validation.
This is controlled primarily by two mechanisms.
1: When creating a settings object, new arguments `validate_only`
   and `validate_exclude` have been added which receive a list of settings
   paths.
2: When manually calling validate, new arguments `only` and `exclude`
   have been added.
All of these allow for either a string or list of strings representing
settings paths. For example:
    `settings.validators.validate(only=["settings.something",
    "settings.another"])`

    settings = Dynaconf(..., validate_exclude="settings.bad")

Fixes #508

Patch:
diff --git a/docs/validation.md b/docs/validation.md
index cde7e7d..8f3669a 100644
--- a/docs/validation.md
+++ b/docs/validation.md
@@ -135,9 +135,7 @@ Validator('DATABASE.HOST', must_exist=True) & Validator('DATABASE.CONN', must_ex
 
 ## CLI and dynaconf_validators.toml
 
-> **NEW in 1.0.1**
-
-Starting on version 1.0.1 it is possible to define validators in **TOML** file called **dynaconf_validators.toml** placed in the same folder as your settings files.
+It is possible to define validators in **TOML** file called **dynaconf_validators.toml** placed in the same folder as your settings files.
 
 `dynaconf_validators.toml` equivalent to program above
 
@@ -167,3 +165,71 @@ $ dynaconf validate
 ```
 
 This returns code 0 (success) if validation is ok.
+
+
+## Selective Validation
+
+> **New in 3.2.0**
+
+You can also choose what sections of the settings you do or don't want to validate.
+This is useful when:
+
+ - You want to add additional validators after the settings object is created.
+ - You want settings validated only when certain sections of your project are loaded.
+ - You want to offer incremental configuration levels, validating only what is needed.
+
+Selective validation can be performed both when creating a settings object and when calling validate on a settings object's validators. The new arguments accept either a string representing a settings path or a list of strings representing settings paths.
+
+A settings path starts at the top level element and can be specified down to the lowest component. For example: `my_settings.server.user.password` can have the following settings paths passed in `server`, `server.user`, `server.user.password`.
+
+*Note:* Selective validation matches the passed in value(s) to settings paths that start with that value. This means that passing `exclude="FOO"` will exclude not only paths that start with `FOO` but also `FOOBAR`.
+
+Examples:
+
+**-- config.py --**
+```python
+...
+# create a settings object, validating only settings under settings.server
+settings = Dynaconf(
+    validators=[
+        Validator(
+            "server.hostname",
+            "server.port",
+            "server.auth",
+            must_exist=True
+        ),
+        Validator(
+            "module1.value1",
+            "module1.value2",
+            "module1.value3",
+            must_exist=True
+        ),
+        Validator(
+            "module2.value1",
+            "module2.value2",
+            "module2.bad",
+            must_exist=True
+        )
+    ],
+    validate_only="server"
+)
+```
+
+**-- module1.py --**
+```python
+...
+# call validation on module1 settings
+settings.validators.validate(only=["module1"])
+```
+
+**-- module2.py --**
+```python
+...
+# call validation on module2 settings
+# ignore validation for a subsection of module2's settings
+settings.validators.validate(
+    only=["module2"],
+    exclude=["module2.bad"]
+)
+
+```
\ No newline at end of file
diff --git a/dynaconf/base.py b/dynaconf/base.py
index 3f731ac..f550c22 100644
--- a/dynaconf/base.py
+++ b/dynaconf/base.py
@@ -201,6 +201,8 @@ class Settings:
         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 @@ class Settings:
         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 @@ RESERVED_ATTRS = (
         "environ",
         "SETTINGS_MODULE",
         "validators",
+        "_validate_only",
+        "_validate_exclude",
     ]
 )
diff --git a/dynaconf/validator.py b/dynaconf/validator.py
index cbac8d2..ced1271 100644
--- a/dynaconf/validator.py
+++ b/dynaconf/validator.py
@@ -2,6 +2,7 @@ from itertools import chain
 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 @@ class Validator:
 
         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 @@ class Validator:
                 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 @@ class Validator:
             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 @@ class CombinedValidator(Validator):
                 )
                 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 @@ class CombinedValidator(Validator):
 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 @@ class OrValidator(CombinedValidator):
 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 @@ class ValidatorList(list):
             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
index aad6a0d..8c0ca7f 100644
--- a/example/validators/with_python/conf.py
+++ b/example/validators/with_python/conf.py
@@ -45,6 +45,8 @@ settings.validators.register(
     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
index 43ffd49..25efc5c 100644
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -47,6 +47,56 @@ MYSQL_PASSWD = "SuperSecret"
 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"