Repository: sloria/environs

Issue:
Env.dj_db_url, Env.dj_email_url and custom parsers don't work with deferred validation
`Env.dj_db_url` and `Env.dj_email_url` typecasting methods and custom parsers don't work with deferred validation. Here's minimal code to reproduce:
```python
env = Env(eager=False)
env.dj_email_url("NON_EXISTING_ENVVAR")
```
This raises an exception, but shouldn't due to `eager=False` being set.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9948757..27083f0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,6 +31,12 @@ env = Env(expand_vars=True)
 SMTP_LOGIN = env.str("SMTP_LOGIN")  # => 'sloria'
 ```
 
+Bug fixes:
+
+- Fix deferred validation behavior for `dj_db_url`, `dj_email_url`, `dj_cache_url`,
+  and custom parsers ([#121](https://github.com/sloria/environs/issues/121)).
+  Thanks [hukkinj1](https://github.com/hukkinj1) for reporting.
+
 Other changes:
 
 - Test against Python 3.9.
diff --git a/environs/__init__.py b/environs/__init__.py
index e936e35..14e6b98 100644
--- a/environs/__init__.py
+++ b/environs/__init__.py
@@ -14,6 +14,7 @@ from urllib.parse import urlparse, ParseResult
 from pathlib import Path
 
 import marshmallow as ma
+from marshmallow.utils import _Missing
 from dotenv.main import load_dotenv, _walk_to_root
 
 __version__ = "8.0.0"
@@ -34,7 +35,7 @@ FieldFactory = typing.Callable[..., ma.fields.Field]
 Subcast = typing.Union[typing.Type, typing.Callable[..., _T]]
 FieldType = typing.Type[ma.fields.Field]
 FieldOrFactory = typing.Union[FieldType, FieldFactory]
-ParserMethod = typing.Callable[..., _T]
+ParserMethod = typing.Callable[..., typing.Union[_T, _Missing]]
 
 
 class EnvError(ValueError):
@@ -64,7 +65,7 @@ def _field2method(
 ) -> ParserMethod:
     def method(
         self: "Env", name: str, default: typing.Any = ma.missing, subcast: Subcast = None, **kwargs
-    ) -> _T:
+    ) -> typing.Union[_T, _Missing]:
         if self._sealed:
             raise EnvSealedError("Env has already been sealed. New values cannot be parsed.")
         missing = kwargs.pop("missing", None) or default
@@ -76,11 +77,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:
-            message = "Environment variable not set."
             if self.eager:
-                raise EnvValidationError('Environment variable "{}" not set'.format(source_key), [message])
+                raise EnvError('Environment variable "{}" not set'.format(proxied_key or parsed_key))
             else:
-                self._errors[parsed_key].append(message)
+                self._errors[parsed_key].append("Environment variable not set.")
+                return ma.missing
         if raw_value or raw_value == "":
             value = raw_value
         else:
@@ -110,11 +111,29 @@ def _func2method(func: typing.Callable, method_name: str) -> ParserMethod:
         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))
-        value = func(raw_value, **kwargs)
         self._fields[parsed_key] = ma.fields.Field(**kwargs)
-        self._values[parsed_key] = value
+        source_key = proxied_key or parsed_key
+        if raw_value is ma.missing:
+            if self.eager:
+                raise EnvError('Environment variable "{}" not set'.format(proxied_key or parsed_key))
+            else:
+                self._errors[parsed_key].append("Environment variable not set.")
+                return ma.missing
+        if raw_value or raw_value == "":
+            value = raw_value
+        else:
+            value = ma.missing
+        try:
+            value = func(raw_value, **kwargs)
+        except (EnvError, ma.ValidationError) as error:
+            messages = error.messages if isinstance(error, ma.ValidationError) else [error.args[0]]
+            if self.eager:
+                raise EnvValidationError(
+                    'Environment variable "{}" invalid: {}'.format(source_key, error.args[0]), messages
+                ) from error
+            self._errors[parsed_key].extend(messages)
+        else:
+            self._values[parsed_key] = value
         return value
 
     method.__name__ = method_name
@@ -178,7 +197,10 @@ def _dj_db_url_parser(value: str, **kwargs) -> dict:
             "The dj_db_url parser requires the dj-database-url package. "
             "You can install it with: pip install dj-database-url"
         ) from error
-    return dj_database_url.parse(value, **kwargs)
+    try:
+        return dj_database_url.parse(value, **kwargs)
+    except Exception as error:
+        raise ma.ValidationError("Not a valid database URL.") from error
 
 
 def _dj_email_url_parser(value: str, **kwargs) -> dict:
@@ -189,7 +211,10 @@ def _dj_email_url_parser(value: str, **kwargs) -> dict:
             "The dj_email_url parser requires the dj-email-url package. "
             "You can install it with: pip install dj-email-url"
         ) from error
-    return dj_email_url.parse(value, **kwargs)
+    try:
+        return dj_email_url.parse(value, **kwargs)
+    except Exception as error:
+        raise ma.ValidationError("Not a valid email URL.") from error
 
 
 def _dj_cache_url_parser(value: str, **kwargs) -> dict:
@@ -200,7 +225,12 @@ def _dj_cache_url_parser(value: str, **kwargs) -> dict:
             "The dj_cache_url parser requires the django-cache-url package. "
             "You can install it with: pip install django-cache-url"
         ) from error
-    return django_cache_url.parse(value, **kwargs)
+    try:
+        return django_cache_url.parse(value, **kwargs)
+    except Exception as error:
+        # django_cache_url may raise Exception("Unknown backend...")
+        #   so use that error message in the validation error
+        raise ma.ValidationError(error.args[0]) from error
 
 
 class URLField(ma.fields.URL):


Test patch:
diff --git a/tests/test_environs.py b/tests/test_environs.py
index 44ee6ad..f442e9c 100644
--- a/tests/test_environs.py
+++ b/tests/test_environs.py
@@ -620,6 +620,19 @@ class TestDeferredValidation:
         assert "INT" in exc.error_messages
         assert "DTIME" in exc.error_messages
 
+    def test_deferred_required_validation(self, env):
+        env.int("STR")
+        env.int("INT")
+        env.datetime("DTIME")
+        with pytest.raises(environs.EnvValidationError) as excinfo:
+            env.seal()
+        exc = excinfo.value
+        assert exc.error_messages == {
+            "STR": ["Environment variable not set."],
+            "INT": ["Environment variable not set."],
+            "DTIME": ["Environment variable not set."],
+        }
+
     def test_cannot_add_after_seal(self, env, set_env):
         set_env({"STR": "foo", "INT": "42"})
         env.str("STR")
@@ -638,6 +651,72 @@ class TestDeferredValidation:
         with pytest.raises(environs.EnvSealedError, match="Env has already been sealed"):
             env.https_url("URL")
 
+    # Regression tests for https://github.com/sloria/environs/issues/121
+    def test_dj_db_url_with_deferred_validation_missing(self, env):
+        env.dj_db_url("DATABASE_URL")
+        with pytest.raises(environs.EnvValidationError) as excinfo:
+            env.seal()
+
+        exc = excinfo.value
+        assert exc.error_messages == {"DATABASE_URL": ["Environment variable not set."]}
+
+    def test_dj_db_url_with_deferred_validation_invalid(self, env, set_env):
+        set_env({"DATABASE_URL": "invalid://"})
+        env.dj_db_url("DATABASE_URL")
+        with pytest.raises(environs.EnvValidationError) as excinfo:
+            env.seal()
+        exc = excinfo.value
+        assert exc.error_messages == {"DATABASE_URL": ["Not a valid database URL."]}
+
+    def test_dj_email_url_with_deferred_validation_missing(self, env):
+        env.dj_email_url("EMAIL_URL")
+        with pytest.raises(environs.EnvValidationError) as excinfo:
+            env.seal()
+        exc = excinfo.value
+        assert exc.error_messages == {"EMAIL_URL": ["Environment variable not set."]}
+
+    def test_dj_cache_url_with_deferred_validation_missing(self, env):
+        env.dj_cache_url("CACHE_URL")
+        with pytest.raises(environs.EnvValidationError) as excinfo:
+            env.seal()
+
+        exc = excinfo.value
+        assert exc.error_messages == {"CACHE_URL": ["Environment variable not set."]}
+
+    def test_dj_cache_url_with_deferred_validation_invalid(self, env, set_env):
+        set_env({"CACHE_URL": "invalid://"})
+        env.dj_cache_url("CACHE_URL")
+        with pytest.raises(environs.EnvValidationError) as excinfo:
+            env.seal()
+        exc = excinfo.value
+        assert exc.error_messages == {"CACHE_URL": ['Unknown backend: "invalid"']}
+
+    def test_custom_parser_with_deferred_validation_missing(self, env):
+        @env.parser_for("always_fail")
+        def always_fail(value):
+            raise environs.EnvError("Invalid!")
+
+        env.always_fail("MY_VAR")
+
+        with pytest.raises(environs.EnvValidationError) as excinfo:
+            env.seal()
+        exc = excinfo.value
+        assert exc.error_messages == {"MY_VAR": ["Environment variable not set."]}
+
+    def test_custom_parser_with_deferred_validation_invalid(self, env, set_env):
+        set_env({"MY_VAR": "foo"})
+
+        @env.parser_for("always_fail")
+        def always_fail(value):
+            raise environs.EnvError("Invalid!")
+
+        env.always_fail("MY_VAR")
+
+        with pytest.raises(environs.EnvValidationError) as excinfo:
+            env.seal()
+        exc = excinfo.value
+        assert exc.error_messages == {"MY_VAR": ["Invalid!"]}
+
 
 class TestExpandVars:
     @pytest.fixture