Skip to content

Coercion

Type coercion logic for converting environment variable strings to Python types.

coercion

Type coercion logic for environment variable strings.

logger module-attribute

logger = logging.getLogger(LOGGER_NAME)

unwrap_optional

unwrap_optional(field_type: TypeForm[Any]) -> TypeForm[Any]

Return the non-None member of an Optional[T] / T | None type.

Optional[T] and T | None resolve to T. Any other type — including non-Optional Unions with multiple non-None members, which this library rejects elsewhere — is returned unchanged.

Parameters:

Name Type Description Default
field_type TypeForm[Any]

The field's type annotation

required

Returns:

Type Description
TypeForm[Any]

The Optional-unwrapped type, or field_type itself when it is not

TypeForm[Any]

a single-member Optional.

Source code in dotenvmodel/coercion.py
def unwrap_optional(field_type: TypeForm[Any]) -> TypeForm[Any]:
    """Return the non-None member of an ``Optional[T]`` / ``T | None`` type.

    ``Optional[T]`` and ``T | None`` resolve to ``T``. Any other type —
    including non-Optional Unions with multiple non-None members, which this
    library rejects elsewhere — is returned unchanged.

    Args:
        field_type: The field's type annotation

    Returns:
        The Optional-unwrapped type, or ``field_type`` itself when it is not
        a single-member Optional.
    """
    origin = get_origin(field_type)
    if origin is types.UnionType or origin is Union:
        non_none = [arg for arg in get_args(field_type) if arg is not type(None)]
        if len(non_none) == 1:
            return non_none[0]
    return field_type

is_string_like_type

is_string_like_type(field_type: TypeForm[Any]) -> bool

Check whether a field type is string-like (eligible for strip).

Unwraps Optional[T] and checks for str, SecretStr, a str subclass (e.g. HttpUrl, PostgresDsn, RedisDsn), or a Literal[...] whose every argument is str (e.g. Literal["dev", "prod"]), so that strip is applied before coercion.

Parameters:

Name Type Description Default
field_type TypeForm[Any]

The field's type annotation

required

Returns:

Type Description
bool

True if the field holds a string-like value

Source code in dotenvmodel/coercion.py
def is_string_like_type(field_type: TypeForm[Any]) -> bool:
    """Check whether a field type is string-like (eligible for ``strip``).

    Unwraps ``Optional[T]`` and checks for ``str``, ``SecretStr``, a
    ``str`` subclass (e.g. ``HttpUrl``, ``PostgresDsn``, ``RedisDsn``), or a
    ``Literal[...]`` whose every argument is ``str`` (e.g.
    ``Literal["dev", "prod"]``), so that strip is applied before coercion.

    Args:
        field_type: The field's type annotation

    Returns:
        True if the field holds a string-like value
    """
    unwrapped = unwrap_optional(field_type)
    if inspect.isclass(unwrapped):
        return issubclass(unwrapped, (str, SecretStr))
    origin = get_origin(unwrapped)
    if origin is Literal:
        return all(isinstance(arg, str) for arg in get_args(unwrapped))
    return False

apply_strip

apply_strip(
    value: str, mode: bool | str | Pattern[str]
) -> str

Apply a strip mode to a raw string value.

Parameters:

Name Type Description Default
value str

The raw string from the environment

required
mode bool | str | Pattern[str]

Strip mode — True for whitespace stripping, False for no stripping, a non-empty str for char-set stripping (value.strip(chars)), or a compiled re.Pattern to remove every match (pattern.sub("", value))

required

Returns:

Type Description
str

The stripped string

Source code in dotenvmodel/coercion.py
def apply_strip(value: str, mode: bool | str | re.Pattern[str]) -> str:
    """Apply a strip mode to a raw string value.

    Args:
        value: The raw string from the environment
        mode: Strip mode — ``True`` for whitespace stripping, ``False`` for no
            stripping, a non-empty ``str`` for char-set stripping
            (``value.strip(chars)``), or a compiled ``re.Pattern`` to remove
            every match (``pattern.sub("", value)``)

    Returns:
        The stripped string
    """
    if mode is False:
        return value
    if mode is True:
        return value.strip()
    if isinstance(mode, str):
        return value.strip(mode)
    return mode.sub("", value)

coerce_value

