Repository: sloria/environs

Issue:
Default values and subcast
As far as I can tell the default value can either be of the cast type or a string. So `env.bool('FOO', True)`, `env.bool('FOO', 'true')`, `env.bool('FOO', '1')` and `env.bool('FOO', 'YES')` all are basically the same.

```python
>>> os.environ.get('FOO')
>>> env.bool('FOO', True)
True
>>> env.bool('FOO', 'true')
True
>>> env.bool('FOO', '1')
True
>>> env.bool('FOO', 'YES')
True
```

The same goes for lists with a subcast to a basic type. `env.list('FOO', '1, 2, 42', subcast=float)` and `env.list('FOO', [1.0, 2.0, 42.0], subcast=float)` both do work.

```python
>>> env.list('FOO', '1, 2, 42', subcast=float)
[1.0, 2.0, 42.0]
>>> env.list('FOO', [1.0, 2.0, 42.0], subcast=float)
[1.0, 2.0, 42.0]
```

But, using the target type as default only works as long as the subcast type/callable can be called with the cast type itself. It breaks when the callable expects a string and returns something else. For example:

```python
>>> env.list('FOO', 'a:b, b:c', subcast=lambda s: tuple(s.split(':')))
[('a', 'b'), (' b', 'c')]
>>> env.list('FOO', ['a:b', 'b:c'], subcast=lambda s: tuple(s.split(':')))
[('a', 'b'), ('b', 'c')]
>>> env.list('FOO', [('a', 'b'), ('b', 'c')], subcast=lambda s: tuple(s.split(':')))
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "[…]\lib\site-packages\environs\__init__.py", line 123, in method
    value = field.deserialize(value)
  File "[…]\lib\site-packages\marshmallow\fields.py", line 368, in deserialize
    output = self._deserialize(value, attr, data, **kwargs)
  File "[…]\lib\site-packages\marshmallow\fields.py", line 784, in _deserialize
    result.append(self.inner.deserialize(each, **kwargs))
  File "[…]\lib\site-packages\marshmallow\fields.py", line 368, in deserialize
    output = self._deserialize(value, attr, data, **kwargs)
  File "[…]\lib\site-packages\environs\__init__.py", line 188, in _deserialize
    return func(value)
  File "<console>", line 1, in <lambda>
AttributeError: 'tuple' object has no attribute 'split'
```

If this is expected behaviour, I’d be happy to provide a pull request with a supplement to the documentation (well, README.md).

Discussion:

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 4a1d43b..9270a4e 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -14,7 +14,7 @@ repos:
   rev: 1.19.1
   hooks:
   - id: blacken-docs
-    additional_dependencies: [black==24.10.0 ]
+    additional_dependencies: [black==24.10.0]
 - repo: https://github.com/thlorenz/doctoc.git
   rev: v2.2.0
   hooks:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ae89b02..67a246d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,33 @@
 # Changelog
 
