Visitar URL original
feat: New zoned timestamp feature type (#6536) by nquinn408 · Pull Request #6537 · feast-dev/feast · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix: Address review feedback for zoned timestamp type
Address Codex review findings on PR #6537:

- Serialize ZonedTimestamp as an ISO 8601 string in feature_server_utils
  and proto_json so JSONResponse can encode zoned-timestamp features
  (the raw protobuf message is not JSON-serializable).
- Reject ZonedTimestamp as an Array/Set base type, since there is no
  ZONED_TIMESTAMP_LIST/_SET ValueType for to_value_type() to resolve.
- Preserve fixed-offset zones (e.g. UTC-07:00) on decode instead of
  silently falling back to UTC and shifting the wall-clock time.
- Resolve 'ZONED_TIMESTAMP' (and 'SCALAR_MAP') in
  _convert_value_type_str_to_value_type so registry REST/codegen no
  longer render the type as String.

Signed-off-by: Nick Quinn <nicholas_quinn@apple.com>
  • Loading branch information
nickquinn408 committed Jun 18, 2026
commit a716fcc4df051409acc38c0f62b213dcf6829cbe
19 changes: 19 additions & 0 deletions sdk/python/feast/feature_server_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ def _value_to_native(v: Value) -> Optional[Any]:
"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]
Expand All @@ -109,6 +114,20 @@ def _value_to_native(v: Value) -> Optional[Any]:
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.

Expand Down
8 changes: 7 additions & 1 deletion sdk/python/feast/proto_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,15 @@ def to_json_object(printer: _Printer, message: ProtoMessage) -> JsonObject:
elif which in ("list_val", "set_val"):
# Nested collection: RepeatedValue containing Values
repeated = getattr(message, which)
value = [
value: JsonObject = [
printer._MessageToJsonObject(inner_val) for inner_val in repeated.val
]
elif which == "zoned_timestamp_val":
# ZonedTimestamp is a message; render it as an ISO 8601 string in its
# stored zone so the result is JSON-serializable (the raw message is not).
from feast.feature_server_utils import _zoned_timestamp_to_str

value = _zoned_timestamp_to_str(message.zoned_timestamp_val)
elif "_list_" in which:
value = list(getattr(message, which).val)
else:
Expand Down
37 changes: 34 additions & 3 deletions sdk/python/feast/type_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@
import decimal
import json
import logging
import re
import uuid as uuid_module
from collections import defaultdict
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import (
TYPE_CHECKING,
Any,
Expand Down Expand Up @@ -99,12 +100,40 @@ def _zone_from_name(zone: str):

return ZoneInfo(zone)
except Exception:
# Not an IANA name (e.g. a fixed-offset string we couldn't resolve);
# fall back to UTC rather than failing the whole read.
# Not an IANA name. It may be a fixed-offset string produced by
# ``_zone_name`` (e.g. "UTC", "UTC-07:00", "+05:30"); parse it so the
# original offset is preserved on round trips rather than silently
# shifting the wall-clock time to UTC.
offset = _fixed_offset_from_name(zone)
if offset is not None:
return offset
logger.warning("Could not resolve zone %r; decoding as UTC", zone)
return timezone.utc
Comment thread
nquinn408 marked this conversation as resolved.


def _fixed_offset_from_name(zone: str) -> Optional[timezone]:
"""Parse a fixed-offset zone string into a ``timezone``.

Accepts the forms ``_zone_name`` emits for offset-only tzinfos: a bare
``UTC``/``GMT``, or an offset like ``UTC-07:00``, ``-07:00``, ``+05:30`` or
``+0530``. Returns ``None`` if the string is not a recognizable offset.
"""
text = zone.strip()
if text in ("UTC", "GMT"):
return timezone.utc
match = re.fullmatch(
r"(?:UTC|GMT)?([+-])(\d{2}):?(\d{2})",
text,
)
if not match:
return None
sign, hours, minutes = match.groups()
delta = timedelta(hours=int(hours), minutes=int(minutes))
if sign == "-":
delta = -delta
return timezone(delta)


def feast_value_type_to_python_type(
field_value_proto: ProtoValue,
feature_type: Optional[ValueType] = None,
Expand Down Expand Up @@ -564,6 +593,8 @@ def _convert_value_type_str_to_value_type(type_str: str) -> ValueType:
"DECIMAL": ValueType.DECIMAL,
"DECIMAL_LIST": ValueType.DECIMAL_LIST,
"DECIMAL_SET": ValueType.DECIMAL_SET,
"SCALAR_MAP": ValueType.SCALAR_MAP,
"ZONED_TIMESTAMP": ValueType.ZONED_TIMESTAMP,
}
return type_map.get(type_str, ValueType.STRING)

Expand Down
24 changes: 15 additions & 9 deletions sdk/python/feast/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,13 +191,16 @@ class Array(ComplexFeastType):

def __init__(self, base_type: Union[PrimitiveFeastType, "ComplexFeastType"]):
# Allow Struct, Array, and Set as base types for nested collections
if (
not isinstance(base_type, (Struct, Array, Set))
and base_type not in SUPPORTED_BASE_TYPES
):
raise ValueError(
f"Type {type(base_type)} is currently not supported as a base type for Array."
)
if not isinstance(base_type, (Struct, Array, Set)):
# Arrays do not support ZonedTimestamp: there is no ZONED_TIMESTAMP_LIST
# ValueType for it to map to.
supported_array_types = [
t for t in SUPPORTED_BASE_TYPES if t not in (ZonedTimestamp,)
]
if base_type not in supported_array_types:
raise ValueError(
f"Type {type(base_type)} is currently not supported as a base type for Array."
)

self.base_type = base_type

Expand Down Expand Up @@ -236,8 +239,11 @@ class Set(ComplexFeastType):
def __init__(self, base_type: Union[PrimitiveFeastType, ComplexFeastType]):
# Allow Array and Set as base types for nested collections
if not isinstance(base_type, (Array, Set)):
# Sets do not support MAP as a base type
supported_set_types = [t for t in SUPPORTED_BASE_TYPES if t not in (Map,)]
# Sets do not support MAP as a base type, nor ZonedTimestamp (there is no
# ZONED_TIMESTAMP_SET ValueType for it to map to).
supported_set_types = [
t for t in SUPPORTED_BASE_TYPES if t not in (Map, ZonedTimestamp)
]
if base_type not in supported_set_types:
raise ValueError(
f"Type {type(base_type)} is currently not supported as a base type for Set."
Expand Down
22 changes: 22 additions & 0 deletions sdk/python/tests/unit/test_feature_server_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
StringList,
StringSet,
Value,
ZonedTimestamp,
)


Expand Down Expand Up @@ -204,6 +205,27 @@ def test_json_val(self):
result = _value_to_native(v)
assert result == '{"foo": 1}'

def test_zoned_timestamp_val(self):
# 2026-06-09T16:00:00Z viewed in America/Los_Angeles (= 09:00 -07:00).
v = Value(
zoned_timestamp_val=ZonedTimestamp(
unix_timestamp=1781020800, zone="America/Los_Angeles"
)
)
result = _value_to_native(v)
# Rendered as an ISO 8601 string in the stored zone, and JSON-serializable.
assert isinstance(result, str)
assert result == "2026-06-09T09:00:00-07:00"
json.dumps(result)

def test_zoned_timestamp_val_utc(self):
v = Value(
zoned_timestamp_val=ZonedTimestamp(unix_timestamp=1781020800, zone="")
)
result = _value_to_native(v)
assert result == "2026-06-09T16:00:00+00:00"
json.dumps(result)


class TestTimestampToStr:
"""Tests for _timestamp_to_str function."""
Expand Down
34 changes: 34 additions & 0 deletions sdk/python/tests/unit/test_type_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -2281,3 +2281,37 @@ def test_value_type_round_trips_to_feast_type(self):

assert from_value_type(ValueType.ZONED_TIMESTAMP) == ZonedTimestamp
assert ZonedTimestamp.to_value_type() == ValueType.ZONED_TIMESTAMP

def test_fixed_offset_zone_round_trips(self):
from datetime import datetime, timedelta, timezone

# A fixed-offset tzinfo has no IANA key, so _zone_name stores it as an
# offset string. Decode must preserve the offset rather than dropping to UTC.
offset = timezone(timedelta(hours=-7))
dt = datetime(2026, 6, 17, 9, 0, 0, tzinfo=offset)
protos = python_values_to_proto_values([dt], ValueType.ZONED_TIMESTAMP)

converted = feast_value_type_to_python_type(protos[0])
# Same instant AND same wall-clock offset preserved.
assert converted == dt
assert converted.utcoffset() == timedelta(hours=-7)
assert converted.hour == 9

def test_str_to_value_type_resolves_zoned_timestamp(self):
from feast.type_map import _convert_value_type_str_to_value_type

# Must not silently fall back to STRING.
assert (
_convert_value_type_str_to_value_type("ZONED_TIMESTAMP")
== ValueType.ZONED_TIMESTAMP
)

def test_not_supported_as_collection_base_type(self):
# ZonedTimestamp has no ZONED_TIMESTAMP_LIST/_SET ValueType, so it must be
# rejected as an Array/Set base type rather than KeyError-ing later.
from feast.types import Array, Set, ZonedTimestamp

with pytest.raises(ValueError):
Array(ZonedTimestamp)
with pytest.raises(ValueError):
Set(ZonedTimestamp)
Loading