Repository: sloria/environs

Issue:
Update Python version support
Should support 3.9-3.13

Discussion:

Patch:
diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml
index dba9ee1..f56d24f 100644
--- a/.github/workflows/build-release.yml
+++ b/.github/workflows/build-release.yml
@@ -13,10 +13,12 @@ jobs:
       fail-fast: false
       matrix:
         include:
-          - { name: "3.8", python: "3.8", tox: py38-marshmallow3 }
-          - { name: "3.12", python: "3.12", tox: py312-marshmallow3 }
-          - { name: "lowest", python: "3.8", tox: py38-marshmallowlowest }
-          - { name: "dev", python: "3.12", tox: py312-marshmallowdev }
+          - { name: "3.9", python: "3.9", tox: py39-marshmallow3 }
+          - { name: "3.13", python: "3.13", tox: py313-marshmallow3 }
+          - { name: "lowest", python: "3.9", tox: py39-marshmallowlowest }
+          - { name: "highest", python: "3.13", tox: py313-marshmallowdev }
+          - { name: "mypy-ma3", python: "3.13", tox: mypy-marshmallow3 }
+          - { name: "mypy-madev", python: "3.13", tox: mypy-marshmallowdev }
     steps:
       - uses: actions/checkout@v4.0.0
       - uses: actions/setup-python@v5
@@ -31,7 +33,7 @@ jobs:
       - uses: actions/checkout@v4
       - uses: actions/setup-python@v5
         with:
-          python-version: "3.11"
+          python-version: "3.13"
       - name: Install pypa/build
         run: python -m pip install build
       - name: Build a binary wheel and a source tarball
@@ -54,7 +56,7 @@ jobs:
       - uses: actions/checkout@v4.0.0
       - uses: actions/setup-python@v5
         with:
-          python-version: "3.11"
+          python-version: "3.13"
       - run: python -m pip install tox
       - run: python -m tox -e lint
   publish-to-pypi:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 3ece3e0..4a1d43b 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -2,7 +2,7 @@ ci:
   autoupdate_schedule: monthly
 repos:
 - repo: https://github.com/astral-sh/ruff-pre-commit
-  rev: v0.8.1
+  rev: v0.8.6
   hooks:
     - id: ruff
     - id: ruff-format
@@ -14,12 +14,7 @@ repos:
   rev: 1.19.1
   hooks:
   - id: blacken-docs
-    additional_dependencies: [black==23.12.1]
-- repo: https://github.com/pre-commit/mirrors-mypy
-  rev: v1.13.0
-  hooks:
-    - id: mypy
-      additional_dependencies: ["marshmallow>=3,<4"]
+    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 df814a9..c07dff2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
 # Changelog
 
+## 12.0.0 (unreleased)
+
+Features:
+
+- `Env.enum` allows passing an `Enum` as `default`.
+
+Other changes:
+
+- Support marshmallow 4.
+- Support Python 3.9-3.13.
+- _Backwards-incompatible_: Make `Field` classes private. Users should
+  not need to access these.
+
 ## 11.2.1 (2024-11-20)
 
 Bug fixes:
diff --git a/README.md b/README.md
index 38d6874..1b50ba6 100644
--- a/README.md
+++ b/README.md
@@ -203,13 +203,19 @@ year = env.int("YEAR")  # =>2020
 # export NODE_ENV='invalid'
 # export EMAIL='^_^'
 
-from environs import Env
+from environs import Env, ValidationError
 from marshmallow.validate import OneOf, Length, Email
 
 env = Env()
 
+
 # simple validator
-env.int("TTL", validate=lambda n: n > 0)
+def validator(n):
+    if n <= 0:
+        raise ValidationError("Invalid value.")
+
+
+env.int("TTL", validate=validator)
 # => Environment variable "TTL" invalid: ['Invalid value.']
 
 
