Repository: rochacbruno/dynaconf
Pull request #353 (2020-06-05 07:27:27)

Validator improvements on conditions

codecov[bot]: # [Codecov](https://codecov.io/gh/rochacbruno/dynaconf/pull/353?src=pr&el=h1) Report
> Merging [#353](https://codecov.io/gh/rochacbruno/dynaconf/pull/353?src=pr&el=desc) into [master](https://codecov.io/gh/rochacbruno/dynaconf/commit/a8038b87763ae8e790ff7e745b9335f997d5bd16&el=desc) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/rochacbruno/dynaconf/pull/353/graphs/tree.svg?width=650&height=150&src=pr&token=GXszVzJcqW)](https://codecov.io/gh/rochacbruno/dynaconf/pull/353?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##            master      #353   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           17        17           
  Lines          774       813   +39     
=========================================
+ Hits           774       813   +39     
```


| [Impacted Files](https://codecov.io/gh/rochacbruno/dynaconf/pull/353?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [dynaconf/validator.py](https://codecov.io/gh/rochacbruno/dynaconf/pull/353/diff?src=pr&el=tree#diff-ZHluYWNvbmYvdmFsaWRhdG9yLnB5) | `100.00% <100.00%> (ø)` | |
| [dynaconf/validator\_conditions.py](https://codecov.io/gh/rochacbruno/dynaconf/pull/353/diff?src=pr&el=tree#diff-ZHluYWNvbmYvdmFsaWRhdG9yX2NvbmRpdGlvbnMucHk=) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/rochacbruno/dynaconf/pull/353?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/rochacbruno/dynaconf/pull/353?src=pr&el=footer). Last update [a8038b8...8c84052](https://codecov.io/gh/rochacbruno/dynaconf/pull/353?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/docs/guides/validation.md b/docs/guides/validation.md
index 827f511..9727557 100644
--- a/docs/guides/validation.md
+++ b/docs/guides/validation.md
@@ -75,6 +75,23 @@ settings.validators.register(
 settings.validators.validate()
 ```
 
+### Combining validators
+
+Validators can be combined using:
+
+
+#### `|` **or** operator.
+
+```py
+Validator('DATABASE.USER', must_exist=True) | Validator('DATABASE.KEY', must_exist=True)
+```
+
+#### `&` **and** operator.
+
+```py
+Validator('DATABASE.HOST', must_exist=True) & Validator('DATABASE.CONN', must_exist=True)
+```
+
 ## CLI and dynaconf_validators.toml
 
 > **NEW in 1.0.1**
diff --git a/dynaconf/base.py b/dynaconf/base.py
index d79cb11..b4ac6c8 100644
--- a/dynaconf/base.py
+++ b/dynaconf/base.py
@@ -213,6 +213,10 @@ class Settings(object):
         self._not_installed_warnings = []
         self._memoized = None
 
+        self.validators = ValidatorList(
+            self, validators=kwargs.pop("validators", None)
+        )
+
         compat_kwargs(kwargs)
         if settings_module:
             self.set("SETTINGS_FILE_FOR_DYNACONF", settings_module)
@@ -223,6 +227,8 @@ class Settings(object):
         self.logger.debug(f"Initializing Dynaconf ({self._store})")
         self.execute_loaders()
 
+        self.validators.validate()
+
     def __call__(self, *args, **kwargs):
         """Allow direct call of `settings('val')`
         in place of `settings.get('val')`
@@ -1054,13 +1060,6 @@ class Settings(object):
         )
         return find_file(*args, **kwargs)
 
-    @property
-    def validators(self):
-        """Gets or creates validator wrapper"""
-        if not hasattr(self, "_validators"):
-            self._validators = ValidatorList(self)
-        return self._validators
-
     def flag(self, key, env=None):
         """Feature flagging system
         write flags to redis
@@ -1140,5 +1139,6 @@ RESERVED_ATTRS = (
         "SETTINGS_MODULE",
         "_not_installed_warnings",
         "_memoized",
+        "validators",
     ]
 )
diff --git a/dynaconf/utils/functional.py b/dynaconf/utils/functional.py
index 1200055..fea4c94 100644
--- a/dynaconf/utils/functional.py
+++ b/dynaconf/utils/functional.py
@@ -1,7 +1,13 @@
 import copy
 import operator
 
-empty = object()
+
+class Empty:
+    def __str__(self):
+        return "EMPTY"
+
+
+empty = Empty()
 
 
 def new_method_proxy(func):
diff --git a/dynaconf/validator.py b/dynaconf/validator.py
index 76cab1d..d78189d 100644
--- a/dynaconf/validator.py
+++ b/dynaconf/validator.py
@@ -1,6 +1,8 @@
+from itertools import chain
 from types import MappingProxyType
 
 from dynaconf import validator_conditions  # noqa
+from dynaconf.utils.functional import empty
 
 
 class ValidationError(Exception):
