Repository: dynaconf/dynaconf

Issue:
[RFC] Allow customization of validation message
Request from @bmbouter https://github.com/pulp/pulpcore/pull/358#issuecomment-548439319

The error message I think will be confusing to users because most users use Pulp without much awareness they are also using dynaconf. The error message is: 
```
dynaconf.validator.ValidationError: CONTENT_ORIGIN is required in env DEVELOPMENT
```

We would like to be able to specify custom validator message

```python
from dynaconf import settings, Validator

settings.validators.register(
    # Ensure some password cannot exist
    Validator('CONTENT_ORIGIN', must_exist=True, message="Custom Error Message"),
)

settings.validators.validate()
```




Discussion:

Resolving patch:
diff --git a/dynaconf/cli.py b/dynaconf/cli.py
--- a/dynaconf/cli.py
+++ b/dynaconf/cli.py
@@ -625,6 +625,9 @@ def validate(path):  # pragma: no cover
 
     if success:
         click.echo(click.style("Validation success!", fg="white", bg="green"))
+    else:
+        click.echo(click.style("Validation error!", fg="white", bg="red"))
+        sys.exit(1)
 
 
 if __name__ == "__main__":  # pragma: no cover
diff --git a/dynaconf/validator.py b/dynaconf/validator.py
--- a/dynaconf/validator.py
+++ b/dynaconf/validator.py
@@ -1,4 +1,6 @@
-from dynaconf import validator_conditions
+from types import MappingProxyType
+
+from dynaconf import validator_conditions  # noqa
 
 
 class ValidationError(Exception):