coerce_value(
    field_name: str,
    value: str | None,
    field_type: TypeForm[Any],
    env_var_name: str,
    field_info: FieldInfo | None = None,
) -> Any

Coerce a string value from an environment variable to the target type.

When to use
  • Called automatically by DotEnvConfig.load() — rarely called directly
  • Call directly if you need to coerce a value outside of config loading
Supported types
  • Basic: str, int, float, bool, Path
  • Collections: list[T], set[T], tuple[T, ...], dict[K, V]
  • Advanced: UUID, Decimal, datetime, timedelta
  • Special: SecretStr, HttpUrl, PostgresDsn, RedisDsn, Json[T]
  • Optional: T | None, Optional[T]
  • Literal: Literal["a", "b"]

Parameters:

Name Type Description Default
field_name str

Name of the field being coerced (for error messages)

required
value str | None

String value from environment variable (or None)

required
field_type TypeForm[Any]

Target type to coerce to

required
env_var_name str

Name of the environment variable (for error messages)

required
field_info FieldInfo | None

Optional field metadata (used for separator, url_unquote, etc.)

None

Returns:

Type Description
Any

Coerced value of the target type, or None for empty optional values

Raises:

Type Description
TypeCoercionError

If coercion fails (with helpful error message)

TypeError

For unsupported non-optional Union types

