Repository: sloria/environs
Pull request #183 (2020-11-01 18:26:09)

Return None for missing or invalid variables when eager=False


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 081c4fa..d79964a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
 # Changelog
 
+## 9.1.0 (unreleased)
+
+- When using deferred validation (`eager=False`), parser methods return `None`
+  for missing or invalid values.
+  _Note_: This may break code that depended on parser methods returning `marshmallow.missing`,
+  but this behavior was not documented nor tested and therefore is not considered public API.
+
 ## 9.0.0 (2020-10-31)
 
 - _Backwards-incompatible_: Rename `subcast` param of `env.dict` to `subcast_values` for consistency with `subcast_keys`.
diff --git a/README.md b/README.md
index ad91d77..0d17f67 100644
--- a/README.md
+++ b/README.md
@@ -93,7 +93,7 @@ The following are all type-casting methods of `Env`:
 - `env.int`
 - `env.float`
 - `env.decimal`
-- `env.list` (accepts optional `subcast` keyword argument)
+- `env.list` (accepts optional `subcast` and `delimiter` keyword arguments)
 - `env.dict` (accepts optional `subcast_keys` and `subcast_values` keyword arguments)
 - `env.json`
 - `env.datetime`
diff --git a/environs/__init__.py b/environs/__init__.py
index e3c41ff..25a1218 100644
--- a/environs/__init__.py
+++ b/environs/__init__.py
@@ -13,7 +13,6 @@ 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__ = "9.0.0"
@@ -31,7 +30,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[..., typing.Union[_T, _Missing]]
+ParserMethod = typing.Callable[..., typing.Optional[_T]]
 
 
 _EXPANDED_VAR_PATTERN = re.compile(r"(?<!\\)\$\{([A-Za-z0-9_]+)(:-[^\}:]*)?\}")
@@ -64,7 +63,7 @@ def _field2method(
         default: typing.Any = ma.missing,
         subcast: typing.Optional[Subcast] = None,
         **kwargs,
-    ) -> typing.Union[_T, _Missing]:
+    ) -> typing.Optional[_T]:
         if self._sealed:
             raise EnvSealedError("Env has already been sealed. New values cannot be parsed.")
         missing = kwargs.pop("missing", None) or default
@@ -80,11 +79,11 @@ def _field2method(
                 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
+                return None
         if raw_value or raw_value == "":
             value = raw_value
         else:
-            value = field.missing
+            value = field.missing if field.missing is not ma.missing else None
         if preprocess:
             value = preprocess(value, subcast=subcast, **kwargs)
         try:
@@ -110,7 +109,7 @@ def _func2method(func: typing.Callable, method_name: str) -> ParserMethod:
         default: typing.Any = ma.missing,
         subcast: typing.Optional[typing.Type] = None,
         **kwargs,
-    ):
+    ) -> typing.Optional[_T]:
         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)
@@ -121,11 +120,11 @@ def _func2method(func: typing.Callable, method_name: str) -> ParserMethod:
                 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
+                return None
         if raw_value or raw_value == "":
             value = raw_value
         else:
-            value = ma.missing
+            value = None
         try:
             value = func(raw_value, **kwargs)
         except (EnvError, ma.ValidationError) as error:
@@ -148,10 +147,12 @@ def _make_list_field(*, subcast: typing.Optional[type], **kwargs) -> ma.fields.L
     return ma.fields.List(inner_field, **kwargs)
 
 
-def _preprocess_list(value: typing.Union[str, typing.Iterable], **kwargs) -> typing.Iterable:
+def _preprocess_list(
+    value: typing.Union[str, typing.Iterable], *, delimiter: str = ",", **kwargs
+) -> typing.Iterable:
     if ma.utils.is_iterable_but_not_string(value):
         return value
-    return typing.cast(str, value).split(",") if value != "" else []
+    return typing.cast(str, value).split(delimiter) if value != "" else []
 
 
 def _preprocess_dict(


Test patch:
diff --git a/tests/test_environs.py b/tests/test_environs.py
index 37d7db9..7aee702 100644
--- a/tests/test_environs.py
+++ b/tests/test_environs.py
@@ -101,6 +101,10 @@ class TestCasting:
         set_env({"LIST": " 1,  2,3"})
         assert env.list("LIST", subcast=int) == [1, 2, 3]
 
+    def test_list_with_spaces_as_delimiter(self, set_env, env):
+        set_env({"LIST": "a b c"})
+        assert env.list("LIST", delimiter=" ") == ["a", "b", "c"]
+
     def test_dict(self, set_env, env):
         set_env({"DICT": "key1=1,key2=2"})
         assert env.dict("DICT") == {"key1": "1", "key2": "2"}