Repository: sloria/environs

Issue:
Validation only happens one field at a time
Currently, with the following code:

```
from environs import Env
env = Env()
var1 = env("VAR1")
var2 = env("VAR2")
```

If both env vars are missing, environs will simply display:
```
environs.EnvError: Environment variable "VAR1" not set
```

And only when that's set will it display the second error.

It'd be great to have it validate as far as possible all the required environment variables, perhaps with a manual flagging of when to start and stop such a process:

```
from environs import Env
env = Env(eager=False)
var1 = env("VAR1")
var2 = env("VAR2")
var3 = env.int("VAR3")
env.read()

environs.EnvError: Environment variables invalid: {"VAR1": "missing", "VAR2": "missing", "VAR3": "wrong type"]. 
```

Discussion:
sloria: This opens up a path to something like:

```python
with Env.validated() as env:
    DEBUG= env.bool("DEBUG", False)
    TTL = env.int("TTL", validate=lambda x: x > 0)
```

Not sure if that's necessary or useful though. It becomes a little more awkward to do error handling that way.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5818c4c..740c221 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
 # Changelog
 
+## 6.1.0 (unreleased)
+
+Features:
+
+- Add deferred validation via the `eager` parameter and `env.seal()` ([#56](https://github.com/sloria/environs/issues/56)).
+  Thanks [robertlagrant](https://github.com/robertlagrant) for the suggestion.
+
 ## 6.0.0 (2019-09-22)
 
 Features:
diff --git a/README.md b/README.md
index 95b41eb..a5b7e11 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,7 @@ It allows you to store configuration separate from your code, as per
 - [Handling prefixes](#handling-prefixes)
 - [Proxied variables](#proxied-variables)
 - [Validation](#validation)
+- [Deferred validation](#deferred-validation)
 - [Serialization](#serialization)
 - [Defining custom parser behavior](#defining-custom-parser-behavior)
 - [Usage with Flask](#usage-with-flask)
@@ -183,14 +184,17 @@ smtp_login = env("SMTP_LOGIN")  # =>'sloria'
 # export NODE_ENV='invalid'
 # export EMAIL='^_^'
 
+from environs import Env
+from marshmallow.validate import OneOf, Length, Email
+
+env = Env()
 
 # simple validator
 env.int("TTL", validate=lambda n: n > 0)
 # => Environment variable "TTL" invalid: ['Invalid value.']
 
-# using marshmallow validators
-from marshmallow.validate import OneOf
 
+# using marshmallow validators
 env.str(
     "NODE_ENV",
     validate=OneOf(
@@ -200,12 +204,41 @@ env.str(
 # => Environment variable "NODE_ENV" invalid: ['NODE_ENV must be one of: production, development']
 
 # multiple validators
-from marshmallow.validate import Length, Email
-
 env.str("EMAIL", validate=[Length(min=4), Email()])
 # => Environment variable "EMAIL" invalid: ['Shorter than minimum length 4.', 'Not a valid email address.']
 ```
 
+## Deferred validation
+
+By default, a validation error is raised immediately upon calling a parser method for an invalid environment variable.
+To defer validation and raise an exception with the combined error messages for all invalid variables, pass `eager=False` to `Env`.
+Call `env.seal()` after all variables have been parsed.
+
+```python
+# export TTL=-2
+# export NODE_ENV='invalid'
+# export EMAIL='^_^'
+
+from environs import Env
+from marshmallow.validate import OneOf, Email, Length, Range
+
+env = Env(eager=False)
+
+TTL = env.int("TTL", validate=Range(min=0, max=100))
+NODE_ENV = env.str(
+    "NODE_ENV",
+    validate=OneOf(
+        ["production", "development"], error="NODE_ENV must be one of: {choices}"
+    ),
+)
+EMAIL = env.str("EMAIL", validate=[Length(min=4), Email()])
+
+env.seal()
+# environs.EnvValidationError: Environment variables invalid: {'TTL': ['Must be greater than or equal to 0 and less than or equal to 100.'], 'NODE_ENV': ['NODE_ENV must be one of: production, development'], 'EMAIL': ['Shorter than minimum length 4.', 'Not a valid email address.']}
+```
+
+`env.seal()` validates all parsed variables and prevents further parsing (calling a parser method will raise an error).
+
 ## Serialization
 
 ```python
diff --git a/environs.py b/environs.py
index d762b21..251aaae 100644
--- a/environs.py
+++ b/environs.py
@@ -1,3 +1,4 @@
+import collections
 import contextlib
 import inspect
 import functools
@@ -24,6 +25,8 @@ _StrType = str
 _BoolType = bool
 _IntType = int
 
+ErrorMapping = typing.Mapping[str, typing.List[str]]
+ErrorList = typing.List[str]
 FieldFactory = typing.Callable[..., ma.fields.Field]
 Subcast = typing.Union[typing.Type, typing.Callable[..., _T]]
 FieldType = typing.Type[ma.fields.Field]
@@ -35,6 +38,16 @@ class EnvError(ValueError):
     """Raised when an environment variable or if a required environment variable is unset."""
 
 
+class EnvValidationError(EnvError):
+    def __init__(self, message: str, error_messages: typing.Union[ErrorList, ErrorMapping]):
+        self.error_messages = error_messages
+        super().__init__(message)
+
+
+class EnvSealedError(TypeError, EnvError):
+    pass
+
+
 class ParserConflictError(ValueError):
     """Raised when adding a custom parser that conflicts with a built-in parser method."""
 
@@ -45,6 +58,8 @@ def _field2method(
     def method(
         self: "Env", name: str, default: typing.Any = ma.missing, subcast: Subcast = None, **kwargs
     ) -> _T:
+        if self._sealed:
+            raise EnvSealedError("Env has already been sealed. New values cannot be parsed.")
         missing = kwargs.pop("missing", None) or default
         if isinstance(field_or_factory, type) and issubclass(field_or_factory, ma.fields.Field):
             field = typing.cast(typing.Type[ma.fields.Field], field_or_factory)(missing=missing, **kwargs)
@@ -54,7 +69,11 @@ def _field2method(
         self._fields[parsed_key] = field
         source_key = proxied_key or parsed_key
         if raw_value is ma.missing and field.missing is ma.missing:
-            raise EnvError('Environment variable "{}" not set'.format(source_key))
+            message = "Environment variable not set."
+            if self.eager:
+                raise EnvValidationError('Environment variable "{}" not set'.format(source_key), [message])
+            else:
+                self._errors[parsed_key].append(message)
         if raw_value or raw_value == "":
             value = raw_value
         else:
@@ -64,12 +83,14 @@ def _field2method(
         try:
             value = field.deserialize(value)
         except ma.ValidationError as error:
-            raise EnvError(
-                'Environment variable "{}" invalid: {}'.format(source_key, error.args[0])
-            ) from error
+            if self.eager:
+                raise EnvValidationError(
+                    'Environment variable "{}" invalid: {}'.format(source_key, error.args[0]), error.messages
+                ) from error
+            self._errors[parsed_key].extend(error.messages)
         else:
             self._values[parsed_key] = value
-            return value
+        return value
 
     method.__name__ = method_name
     return method
@@ -79,6 +100,8 @@ def _func2method(func: typing.Callable, method_name: str) -> ParserMethod:
     def method(
         self: "Env", name: str, default: typing.Any = ma.missing, subcast: typing.Type = None, **kwargs
     ):
+        if self._sealed:
+            raise EnvSealedError("Env has already been sealed. New values cannot be parsed.")
         parsed_key, raw_value, proxied_key = self._get_from_environ(name, default)
         if raw_value is ma.missing:
             raise EnvError('Environment variable "{}" not set'.format(proxied_key or parsed_key))
@@ -205,11 +228,14 @@ class Env:
     dj_db_url = _func2method(_dj_db_url_parser, "dj_db_url")
     dj_email_url = _func2method(_dj_email_url_parser, "dj_email_url")
 
-    def __init__(self):
+    def __init__(self, *, eager: _BoolType = True):
+        self.eager = eager
+        self._sealed = False  # type: bool
         self._fields = {}  # type: typing.Dict[_StrType, ma.fields.Field]
         self._values = {}  # type: typing.Dict[_StrType, typing.Any]
+        self._errors = collections.defaultdict(list)  # type: ErrorMapping
         self._prefix = None  # type: typing.Optional[_StrType]
-        self.__custom_parsers__ = {}
+        self.__custom_parsers__ = {}  # type: typing.Dict[_StrType, ParserMethod]
 
     def __repr__(self) -> _StrType:
         return "<{} {}>".format(self.__class__.__name__, self._values)
@@ -269,6 +295,19 @@ class Env:
             self._prefix = None
         self._prefix = old_prefix
 
+    def seal(self):
+        """Validate parsed values and prevent new values from being added.
+
+        :raises: environs.EnvValidationError
+        """
+        self._sealed = True
+        if self._errors:
+            error_messages = dict(self._errors)
+            self._errors = {}
+            raise EnvValidationError(
+                "Environment variables invalid: {}".format(error_messages), error_messages
+            )
+
     def __getattr__(self, name: _StrType, **kwargs):
         try:
             return functools.partial(self.__custom_parsers__[name], self)
diff --git a/examples/deferred_validation_example.py b/examples/deferred_validation_example.py
new file mode 100644
index 0000000..3042e03
--- /dev/null
+++ b/examples/deferred_validation_example.py
@@ -0,0 +1,24 @@
+import os
+from pprint import pprint
+
+from environs import Env, EnvValidationError
+from marshmallow.validate import OneOf, Email, Length, Range
+
+
+os.environ["TTL"] = "-2"
+os.environ["NODE_ENV"] = "invalid"
+os.environ["EMAIL"] = "^_^"
+
+
+env = Env(eager=False)
+TTL = env.int("TTL", validate=Range(min=0, max=100))
+NODE_ENV = env.str(
+    "NODE_ENV", validate=OneOf(["production", "development"], error="NODE_ENV must be one of: {choices}")
+)
+EMAIL = env.str("EMAIL", validate=[Length(min=4), Email()])
+
+# This will raise an error with the combined validation messages
+try:
+    env.seal()
+except EnvValidationError as error:
+    pprint(error.error_messages)
diff --git a/setup.py b/setup.py
index 6113a24..dbd695f 100644
--- a/setup.py
+++ b/setup.py
@@ -6,7 +6,7 @@ DJANGO_REQUIRES = ["dj-database-url", "dj-email-url"]
 EXTRAS_REQUIRE = {
     "django": DJANGO_REQUIRES,
     "tests": ["pytest"] + DJANGO_REQUIRES,
-    "lint": ["flake8==3.7.8", "flake8-bugbear==19.8.0", "pre-commit~=1.18"],
+    "lint": ["flake8==3.7.9", "flake8-bugbear==19.8.0", "pre-commit~=1.18"],
 }
 EXTRAS_REQUIRE["dev"] = EXTRAS_REQUIRE["tests"] + EXTRAS_REQUIRE["lint"] + ["tox"]
 PYTHON_REQUIRES = ">=3.5"


Test patch:
diff --git a/tests/test_environs.py b/tests/test_environs.py
index bc5d284..29c864b 100644
--- a/tests/test_environs.py
+++ b/tests/test_environs.py
@@ -56,9 +56,10 @@ class TestCasting:
 
     def test_invalid_int(self, set_env, env):
         set_env({"INT": "invalid"})
-        with pytest.raises(environs.EnvError) as excinfo:
+        with pytest.raises(environs.EnvError, match='Environment variable "INT" invalid') as excinfo:
             env.int("INT")
-        assert 'Environment variable "INT" invalid' in excinfo.value.args[0]
+        exc = excinfo.value
+        assert "Not a valid integer." in exc.error_messages
 
     def test_float_cast(self, set_env, env):
         set_env({"FLOAT": "33.3"})
@@ -535,3 +536,51 @@ class TestDjango:
         set_env({"EMAIL_URL": email_url})
         res = env.dj_email_url("EMAIL_URL")
         assert res == dj_email_url.parse(email_url)
+
+
+class TestDeferredValidation:
+    @pytest.fixture
+    def env(self):
+        return environs.Env(eager=False)
+
+    def test_valid(self, env, set_env):
+        set_env({"STR": "foo", "INT": "42"})
+        str_val = env.str("STR")
+        int_val = env.int("INT")
+        env.seal()
+        assert str_val == "foo"
+        assert int_val == 42
+
+    def test_validation(self, env, set_env):
+        set_env({"INT": "invalid", "DTIME": "notadatetime"})
+        env.int("INT")
+        env.datetime("DTIME")
+        env.str("REQUIRED")
+        with pytest.raises(environs.EnvValidationError) as excinfo:
+            env.seal()
+        exc = excinfo.value
+        msg = exc.args[0]
+        assert "REQUIRED" in msg
+        assert "INT" in msg
+        assert "DTIME" in msg
+        assert "REQUIRED" in exc.error_messages
+        assert "INT" in exc.error_messages
+        assert "DTIME" in exc.error_messages
+
+    def test_cannot_add_after_seal(self, env, set_env):
+        set_env({"STR": "foo", "INT": "42"})
+        env.str("STR")
+        env.seal()
+        with pytest.raises(environs.EnvSealedError, match="Env has already been sealed"):
+            env.int("INT")
+
+    def test_custom_parser_not_called_after_seal(self, env, set_env):
+        set_env({"URL": "test.test/"})
+
+        @env.parser_for("https_url")
+        def https_url(value):
+            return "https://" + value
+
+        env.seal()
+        with pytest.raises(environs.EnvSealedError, match="Env has already been sealed"):
+            env.https_url("URL")