+## 14.0.0 (unreleased)
+
+Changes:
+
+- `default` values are expected to be their in their deserialized form.
+  _Backwards-incompatible_: Passing serialized values to `default`
+  is no longer supported.
+
+```python
+from datetime import date, timedelta
+import environs
+
+# DO
+enable_login = env.bool("ENABLE_LOGIN", True)
+ttl = env.timedelta("TTL", default=timedelta(seconds=600))
+release_date = env.date("RELEASE", date(2025, 1, 7))
+numbers = env.list("FOO", [1.0, 2.0, 3.0], subcast=float)
+
+# DON'T
+enable_login = env.bool("ENABLE_LOGIN", "true")
+ttl = env.timedelta("TTL", default=600)
+release_date = env.date("RELEASE", "2025-01-07")
+numbers = env.list("NUMBERS", "1,2,42", subcast=float)
+```
+
+This fixes [#297](https://github.com/sloria/environs/issues/297)
+and [#270](https://github.com/sloria/environs/issues/270).
+
 ## 13.0.0 (2025-01-07)
 
 Features:
diff --git a/src/environs/__init__.py b/src/environs/__init__.py
index 68f993e..0c44142 100644
--- a/src/environs/__init__.py
+++ b/src/environs/__init__.py
@@ -107,11 +107,14 @@ def _field2method(
                 load_default=load_default,
             )
         parsed_key, value, proxied_key = self._get_from_environ(
-            name, default=field.load_default
+            name, default=ma.missing
         )
         self._fields[parsed_key] = field
         source_key = proxied_key or parsed_key
         if value is ma.missing:
+            if default is not ...:
+                self._values[parsed_key] = default
+                return default
             if self.eager:
                 raise EnvError(
                     f'Environment variable "{proxied_key or parsed_key}" not set'


Test patch:
diff --git a/tests/test_environs.py b/tests/test_environs.py
index 9a22542..89e9a49 100644
--- a/tests/test_environs.py
+++ b/tests/test_environs.py
@@ -90,12 +90,17 @@ class TestCasting:
         set_env({"LIST": "1,2,3"})
         assert env.list("LIST") == ["1", "2", "3"]
 
-    def test_list_with_default_from_string(self, env: environs.Env):
-        assert env.list("LIST", "1,2") == ["1", "2"]
-
     def test_list_with_default_from_list(self, env: environs.Env):
         assert env.list("LIST", ["1"]) == ["1"]
 
+    # https://github.com/sloria/environs/issues/270
+    def test_list_with_default_list_and_subcast(self, set_env, env: environs.Env):
+        expected = [("a", "b"), ("b", "c")]
+        assert (
+            env.list("LIST", expected, subcast=lambda s: tuple(s.split(":")))
+            == expected
+        )
+
     # https://github.com/sloria/environs/issues/298
     def test_list_with_default_none(self, env: environs.Env):
         assert env.list("LIST", default=None) is None
@@ -174,10 +179,7 @@ class TestCasting:
             "DICT", subcast_keys=custom_tuple, subcast_values=custom_tuple
         ) == {("1", "1"): ("foo", "bar")}
 
-    def test_dict_with_default_from_string(self, set_env, env: environs.Env):
-        assert env.dict("DICT", "key1=1,key2=2") == {"key1": "1", "key2": "2"}
-
-    def test_dict_with_default_from_dict(self, set_env, env: environs.Env):
+    def test_dict_with_dict_default(self, env: environs.Env):
         assert env.dict("DICT", {"key1": "1"}) == {"key1": "1"}
 
     def test_dict_with_equal(self, set_env, env: environs.Env):
@@ -212,9 +214,6 @@ class TestCasting:
     def test_json_default(self, set_env, env: environs.Env):
         assert env.json("JSON", {"foo": "bar"}) == {"foo": "bar"}
         assert env.json("JSON", ["foo", "bar"]) == ["foo", "bar"]
-        with pytest.raises(environs.EnvError) as exc:
-            env.json("JSON", "invalid")  # type: ignore[call-overload]
-        assert "Not valid JSON." in exc.value.args[0]
 
     def test_datetime_cast(self, set_env, env: environs.Env):
         dtime = dt.datetime.now(dt.timezone.utc)
@@ -230,10 +229,6 @@ class TestCasting:
         set_env({"DATE": date.isoformat()})
         assert env.date("DATE") == date
 
-    @pytest.mark.xfail(
-        MARSHMALLOW_VERSION.major < 4,
-        reason="marshmallow 3 does not allow all fields to accept internal types",
-    )
     @pytest.mark.parametrize(
         ("method_name", "value"),
         [
@@ -242,16 +237,13 @@ class TestCasting:
             pytest.param("time", dt.time(1, 2, 3), id="time"),
         ],
     )
-    def test_default_can_be_set_to_internal_type(
+    def test_default_set_to_internal_type(
         self, env: environs.Env, method_name: str, value
     ):
         method = getattr(env, method_name)
         assert method("NOTFOUND", value) == value
 
     def test_timedelta_cast(self, set_env, env: environs.Env):
-        # default values may be in serialized form
-        assert env.timedelta("TIMEDELTA", "42") == dt.timedelta(seconds=42)  # type: ignore[call-overload]
-        assert env.timedelta("TIMEDELTA", 42) == dt.timedelta(seconds=42)  # type: ignore[call-overload]
         # marshmallow 4 preserves float values as microseconds
         if MARSHMALLOW_VERSION.major >= 4:
             set_env({"TIMEDELTA": "42.9"})
@@ -490,8 +482,9 @@ class TestValidation:
             env("NODE_ENV", validate=validate.OneOf(["development", "production"]))
 
     def test_validator_can_raise_enverror(self, set_env, env: environs.Env):
+        set_env({"NODE_ENV": "test"})
         with pytest.raises(environs.EnvError) as excinfo:
-            env("NODE_ENV", "development", validate=always_fail)
+            env("NODE_ENV", validate=always_fail)
         assert "something went wrong" in excinfo.value.args[0]
 
     def test_failed_vars_are_not_serialized(self, set_env, env: environs.Env):
@@ -974,21 +967,6 @@ class TestExpandVars:
         )
         assert env.str("PGURL") == "postgres://gnarvaja:secret@localhost"
 
-    def test_default_expands(self, env: environs.Env, set_env):
-        set_env(
-            {
-                "MAIN": "${SUBSTI}",
-                "SUBSTI": "substivalue",
-            }
-        )
-        assert env.str("NOT_SET", "${SUBSTI}") == "substivalue"
-        assert env.str("NOT_SET", "${MAIN}") == "substivalue"
-        assert env.str("NOT_SET", "${NOT_SET2:-set2}") == "set2"
-        with pytest.raises(
-            environs.EnvError, match='Environment variable "NOT_SET2" not set'
-        ):
-            assert env.str("NOT_SET", "${NOT_SET2}")
-
     def test_escaped_expand(self, env: environs.Env, set_env):
         set_env({"ESCAPED_EXPAND": r"\${ESCAPED}", "ESCAPED": "fail"})
         assert env.str("ESCAPED_EXPAND") == r"${ESCAPED}"