diff --git a/pyproject.toml b/pyproject.toml
index 7097476..367589c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,13 +10,13 @@ classifiers = [
   "License :: OSI Approved :: MIT License",
   "Natural Language :: English",
   "Programming Language :: Python :: 3",
-  "Programming Language :: Python :: 3.8",
   "Programming Language :: Python :: 3.9",
   "Programming Language :: Python :: 3.10",
   "Programming Language :: Python :: 3.11",
   "Programming Language :: Python :: 3.12",
+  "Programming Language :: Python :: 3.13",
 ]
-requires-python = ">=3.8"
+requires-python = ">=3.9"
 dependencies = ["python-dotenv", "marshmallow>=3.13.0"]
 
 [project.urls]
@@ -26,8 +26,8 @@ Source = "https://github.com/sloria/environs"
 
 [project.optional-dependencies]
 django = ["dj-database-url", "dj-email-url", "django-cache-url"]
-tests = ["environs[django]", "pytest"]
-dev = ["environs[tests]", "tox", "pre-commit>=3.5,<5.0"]
+tests = ["environs[django]", "pytest", "packaging"]
+dev = ["environs[tests]", "tox", "pre-commit>=4.0,<5.0"]
 
 [build-system]
 requires = ["flit_core<4"]
@@ -65,6 +65,5 @@ ignore-overlong-task-comments = true
 [tool.mypy]
 ignore_missing_imports = true
 warn_unreachable = true
-warn_unused_ignores = true
 warn_redundant_casts = true
 no_implicit_optional = true
diff --git a/src/environs/__init__.py b/src/environs/__init__.py
index a8e6edc..08b3f74 100644
--- a/src/environs/__init__.py
+++ b/src/environs/__init__.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 import collections
 import contextlib
 import functools
@@ -16,19 +18,25 @@ from urllib.parse import ParseResult, urlparse
 import marshmallow as ma
 from dotenv.main import _walk_to_root, load_dotenv
 
-__all__ = ["EnvError", "Env"]
+if typing.TYPE_CHECKING:
+    try:
+        from dj_database_url import DBConfig
+    except ImportError:
+        pass
+
+__all__ = ["Env", "EnvError", "ValidationError"]
 
 _T = typing.TypeVar("_T")
 _StrType = str
 _BoolType = bool
 
-ErrorMapping = typing.Mapping[str, typing.List[str]]
-ErrorList = typing.List[str]
+ErrorMapping = typing.Mapping[str, list[str]]
+ErrorList = list[str]
 FieldFactory = typing.Callable[..., ma.fields.Field]
-Subcast = typing.Union[typing.Type, typing.Callable[..., _T], ma.fields.Field]
-FieldType = typing.Type[ma.fields.Field]
+Subcast = typing.Union[type, typing.Callable[..., _T], ma.fields.Field]
+FieldType = type[ma.fields.Field]
 FieldOrFactory = typing.Union[FieldType, FieldFactory]
-ParserMethod = typing.Callable
+ParserMethod = typing.Callable[..., typing.Any]
 
 
 _EXPANDED_VAR_PATTERN = re.compile(r"(?<!\\)\$\{([A-Za-z0-9_]+)(:-[^\}:]*)?\}")
@@ -51,6 +59,10 @@ _TIMEDELTA_PATTERN = re.compile(
 )
 
 
