Repository: rochacbruno/dynaconf (python)

Issue:
[bug] Only one CombinedValidator is registered - subsequent are silently ignored
**Describe the bug**
If validators are added through `settings.validators.register()`, only first CombinedValidator is registered - subsequent are silently skipped.

**Analysis**
The root cause is `Validator.__eq__()` method. `ValidatorList.register()` will go through provided validators and add them, but only if they aren't already on the list (`validator not in self`). `in` will return "`True` if an item of *s* is equal to *x*" ([docs](https://docs.python.org/3.8/library/stdtypes.html#common-sequence-operations)). That logic was added in #256 .

When `Validator.__eq__()` compares different objects, it looks into various `Validator` properties and compares them in pair. If they are all the same, `__eq__()` will assume these are two instances of effectively the same validation rule.

The problem is, `CombinedValidator` doesn't have any of these properties, so two completely different `CombinedValidator` will appear to be the same for `__eq__()` method.


**To Reproduce**
In python shell:
```
>>> from dynaconf import Validator
>>> (Validator("foo.foo") | Validator("foo.bar")) == (Validator("bar.foo") & Validator("bar.bar"))
True
```

This should return False, as these two `CombinedValidator`s have nothing in common.

**Environment (please complete the following information):**
 - OS: Linux/Fedora32
 - Dynaconf master (6c568d687e29ca5ed9806a74f1f4fb7e4b96be2f), 3.0.1

**Additional context**
I might try working on patch, but I'm not sure about best approach. Perhaps we need type comparison inside `__eq__()` (so comparing AndValidator to OrValidator can return early). But how do we compare two `AndValidator`s? Look into combined validators properties recursively?

Pull request:
Fix #407 - add proper equality test for CombinedValidator
Fix bug reported as #407 by introduction of proper equality test in `CombinedValidator`.

I considered adding new `combined_attributes` property to `Validator`, something that would return tuple of all attributes mentioned in `__eq__` method. Then `CombinedValidator` would override this property and return tuple of two validators `combined_attributes`.

However, the other method didn't allow to provide custom properties values by instantiating `CombinedValidator` directly. In current approach, following does work:

```python
from dynaconf.validator import Validator, AndValidator
a1 = AndValidator(Validator("FOO"), Validator("BAR"), when=Validator("BASE", must_exist=True))
a2 = AndValidator(Validator("FOO"), Validator("BAR"), when=Validator("FOUNDATION", must_exist=True))
assert a1 != a2
```

I rewrote equality tests to use pytest parameters, to extend checking for combined validators that were not checked before. I'm not huge fan of readability of these tests now, so let me know if you would prefer separate test for each input pair.

```
$ pytest -v -k equality tests/test_validators.py 
===================================================================================== test session starts =====================================================================================
platform linux -- Python 3.8.5, pytest-6.0.1, py-1.9.0, pluggy-0.13.1 -- /home/mzalewsk/.virtualenvs/dynaconf/bin/python3
cachedir: .pytest_cache
rootdir: /home/mzalewsk/sources/dynaconf, configfile: pytest.ini
plugins: cov-2.10.1, mock-3.3.1, lovely-pytest-docker-0.1.0
collected 26 items / 17 deselected / 9 selected                                                                                                                                               

tests/test_validators.py::test_equality[this_validator0-another_validator0] PASSED                                                                                                      [ 11%]
tests/test_validators.py::test_equality[this_validator1-another_validator1] PASSED                                                                                                      [ 22%]
tests/test_validators.py::test_equality[this_validator2-another_validator2] PASSED                                                                                                      [ 33%]
tests/test_validators.py::test_inequality[this_validator0-another_validator0] PASSED                                                                                                    [ 44%]
tests/test_validators.py::test_inequality[this_validator1-another_validator1] PASSED                                                                                                    [ 55%]
tests/test_validators.py::test_inequality[this_validator2-another_validator2] PASSED                                                                                                    [ 66%]
tests/test_validators.py::test_inequality[this_validator3-another_validator3] PASSED                                                                                                    [ 77%]
tests/test_validators.py::test_inequality[this_validator4-another_validator4] PASSED                                                                                                    [ 88%]
tests/test_validators.py::test_validator_equality_by_identity PASSED                                                                                                                    [100%]

============================================================================== 9 passed, 17 deselected in 0.02s ===============================================================================
```

