[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/feast-dev/feast/v0.65.0/sdk/python/feast/feature_server_utils.py [Back]  [Original]

"""Fast serialization utilities for Feature Server responses.

Matches the output format of MessageToDict with proto_json.patch() applied.
Values are serialized as native Python types (not wrapped dicts).
"""

import base64
import logging
from datetime import datetime, timezone
from typing import Any, Dict, Optional

from feast.protos.feast.serving.ServingService_pb2 import GetOnlineFeaturesResponse
from feast.protos.feast.types.Value_pb2 import Value

logger = logging.getLogger(__name__)

# FieldStatus enum mapping (protos/feast/serving/ServingService.proto)
_STATUS_NAMES: Dict[int, str] = {
    0: "INVALID",
    1: "PRESENT",
    2: "NULL_VALUE",
    3: "NOT_FOUND",
    4: "OUTSIDE_MAX_AGE",
}


def convert_response_to_dict(response: GetOnlineFeaturesResponse) -> Dict[str, Any]:
    """Convert GetOnlineFeaturesResponse to a JSON-serializable dict.

    Matches the structure produced by MessageToDict(proto, preserving_proto_field_name=True)
    with proto_json.patch() applied, with one intentional difference:

    - double_val fields are returned as Python float objects (json.dumps uses Python 3.1+
      shortest round-trip form, ~15-17 sig digits) rather than 18 fixed significant digits
      (float_precision=18). Values are numerically identical; only the JSON string length
      may differ. This is safe for all ML feature types and avoids unnecessary precision
      overhead.
    """
    result: Dict[str, Any] = {
        "results": [
            {
                "values": [_value_to_native(v) for v in feature_vector.values],
                "statuses": [
                    _STATUS_NAMES.get(s, "INVALID") for s in feature_vector.statuses
                ],
                **(
                    {
                        "event_timestamps": [
                            _timestamp_to_str(ts)
                            for ts in feature_vector.event_timestamps
                        ]
                    }
                    if feature_vector.event_timestamps
                    else {}
                ),
            }
            for feature_vector in response.results
        ]
    }

    if response.HasField("metadata"):
        result["metadata"] = _metadata_to_dict(response.metadata)

    if response.status:
        result["status"] = response.status

    return result


def _value_to_native(v: Value) -> Optional[Any]:
    """Convert a Value proto to a JSON-serializable Python type.

    bytes_val and bytes_list_val are base64-encoded (RFC 4648) so that
    JSONResponse can serialize them without TypeError. This matches standard
    protobuf JSON encoding for bytes fields and is safe for all HTTP clients.
    """
    which = v.WhichOneof("val")
    if which is None or which == "null_val":
        return None
    # bytes must be base64-encoded for JSON serialization
    elif which == "bytes_val":
        return base64.b64encode(v.bytes_val).decode("ascii")
    # RepeatedValue  nested Values that must be recursively converted
    elif which in ("list_val", "set_val"):
        return [_value_to_native(nested) for nested in getattr(v, which).val]
    # Map  recursively convert nested Values
    elif which in ("map_val", "struct_val"):
        return {k: _value_to_native(vv) for k, vv in getattr(v, which).val.items()}
    # MapList  list of Map
    elif which in ("map_list_val", "struct_list_val"):
        return [
            {k: _value_to_native(vv) for k, vv in m.val.items()}
            for m in getattr(v, which).val
        ]
    # scalar_map_val has non-string keys; full conversion requires extra work and
    # this type is not returned by standard get_online_features paths today.
    elif which == "scalar_map_val":
        logger.warning(
            "scalar_map_val is not yet supported by convert_response_to_dict; value will be None"
        )
        return None
    # zoned_timestamp_val is a ZonedTimestamp message; serialize as an ISO 8601
    # string in its stored zone so JSONResponse can encode it (the raw message
    # is not JSON-serializable).
    elif which == "zoned_timestamp_val":
        return _zoned_timestamp_to_str(v.zoned_timestamp_val)
    # bytes_list_val / bytes_set_val  base64-encode each element
    elif which in ("bytes_list_val", "bytes_set_val"):
        return [base64.b64encode(b).decode("ascii") for b in getattr(v, which).val]
    # All other list/set types have scalar .val fields
    elif "_list_" in which or "_set_" in which:
        return list(getattr(v, which).val)
    else:
        return getattr(v, which)


def _zoned_timestamp_to_str(zoned) -> Optional[str]:
    """Convert a ZonedTimestamp proto to an ISO 8601 string in its stored zone.

    A null instant (NaT sentinel) returns None. The zone is resolved via the
    same logic used on the read path so IANA names and fixed offsets round-trip.
    """
    from feast.type_map import NULL_TIMESTAMP_INT_VALUE, _zone_from_name

    if zoned.unix_timestamp == NULL_TIMESTAMP_INT_VALUE:
        return None
    tz = _zone_from_name(zoned.zone)
    return datetime.fromtimestamp(zoned.unix_timestamp, tz=tz).isoformat()


def _timestamp_to_str(ts) -> str:
    """Convert protobuf Timestamp to RFC 3339 format with Z suffix.

    Uses adaptive precision to match MessageToDict output:
    - No fractional seconds when nanos == 0
    - 3 digits (milliseconds) when nanos % 1_000_000 == 0
    - 6 digits (microseconds) when nanos % 1_000 == 0
    - 9 digits (nanoseconds) otherwise
    """
    if ts.seconds == 0 and ts.nanos == 0:
        return "1970-01-01T00:00:00Z"
    dt = datetime.fromtimestamp(ts.seconds, tz=timezone.utc)
    base = dt.strftime("%Y-%m-%dT%H:%M:%S")
    nanos = ts.nanos
    if nanos == 0:
        return base + "Z"
    elif nanos % 1_000_000 == 0:
        return base + ".%03dZ" % (nanos // 1_000_000)
    elif nanos % 1_000 == 0:
        return base + ".%06dZ" % (nanos // 1_000)
    else:
        return base + ".%09dZ" % nanos


def _metadata_to_dict(metadata) -> Dict[str, Any]:
    """Convert FeatureResponseMeta to dict (matches proto_json.patch() format)."""
    result: Dict[str, Any] = {}
    if metadata.HasField("feature_names"):
        result["feature_names"] = list(metadata.feature_names.val)
    return result

Web Proxy Viewer  |  New URL  |  Original Page