+# Reexport marshmallow's ValidationError. Custom validators should raise this for invalid input.
+ValidationError = ma.ValidationError
+
+
 class EnvError(ValueError):
     """Raised when an environment variable or
     if a required environment variable is unset.
@@ -58,9 +70,7 @@ class EnvError(ValueError):
 
 
 class EnvValidationError(EnvError):
-    def __init__(
-        self, message: str, error_messages: typing.Union[ErrorList, ErrorMapping]
-    ):
+    def __init__(self, message: str, error_messages: ErrorList | ErrorMapping):
         self.error_messages = error_messages
         super().__init__(message)
 
@@ -79,29 +89,28 @@ def _field2method(
     field_or_factory: FieldOrFactory,
     method_name: str,
     *,
-    preprocess: typing.Optional[typing.Callable] = None,
+    preprocess: typing.Callable | None = None,
     preprocess_kwarg_names: typing.Sequence[str] = tuple(),
 ) -> ParserMethod:
     def method(
-        self: "Env",
+        self: Env,
         name: str,
         default: typing.Any = ma.missing,
-        subcast: typing.Optional[Subcast] = None,
+        subcast: Subcast | None = None,
         *,
         # Subset of relevant marshmallow.Field kwargs
         load_default: typing.Any = ma.missing,
-        validate: typing.Optional[
-            typing.Union[
-                typing.Callable[[typing.Any], typing.Any],
-                typing.Iterable[typing.Callable[[typing.Any], typing.Any]],
-            ]
-        ] = None,
+        validate: (
+            typing.Callable[[typing.Any], typing.Any]
+            | typing.Iterable[typing.Callable[[typing.Any], typing.Any]]
+            | None
+        ) = None,
         required: bool = False,
-        allow_none: typing.Optional[bool] = None,
-        error_messages: typing.Optional[typing.Dict[str, str]] = None,
-        metadata: typing.Optional[typing.Mapping[str, typing.Any]] = None,
+        allow_none: bool | None = None,
+        error_messages: dict[str, str] | None = None,
+        metadata: typing.Mapping[str, typing.Any] | None = None,
         **kwargs,
-    ) -> typing.Optional[_T]:
+    ) -> _T | None:
         if self._sealed:
             raise EnvSealedError(
                 "Env has already been sealed. New values cannot be parsed."
@@ -122,7 +131,7 @@ def _field2method(
         ):
             field = field_or_factory(**field_kwargs, **kwargs)
         else:
-            parsed_subcast = _make_subcast_field(subcast)
+            parsed_subcast = _make_subcast_field(subcast) if subcast else ma.fields.Raw
             field = typing.cast(FieldFactory, field_or_factory)(
                 subcast=parsed_subcast, **field_kwargs
             )
@@ -160,17 +169,17 @@ def _field2method(
 
 def _func2method(func: typing.Callable, method_name: str) -> ParserMethod:
     def method(
-        self: "Env",
+        self: Env,
         name: str,
         default: typing.Any = ma.missing,
         **kwargs,
-    ) -> typing.Optional[_T]:
+    ) -> _T | None:
         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)
-        self._fields[parsed_key] = ma.fields.Field()
+        self._fields[parsed_key] = ma.fields.Raw()
         source_key = proxied_key or parsed_key
         if raw_value is ma.missing:
             if self.eager:
@@ -207,8 +216,8 @@ def _func2method(func: typing.Callable, method_name: str) -> ParserMethod:
 
 
 def _make_subcast_field(
-    subcast: typing.Optional[Subcast],
-) -> typing.Type[ma.fields.Field]:
+    subcast: Subcast,
+) -> type[ma.fields.Field]:
     if isinstance(subcast, type) and subcast in ma.Schema.TYPE_MAPPING:
         inner_field = ma.Schema.TYPE_MAPPING[subcast]
     elif isinstance(subcast, type) and issubclass(subcast, ma.fields.Field):
@@ -222,17 +231,20 @@ def _make_subcast_field(
 
         inner_field = SubcastField
     else:
-        inner_field = ma.fields.Field
+        inner_field = ma.fields.Raw
     return inner_field
 
 
-def _make_list_field(*, subcast: typing.Optional[type], **kwargs) -> ma.fields.List:
-    inner_field = _make_subcast_field(subcast)
+def _make_list_field(*, subcast: Subcast | None, **kwargs) -> ma.fields.List:
+    if subcast:
+        inner_field = _make_subcast_field(subcast)
+    else:
+        inner_field = ma.fields.Raw
     return ma.fields.List(inner_field, **kwargs)
 
 
 def _preprocess_list(
-    value: typing.Union[str, typing.Iterable], *, delimiter: str = ",", **kwargs
+    value: str | typing.Iterable, *, delimiter: str = ",", **kwargs
 ) -> typing.Iterable:
     if ma.utils.is_iterable_but_not_string(value) or value is None:
         return value
@@ -240,19 +252,25 @@ def _preprocess_list(
 
 
 def _preprocess_dict(
-    value: typing.Union[str, typing.Mapping],
+    value: str | typing.Mapping,
     *,
-    subcast_keys: typing.Optional[Subcast] = None,
-    subcast_values: typing.Optional[Subcast] = None,
+    subcast_keys: Subcast | None = None,
+    subcast_values: Subcast | None = None,
     delimiter: str = ",",
     **kwargs,
 ) -> typing.Mapping:
     if isinstance(value, Mapping):
         return value
-    subcast_keys_instance: ma.fields.Field = _make_subcast_field(subcast_keys)(**kwargs)
-    subcast_values_instance: ma.fields.Field = _make_subcast_field(subcast_values)(
-        **kwargs
-    )
+    subcast_keys_instance: ma.fields.Field
+    if subcast_keys:
+        subcast_keys_instance = _make_subcast_field(subcast_keys)(**kwargs)
+    else:
+        subcast_keys_instance = ma.fields.Raw()
+    subcast_values_instance: ma.fields.Field
+    if subcast_values:
+        subcast_values_instance = _make_subcast_field(subcast_values)(**kwargs)
+    else:
+        subcast_values_instance = ma.fields.Raw()
 
     return {
         subcast_keys_instance.deserialize(
@@ -262,7 +280,7 @@ def _preprocess_dict(
     }
 
 
-def _preprocess_json(value: typing.Union[str, typing.Mapping, typing.List], **kwargs):
+def _preprocess_json(value: str | typing.Mapping | list, **kwargs):
     try:
         if isinstance(value, str):
             return pyjson.loads(value)
@@ -277,7 +295,10 @@ def _preprocess_json(value: typing.Union[str, typing.Mapping, typing.List], **kw
 _EnumT = typing.TypeVar("_EnumT", bound=Enum)
 
 
-def _enum_parser(value, type: typing.Type[_EnumT], ignore_case: bool = False) -> _EnumT:
+def _enum_parser(value, type: type[_EnumT], ignore_case: bool = False) -> _EnumT:
+    if isinstance(value, type):
+        return value
+
     invalid_exc = ma.ValidationError(f"Not a valid '{type.__name__}' enum.")
 
     if not ignore_case:
@@ -293,7 +314,7 @@ def _enum_parser(value, type: typing.Type[_EnumT], ignore_case: bool = False) ->
     raise invalid_exc
 
 
-def _dj_db_url_parser(value: str, **kwargs) -> dict:
+def _dj_db_url_parser(value: str, **kwargs) -> DBConfig:
     try:
         import dj_database_url
     except ImportError as error:
@@ -337,24 +358,30 @@ def _dj_cache_url_parser(value: str, **kwargs) -> dict:
         raise ma.ValidationError(error.args[0]) from error
 
 
-class URLField(ma.fields.URL):
-    def _serialize(self, value: ParseResult, *args, **kwargs) -> str:
+class _URLField(ma.fields.Url):
+    def _serialize(self, value: ParseResult, *args, **kwargs) -> str:  # type: ignore[override]
         return value.geturl()
 
     # Override deserialize rather than _deserialize because we need
     # to call urlparse *after* validation has occurred
-    def deserialize(
+    def deserialize(  # type: ignore[override]
         self,
-        value: str,
-        attr: typing.Optional[str] = None,
-        data: typing.Optional[typing.Mapping] = None,
+        value: typing.Any,
+        attr: str | None = None,
+        data: typing.Mapping[str, typing.Any] | None = None,
         **kwargs,
     ) -> ParseResult:
         ret = super().deserialize(value, attr, data, **kwargs)
         return urlparse(ret)
 
 
-class PathField(ma.fields.Str):
+# TODO: Change to ma.fields.Field[Path] after dropping marshmallow 3 support
+class _PathField(ma.fields.Field):
+    def _serialize(self, value: Path | None, *args, **kwargs) -> str | None:
+        if value is None:
+            return None
+        return str(value)
+
     def _deserialize(self, value, *args, **kwargs) -> Path:
         if isinstance(value, Path):
             return value
@@ -362,7 +389,7 @@ class PathField(ma.fields.Str):
         return Path(ret)
 
 
-class LogLevelField(ma.fields.Int):
+class _LogLevelField(ma.fields.Integer):
     def _format_num(self, value) -> int:
         try:
             return super()._format_num(value)
@@ -374,7 +401,7 @@ class LogLevelField(ma.fields.Int):
                 raise ma.ValidationError("Not a valid log level.") from error
 
 
-class TimeDeltaField(ma.fields.TimeDelta):
+class _TimeDeltaField(ma.fields.TimeDelta):
     def _deserialize(self, value, *args, **kwargs) -> timedelta:
         if isinstance(value, timedelta):
             return value
@@ -396,7 +423,7 @@ class TimeDeltaField(ma.fields.TimeDelta):
 class Env:
     """An environment variable reader."""
 
-    __call__: ParserMethod = _field2method(ma.fields.Field, "__call__")
+    __call__: ParserMethod = _field2method(ma.fields.Raw, "__call__")
 
     int = _field2method(ma.fields.Int, "int")
     bool = _field2method(ma.fields.Bool, "bool")
@@ -425,11 +452,11 @@ class Env:
     datetime = _field2method(ma.fields.DateTime, "datetime")
     date = _field2method(ma.fields.Date, "date")
     time = _field2method(ma.fields.Time, "time")
-    path = _field2method(PathField, "path")
-    log_level = _field2method(LogLevelField, "log_level")
-    timedelta = _field2method(TimeDeltaField, "timedelta")
+    path = _field2method(_PathField, "path")
+    log_level = _field2method(_LogLevelField, "log_level")
+    timedelta = _field2method(_TimeDeltaField, "timedelta")
     uuid = _field2method(ma.fields.UUID, "uuid")
-    url = _field2method(URLField, "url")
+    url = _field2method(_URLField, "url")
     enum = _func2method(_enum_parser, "enum")
     dj_db_url = _func2method(_dj_db_url_parser, "dj_db_url")
     dj_email_url = _func2method(_dj_email_url_parser, "dj_email_url")
@@ -439,23 +466,23 @@ class Env:
         self.eager = eager
         self._sealed: bool = False
         self.expand_vars = expand_vars
-        self._fields: typing.Dict[_StrType, typing.Union[ma.fields.Field, type]] = {}
-        self._values: typing.Dict[_StrType, typing.Any] = {}
+        self._fields: dict[_StrType, ma.fields.Field] = {}
+        self._values: dict[_StrType, typing.Any] = {}
         self._errors: ErrorMapping = collections.defaultdict(list)
-        self._prefix: typing.Optional[_StrType] = None
-        self.__custom_parsers__: typing.Dict[_StrType, ParserMethod] = {}
+        self._prefix: _StrType | None = None
+        self.__custom_parsers__: dict[_StrType, ParserMethod] = {}
 
     def __repr__(self) -> _StrType:
         return f"<{self.__class__.__name__}(eager={self.eager}, expand_vars={self.expand_vars})>"  # noqa: E501
 
     @staticmethod
     def read_env(
-        path: typing.Optional[_StrType] = None,
+        path: _StrType | None = None,
         recurse: _BoolType = True,
         verbose: _BoolType = False,
         override: _BoolType = False,
         return_path: _BoolType = False,
-    ) -> typing.Union[_BoolType, typing.Optional[_StrType]]:
+    ) -> _BoolType | _StrType | None:
         """Read a .env file into os.environ.
 
         If .env is not found in the directory from which this method is called,