@@ -70,6 +72,7 @@ class Validator(object):
                 "{name} must {operation} {op_value} "
                 "but it is {value} in env {env}"
             ),
+            "combined": "combined validators failed {errors}",
         }
     )
 
@@ -81,6 +84,7 @@ class Validator(object):
         when=None,
         env=None,
         messages=None,
+        cast=None,
         **operations
     ):
         # Copy immutable MappingProxyType as a mutable dict
@@ -98,6 +102,7 @@ class Validator(object):
         self.must_exist = must_exist
         self.condition = condition
         self.when = when
+        self.cast = cast or (lambda value: value)
         self.operations = operations
 
         if isinstance(env, str):
@@ -107,6 +112,12 @@ class Validator(object):
         else:
             self.envs = None
 
+    def __or__(self, other):
+        return OrValidator(self, other)
+
+    def __and__(self, other):
+        return AndValidator(self, other)
+
     def __eq__(self, other):
         if self is other:
             return True
@@ -171,12 +182,10 @@ class Validator(object):
                         name=name, env=env
                     )
                 )
-
-            # if not exists and not required cancel validation flow
-            if not exists:
+            elif self.must_exist is False and not exists:
                 return
 
-            value = settings[name]
+            value = self.cast(settings.get(name, empty))
 
             # is there a callable condition?
             if self.condition is not None:
@@ -205,14 +214,71 @@ class Validator(object):
                     )
 
 
+class CombinedValidator(Validator):
+    def __init__(self, validator_a, validator_b, *args, **kwargs):
+        """Takes 2 validators and combines the validation"""
+        self.validators = (validator_a, validator_b)
+        super().__init__(*args, **kwargs)
+
+    def validate(self, settings):  # pragma: no cover
+        raise NotImplementedError(
+            "subclasses OrValidator or AndValidator implements this method"
+        )
+
+
+class OrValidator(CombinedValidator):
+    """Evaluates on Validator() | Validator()"""
+
+    def validate(self, settings):
+        """Ensure at least one of the validators are valid"""
+        errors = []
+        for validator in self.validators:
+            try:
+                validator.validate(settings)
+            except ValidationError as e:
+                errors.append(e)
+                continue
+            else:
+                return
+        raise ValidationError(
+            self.messages["combined"].format(
+                errors=" or ".join(str(e) for e in errors)
+            )
+        )
+
+
+class AndValidator(CombinedValidator):
+    """Evaluates on Validator() & Validator()"""
+
+    def validate(self, settings):
+        """Ensure both the validators are valid"""
+        errors = []
+        for validator in self.validators:
+            try:
+                validator.validate(settings)
+            except ValidationError as e:
+                errors.append(e)
+                continue
+        if errors:
+            raise ValidationError(
+                self.messages["combined"].format(
+                    errors=" and ".join(str(e) for e in errors)
+                )
+            )
+
+
 class ValidatorList(list):
-    def __init__(self, settings, *args, **kwargs):
-        super(ValidatorList, self).__init__(*args, **kwargs)
+    def __init__(self, settings, validators=None, *args, **kwargs):
+        if isinstance(validators, (list, tuple)):
+            args = list(args) + list(validators)
+        super(ValidatorList, self).__init__(args, **kwargs)
         self.settings = settings
 
-    def register(self, *args):
-        for validator in args:
-            if validator not in self:
+    def register(self, *args, **kwargs):
+        validators = list(chain.from_iterable(kwargs.values()))
+        validators.extend(args)
+        for validator in validators:
+            if validator and validator not in self:
                 self.append(validator)
 
     def validate(self):
diff --git a/dynaconf/validator_conditions.py b/dynaconf/validator_conditions.py
index 4120a29..cb1c3f5 100644
--- a/dynaconf/validator_conditions.py
+++ b/dynaconf/validator_conditions.py
@@ -61,19 +61,24 @@ def cont(value, other):
 
 def len_eq(value, other):
     """Length Equal"""
-    return len(str(value)) == other
+    return len(value) == other
 
 
 def len_ne(value, other):
     """Length Not equal"""
-    return len(str(value)) != other
+    return len(value) != other
 
 
 def len_min(value, other):
     """Minimum length"""
-    return len(str(value)) > other
+    return len(value) >= other
 
 
 def len_max(value, other):
     """Maximum lenght"""
-    return len(str(value)) < other
+    return len(value) <= other
+
+
+def startswith(value, term):
+    """returns value.startswith(term) result"""
+    return value.startswith(term)