@@ -54,6 +56,18 @@ class Validator(object):
 
     """
 
+    default_messages = MappingProxyType(
+        {
+            "must_exist_true": "{name} is required in env {env}",
+            "must_exist_false": "{name} cannot exists in env {env}",
+            "condition": "{name} invalid for {function}({value}) in env {env}",
+            "operations": (
+                "{name} must {operation} {op_value} "
+                "but it is {value} in env {env}"
+            ),
+        }
+    )
+
     def __init__(
         self,
         *names,
@@ -61,8 +75,13 @@ def __init__(
         condition=None,
         when=None,
         env=None,
+        messages=None,
         **operations
     ):
+        # Copy immutable MappingProxyType as a mutable dict
+        self.messages = dict(self.default_messages)
+        if messages:
+            self.messages.update(messages)
 
         if when is not None and not isinstance(when, Validator):
             raise TypeError("when must be Validator instance")
@@ -139,11 +158,13 @@ def _validate_items(self, settings, env=None):
             # is name required but not exists?
             if self.must_exist is True and not exists:
                 raise ValidationError(
-                    "{0} is required in env {1}".format(name, env)
+                    self.messages["must_exist_true"].format(name=name, env=env)
                 )
             elif self.must_exist is False and exists:
                 raise ValidationError(
-                    "{0} cannot exists in env {1}".format(name, env)
+                    self.messages["must_exist_false"].format(
+                        name=name, env=env
+                    )
                 )
 
             # if not exists and not required cancel validation flow
@@ -156,9 +177,11 @@ def _validate_items(self, settings, env=None):
             if self.condition is not None:
                 if not self.condition(value):
                     raise ValidationError(
-                        "{0} invalid for {1}({2}) "
-                        "in env {3}".format(
-                            name, self.condition.__name__, value, env
+                        self.messages["condition"].format(
+                            name=name,
+                            function=self.condition.__name__,
+                            value=value,
+                            env=env,
                         )
                     )
 
@@ -167,9 +190,12 @@ def _validate_items(self, settings, env=None):
                 op_function = getattr(validator_conditions, op_name)
                 if not op_function(value, op_value):
                     raise ValidationError(
-                        "{0} must {1} {2} but it is {3} "
-                        "in env {4}".format(
-                            name, op_function.__name__, op_value, value, env
+                        self.messages["operations"].format(
+                            name=name,
+                            operation=op_function.__name__,
+                            op_value=op_value,
+                            value=value,
+                            env=env,
                         )
                     )
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -73,9 +73,7 @@ def docker_compose_files(pytestconfig):
     """Get the docker-compose.yml absolute path.
     Override this fixture in your tests if you need a custom location.
     """
-    return [
-        os.path.join(os.getcwd(), "tests", "docker-compose.yml"),
-    ]
+    return [os.path.join(os.getcwd(), "tests", "docker-compose.yml")]
 
 
 @pytest.fixture(scope="session")
diff --git a/tests/test_validators.py b/tests/test_validators.py
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -1,3 +1,5 @@
+from types import MappingProxyType
+
 import pytest
 
 from dynaconf import LazySettings
@@ -222,3 +224,96 @@ def test_validator_equality_by_identity():
     validator1 = Validator("FOO", must_exist=True)
     validator2 = validator1
     assert validator1 == validator2
+
+
+def test_validator_custom_message(tmpdir):
+    """Assert custom message is being processed by validator."""
+    tmpfile = tmpdir.join("settings.toml")
+    tmpfile.write(TOML)
+
+    settings = LazySettings(
+        SETTINGS_FILE_FOR_DYNACONF=str(tmpfile), silent=True
+    )
+
+    custom_msg = "You cannot set {name} to {value} in env {env}"
+    settings.validators.register(
+        Validator("MYSQL_HOST", eq="development.com", env="DEVELOPMENT"),
+        Validator("MYSQL_HOST", ne="development.com", env="PRODUCTION"),
+        Validator("VERSION", ne=1, messages={"operations": custom_msg}),
+    )
+
+    with pytest.raises(ValidationError) as error:
+        settings.validators.validate()
+
+    assert custom_msg.format(
+        name="VERSION", value="1", env="DEVELOPMENT"
+    ) in str(error)
+
+
+def test_validator_subclass_messages(tmpdir):
+    """Assert message can be customized via class attributes"""
+    tmpfile = tmpdir.join("settings.toml")
+    tmpfile.write(TOML)
+
+    settings = LazySettings(
+        SETTINGS_FILE_FOR_DYNACONF=str(tmpfile), silent=True
+    )
+
+    class MyValidator(Validator):
+        default_messages = MappingProxyType(
+            {
+                "must_exist_true": "{name} should exist in {env}",
+                "must_exist_false": "{name} CANNOT BE THERE IN {env}",
+                "condition": (
+                    "{name} BROKE THE {function}({value}) IN env {env}"
+                ),
+                "operations": (
+                    "{name} SHOULD BE {operation} {op_value} "
+                    "BUT YOU HAVE {value} IN ENV {env}, PAY ATTENTION!"
+                ),
+            }
+        )
+
+    with pytest.raises(ValidationError) as error_custom_message:
+        custom_msg = "You cannot set {name} to {value} in env {env}"
+        MyValidator(
+            "VERSION", ne=1, messages={"operations": custom_msg}
+        ).validate(settings)
+
+    assert custom_msg.format(
+        name="VERSION", value="1", env="DEVELOPMENT"
+    ) in str(error_custom_message)
+
+    with pytest.raises(ValidationError) as error_operations:
+        MyValidator("VERSION", ne=1).validate(settings)
+
+    assert (
+        "VERSION SHOULD BE ne 1 "
+        "BUT YOU HAVE 1 IN ENV DEVELOPMENT, "
+        "PAY ATTENTION!"
+    ) in str(error_operations)
+
+    with pytest.raises(ValidationError) as error_conditions:
+        MyValidator("VERSION", condition=lambda value: False).validate(
+            settings
+        )
+
+    assert ("VERSION BROKE THE <lambda>(1) IN env DEVELOPMENT") in str(
+        error_conditions
+    )
+
+    with pytest.raises(ValidationError) as error_must_exist_false:
+        MyValidator("VERSION", must_exist=False).validate(settings)
+
+    assert ("VERSION CANNOT BE THERE IN DEVELOPMENT") in str(
+        error_must_exist_false
+    )
+
+    with pytest.raises(ValidationError) as error_must_exist_true:
+        MyValidator("BLARGVARGST_DONT_EXIST", must_exist=True).validate(
+            settings
+        )
+
+    assert ("BLARGVARGST_DONT_EXIST should exist in DEVELOPMENT") in str(
+        error_must_exist_true
+    )