@@ -501,7 +528,7 @@ class Env:
             return is_env_loaded
 
     @contextlib.contextmanager
-    def prefixed(self, prefix: _StrType) -> typing.Iterator["Env"]:
+    def prefixed(self, prefix: _StrType) -> typing.Iterator[Env]:
         """Context manager for parsing envvars with a common prefix."""
         try:
             old_prefix = self._prefix
@@ -558,9 +585,7 @@ class Env:
 
         return decorator
 
-    def add_parser_from_field(
-        self, name: _StrType, field_cls: typing.Type[ma.fields.Field]
-    ):
+    def add_parser_from_field(self, name: _StrType, field_cls: type[ma.fields.Field]):
         """Register a new parser method with name ``name``,
         given a marshmallow ``Field``.
         """
@@ -575,7 +600,7 @@ class Env:
 
     def _get_from_environ(
         self, key: _StrType, default: typing.Any, *, proxied: _BoolType = False
-    ) -> typing.Tuple[_StrType, typing.Any, typing.Optional[_StrType]]:
+    ) -> tuple[_StrType, typing.Any, _StrType | None]:
         """Access a value from os.environ. Handles proxied variables,
         e.g. SMTP_LOGIN={{MAILGUN_LOGIN}}.
 
@@ -592,7 +617,7 @@ class Env:
             expand_match = self.expand_vars and _EXPANDED_VAR_PATTERN.match(value)
             if expand_match:  # Full match expand_vars - special case keep default
                 proxied_key: _StrType = expand_match.groups()[0]
-                subs_default: typing.Optional[_StrType] = expand_match.groups()[1]
+                subs_default: _StrType | None = expand_match.groups()[1]
                 if subs_default is not None:
                     default = subs_default[2:]
                 elif (
diff --git a/tox.ini b/tox.ini
index 6e9877c..a6be002 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,8 +1,8 @@
 [tox]
 envlist=
     lint
-    py{38,39,310,311,312}-marshmallow{3,lowest}
-    py312-marshmallowdev
+    py{39,310,311,312,313}-marshmallow{3,lowest,dev}
+    mypy-marshmallow{3,dev}
 
 [testenv]
 extras = tests
@@ -13,11 +13,24 @@ deps =
     marshmallowdev: https://github.com/marshmallow-code/marshmallow/archive/dev.tar.gz
 commands = pytest {posargs}
 
+[testenv:mypy-marshmallow3]
+extras = django
+deps =
+    mypy
+    marshmallow>=3.13.0,<4.0.0
+commands = mypy .
+
+[testenv:mypy-marshmallowdev]
+extras = django
+deps =
+    mypy
+    https://github.com/marshmallow-code/marshmallow/archive/dev.tar.gz
+commands = mypy --warn-unused-ignores .
+
 [testenv:lint]
-deps = pre-commit~=3.6
+deps = pre-commit~=4.0.1
 skip_install = true
-commands =
-    pre-commit run --all-files
+commands = pre-commit run --all-files
 
 ; Below tasks are for development only (not run in CI)
 


Test patch:
diff --git a/tests/test_environs.py b/tests/test_environs.py
index f8ac109..6c16c5e 100644
--- a/tests/test_environs.py
+++ b/tests/test_environs.py
@@ -1,4 +1,5 @@
 import datetime as dt
+import importlib.metadata
 import logging
 import os
 import pathlib
@@ -13,10 +14,12 @@ import django_cache_url
 import marshmallow as ma
 import pytest
 from marshmallow import fields, validate
+from packaging.version import Version
 
 import environs
 
 HERE = pathlib.Path(__file__).parent
+MARSHMALLOW_VERSION = Version(importlib.metadata.version("marshmallow"))
 
 
 @pytest.fixture
@@ -190,7 +193,7 @@ class TestCasting:
         assert exc.value.args[0] == 'Environment variable "FOO" not set'
 
     def test_default_set(self, env):
-        if ma.__version_info__ >= (3, 13):
+        if MARSHMALLOW_VERSION >= Version("3.13"):
             assert env.str("FOO", load_default="foo") == "foo"
         else:
             assert env.str("FOO", missing="foo") == "foo"
@@ -228,14 +231,35 @@ 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"),
+        [
+            pytest.param("timedelta", dt.timedelta(seconds=42), id="timedelta"),
+            pytest.param("date", dt.date(2020, 1, 1), id="date"),
+            pytest.param("datetime", dt.datetime(2020, 1, 1, 1, 2, 3), id="datetime"),
+            pytest.param("time", dt.time(1, 2, 3), id="time"),
+            pytest.param("uuid", uuid.uuid4(), id="uuid"),
+        ],
+    )
+    def test_default_can_be_set_to_internal_type(self, env, method_name: str, value):
+        method = getattr(env, method_name)
+        assert method("NOTFOUND", value) == value
+
     def test_timedelta_cast(self, set_env, env):
         # default values
         assert env.timedelta("TIMEDELTA", "42") == dt.timedelta(seconds=42)
         assert env.timedelta("TIMEDELTA", 42) == dt.timedelta(seconds=42)
-        assert env.timedelta("TIMEDELTA", 42.9) == dt.timedelta(seconds=42)  # bug?
-        assert env.timedelta("TIMEDELTA", dt.timedelta(seconds=42)) == dt.timedelta(
-            seconds=42,
-        )
+        # marshmallow 4 preserves float values as microseconds
+        if MARSHMALLOW_VERSION.major >= 4:
+            assert env.timedelta("TIMEDELTA", 42.9) == dt.timedelta(
+                seconds=42, microseconds=900000
+            )
+        else:
+            assert env.timedelta("TIMEDELTA", 42.9) == dt.timedelta(seconds=42)
         # seconds as integer
         set_env({"TIMEDELTA": "0"})
         assert env.timedelta("TIMEDELTA") == dt.timedelta()
@@ -273,10 +297,6 @@ class TestCasting:
             env.timedelta("TIMEDELTA")
         # empty string with whitespace not allowed
         set_env({"TIMEDELTA": " "})
-        with pytest.raises(environs.EnvError):
-            env.timedelta("TIMEDELTA")
-        # float not allowed
-        set_env({"TIMEDELTA": "4.2"})
         with pytest.raises(environs.EnvError):
             env.timedelta("TIMEDELTA")
         set_env({"TIMEDELTA": "4.2s"})
@@ -362,6 +382,11 @@ class TestCasting:
         with pytest.raises(environs.EnvError):
             assert env.enum("DAY", type=DayEnum, ignore_case=True)
 
+    def test_enum_default(self, env: environs.Env):
+        assert (
+            env.enum("NOTFOUND", type=DayEnum, default=DayEnum.SUNDAY) == DayEnum.SUNDAY
+        )
+
 
 class TestEnvFileReading:
     def test_read_env(self, env):
@@ -454,8 +479,12 @@ class TestValidation:
     def test_can_add_validator(self, set_env, env):
         set_env({"NUM": "3"})
 
+        def validate(n):
+            if n <= 3:
+                raise environs.EnvError("Invalid value.")
+
         with pytest.raises(environs.EnvError) as excinfo:
-            env.int("NUM", validate=lambda n: n > 3)
+            env.int("NUM", validate=validate)
         assert "Invalid value." in excinfo.value.args[0]
 
     def test_can_add_marshmallow_validator(self, set_env, env):
@@ -641,11 +670,15 @@ class TestPrefix:
         }
 
     def test_error_message_for_prefixed_var(self, env):
+        def validate(val):
+            if val >= 42:
+                raise environs.ValidationError("Invalid value.")
+
         with env.prefixed("APP_"):
             with pytest.raises(
                 environs.EnvError, match='Environment variable "APP_INT" invalid'
             ):
-                env.int("INT", validate=lambda val: val < 42)
+                env.int("INT", validate=validate)
 
 
 class TestNestedPrefix: