Repository: rochacbruno/dynaconf
Pull request #634 (2021-08-10 14:19:57)

description and type annotation for validator
Added description to Validator

```py
Validator("foo", description="foo description")
```

Given a ValidatorList one can do.

```py
>>> settings.validators.descriptions(flat=True)
{"foo": "foo description"}
```

Not sure what to do with this yet but the support is added.

Probably the next step is to add a `CLI` to fetch those descriptions and generate/export settings files with descriptions as hints.

Fix: #115 

codecov[bot]: # [Codecov](https://codecov.io/gh/rochacbruno/dynaconf/pull/634?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Bruno+Rocha) Report
> Merging [#634](https://codecov.io/gh/rochacbruno/dynaconf/pull/634?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Bruno+Rocha) (76d0d4a) into [master](https://codecov.io/gh/rochacbruno/dynaconf/commit/91e2b0dc5e27bdb28417f4410ad1617134d8f7d6?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Bruno+Rocha) (91e2b0d) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/rochacbruno/dynaconf/pull/634/graphs/tree.svg?width=650&height=150&src=pr&token=GXszVzJcqW&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Bruno+Rocha)](https://codecov.io/gh/rochacbruno/dynaconf/pull/634?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Bruno+Rocha)

```diff
@@            Coverage Diff            @@
##            master      #634   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           21        21           
  Lines         1584      1593    +9     
=========================================
+ Hits          1584      1593    +9     
```


| [Impacted Files](https://codecov.io/gh/rochacbruno/dynaconf/pull/634?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Bruno+Rocha) | Coverage Δ | |
|---|---|---|
| [dynaconf/validator.py](https://codecov.io/gh/rochacbruno/dynaconf/pull/634/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Bruno+Rocha#diff-ZHluYWNvbmYvdmFsaWRhdG9yLnB5) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/rochacbruno/dynaconf/pull/634?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Bruno+Rocha).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Bruno+Rocha)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/rochacbruno/dynaconf/pull/634?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Bruno+Rocha). Last update [91e2b0d...76d0d4a](https://codecov.io/gh/rochacbruno/dynaconf/pull/634?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Bruno+Rocha). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Bruno+Rocha).


Patch:
diff --git a/dynaconf/validator.py b/dynaconf/validator.py
index ced1271..8a77a5d 100644
--- a/dynaconf/validator.py
+++ b/dynaconf/validator.py
@@ -1,7 +1,17 @@
+from __future__ import annotations
+
+from collections import defaultdict
 from itertools import chain
 from types import MappingProxyType
-
-from dynaconf import validator_conditions  # noqa
+from typing import Any
+from typing import Callable
+from typing import Dict
+from typing import List
+from typing import Optional
+from typing import Sequence
+from typing import Union
+
+from dynaconf import validator_conditions
 from dynaconf.utils import ensure_a_list
 from dynaconf.utils.functional import empty
 
@@ -89,17 +99,18 @@ class Validator:
 
     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
-    ):
+        *names: str,
+        must_exist: Optional[bool] = None,
+        required: Optional[bool] = None,  # alias for `must_exist`
+        condition: Optional[Callable[[Any], bool]] = None,
+        when: Optional[Validator] = None,
+        env: Optional[Union[str, Sequence[str]]] = None,
+        messages: Optional[Dict[str, str]] = None,
+        cast: Optional[Callable[[Any], Any]] = None,
+        default: Optional[Union[Any, Callable[[Any, Validator], Any]]] = empty,
+        description: Optional[str] = None,
+        **operations: Any,
+    ) -> None:
         # Copy immutable MappingProxyType as a mutable dict
         self.messages = dict(self.default_messages)
         if messages:
@@ -118,21 +129,21 @@ class Validator:
         self.cast = cast or (lambda value: value)
         self.operations = operations
         self.default = default
+        self.description = description
+        self.envs: Optional[Sequence[str]] = None
 
         if isinstance(env, str):
             self.envs = [env]
         elif isinstance(env, (list, tuple)):
             self.envs = env
-        else:
-            self.envs = None
 
-    def __or__(self, other):
-        return OrValidator(self, other)
+    def __or__(self, other: Validator) -> CombinedValidator:
+        return OrValidator(self, other, description=self.description)
 
-    def __and__(self, other):
-        return AndValidator(self, other)
+    def __and__(self, other: Validator) -> CombinedValidator:
+        return AndValidator(self, other, description=self.description)
 
-    def __eq__(self, other):
+    def __eq__(self, other: object) -> bool:
         if self is other:
             return True
 
@@ -148,7 +159,12 @@ class Validator:
 
         return False
 
-    def validate(self, settings, only=None, exclude=None):
+    def validate(
+        self,
+        settings: Any,
+        only: Optional[Union[str, Sequence]] = None,
+        exclude: Optional[Union[str, Sequence]] = None,
+    ) -> None:
         """Raise ValidationError if invalid"""
         # If only or exclude are not set, this value always passes startswith
         only = ensure_a_list(only or [""])
@@ -188,15 +204,23 @@ class Validator:
                 settings.from_env(env), only=only, exclude=exclude
             )
 
-    def _validate_items(self, settings, env=None, only=None, exclude=None):
+    def _validate_items(
+        self,
+        settings: Any,
+        env: Optional[str] = None,
+        only: Optional[Union[str, Sequence]] = None,
+        exclude: Optional[Union[str, Sequence]] = None,
+    ) -> 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):
+            if only and 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):
+            if exclude and any(name.startswith(sub) for sub in exclude):
                 continue
