Skip to content

coercion

Type coercion for schema validation.

Automatically converts values between compatible types when safe and unambiguous, making configs more flexible while maintaining type safety. Supports string to numeric/bool conversions, int to float, and recursive coercion through nested structures. Enabled by default via Config(coerce=True).

_is_union_type(origin)

Check if origin is a Union type.

Source code in src/sparkwheel/coercion.py
def _is_union_type(origin: Any) -> bool:
    """Check if origin is a Union type."""
    if origin is Union:
        return True
    if hasattr(types, "UnionType") and origin is types.UnionType:
        return True
    return False

can_coerce(value, target_type)

Check if value can be coerced to target type.

Source code in src/sparkwheel/coercion.py
def can_coerce(value: Any, target_type: type) -> bool:
    """Check if value can be coerced to target type."""
    if isinstance(value, target_type):
        return True

    # String to numeric
    if target_type in (int, float) and isinstance(value, str):
        try:
            target_type(value)
            return True
        except (ValueError, TypeError):
            return False

    # Int to float
    if target_type is float and isinstance(value, int):
        return True

    # String to bool
    if target_type is bool and isinstance(value, str):
        return value.lower() in ("true", "false", "1", "0", "yes", "no")

    return False

coerce_value(value, target_type, field_path='')

Coerce value to target type if possible.

Parameters:

Name Type Description Default
value Any

Value to coerce

required
target_type type

Target type (may be generic like List[int])

required
field_path str

Path for error messages

''

Returns:

Type Description
Any

Coerced value

Raises:

Type Description
ValueError

If coercion not possible

Source code in src/sparkwheel/coercion.py
def coerce_value(value: Any, target_type: type, field_path: str = "") -> Any:
    """Coerce value to target type if possible.

    Args:
        value: Value to coerce
        target_type: Target type (may be generic like List[int])
        field_path: Path for error messages

    Returns:
        Coerced value

    Raises:
        ValueError: If coercion not possible
    """
    origin = get_origin(target_type)
    args = get_args(target_type)

    # Handle Union types (including Optional)
    if _is_union_type(origin):
        # Try coercing to each type in order
        for union_type in args:
            if union_type is type(None) and value is None:
                return None
            try:
                return coerce_value(value, union_type, field_path)
            except (ValueError, TypeError):
                continue
        # No coercion worked
        raise ValueError(f"Cannot coerce {type(value).__name__} to any type in union at '{field_path}'")

    # Handle List[T]
    if origin is list:
        if not isinstance(value, list):
            raise ValueError(f"Cannot coerce {type(value).__name__} to list")
        if args:
            item_type = args[0]
            return [coerce_value(item, item_type, f"{field_path}[{i}]") for i, item in enumerate(value)]
        return value

    # Handle Dict[K, V]
    if origin is dict:
        if not isinstance(value, dict):
            raise ValueError(f"Cannot coerce {type(value).__name__} to dict")
        if args and len(args) == 2:
            key_type, val_type = args
            return {
                coerce_value(k, key_type, f"{field_path}.key"): coerce_value(v, val_type, f"{field_path}[{k!r}]")
                for k, v in value.items()
            }
        return value

    # Handle nested dataclasses - recursively coerce fields
    if dataclasses.is_dataclass(target_type):
        if not isinstance(value, dict):
            raise ValueError(f"Cannot coerce {type(value).__name__} to dataclass {target_type.__name__}")

        coerced = {}
        schema_fields = {f.name: f for f in dataclasses.fields(target_type)}

        for field_name, field_value in value.items():
            if field_name in schema_fields:
                field_info = schema_fields[field_name]
                field_path_full = f"{field_path}.{field_name}" if field_path else field_name
                # field_info.type can be str in some edge cases, but for our use it's always type
                assert isinstance(field_info.type, type)
                coerced[field_name] = coerce_value(field_value, field_info.type, field_path_full)
            else:
                # Keep unknown fields as-is (strict mode will catch them)
                coerced[field_name] = field_value

        return coerced

    # Already correct type
    if isinstance(value, target_type):
        return value

    # String to numeric
    if target_type in (int, float):
        if isinstance(value, str):
            try:
                return target_type(value)
            except (ValueError, TypeError) as e:
                raise ValueError(f"Cannot coerce string '{value}' to {target_type.__name__}") from e

    # Int to float
    if target_type is float and isinstance(value, int):
        return float(value)

    # String to bool
    if target_type is bool and isinstance(value, str):
        lower = value.lower()
        if lower in ("true", "1", "yes"):
            return True
        elif lower in ("false", "0", "no"):
            return False
        else:
            raise ValueError(f"Cannot coerce string '{value}' to bool")

    # No coercion available
    raise ValueError(f"Cannot coerce {type(value).__name__} to {target_type.__name__}")