Test patch:
diff --git a/tests/test_validators.py b/tests/test_validators.py
index 9e1e934..63a8684 100644
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -48,6 +48,25 @@ TESTVALUE = "value"
 """
 
 
+def test_validators_on_init(tmpdir):
+    TOML = """
+    [default]
+    hostname = 'devserver.com'
+    username = 'admin'
+    """
+    tmpdir.join("settings.toml").write(TOML)
+
+    settings = LazySettings(
+        validators=(
+            Validator("hostname", eq="devserver.com"),
+            Validator("username", ne="admin"),
+        )
+    )
+
+    with pytest.raises(ValidationError):
+        settings.validators.validate()
+
+
 def test_validators(tmpdir):
 
     tmpfile = tmpdir.join("settings.toml")
@@ -100,7 +119,7 @@ def test_validators(tmpdir):
         Validator("FALSE", is_type_of=bool, eq=False),
         Validator("NAME", len_min=3, len_max=125),
         Validator("DEV_SERVERS", cont="localhost"),
-        Validator("PORT", len_eq=4),
+        Validator("PORT", condition=lambda value: len(str(value)) == 4),
         Validator("PROJECT", len_ne=0),
     )
 
@@ -321,3 +340,85 @@ def test_validator_subclass_messages(tmpdir):
     assert ("BLARGVARGST_DONT_EXIST should exist in DEVELOPMENT") in str(
         error_must_exist_true
     )
+
+
+def test_positive_combined_validators(tmpdir):
+    tmpfile = tmpdir.join("settings.toml")
+    tmpfile.write(TOML)
+    settings = LazySettings(
+        SETTINGS_FILE_FOR_DYNACONF=str(tmpfile), silent=True
+    )
+    settings.validators.register(
+        Validator("VERSION", ne=1) | Validator("VERSION", ne=2),
+        Validator("VERSION", ne=4) & Validator("VERSION", ne=2),
+    )
+    settings.validators.validate()
+
+
+def test_negative_combined_or_validators(tmpdir):
+    tmpfile = tmpdir.join("settings.toml")
+    tmpfile.write(TOML)
+    settings = LazySettings(
+        SETTINGS_FILE_FOR_DYNACONF=str(tmpfile), silent=True
+    )
+    settings.validators.register(
+        Validator("VERSION", ne=1) | Validator("VERSION", ne=1),
+    )
+    with pytest.raises(ValidationError):
+        settings.validators.validate()
+
+
+def test_negative_combined_and_validators(tmpdir):
+    tmpfile = tmpdir.join("settings.toml")
+    tmpfile.write(TOML)
+    settings = LazySettings(
+        SETTINGS_FILE_FOR_DYNACONF=str(tmpfile), silent=True
+    )
+    settings.validators.register(
+        Validator("VERSION", ne=1) & Validator("VERSION", ne=1),
+    )
+    with pytest.raises(ValidationError):
+        settings.validators.validate()
+
+
+def test_envless_and_combined_validators(tmpdir):
+    tmpfile = tmpdir.join("settings.toml")
+    TOML = """
+    value = true
+    version = 1
+    name = 'Bruno'
+    """
+    tmpfile.write(TOML)
+    settings = LazySettings(
+        SETTINGS_FILE_FOR_DYNACONF=str(tmpfile), silent=True, envless_mode=True
+    )
+    settings.validators.register(
+        Validator("VERSION", ne=1) & Validator("value", ne=True),
+    )
+    with pytest.raises(ValidationError):
+        settings.validators.validate()
+
+
+def test_cast_before_validate(tmpdir):
+    tmpfile = tmpdir.join("settings.toml")
+    TOML = """
+    name = 'Bruno'
+    colors = ['red', 'green', 'blue']
+    """
+    tmpfile.write(TOML)
+    settings = LazySettings(
+        settings_file=str(tmpfile),
+        silent=True,
+        envless_mode=True,
+        lowercase_read=True,
+        validators=[
+            Validator("name", len_eq=5),
+            Validator("name", len_min=1),
+            Validator("name", len_max=5),
+            Validator("colors", len_eq=3),
+            Validator("colors", len_eq=3),
+            Validator("colors", len_eq=24, cast=str),
+        ],
+    )
+    assert settings.name == "Bruno"
+    assert settings.colors == ["red", "green", "blue"]
diff --git a/tests/test_validators_conditions.py b/tests/test_validators_conditions.py
index f751472..9b8bf68 100644
--- a/tests/test_validators_conditions.py
+++ b/tests/test_validators_conditions.py
@@ -16,9 +16,14 @@ positive_conditions = [
     ("is_not_in", 42, [55, 34]),
     ("cont", "This word(s) contains in text", "in"),
     ("len_eq", "Length Equal", 12),
+    ("len_eq", [1, 2, 3], 3),
     ("len_ne", "Length Not equal", 0),
+    ("len_ne", [], 1),
     ("len_min", "Minimum length", 3),
+    ("len_min", [1, 2, 3, 4, 5], 3),
     ("len_max", "Maximum lenght", 15),
+    ("len_max", [1, 2, 3, 4, 5], 5),
+    ("startswith", "codeshow", "code"),
 ]