Patch:
diff --git a/dynaconf/validator.py b/dynaconf/validator.py
index 4f8d3a4..81a0526 100644
--- a/dynaconf/validator.py
+++ b/dynaconf/validator.py
@@ -5,6 +5,16 @@ from dynaconf import validator_conditions  # noqa
 from dynaconf.utils.functional import empty
 
 
+EQUALITY_ATTRS = (
+    "names",
+    "must_exist",
+    "when",
+    "condition",
+    "operations",
+    "envs",
+)
+
+
 class ValidationError(Exception):
     pass
 
@@ -125,16 +135,12 @@ class Validator(object):
         if self is other:
             return True
 
+        if type(self).__name__ != type(other).__name__:
+            return False
+
         identical_attrs = (
             getattr(self, attr) == getattr(other, attr)
-            for attr in (
-                "names",
-                "must_exist",
-                "when",
-                "condition",
-                "operations",
-                "envs",
-            )
+            for attr in EQUALITY_ATTRS
         )
         if all(identical_attrs):
             return True
@@ -229,6 +235,12 @@ class CombinedValidator(Validator):
         """Takes 2 validators and combines the validation"""
         self.validators = (validator_a, validator_b)
         super().__init__(*args, **kwargs)
+        for attr in EQUALITY_ATTRS:
+            if not getattr(self, attr, None):
+                value = tuple(
+                    getattr(validator, attr) for validator in self.validators
+                )
+                setattr(self, attr, value)
 
     def validate(self, settings):  # pragma: no cover
         raise NotImplementedError(


Test patch:
diff --git a/tests/test_validators.py b/tests/test_validators.py
index 101d8da..43ffd49 100644
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -211,21 +211,53 @@ def test_no_reload_on_single_env(tmpdir, mocker):
     assert using_env.call_count == 1
 
 
-def test_equality():
-    validator1 = Validator("VERSION", "AGE", "NAME", must_exist=True)
-    validator2 = Validator("VERSION", "AGE", "NAME", must_exist=True)
+@pytest.mark.parametrize(
+    "this_validator,another_validator",
+    [
+        (
+            Validator("VERSION", "AGE", "NAME", must_exist=True),
+            Validator("VERSION", "AGE", "NAME", must_exist=True),
+        ),
+        (
+            Validator("VERSION") | Validator("AGE"),
+            Validator("VERSION") | Validator("AGE"),
+        ),
+        (
+            Validator("VERSION") & Validator("AGE"),
+            Validator("VERSION") & Validator("AGE"),
+        ),
+    ],
+)
+def test_equality(this_validator, another_validator):
+    assert this_validator == another_validator
+    assert this_validator is not another_validator
 
-    assert validator1 == validator2
-    assert validator1 is not validator2
 
-    validator3 = (
-        Validator("IMAGE_1", when=Validator("BASE_IMAGE", must_exist=True)),
-    )
-    validator4 = (
-        Validator("IMAGE_1", when=Validator("MYSQL_HOST", must_exist=True)),
-    )
-
-    assert validator3 != validator4
+@pytest.mark.parametrize(
+    "this_validator,another_validator",
+    [
+        (
+            Validator(
+                "IMAGE_1", when=Validator("BASE_IMAGE", must_exist=True)
+            ),
+            Validator(
+                "IMAGE_1", when=Validator("MYSQL_HOST", must_exist=True)
+            ),
+        ),
+        (Validator("VERSION"), Validator("VERSION") & Validator("AGE")),
+        (Validator("VERSION"), Validator("VERSION") | Validator("AGE")),
+        (
+            Validator("VERSION") | Validator("AGE"),
+            Validator("VERSION") & Validator("AGE"),
+        ),
+        (
+            Validator("VERSION") | Validator("AGE"),
+            Validator("NAME") | Validator("BASE_IMAGE"),
+        ),
+    ],
+)
+def test_inequality(this_validator, another_validator):
+    assert this_validator != another_validator
 
 
 def test_ignoring_duplicate_validators(tmpdir):