See Also
Source code in dotenvmodel/coercion.py
def coerce_value(
    field_name: str,
    value: str | None,
    field_type: TypeForm[Any],
    env_var_name: str,
    field_info: "FieldInfo | None" = None,
) -> Any:
    """Coerce a string value from an environment variable to the target type.

    When to use:
        - Called automatically by `DotEnvConfig.load()` — rarely called directly
        - Call directly if you need to coerce a value outside of config loading

    Supported types:
        - Basic: `str`, `int`, `float`, `bool`, `Path`
        - Collections: `list[T]`, `set[T]`, `tuple[T, ...]`, `dict[K, V]`
        - Advanced: `UUID`, `Decimal`, `datetime`, `timedelta`
        - Special: `SecretStr`, `HttpUrl`, `PostgresDsn`, `RedisDsn`, `Json[T]`
        - Optional: `T | None`, `Optional[T]`
        - Literal: `Literal["a", "b"]`

    Args:
        field_name: Name of the field being coerced (for error messages)
        value: String value from environment variable (or None)
        field_type: Target type to coerce to
        env_var_name: Name of the environment variable (for error messages)
        field_info: Optional field metadata (used for separator, url_unquote, etc.)

    Returns:
        Coerced value of the target type, or None for empty optional values

    Raises:
        TypeCoercionError: If coercion fails (with helpful error message)
        TypeError: For unsupported non-optional Union types

    See Also:
        - [`FieldInfo`][dotenvmodel.fields.FieldInfo]: Provides separator and other options.
        - [`TypeCoercionError`][dotenvmodel.exceptions.TypeCoercionError]: Exception on failure.
    """
    # Handle None/Optional types
    origin = get_origin(field_type)

    # Handle Literal types first
    if origin is Literal:
        return _coerce_literal(field_name, value, field_type, env_var_name)

    # Handle non-optional Enum types directly
    # Note: Optional[Enum] is handled by Union handler first, which then recursively calls this
    if inspect.isclass(field_type) and issubclass(field_type, Enum):
        return _coerce_enum(field_name, value, field_type, env_var_name)

    # Handle Union types (including Optional[T] and str | None)
    # types.UnionType is for `str | None` syntax, typing.Union is for `Union[str, None]`
    if origin is types.UnionType or origin is Union:
        args = get_args(field_type)
        # Filter out NoneType to get non-None types
        non_none_types = [arg for arg in args if arg is not type(None)]

        if len(non_none_types) == len(args):
            # No None in args - this is a non-Optional Union (e.g., Union[str, int])
            type_names = ", ".join(
                str(arg.__name__ if hasattr(arg, "__name__") else arg) for arg in args
            )
            raise TypeCoercionError(
                field_name=field_name,
                value=value,
                error_msg=f"Union types with multiple non-None types are not supported. "
                f"Use Optional[T] or T | None for nullable fields. Got: Union[{type_names}]",
                field_type=field_type,
                env_var_name=env_var_name,
            )

        if len(non_none_types) != 1:
            # Multiple non-None types (e.g., Union[str, int, None])
            type_names = ", ".join(
                str(arg.__name__ if hasattr(arg, "__name__") else arg) for arg in non_none_types
            )
            raise TypeCoercionError(
                field_name=field_name,
                value=value,
                error_msg=f"Union types with multiple non-None types are not supported. "
                f"Got non-None types: {type_names}",
                field_type=field_type,
                env_var_name=env_var_name,
            )

        # This is Optional[T] or T | None
        if value is None or value == "":
            return None
        actual_type = non_none_types[0]
        return coerce_value(field_name, value, actual_type, env_var_name, field_info)

    # Handle other generic types (list, dict, set, tuple)
    if origin is not None and origin not in (Literal,):
        # This is a generic type like list[str], dict[str, str], etc.
        separator = field_info.separator if field_info else ","
        return _coerce_generic(
            field_name, value, field_type, origin, env_var_name, separator, field_info
        )

    # If value is None, return None (empty string handling depends on type)
    if value is None:
        return None

    # Handle bool type (empty string is falsy for bool)
    if field_type is bool:
        return _coerce_bool(field_name, value, env_var_name)

    # Handle str type explicitly - preserve empty strings
    if field_type is str:
        return value  # Allow empty strings for str fields

    # For other non-collection types (not str, not bool), empty string is treated as None
    # This will cause required fields to fail validation
    if value == "":
        return None

    # Import types module here to avoid circular imports
    from dotenvmodel import types as dotenv_types

    # Handle basic and special types using match/case
    match field_type:
        case type() if field_type is str:
            # Already handled above, but keep for completeness
            return value

        case type() if field_type is int:
            try:
                return int(value)
            except (ValueError, TypeError) as e:
                raise TypeCoercionError(
                    field_name=field_name,
                    value=value,
                    error_msg=str(e),
                    field_type=int,
                    env_var_name=env_var_name,
                ) from e

        case type() if field_type is float:
            try:
                return float(value)
            except (ValueError, TypeError) as e:
                raise TypeCoercionError(
                    field_name=field_name,
                    value=value,
                    error_msg=str(e),
                    field_type=float,
                    env_var_name=env_var_name,
                ) from e

        case type() if field_type is Path:
            path = Path(value)
            if field_info and field_info.resolve_path:
                try:
                    path = path.expanduser().resolve()
                except (OSError, RuntimeError) as e:
                    logger.warning(
                        "Failed to resolve path for field '%s' (value=%s): %s. Using raw path.",
                        field_name,
                        value,
                        e,
                    )
            if field_info and field_info.require_exists and not path.exists():
                raise TypeCoercionError(
                    field_name=field_name,
                    value=value,
                    error_msg=f"Path does not exist: {path}",
                    field_type=Path,
                    env_var_name=env_var_name,
                )
            return path

        case type() if field_type is UUID:
            return dotenv_types.coerce_uuid(value, field_name, env_var_name)

        case type() if field_type is Decimal:
            return dotenv_types.coerce_decimal(value, field_name, env_var_name)

        case type() if field_type is datetime:
            return dotenv_types.coerce_datetime(value, field_name, env_var_name)

        case type() if field_type is timedelta:
            return dotenv_types.coerce_timedelta(value, field_name, env_var_name)

        case type() if field_type is dotenv_types.SecretStr:
            # Apply URL unquoting if requested (default: True)
            if field_info and field_info.url_unquote:
                from urllib.parse import unquote

                value = unquote(value)
            return dotenv_types.SecretStr(value)

        case type() if field_type in (
            dotenv_types.HttpUrl,
            dotenv_types.PostgresDsn,
            dotenv_types.RedisDsn,
        ):
            try:
                return field_type(value)
            except ValueError as e:
                raise TypeCoercionError(
                    field_name=field_name,
                    value=value,
                    error_msg=str(e),
                    field_type=field_type,
                    env_var_name=env_var_name,
                ) from e

        case type() if hasattr(field_type, "__name__") and field_type.__name__.startswith("Json["):
            # Handle Json[T] type
            inner_type = getattr(field_type, "__inner_type__", None)
            return dotenv_types.coerce_json(value, field_name, env_var_name, inner_type)

        case _:
            # If we get here, the type is not supported
            raise TypeCoercionError(
                field_name=field_name,
                value=value,
                error_msg=f"Unsupported type: {field_type}",
                field_type=field_type,
                env_var_name=env_var_name,
            )