+
             if self.default is not empty:
                 default_value = (
                     self.default(settings, self)
@@ -252,7 +276,13 @@ class Validator:
 
 
 class CombinedValidator(Validator):
-    def __init__(self, validator_a, validator_b, *args, **kwargs):
+    def __init__(
+        self,
+        validator_a: Validator,
+        validator_b: Validator,
+        *args: Any,
+        **kwargs: Any,
+    ) -> None:
         """Takes 2 validators and combines the validation"""
         self.validators = (validator_a, validator_b)
         super().__init__(*args, **kwargs)
@@ -263,7 +293,12 @@ class CombinedValidator(Validator):
                 )
                 setattr(self, attr, value)
 
-    def validate(self, settings, only=None, exclude=None):  # pragma: no cover
+    def validate(
+        self,
+        settings: Any,
+        only: Optional[Union[str, Sequence]] = None,
+        exclude: Optional[Union[str, Sequence]] = None,
+    ) -> None:  # pragma: no cover
         raise NotImplementedError(
             "subclasses OrValidator or AndValidator implements this method"
         )
@@ -272,7 +307,12 @@ class CombinedValidator(Validator):
 class OrValidator(CombinedValidator):
     """Evaluates on Validator() | Validator()"""
 
-    def validate(self, settings, only=None, exclude=None):
+    def validate(
+        self,
+        settings: Any,
+        only: Optional[Union[str, Sequence]] = None,
+        exclude: Optional[Union[str, Sequence]] = None,
+    ) -> None:
         """Ensure at least one of the validators are valid"""
         errors = []
         for validator in self.validators:
@@ -297,7 +337,12 @@ class OrValidator(CombinedValidator):
 class AndValidator(CombinedValidator):
     """Evaluates on Validator() & Validator()"""
 
-    def validate(self, settings, only=None, exclude=None):
+    def validate(
+        self,
+        settings: Any,
+        only: Optional[Union[str, Sequence]] = None,
+        exclude: Optional[Union[str, Sequence]] = None,
+    ) -> None:
         """Ensure both the validators are valid"""
         errors = []
         for validator in self.validators:
@@ -319,23 +364,51 @@ class AndValidator(CombinedValidator):
 
 
 class ValidatorList(list):
-    def __init__(self, settings, validators=None, *args, **kwargs):
+    def __init__(
+        self,
+        settings: Any,
+        validators: Optional[Sequence[Validator]] = None,
+        *args: Validator,
+        **kwargs: Any,
+    ) -> None:
         if isinstance(validators, (list, tuple)):
-            args = list(args) + list(validators)
+            args = list(args) + list(validators)  # type: ignore
         self._only = kwargs.pop("validate_only", None)
         self._exclude = kwargs.pop("validate_exclude", None)
-        super(ValidatorList, self).__init__(args, **kwargs)
+        super(ValidatorList, self).__init__(args, **kwargs)  # type: ignore
         self.settings = settings
 
-    def register(self, *args, **kwargs):
-        validators = list(chain.from_iterable(kwargs.values()))
+    def register(self, *args: Validator, **kwargs: Validator):
+        validators: List[Validator] = list(
+            chain.from_iterable(kwargs.values())  # type: ignore
+        )
         validators.extend(args)
         for validator in validators:
             if validator and validator not in self:
                 self.append(validator)
 
-    def validate(self, only=None, exclude=None):
-        # only = only or self._only
-        # exclude = exclude or self._exclude
+    def descriptions(
+        self, flat: bool = False
+    ) -> Dict[str, Union[str, List[str]]]:
+        if flat:
+            descriptions = {}
+        else:
+            descriptions = defaultdict(list)
+
+        for validator in self:
+            for name in validator.names:
+                if isinstance(name, tuple) and len(name) > 0:
+                    name = name[0]
+                if flat:
+                    descriptions.setdefault(name, validator.description)
+                else:
+                    descriptions[name].append(validator.description)
+        return descriptions
+
+    def validate(
+        self,
+        only: Optional[Union[str, Sequence]] = None,
+        exclude: Optional[Union[str, Sequence]] = None,
+    ) -> None:
         for validator in self:
             validator.validate(self.settings, only=only, exclude=exclude)


Test patch:
diff --git a/tests/test_validators.py b/tests/test_validators.py
index ab18d74..9a592e2 100644
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -5,6 +5,7 @@ import pytest
 from dynaconf import LazySettings
 from dynaconf import ValidationError
 from dynaconf import Validator
+from dynaconf.validator import ValidatorList
 
 
 TOML = """
@@ -614,3 +615,47 @@ def test_raises_on_invalid_selective_args(tmpdir, yaml_validators_good):
     )
     with pytest.raises(ValueError):
         settings.validator_instance.validate()
+
+
+def test_validator_descriptions(tmpdir):
+    validators = ValidatorList(
+        LazySettings(),
+        validators=[
+            Validator("foo", description="foo"),
+            Validator("bar", description="bar"),
+            Validator("baz", "zaz", description="baz zaz"),
+            Validator("foo", description="another message"),
+            Validator("a", description="a") & Validator("b"),
+        ],
+    )
+
+    assert validators.descriptions() == {
+        "bar": ["bar"],
+        "baz": ["baz zaz"],
+        "zaz": ["baz zaz"],
+        "foo": ["foo", "another message"],
+        "a": ["a"],
+        "b": ["a"],
+    }
+
+
+def test_validator_descriptions_flat(tmpdir):
+    validators = ValidatorList(
+        LazySettings(),
+        validators=[
+            Validator("foo", description="foo"),
+            Validator("bar", description="bar"),
+            Validator("baz", "zaz", description="baz zaz"),
+            Validator("foo", description="another message"),
+            Validator("a", description="a") & Validator("b"),
+        ],
+    )
+
+    assert validators.descriptions(flat=True) == {
+        "bar": "bar",
+        "baz": "baz zaz",
+        "zaz": "baz zaz",
+        "foo": "foo",
+        "a": "a",
+        "b": "a",
+    }