Source code for imednet.utils.arrow
"""PyArrow serialization helpers."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Protocol
from imednet.utils.validators import is_boolean_token, parse_bool
try:
import pyarrow as pa
except ImportError: # pragma: no cover - exercised when optional dependency is absent
pa: Any = None # type: ignore[no-redef]
class _ModelDumpable(Protocol):
"""Protocol for objects that can be dumped to a dictionary (e.g., Pydantic models)."""
def model_dump(self) -> dict[str, Any]: ...
def _normalize_datetime(value: datetime) -> datetime:
"""Ensure a datetime object is timezone-aware and set to UTC."""
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _normalize_record(record: Any) -> dict[str, Any]:
"""Convert an input record (dict or model) into a standard dictionary."""
if isinstance(record, dict):
return record
model_dump = getattr(record, "model_dump", None)
if callable(model_dump):
dumped = model_dump()
if isinstance(dumped, dict):
return dumped
raise TypeError("Each record must be a dictionary or expose model_dump().")
def _normalize_value(value: Any) -> Any:
"""Normalize individual values for Arrow serialization (empty strings to None, etc.)."""
if value is None:
return None
if isinstance(value, str) and value == "":
return None
if isinstance(value, datetime):
return _normalize_datetime(value)
if isinstance(value, bool):
return bool(value)
if isinstance(value, float):
return float(value)
return value
def _infer_type(values: list[Any]) -> pa.DataType:
"""Infer the Arrow data type from a list of normalized values."""
non_null_values = [v for v in values if v is not None]
if not non_null_values:
return pa.null()
if all(isinstance(v, bool) for v in non_null_values):
return pa.bool_()
if all(isinstance(v, datetime) for v in non_null_values):
return pa.timestamp("us")
if all(isinstance(v, float) for v in non_null_values):
return pa.float64()
return pa.infer_type(non_null_values)
def _coerce_value(value: Any, target_type: pa.DataType) -> Any:
"""Coerce a value to the target Arrow data type."""
if value is None:
return None
if pa.types.is_null(target_type):
return None
if pa.types.is_timestamp(target_type):
return value if isinstance(value, datetime) else None
if pa.types.is_boolean(target_type):
if isinstance(value, str):
return parse_bool(value) if is_boolean_token(value) else None
return bool(value)
if pa.types.is_floating(target_type):
try:
return float(value)
except (TypeError, ValueError):
return None
return value
[docs]def to_arrow_table(
data_records: list[dict[str, Any] | _ModelDumpable], schema: pa.Schema | None = None
) -> pa.Table:
"""Serialize record dictionaries (or Pydantic-like objects) into a ``pyarrow.Table``.
Args:
data_records: Record payloads to serialize. Each item must be a dictionary
or expose a ``model_dump()`` method that returns a dictionary.
schema: Optional explicit Arrow schema. When provided, output columns follow
schema order and types; when omitted, columns and types are inferred.
Naive ``datetime`` values are interpreted as UTC.
When schema inference is used, datetime columns use microsecond precision.
Boolean strings accept ``true/false``, ``1/0``, ``yes/no``, ``y/n``,
and ``t/f``.
Returns:
A fully initialized ``pyarrow.Table`` with deterministic columns and null
values for missing or empty-string inputs.
Raises:
ImportError: If ``pyarrow`` is not installed.
TypeError: If a record is not dict-like and does not expose ``model_dump``.
"""
if pa is None:
raise ImportError(
"pyarrow is required for to_arrow_table. Install with \"pip install 'imednet[export]'\"."
)
records = [_normalize_record(record) for record in data_records]
if schema is None:
if not records:
return pa.table({})
column_names = sorted({key for record in records for key in record})
else:
column_names = list(schema.names)
arrays: list[pa.Array] = []
for name in column_names:
values = [_normalize_value(record.get(name)) for record in records]
target_type = schema.field(name).type if schema is not None else _infer_type(values)
# Optimize by avoiding pa.types.* checks in a tight loop
coerced_values: list[Any]
if pa.types.is_null(target_type):
coerced_values = [None] * len(values)
elif pa.types.is_timestamp(target_type):
coerced_values = [v if isinstance(v, datetime) else None for v in values]
elif pa.types.is_boolean(target_type):
# Fast path for boolean coercion leveraging core validator
coerced_values = []
for value in values:
if value is None:
coerced_values.append(None)
elif isinstance(value, str):
coerced_values.append(parse_bool(value) if is_boolean_token(value) else None)
else:
coerced_values.append(bool(value))
elif pa.types.is_floating(target_type):
coerced_values = []
for value in values:
if value is None:
coerced_values.append(None)
else:
try:
coerced_values.append(float(value))
except (TypeError, ValueError):
coerced_values.append(None)
else:
coerced_values = [None if v is None else v for v in values]
arrays.append(pa.array(coerced_values, type=target_type))
if schema is not None:
return pa.Table.from_arrays(arrays, schema=schema)
return pa.Table.from_arrays(arrays, names=column_names)