diff --git a/docs/getting-started/concepts/feast-types.md b/docs/getting-started/concepts/feast-types.md index 7d864b6a18f..62cabcd3940 100644 --- a/docs/getting-started/concepts/feast-types.md +++ b/docs/getting-started/concepts/feast-types.md @@ -8,6 +8,7 @@ Feast's type system is built on top of [protobuf](https://github.com/protocolbuf Feast supports the following categories of data types: - **Primitive types**: numerical values (`Int32`, `Int64`, `Float32`, `Float64`), `String`, `Bytes`, `Bool`, and `UnixTimestamp`. +- **Zoned timestamp type**: `ZonedTimestamp` stores a timezone-aware datetime as both the UTC instant and its originating zone, so the original wall-clock zone round-trips losslessly. This differs from `UnixTimestamp`, which is always decoded as UTC and discards the source zone. Use `ZonedTimestamp` when local time-of-day or the offset/zone itself is meaningful. It must be explicitly declared in schema (it is not inferred by any backend), and is not supported as an entity key. - **Domain-specific primitives**: `PdfBytes` (PDF binary data for RAG/document pipelines) and `ImageBytes` (image binary data for multimodal pipelines). These are semantic aliases over `Bytes` and must be explicitly declared in schema — no backend infers them. - **UUID types**: `Uuid` and `TimeUuid` for universally unique identifiers. Stored as strings at the proto level but deserialized to `uuid.UUID` objects in Python. - **Array types**: ordered lists of any primitive type, e.g. `Array(Int64)`, `Array(String)`, `Array(Uuid)`. diff --git a/docs/reference/type-system.md b/docs/reference/type-system.md index eb483c6e769..97cc6036dc8 100644 --- a/docs/reference/type-system.md +++ b/docs/reference/type-system.md @@ -24,6 +24,7 @@ Feast supports the following data types: | `Bytes` | `bytes` | Binary data | | `Bool` | `bool` | Boolean value | | `UnixTimestamp` | `datetime` | Unix timestamp (nullable) | +| `ZonedTimestamp` | `datetime` | Timezone-aware datetime preserving its source zone (nullable) | | `Uuid` | `uuid.UUID` | UUID (any version) | | `TimeUuid` | `uuid.UUID` | Time-based UUID (version 1) | | `Decimal` | `decimal.Decimal` | Arbitrary-precision decimal number | @@ -202,7 +203,8 @@ from datetime import timedelta from feast import Entity, FeatureView, Field, FileSource from feast.types import ( Int32, Int64, Float32, Float64, String, Bytes, Bool, UnixTimestamp, - Uuid, TimeUuid, Decimal, Array, Set, Map, ScalarMap, Json, Struct + Uuid, TimeUuid, Decimal, Array, Set, Map, ScalarMap, Json, Struct, + ZonedTimestamp ) # Define a data source @@ -232,6 +234,7 @@ user_features = FeatureView( Field(name="profile_picture", dtype=Bytes), Field(name="is_active", dtype=Bool), Field(name="last_login", dtype=UnixTimestamp), + Field(name="event_time", dtype=ZonedTimestamp), Field(name="session_id", dtype=Uuid), Field(name="event_id", dtype=TimeUuid), Field(name="price", dtype=Decimal), @@ -362,6 +365,43 @@ unique_prices = {decimal.Decimal("9.99"), decimal.Decimal("19.99"), decimal.Deci `Decimal` is **not** inferred from any backend schema. You must declare it explicitly in your feature view schema. The pandas dtype for `Decimal` columns is `object` (holding `decimal.Decimal` instances), not a numeric dtype. {% endhint %} +### ZonedTimestamp Type Usage Examples + +The `ZonedTimestamp` type stores a timezone-aware `datetime` as both the UTC instant +and its originating zone, so the original wall-clock zone round-trips losslessly. +By contrast, `UnixTimestamp` always decodes to UTC and discards the source zone. + +```python +from datetime import datetime, timezone +from zoneinfo import ZoneInfo + +# A datetime in a specific zone — both the instant and "America/Los_Angeles" are kept +event_time = datetime(2026, 6, 17, 9, 0, 0, tzinfo=ZoneInfo("America/Los_Angeles")) + +# ZonedTimestamp values are returned as tz-aware datetime objects, in their own zone +response = store.get_online_features( + features=["event_features:event_time"], + entity_rows=[{"user_id": 1001}], +) +result = response.to_dict() +# result["event_time"][0] == event_time (same instant AND same zone, e.g. 09:00-07:00) + +# Two values at the same instant but different zones stay distinct +la = datetime(2026, 6, 17, 9, 0, 0, tzinfo=ZoneInfo("America/Los_Angeles")) +utc = datetime(2026, 6, 17, 16, 0, 0, tzinfo=timezone.utc) # same instant as `la` + +# A naive (tz-less) datetime is interpreted as UTC +naive = datetime(2026, 6, 17, 12, 0, 0) # stored zone is empty, decoded as UTC +``` + +{% hint style="warning" %} +`ZonedTimestamp` is **not** inferred from any backend schema — you must declare it +explicitly in your feature view schema. It is not supported as an entity key. The +zone is stored as an IANA name (e.g. `America/Los_Angeles`) when available, falling +back to a fixed-offset string; offline stores that cannot natively carry a zone may +normalize to UTC on that backend. +{% endhint %} + ### Nested Collection Type Usage Examples ```python diff --git a/protos/feast/types/Value.proto b/protos/feast/types/Value.proto index 086617ea66e..648045cb1e0 100644 --- a/protos/feast/types/Value.proto +++ b/protos/feast/types/Value.proto @@ -69,6 +69,7 @@ message ValueType { DECIMAL_LIST = 45; DECIMAL_SET = 46; SCALAR_MAP = 47; + ZONED_TIMESTAMP = 48; } } @@ -120,6 +121,7 @@ message Value { StringList decimal_list_val = 45; StringSet decimal_set_val = 46; ScalarMap scalar_map_val = 47; + ZonedTimestamp zoned_timestamp_val = 48; } } @@ -127,6 +129,17 @@ enum Null { NULL = 0; } +// A timezone-aware datetime: the UTC instant plus its originating zone, so a +// zoned datetime round-trips losslessly (unlike UNIX_TIMESTAMP, which is decoded +// as UTC and discards the original zone). +message ZonedTimestamp { + // Epoch seconds (UTC instant), same convention as unix_timestamp_val. + int64 unix_timestamp = 1; + // IANA zone name (e.g. "America/Los_Angeles") or a fixed-offset string + // (e.g. "-07:00", "UTC"). Empty string is treated as UTC on read. + string zone = 2; +} + message BytesList { repeated bytes val = 1; } diff --git a/sdk/python/feast/protos/feast/types/Value_pb2.py b/sdk/python/feast/protos/feast/types/Value_pb2.py index e8c67b76c3f..96d93ceef56 100644 --- a/sdk/python/feast/protos/feast/types/Value_pb2.py +++ b/sdk/python/feast/protos/feast/types/Value_pb2.py @@ -14,7 +14,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17\x66\x65\x61st/types/Value.proto\x12\x0b\x66\x65\x61st.types\"\xa2\x05\n\tValueType\"\x94\x05\n\x04\x45num\x12\x0b\n\x07INVALID\x10\x00\x12\t\n\x05\x42YTES\x10\x01\x12\n\n\x06STRING\x10\x02\x12\t\n\x05INT32\x10\x03\x12\t\n\x05INT64\x10\x04\x12\n\n\x06\x44OUBLE\x10\x05\x12\t\n\x05\x46LOAT\x10\x06\x12\x08\n\x04\x42OOL\x10\x07\x12\x12\n\x0eUNIX_TIMESTAMP\x10\x08\x12\x0e\n\nBYTES_LIST\x10\x0b\x12\x0f\n\x0bSTRING_LIST\x10\x0c\x12\x0e\n\nINT32_LIST\x10\r\x12\x0e\n\nINT64_LIST\x10\x0e\x12\x0f\n\x0b\x44OUBLE_LIST\x10\x0f\x12\x0e\n\nFLOAT_LIST\x10\x10\x12\r\n\tBOOL_LIST\x10\x11\x12\x17\n\x13UNIX_TIMESTAMP_LIST\x10\x12\x12\x08\n\x04NULL\x10\x13\x12\x07\n\x03MAP\x10\x14\x12\x0c\n\x08MAP_LIST\x10\x15\x12\r\n\tBYTES_SET\x10\x16\x12\x0e\n\nSTRING_SET\x10\x17\x12\r\n\tINT32_SET\x10\x18\x12\r\n\tINT64_SET\x10\x19\x12\x0e\n\nDOUBLE_SET\x10\x1a\x12\r\n\tFLOAT_SET\x10\x1b\x12\x0c\n\x08\x42OOL_SET\x10\x1c\x12\x16\n\x12UNIX_TIMESTAMP_SET\x10\x1d\x12\x08\n\x04JSON\x10 \x12\r\n\tJSON_LIST\x10!\x12\n\n\x06STRUCT\x10\"\x12\x0f\n\x0bSTRUCT_LIST\x10#\x12\x08\n\x04UUID\x10$\x12\r\n\tTIME_UUID\x10%\x12\r\n\tUUID_LIST\x10&\x12\x12\n\x0eTIME_UUID_LIST\x10\'\x12\x0c\n\x08UUID_SET\x10(\x12\x11\n\rTIME_UUID_SET\x10)\x12\x0e\n\nVALUE_LIST\x10*\x12\r\n\tVALUE_SET\x10+\x12\x0b\n\x07\x44\x45\x43IMAL\x10,\x12\x10\n\x0c\x44\x45\x43IMAL_LIST\x10-\x12\x0f\n\x0b\x44\x45\x43IMAL_SET\x10.\x12\x0e\n\nSCALAR_MAP\x10/\"\x8a\x0e\n\x05Value\x12\x13\n\tbytes_val\x18\x01 \x01(\x0cH\x00\x12\x14\n\nstring_val\x18\x02 \x01(\tH\x00\x12\x13\n\tint32_val\x18\x03 \x01(\x05H\x00\x12\x13\n\tint64_val\x18\x04 \x01(\x03H\x00\x12\x14\n\ndouble_val\x18\x05 \x01(\x01H\x00\x12\x13\n\tfloat_val\x18\x06 \x01(\x02H\x00\x12\x12\n\x08\x62ool_val\x18\x07 \x01(\x08H\x00\x12\x1c\n\x12unix_timestamp_val\x18\x08 \x01(\x03H\x00\x12\x30\n\x0e\x62ytes_list_val\x18\x0b \x01(\x0b\x32\x16.feast.types.BytesListH\x00\x12\x32\n\x0fstring_list_val\x18\x0c \x01(\x0b\x32\x17.feast.types.StringListH\x00\x12\x30\n\x0eint32_list_val\x18\r \x01(\x0b\x32\x16.feast.types.Int32ListH\x00\x12\x30\n\x0eint64_list_val\x18\x0e \x01(\x0b\x32\x16.feast.types.Int64ListH\x00\x12\x32\n\x0f\x64ouble_list_val\x18\x0f \x01(\x0b\x32\x17.feast.types.DoubleListH\x00\x12\x30\n\x0e\x66loat_list_val\x18\x10 \x01(\x0b\x32\x16.feast.types.FloatListH\x00\x12.\n\rbool_list_val\x18\x11 \x01(\x0b\x32\x15.feast.types.BoolListH\x00\x12\x39\n\x17unix_timestamp_list_val\x18\x12 \x01(\x0b\x32\x16.feast.types.Int64ListH\x00\x12%\n\x08null_val\x18\x13 \x01(\x0e\x32\x11.feast.types.NullH\x00\x12#\n\x07map_val\x18\x14 \x01(\x0b\x32\x10.feast.types.MapH\x00\x12,\n\x0cmap_list_val\x18\x15 \x01(\x0b\x32\x14.feast.types.MapListH\x00\x12.\n\rbytes_set_val\x18\x16 \x01(\x0b\x32\x15.feast.types.BytesSetH\x00\x12\x30\n\x0estring_set_val\x18\x17 \x01(\x0b\x32\x16.feast.types.StringSetH\x00\x12.\n\rint32_set_val\x18\x18 \x01(\x0b\x32\x15.feast.types.Int32SetH\x00\x12.\n\rint64_set_val\x18\x19 \x01(\x0b\x32\x15.feast.types.Int64SetH\x00\x12\x30\n\x0e\x64ouble_set_val\x18\x1a \x01(\x0b\x32\x16.feast.types.DoubleSetH\x00\x12.\n\rfloat_set_val\x18\x1b \x01(\x0b\x32\x15.feast.types.FloatSetH\x00\x12,\n\x0c\x62ool_set_val\x18\x1c \x01(\x0b\x32\x14.feast.types.BoolSetH\x00\x12\x37\n\x16unix_timestamp_set_val\x18\x1d \x01(\x0b\x32\x15.feast.types.Int64SetH\x00\x12\x12\n\x08json_val\x18 \x01(\tH\x00\x12\x30\n\rjson_list_val\x18! \x01(\x0b\x32\x17.feast.types.StringListH\x00\x12&\n\nstruct_val\x18\" \x01(\x0b\x32\x10.feast.types.MapH\x00\x12/\n\x0fstruct_list_val\x18# \x01(\x0b\x32\x14.feast.types.MapListH\x00\x12\x12\n\x08uuid_val\x18$ \x01(\tH\x00\x12\x17\n\rtime_uuid_val\x18% \x01(\tH\x00\x12\x30\n\ruuid_list_val\x18& \x01(\x0b\x32\x17.feast.types.StringListH\x00\x12\x35\n\x12time_uuid_list_val\x18\' \x01(\x0b\x32\x17.feast.types.StringListH\x00\x12.\n\x0cuuid_set_val\x18( \x01(\x0b\x32\x16.feast.types.StringSetH\x00\x12\x33\n\x11time_uuid_set_val\x18) \x01(\x0b\x32\x16.feast.types.StringSetH\x00\x12.\n\x08list_val\x18* \x01(\x0b\x32\x1a.feast.types.RepeatedValueH\x00\x12-\n\x07set_val\x18+ \x01(\x0b\x32\x1a.feast.types.RepeatedValueH\x00\x12\x15\n\x0b\x64\x65\x63imal_val\x18, \x01(\tH\x00\x12\x33\n\x10\x64\x65\x63imal_list_val\x18- \x01(\x0b\x32\x17.feast.types.StringListH\x00\x12\x31\n\x0f\x64\x65\x63imal_set_val\x18. \x01(\x0b\x32\x16.feast.types.StringSetH\x00\x12\x30\n\x0escalar_map_val\x18/ \x01(\x0b\x32\x16.feast.types.ScalarMapH\x00\x42\x05\n\x03val\"\x18\n\tBytesList\x12\x0b\n\x03val\x18\x01 \x03(\x0c\"\x19\n\nStringList\x12\x0b\n\x03val\x18\x01 \x03(\t\"\x18\n\tInt32List\x12\x0b\n\x03val\x18\x01 \x03(\x05\"\x18\n\tInt64List\x12\x0b\n\x03val\x18\x01 \x03(\x03\"\x19\n\nDoubleList\x12\x0b\n\x03val\x18\x01 \x03(\x01\"\x18\n\tFloatList\x12\x0b\n\x03val\x18\x01 \x03(\x02\"\x17\n\x08\x42oolList\x12\x0b\n\x03val\x18\x01 \x03(\x08\"\x17\n\x08\x42ytesSet\x12\x0b\n\x03val\x18\x01 \x03(\x0c\"\x18\n\tStringSet\x12\x0b\n\x03val\x18\x01 \x03(\t\"\x17\n\x08Int32Set\x12\x0b\n\x03val\x18\x01 \x03(\x05\"\x17\n\x08Int64Set\x12\x0b\n\x03val\x18\x01 \x03(\x03\"\x18\n\tDoubleSet\x12\x0b\n\x03val\x18\x01 \x03(\x01\"\x17\n\x08\x46loatSet\x12\x0b\n\x03val\x18\x01 \x03(\x02\"\x16\n\x07\x42oolSet\x12\x0b\n\x03val\x18\x01 \x03(\x08\"m\n\x03Map\x12&\n\x03val\x18\x01 \x03(\x0b\x32\x19.feast.types.Map.ValEntry\x1a>\n\x08ValEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12!\n\x05value\x18\x02 \x01(\x0b\x32\x12.feast.types.Value:\x02\x38\x01\"(\n\x07MapList\x12\x1d\n\x03val\x18\x01 \x03(\x0b\x32\x10.feast.types.Map\"0\n\rRepeatedValue\x12\x1f\n\x03val\x18\x01 \x03(\x0b\x32\x12.feast.types.Value\"\xef\x01\n\x06MapKey\x12\x13\n\tint32_key\x18\x01 \x01(\x05H\x00\x12\x13\n\tint64_key\x18\x02 \x01(\x03H\x00\x12\x13\n\tfloat_key\x18\x03 \x01(\x02H\x00\x12\x14\n\ndouble_key\x18\x04 \x01(\x01H\x00\x12\x12\n\x08\x62ool_key\x18\x05 \x01(\x08H\x00\x12\x1c\n\x12unix_timestamp_key\x18\x06 \x01(\x03H\x00\x12\x13\n\tbytes_key\x18\x07 \x01(\x0cH\x00\x12\x12\n\x08uuid_key\x18\x08 \x01(\tH\x00\x12\x17\n\rtime_uuid_key\x18\t \x01(\tH\x00\x12\x15\n\x0b\x64\x65\x63imal_key\x18\n \x01(\tH\x00\x42\x05\n\x03key\"U\n\x0eScalarMapEntry\x12 \n\x03key\x18\x01 \x01(\x0b\x32\x13.feast.types.MapKey\x12!\n\x05value\x18\x02 \x01(\x0b\x32\x12.feast.types.Value\"5\n\tScalarMap\x12(\n\x03val\x18\x01 \x03(\x0b\x32\x1b.feast.types.ScalarMapEntry*\x10\n\x04Null\x12\x08\n\x04NULL\x10\x00\x42Q\n\x11\x66\x65\x61st.proto.typesB\nValueProtoZ0github.com/feast-dev/feast/go/protos/feast/typesb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17\x66\x65\x61st/types/Value.proto\x12\x0b\x66\x65\x61st.types\"\xb7\x05\n\tValueType\"\xa9\x05\n\x04\x45num\x12\x0b\n\x07INVALID\x10\x00\x12\t\n\x05\x42YTES\x10\x01\x12\n\n\x06STRING\x10\x02\x12\t\n\x05INT32\x10\x03\x12\t\n\x05INT64\x10\x04\x12\n\n\x06\x44OUBLE\x10\x05\x12\t\n\x05\x46LOAT\x10\x06\x12\x08\n\x04\x42OOL\x10\x07\x12\x12\n\x0eUNIX_TIMESTAMP\x10\x08\x12\x0e\n\nBYTES_LIST\x10\x0b\x12\x0f\n\x0bSTRING_LIST\x10\x0c\x12\x0e\n\nINT32_LIST\x10\r\x12\x0e\n\nINT64_LIST\x10\x0e\x12\x0f\n\x0b\x44OUBLE_LIST\x10\x0f\x12\x0e\n\nFLOAT_LIST\x10\x10\x12\r\n\tBOOL_LIST\x10\x11\x12\x17\n\x13UNIX_TIMESTAMP_LIST\x10\x12\x12\x08\n\x04NULL\x10\x13\x12\x07\n\x03MAP\x10\x14\x12\x0c\n\x08MAP_LIST\x10\x15\x12\r\n\tBYTES_SET\x10\x16\x12\x0e\n\nSTRING_SET\x10\x17\x12\r\n\tINT32_SET\x10\x18\x12\r\n\tINT64_SET\x10\x19\x12\x0e\n\nDOUBLE_SET\x10\x1a\x12\r\n\tFLOAT_SET\x10\x1b\x12\x0c\n\x08\x42OOL_SET\x10\x1c\x12\x16\n\x12UNIX_TIMESTAMP_SET\x10\x1d\x12\x08\n\x04JSON\x10 \x12\r\n\tJSON_LIST\x10!\x12\n\n\x06STRUCT\x10\"\x12\x0f\n\x0bSTRUCT_LIST\x10#\x12\x08\n\x04UUID\x10$\x12\r\n\tTIME_UUID\x10%\x12\r\n\tUUID_LIST\x10&\x12\x12\n\x0eTIME_UUID_LIST\x10\'\x12\x0c\n\x08UUID_SET\x10(\x12\x11\n\rTIME_UUID_SET\x10)\x12\x0e\n\nVALUE_LIST\x10*\x12\r\n\tVALUE_SET\x10+\x12\x0b\n\x07\x44\x45\x43IMAL\x10,\x12\x10\n\x0c\x44\x45\x43IMAL_LIST\x10-\x12\x0f\n\x0b\x44\x45\x43IMAL_SET\x10.\x12\x0e\n\nSCALAR_MAP\x10/\x12\x13\n\x0fZONED_TIMESTAMP\x10\x30\"\xc6\x0e\n\x05Value\x12\x13\n\tbytes_val\x18\x01 \x01(\x0cH\x00\x12\x14\n\nstring_val\x18\x02 \x01(\tH\x00\x12\x13\n\tint32_val\x18\x03 \x01(\x05H\x00\x12\x13\n\tint64_val\x18\x04 \x01(\x03H\x00\x12\x14\n\ndouble_val\x18\x05 \x01(\x01H\x00\x12\x13\n\tfloat_val\x18\x06 \x01(\x02H\x00\x12\x12\n\x08\x62ool_val\x18\x07 \x01(\x08H\x00\x12\x1c\n\x12unix_timestamp_val\x18\x08 \x01(\x03H\x00\x12\x30\n\x0e\x62ytes_list_val\x18\x0b \x01(\x0b\x32\x16.feast.types.BytesListH\x00\x12\x32\n\x0fstring_list_val\x18\x0c \x01(\x0b\x32\x17.feast.types.StringListH\x00\x12\x30\n\x0eint32_list_val\x18\r \x01(\x0b\x32\x16.feast.types.Int32ListH\x00\x12\x30\n\x0eint64_list_val\x18\x0e \x01(\x0b\x32\x16.feast.types.Int64ListH\x00\x12\x32\n\x0f\x64ouble_list_val\x18\x0f \x01(\x0b\x32\x17.feast.types.DoubleListH\x00\x12\x30\n\x0e\x66loat_list_val\x18\x10 \x01(\x0b\x32\x16.feast.types.FloatListH\x00\x12.\n\rbool_list_val\x18\x11 \x01(\x0b\x32\x15.feast.types.BoolListH\x00\x12\x39\n\x17unix_timestamp_list_val\x18\x12 \x01(\x0b\x32\x16.feast.types.Int64ListH\x00\x12%\n\x08null_val\x18\x13 \x01(\x0e\x32\x11.feast.types.NullH\x00\x12#\n\x07map_val\x18\x14 \x01(\x0b\x32\x10.feast.types.MapH\x00\x12,\n\x0cmap_list_val\x18\x15 \x01(\x0b\x32\x14.feast.types.MapListH\x00\x12.\n\rbytes_set_val\x18\x16 \x01(\x0b\x32\x15.feast.types.BytesSetH\x00\x12\x30\n\x0estring_set_val\x18\x17 \x01(\x0b\x32\x16.feast.types.StringSetH\x00\x12.\n\rint32_set_val\x18\x18 \x01(\x0b\x32\x15.feast.types.Int32SetH\x00\x12.\n\rint64_set_val\x18\x19 \x01(\x0b\x32\x15.feast.types.Int64SetH\x00\x12\x30\n\x0e\x64ouble_set_val\x18\x1a \x01(\x0b\x32\x16.feast.types.DoubleSetH\x00\x12.\n\rfloat_set_val\x18\x1b \x01(\x0b\x32\x15.feast.types.FloatSetH\x00\x12,\n\x0c\x62ool_set_val\x18\x1c \x01(\x0b\x32\x14.feast.types.BoolSetH\x00\x12\x37\n\x16unix_timestamp_set_val\x18\x1d \x01(\x0b\x32\x15.feast.types.Int64SetH\x00\x12\x12\n\x08json_val\x18 \x01(\tH\x00\x12\x30\n\rjson_list_val\x18! \x01(\x0b\x32\x17.feast.types.StringListH\x00\x12&\n\nstruct_val\x18\" \x01(\x0b\x32\x10.feast.types.MapH\x00\x12/\n\x0fstruct_list_val\x18# \x01(\x0b\x32\x14.feast.types.MapListH\x00\x12\x12\n\x08uuid_val\x18$ \x01(\tH\x00\x12\x17\n\rtime_uuid_val\x18% \x01(\tH\x00\x12\x30\n\ruuid_list_val\x18& \x01(\x0b\x32\x17.feast.types.StringListH\x00\x12\x35\n\x12time_uuid_list_val\x18\' \x01(\x0b\x32\x17.feast.types.StringListH\x00\x12.\n\x0cuuid_set_val\x18( \x01(\x0b\x32\x16.feast.types.StringSetH\x00\x12\x33\n\x11time_uuid_set_val\x18) \x01(\x0b\x32\x16.feast.types.StringSetH\x00\x12.\n\x08list_val\x18* \x01(\x0b\x32\x1a.feast.types.RepeatedValueH\x00\x12-\n\x07set_val\x18+ \x01(\x0b\x32\x1a.feast.types.RepeatedValueH\x00\x12\x15\n\x0b\x64\x65\x63imal_val\x18, \x01(\tH\x00\x12\x33\n\x10\x64\x65\x63imal_list_val\x18- \x01(\x0b\x32\x17.feast.types.StringListH\x00\x12\x31\n\x0f\x64\x65\x63imal_set_val\x18. \x01(\x0b\x32\x16.feast.types.StringSetH\x00\x12\x30\n\x0escalar_map_val\x18/ \x01(\x0b\x32\x16.feast.types.ScalarMapH\x00\x12:\n\x13zoned_timestamp_val\x18\x30 \x01(\x0b\x32\x1b.feast.types.ZonedTimestampH\x00\x42\x05\n\x03val\"6\n\x0eZonedTimestamp\x12\x16\n\x0eunix_timestamp\x18\x01 \x01(\x03\x12\x0c\n\x04zone\x18\x02 \x01(\t\"\x18\n\tBytesList\x12\x0b\n\x03val\x18\x01 \x03(\x0c\"\x19\n\nStringList\x12\x0b\n\x03val\x18\x01 \x03(\t\"\x18\n\tInt32List\x12\x0b\n\x03val\x18\x01 \x03(\x05\"\x18\n\tInt64List\x12\x0b\n\x03val\x18\x01 \x03(\x03\"\x19\n\nDoubleList\x12\x0b\n\x03val\x18\x01 \x03(\x01\"\x18\n\tFloatList\x12\x0b\n\x03val\x18\x01 \x03(\x02\"\x17\n\x08\x42oolList\x12\x0b\n\x03val\x18\x01 \x03(\x08\"\x17\n\x08\x42ytesSet\x12\x0b\n\x03val\x18\x01 \x03(\x0c\"\x18\n\tStringSet\x12\x0b\n\x03val\x18\x01 \x03(\t\"\x17\n\x08Int32Set\x12\x0b\n\x03val\x18\x01 \x03(\x05\"\x17\n\x08Int64Set\x12\x0b\n\x03val\x18\x01 \x03(\x03\"\x18\n\tDoubleSet\x12\x0b\n\x03val\x18\x01 \x03(\x01\"\x17\n\x08\x46loatSet\x12\x0b\n\x03val\x18\x01 \x03(\x02\"\x16\n\x07\x42oolSet\x12\x0b\n\x03val\x18\x01 \x03(\x08\"m\n\x03Map\x12&\n\x03val\x18\x01 \x03(\x0b\x32\x19.feast.types.Map.ValEntry\x1a>\n\x08ValEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12!\n\x05value\x18\x02 \x01(\x0b\x32\x12.feast.types.Value:\x02\x38\x01\"(\n\x07MapList\x12\x1d\n\x03val\x18\x01 \x03(\x0b\x32\x10.feast.types.Map\"0\n\rRepeatedValue\x12\x1f\n\x03val\x18\x01 \x03(\x0b\x32\x12.feast.types.Value\"\xef\x01\n\x06MapKey\x12\x13\n\tint32_key\x18\x01 \x01(\x05H\x00\x12\x13\n\tint64_key\x18\x02 \x01(\x03H\x00\x12\x13\n\tfloat_key\x18\x03 \x01(\x02H\x00\x12\x14\n\ndouble_key\x18\x04 \x01(\x01H\x00\x12\x12\n\x08\x62ool_key\x18\x05 \x01(\x08H\x00\x12\x1c\n\x12unix_timestamp_key\x18\x06 \x01(\x03H\x00\x12\x13\n\tbytes_key\x18\x07 \x01(\x0cH\x00\x12\x12\n\x08uuid_key\x18\x08 \x01(\tH\x00\x12\x17\n\rtime_uuid_key\x18\t \x01(\tH\x00\x12\x15\n\x0b\x64\x65\x63imal_key\x18\n \x01(\tH\x00\x42\x05\n\x03key\"U\n\x0eScalarMapEntry\x12 \n\x03key\x18\x01 \x01(\x0b\x32\x13.feast.types.MapKey\x12!\n\x05value\x18\x02 \x01(\x0b\x32\x12.feast.types.Value\"5\n\tScalarMap\x12(\n\x03val\x18\x01 \x03(\x0b\x32\x1b.feast.types.ScalarMapEntry*\x10\n\x04Null\x12\x08\n\x04NULL\x10\x00\x42Q\n\x11\x66\x65\x61st.proto.typesB\nValueProtoZ0github.com/feast-dev/feast/go/protos/feast/typesb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -24,54 +24,56 @@ _globals['DESCRIPTOR']._serialized_options = b'\n\021feast.proto.typesB\nValueProtoZ0github.com/feast-dev/feast/go/protos/feast/types' _globals['_MAP_VALENTRY']._options = None _globals['_MAP_VALENTRY']._serialized_options = b'8\001' - _globals['_NULL']._serialized_start=3468 - _globals['_NULL']._serialized_end=3484 + _globals['_NULL']._serialized_start=3605 + _globals['_NULL']._serialized_end=3621 _globals['_VALUETYPE']._serialized_start=41 - _globals['_VALUETYPE']._serialized_end=715 + _globals['_VALUETYPE']._serialized_end=736 _globals['_VALUETYPE_ENUM']._serialized_start=55 - _globals['_VALUETYPE_ENUM']._serialized_end=715 - _globals['_VALUE']._serialized_start=718 - _globals['_VALUE']._serialized_end=2520 - _globals['_BYTESLIST']._serialized_start=2522 - _globals['_BYTESLIST']._serialized_end=2546 - _globals['_STRINGLIST']._serialized_start=2548 - _globals['_STRINGLIST']._serialized_end=2573 - _globals['_INT32LIST']._serialized_start=2575 - _globals['_INT32LIST']._serialized_end=2599 - _globals['_INT64LIST']._serialized_start=2601 - _globals['_INT64LIST']._serialized_end=2625 - _globals['_DOUBLELIST']._serialized_start=2627 - _globals['_DOUBLELIST']._serialized_end=2652 - _globals['_FLOATLIST']._serialized_start=2654 - _globals['_FLOATLIST']._serialized_end=2678 - _globals['_BOOLLIST']._serialized_start=2680 - _globals['_BOOLLIST']._serialized_end=2703 - _globals['_BYTESSET']._serialized_start=2705 - _globals['_BYTESSET']._serialized_end=2728 - _globals['_STRINGSET']._serialized_start=2730 - _globals['_STRINGSET']._serialized_end=2754 - _globals['_INT32SET']._serialized_start=2756 - _globals['_INT32SET']._serialized_end=2779 - _globals['_INT64SET']._serialized_start=2781 - _globals['_INT64SET']._serialized_end=2804 - _globals['_DOUBLESET']._serialized_start=2806 - _globals['_DOUBLESET']._serialized_end=2830 - _globals['_FLOATSET']._serialized_start=2832 - _globals['_FLOATSET']._serialized_end=2855 - _globals['_BOOLSET']._serialized_start=2857 - _globals['_BOOLSET']._serialized_end=2879 - _globals['_MAP']._serialized_start=2881 - _globals['_MAP']._serialized_end=2990 - _globals['_MAP_VALENTRY']._serialized_start=2928 - _globals['_MAP_VALENTRY']._serialized_end=2990 - _globals['_MAPLIST']._serialized_start=2992 - _globals['_MAPLIST']._serialized_end=3032 - _globals['_REPEATEDVALUE']._serialized_start=3034 - _globals['_REPEATEDVALUE']._serialized_end=3082 - _globals['_MAPKEY']._serialized_start=3085 - _globals['_MAPKEY']._serialized_end=3324 - _globals['_SCALARMAPENTRY']._serialized_start=3326 - _globals['_SCALARMAPENTRY']._serialized_end=3411 - _globals['_SCALARMAP']._serialized_start=3413 - _globals['_SCALARMAP']._serialized_end=3466 + _globals['_VALUETYPE_ENUM']._serialized_end=736 + _globals['_VALUE']._serialized_start=739 + _globals['_VALUE']._serialized_end=2601 + _globals['_ZONEDTIMESTAMP']._serialized_start=2603 + _globals['_ZONEDTIMESTAMP']._serialized_end=2657 + _globals['_BYTESLIST']._serialized_start=2659 + _globals['_BYTESLIST']._serialized_end=2683 + _globals['_STRINGLIST']._serialized_start=2685 + _globals['_STRINGLIST']._serialized_end=2710 + _globals['_INT32LIST']._serialized_start=2712 + _globals['_INT32LIST']._serialized_end=2736 + _globals['_INT64LIST']._serialized_start=2738 + _globals['_INT64LIST']._serialized_end=2762 + _globals['_DOUBLELIST']._serialized_start=2764 + _globals['_DOUBLELIST']._serialized_end=2789 + _globals['_FLOATLIST']._serialized_start=2791 + _globals['_FLOATLIST']._serialized_end=2815 + _globals['_BOOLLIST']._serialized_start=2817 + _globals['_BOOLLIST']._serialized_end=2840 + _globals['_BYTESSET']._serialized_start=2842 + _globals['_BYTESSET']._serialized_end=2865 + _globals['_STRINGSET']._serialized_start=2867 + _globals['_STRINGSET']._serialized_end=2891 + _globals['_INT32SET']._serialized_start=2893 + _globals['_INT32SET']._serialized_end=2916 + _globals['_INT64SET']._serialized_start=2918 + _globals['_INT64SET']._serialized_end=2941 + _globals['_DOUBLESET']._serialized_start=2943 + _globals['_DOUBLESET']._serialized_end=2967 + _globals['_FLOATSET']._serialized_start=2969 + _globals['_FLOATSET']._serialized_end=2992 + _globals['_BOOLSET']._serialized_start=2994 + _globals['_BOOLSET']._serialized_end=3016 + _globals['_MAP']._serialized_start=3018 + _globals['_MAP']._serialized_end=3127 + _globals['_MAP_VALENTRY']._serialized_start=3065 + _globals['_MAP_VALENTRY']._serialized_end=3127 + _globals['_MAPLIST']._serialized_start=3129 + _globals['_MAPLIST']._serialized_end=3169 + _globals['_REPEATEDVALUE']._serialized_start=3171 + _globals['_REPEATEDVALUE']._serialized_end=3219 + _globals['_MAPKEY']._serialized_start=3222 + _globals['_MAPKEY']._serialized_end=3461 + _globals['_SCALARMAPENTRY']._serialized_start=3463 + _globals['_SCALARMAPENTRY']._serialized_end=3548 + _globals['_SCALARMAP']._serialized_start=3550 + _globals['_SCALARMAP']._serialized_end=3603 # @@protoc_insertion_point(module_scope) diff --git a/sdk/python/feast/protos/feast/types/Value_pb2.pyi b/sdk/python/feast/protos/feast/types/Value_pb2.pyi index 4c24284e1e4..97595d63c97 100644 --- a/sdk/python/feast/protos/feast/types/Value_pb2.pyi +++ b/sdk/python/feast/protos/feast/types/Value_pb2.pyi @@ -98,6 +98,7 @@ class ValueType(google.protobuf.message.Message): DECIMAL_LIST: ValueType._Enum.ValueType # 45 DECIMAL_SET: ValueType._Enum.ValueType # 46 SCALAR_MAP: ValueType._Enum.ValueType # 47 + ZONED_TIMESTAMP: ValueType._Enum.ValueType # 48 class Enum(_Enum, metaclass=_EnumEnumTypeWrapper): ... INVALID: ValueType.Enum.ValueType # 0 @@ -144,6 +145,7 @@ class ValueType(google.protobuf.message.Message): DECIMAL_LIST: ValueType.Enum.ValueType # 45 DECIMAL_SET: ValueType.Enum.ValueType # 46 SCALAR_MAP: ValueType.Enum.ValueType # 47 + ZONED_TIMESTAMP: ValueType.Enum.ValueType # 48 def __init__( self, @@ -197,6 +199,7 @@ class Value(google.protobuf.message.Message): DECIMAL_LIST_VAL_FIELD_NUMBER: builtins.int DECIMAL_SET_VAL_FIELD_NUMBER: builtins.int SCALAR_MAP_VAL_FIELD_NUMBER: builtins.int + ZONED_TIMESTAMP_VAL_FIELD_NUMBER: builtins.int bytes_val: builtins.bytes string_val: builtins.str int32_val: builtins.int @@ -270,6 +273,8 @@ class Value(google.protobuf.message.Message): def decimal_set_val(self) -> global___StringSet: ... @property def scalar_map_val(self) -> global___ScalarMap: ... + @property + def zoned_timestamp_val(self) -> global___ZonedTimestamp: ... def __init__( self, *, @@ -316,13 +321,40 @@ class Value(google.protobuf.message.Message): decimal_list_val: global___StringList | None = ..., decimal_set_val: global___StringSet | None = ..., scalar_map_val: global___ScalarMap | None = ..., + zoned_timestamp_val: global___ZonedTimestamp | None = ..., ) -> None: ... - def HasField(self, field_name: typing_extensions.Literal["bool_list_val", b"bool_list_val", "bool_set_val", b"bool_set_val", "bool_val", b"bool_val", "bytes_list_val", b"bytes_list_val", "bytes_set_val", b"bytes_set_val", "bytes_val", b"bytes_val", "decimal_list_val", b"decimal_list_val", "decimal_set_val", b"decimal_set_val", "decimal_val", b"decimal_val", "double_list_val", b"double_list_val", "double_set_val", b"double_set_val", "double_val", b"double_val", "float_list_val", b"float_list_val", "float_set_val", b"float_set_val", "float_val", b"float_val", "int32_list_val", b"int32_list_val", "int32_set_val", b"int32_set_val", "int32_val", b"int32_val", "int64_list_val", b"int64_list_val", "int64_set_val", b"int64_set_val", "int64_val", b"int64_val", "json_list_val", b"json_list_val", "json_val", b"json_val", "list_val", b"list_val", "map_list_val", b"map_list_val", "map_val", b"map_val", "null_val", b"null_val", "scalar_map_val", b"scalar_map_val", "set_val", b"set_val", "string_list_val", b"string_list_val", "string_set_val", b"string_set_val", "string_val", b"string_val", "struct_list_val", b"struct_list_val", "struct_val", b"struct_val", "time_uuid_list_val", b"time_uuid_list_val", "time_uuid_set_val", b"time_uuid_set_val", "time_uuid_val", b"time_uuid_val", "unix_timestamp_list_val", b"unix_timestamp_list_val", "unix_timestamp_set_val", b"unix_timestamp_set_val", "unix_timestamp_val", b"unix_timestamp_val", "uuid_list_val", b"uuid_list_val", "uuid_set_val", b"uuid_set_val", "uuid_val", b"uuid_val", "val", b"val"]) -> builtins.bool: ... - def ClearField(self, field_name: typing_extensions.Literal["bool_list_val", b"bool_list_val", "bool_set_val", b"bool_set_val", "bool_val", b"bool_val", "bytes_list_val", b"bytes_list_val", "bytes_set_val", b"bytes_set_val", "bytes_val", b"bytes_val", "decimal_list_val", b"decimal_list_val", "decimal_set_val", b"decimal_set_val", "decimal_val", b"decimal_val", "double_list_val", b"double_list_val", "double_set_val", b"double_set_val", "double_val", b"double_val", "float_list_val", b"float_list_val", "float_set_val", b"float_set_val", "float_val", b"float_val", "int32_list_val", b"int32_list_val", "int32_set_val", b"int32_set_val", "int32_val", b"int32_val", "int64_list_val", b"int64_list_val", "int64_set_val", b"int64_set_val", "int64_val", b"int64_val", "json_list_val", b"json_list_val", "json_val", b"json_val", "list_val", b"list_val", "map_list_val", b"map_list_val", "map_val", b"map_val", "null_val", b"null_val", "scalar_map_val", b"scalar_map_val", "set_val", b"set_val", "string_list_val", b"string_list_val", "string_set_val", b"string_set_val", "string_val", b"string_val", "struct_list_val", b"struct_list_val", "struct_val", b"struct_val", "time_uuid_list_val", b"time_uuid_list_val", "time_uuid_set_val", b"time_uuid_set_val", "time_uuid_val", b"time_uuid_val", "unix_timestamp_list_val", b"unix_timestamp_list_val", "unix_timestamp_set_val", b"unix_timestamp_set_val", "unix_timestamp_val", b"unix_timestamp_val", "uuid_list_val", b"uuid_list_val", "uuid_set_val", b"uuid_set_val", "uuid_val", b"uuid_val", "val", b"val"]) -> None: ... - def WhichOneof(self, oneof_group: typing_extensions.Literal["val", b"val"]) -> typing_extensions.Literal["bytes_val", "string_val", "int32_val", "int64_val", "double_val", "float_val", "bool_val", "unix_timestamp_val", "bytes_list_val", "string_list_val", "int32_list_val", "int64_list_val", "double_list_val", "float_list_val", "bool_list_val", "unix_timestamp_list_val", "null_val", "map_val", "map_list_val", "bytes_set_val", "string_set_val", "int32_set_val", "int64_set_val", "double_set_val", "float_set_val", "bool_set_val", "unix_timestamp_set_val", "json_val", "json_list_val", "struct_val", "struct_list_val", "uuid_val", "time_uuid_val", "uuid_list_val", "time_uuid_list_val", "uuid_set_val", "time_uuid_set_val", "list_val", "set_val", "decimal_val", "decimal_list_val", "decimal_set_val", "scalar_map_val"] | None: ... + def HasField(self, field_name: typing_extensions.Literal["bool_list_val", b"bool_list_val", "bool_set_val", b"bool_set_val", "bool_val", b"bool_val", "bytes_list_val", b"bytes_list_val", "bytes_set_val", b"bytes_set_val", "bytes_val", b"bytes_val", "decimal_list_val", b"decimal_list_val", "decimal_set_val", b"decimal_set_val", "decimal_val", b"decimal_val", "double_list_val", b"double_list_val", "double_set_val", b"double_set_val", "double_val", b"double_val", "float_list_val", b"float_list_val", "float_set_val", b"float_set_val", "float_val", b"float_val", "int32_list_val", b"int32_list_val", "int32_set_val", b"int32_set_val", "int32_val", b"int32_val", "int64_list_val", b"int64_list_val", "int64_set_val", b"int64_set_val", "int64_val", b"int64_val", "json_list_val", b"json_list_val", "json_val", b"json_val", "list_val", b"list_val", "map_list_val", b"map_list_val", "map_val", b"map_val", "null_val", b"null_val", "scalar_map_val", b"scalar_map_val", "set_val", b"set_val", "string_list_val", b"string_list_val", "string_set_val", b"string_set_val", "string_val", b"string_val", "struct_list_val", b"struct_list_val", "struct_val", b"struct_val", "time_uuid_list_val", b"time_uuid_list_val", "time_uuid_set_val", b"time_uuid_set_val", "time_uuid_val", b"time_uuid_val", "unix_timestamp_list_val", b"unix_timestamp_list_val", "unix_timestamp_set_val", b"unix_timestamp_set_val", "unix_timestamp_val", b"unix_timestamp_val", "uuid_list_val", b"uuid_list_val", "uuid_set_val", b"uuid_set_val", "uuid_val", b"uuid_val", "zoned_timestamp_val", b"zoned_timestamp_val", "val", b"val"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["bool_list_val", b"bool_list_val", "bool_set_val", b"bool_set_val", "bool_val", b"bool_val", "bytes_list_val", b"bytes_list_val", "bytes_set_val", b"bytes_set_val", "bytes_val", b"bytes_val", "decimal_list_val", b"decimal_list_val", "decimal_set_val", b"decimal_set_val", "decimal_val", b"decimal_val", "double_list_val", b"double_list_val", "double_set_val", b"double_set_val", "double_val", b"double_val", "float_list_val", b"float_list_val", "float_set_val", b"float_set_val", "float_val", b"float_val", "int32_list_val", b"int32_list_val", "int32_set_val", b"int32_set_val", "int32_val", b"int32_val", "int64_list_val", b"int64_list_val", "int64_set_val", b"int64_set_val", "int64_val", b"int64_val", "json_list_val", b"json_list_val", "json_val", b"json_val", "list_val", b"list_val", "map_list_val", b"map_list_val", "map_val", b"map_val", "null_val", b"null_val", "scalar_map_val", b"scalar_map_val", "set_val", b"set_val", "string_list_val", b"string_list_val", "string_set_val", b"string_set_val", "string_val", b"string_val", "struct_list_val", b"struct_list_val", "struct_val", b"struct_val", "time_uuid_list_val", b"time_uuid_list_val", "time_uuid_set_val", b"time_uuid_set_val", "time_uuid_val", b"time_uuid_val", "unix_timestamp_list_val", b"unix_timestamp_list_val", "unix_timestamp_set_val", b"unix_timestamp_set_val", "unix_timestamp_val", b"unix_timestamp_val", "uuid_list_val", b"uuid_list_val", "uuid_set_val", b"uuid_set_val", "uuid_val", b"uuid_val", "zoned_timestamp_val", b"zoned_timestamp_val", "val", b"val"]) -> None: ... + def WhichOneof(self, oneof_group: typing_extensions.Literal["val", b"val"]) -> typing_extensions.Literal["bytes_val", "string_val", "int32_val", "int64_val", "double_val", "float_val", "bool_val", "unix_timestamp_val", "bytes_list_val", "string_list_val", "int32_list_val", "int64_list_val", "double_list_val", "float_list_val", "bool_list_val", "unix_timestamp_list_val", "null_val", "map_val", "map_list_val", "bytes_set_val", "string_set_val", "int32_set_val", "int64_set_val", "double_set_val", "float_set_val", "bool_set_val", "unix_timestamp_set_val", "json_val", "json_list_val", "struct_val", "struct_list_val", "uuid_val", "time_uuid_val", "uuid_list_val", "time_uuid_list_val", "uuid_set_val", "time_uuid_set_val", "list_val", "set_val", "decimal_val", "decimal_list_val", "decimal_set_val", "scalar_map_val", "zoned_timestamp_val"] | None: ... global___Value = Value +class ZonedTimestamp(google.protobuf.message.Message): + """A timezone-aware datetime: the UTC instant plus its originating zone, so a + zoned datetime round-trips losslessly (unlike UNIX_TIMESTAMP, which is decoded + as UTC and discards the original zone). + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + UNIX_TIMESTAMP_FIELD_NUMBER: builtins.int + ZONE_FIELD_NUMBER: builtins.int + unix_timestamp: builtins.int + """Epoch seconds (UTC instant), same convention as unix_timestamp_val.""" + zone: builtins.str + """IANA zone name (e.g. "America/Los_Angeles") or a fixed-offset string + (e.g. "-07:00", "UTC"). Empty string is treated as UTC on read. + """ + def __init__( + self, + *, + unix_timestamp: builtins.int = ..., + zone: builtins.str = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["unix_timestamp", b"unix_timestamp", "zone", b"zone"]) -> None: ... + +global___ZonedTimestamp = ZonedTimestamp + class BytesList(google.protobuf.message.Message): DESCRIPTOR: google.protobuf.descriptor.Descriptor diff --git a/sdk/python/feast/type_map.py b/sdk/python/feast/type_map.py index 91bb56f6386..d6662b42cd4 100644 --- a/sdk/python/feast/type_map.py +++ b/sdk/python/feast/type_map.py @@ -59,6 +59,7 @@ ScalarMapEntry, StringList, StringSet, + ZonedTimestamp, ) from feast.protos.feast.types.Value_pb2 import Value as ProtoValue from feast.value_type import ListType, SetType, ValueType @@ -72,6 +73,38 @@ logger = logging.getLogger(__name__) +def _zone_name(tzinfo: Optional[Any]) -> str: + """Return a storable zone string for a datetime's tzinfo. + + Prefers the IANA name (e.g. ``zoneinfo.ZoneInfo`` key) so DST is preserved; + falls back to a fixed-offset string (e.g. ``-07:00``). A naive datetime + (``tzinfo is None``) yields ``""``, which decodes back as UTC. + """ + if tzinfo is None: + return "" + key = getattr(tzinfo, "key", None) # zoneinfo.ZoneInfo + if key: + return key + name = str(tzinfo) + # zoneinfo prints as the key; pytz prints the name; offsets print as "UTC-07:00" + return name + + +def _zone_from_name(zone: str): + """Resolve a stored zone string back to a tzinfo. Empty → UTC.""" + if not zone: + return timezone.utc + try: + from zoneinfo import ZoneInfo + + 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. + logger.warning("Could not resolve zone %r; decoding as UTC", zone) + return timezone.utc + + def feast_value_type_to_python_type( field_value_proto: ProtoValue, feature_type: Optional[ValueType] = None, @@ -127,6 +160,13 @@ def feast_value_type_to_python_type( elif val_attr == "scalar_map_val": return _handle_scalar_map_value(val) + # Zoned timestamp: a (instant, zone) message → tz-aware datetime in its own zone. + if val_attr == "zoned_timestamp_val": + if val.unix_timestamp == NULL_TIMESTAMP_INT_VALUE: + return None + tz = _zone_from_name(val.zone) + return datetime.fromtimestamp(val.unix_timestamp, tz=tz) + # If it's a _LIST or _SET type extract the values. if hasattr(val, "val"): val = list(val.val) @@ -1041,6 +1081,35 @@ def _convert_scalar_values_to_proto( out[i] = ProtoValue(unix_timestamp_val=ts) # type: ignore return out + if feast_value_type == ValueType.ZONED_TIMESTAMP: + # Lossless zoned datetime: store the UTC instant plus the originating zone. + # Only datetime values are accepted; a naive datetime keeps zone="" (UTC). + out = [] + for value in values: + if _is_array_like(value) or value is None or pd.isnull(value): + out.append(ProtoValue()) + elif isinstance(value, datetime): + # A naive datetime is interpreted as UTC for the instant, but keeps + # zone="" (the "unzoned" sentinel, which decode maps back to UTC). + dt = ( + value + if value.tzinfo is not None + else value.replace(tzinfo=timezone.utc) + ) + out.append( + ProtoValue( + zoned_timestamp_val=ZonedTimestamp( + unix_timestamp=int(dt.timestamp()), + zone=_zone_name(value.tzinfo), + ) + ) # type: ignore + ) + else: + raise TypeError( + f"ZONED_TIMESTAMP expects datetime values, got {type(value)}" + ) + return out + field_name, func, valid_scalar_types = PYTHON_SCALAR_VALUE_TYPE_TO_PROTO_VALUE[ feast_value_type ] @@ -1258,6 +1327,7 @@ def _python_value_to_proto_value( if ( feast_value_type in PYTHON_SCALAR_VALUE_TYPE_TO_PROTO_VALUE or feast_value_type == ValueType.UNIX_TIMESTAMP + or feast_value_type == ValueType.ZONED_TIMESTAMP ): scalar_sample = next( (v for v in values if _non_empty_value(v) and not _is_array_like(v)), diff --git a/sdk/python/feast/types.py b/sdk/python/feast/types.py index 9a9cfeeec84..db64068a5fa 100644 --- a/sdk/python/feast/types.py +++ b/sdk/python/feast/types.py @@ -38,6 +38,7 @@ "MAP": "MAP", "JSON": "JSON", "SCALAR_MAP": "SCALAR_MAP", + "ZONED_TIMESTAMP": "ZONED_TIMESTAMP", } @@ -95,6 +96,7 @@ class PrimitiveFeastType(Enum): TIME_UUID = 14 DECIMAL = 15 SCALAR_MAP = 16 + ZONED_TIMESTAMP = 17 def to_value_type(self) -> ValueType: """ @@ -133,6 +135,7 @@ def __hash__(self): TimeUuid = PrimitiveFeastType.TIME_UUID Decimal = PrimitiveFeastType.DECIMAL ScalarMap = PrimitiveFeastType.SCALAR_MAP +ZonedTimestamp = PrimitiveFeastType.ZONED_TIMESTAMP SUPPORTED_BASE_TYPES = [ Invalid, @@ -151,6 +154,7 @@ def __hash__(self): Uuid, TimeUuid, Decimal, + ZonedTimestamp, ] PRIMITIVE_FEAST_TYPES_TO_STRING = { @@ -171,6 +175,7 @@ def __hash__(self): "TIME_UUID": "TimeUuid", "DECIMAL": "Decimal", "SCALAR_MAP": "ScalarMap", + "ZONED_TIMESTAMP": "ZonedTimestamp", } @@ -351,6 +356,7 @@ def __hash__(self): ValueType.DECIMAL_LIST: Array(Decimal), ValueType.DECIMAL_SET: Set(Decimal), ValueType.SCALAR_MAP: ScalarMap, + ValueType.ZONED_TIMESTAMP: ZonedTimestamp, } FEAST_TYPES_TO_PYARROW_TYPES = { @@ -362,6 +368,8 @@ def __hash__(self): Float64: pyarrow.float64(), # Note: datetime only supports microseconds https://github.com/python/cpython/blob/3.8/Lib/datetime.py#L1559 UnixTimestamp: pyarrow.timestamp("us", tz=_utc_now().tzname()), + # Per-value zone is carried in the proto; the Arrow column type is tz-aware UTC. + ZonedTimestamp: pyarrow.timestamp("us", tz="UTC"), Map: pyarrow.map_(pyarrow.string(), pyarrow.string()), Json: pyarrow.large_string(), Uuid: pyarrow.string(), diff --git a/sdk/python/feast/value_type.py b/sdk/python/feast/value_type.py index e8b0b5a10d6..0ea16002f80 100644 --- a/sdk/python/feast/value_type.py +++ b/sdk/python/feast/value_type.py @@ -83,6 +83,7 @@ class ValueType(enum.Enum): DECIMAL_LIST = 45 DECIMAL_SET = 46 SCALAR_MAP = 47 + ZONED_TIMESTAMP = 48 ListType = Union[ diff --git a/sdk/python/tests/unit/test_type_map.py b/sdk/python/tests/unit/test_type_map.py index 8a865c72fb1..ec9d814ad5d 100644 --- a/sdk/python/tests/unit/test_type_map.py +++ b/sdk/python/tests/unit/test_type_map.py @@ -2229,3 +2229,55 @@ def test_mixed_batch_simulating_athena_chunk(self): assert protos[2] == ProtoValue() assert list(protos[3].string_list_val.val) == ["baz"] assert list(protos[4].string_list_val.val) == ["qux", ""] + + +class TestZonedTimestamp: + def test_round_trip_preserves_zone(self): + from datetime import datetime + from zoneinfo import ZoneInfo + + dt = datetime(2026, 6, 17, 9, 0, 0, tzinfo=ZoneInfo("America/Los_Angeles")) + protos = python_values_to_proto_values([dt], ValueType.ZONED_TIMESTAMP) + assert protos[0].WhichOneof("val") == "zoned_timestamp_val" + assert protos[0].zoned_timestamp_val.zone == "America/Los_Angeles" + + converted = feast_value_type_to_python_type(protos[0]) + # Same instant AND same wall-clock zone (unlike UNIX_TIMESTAMP, which is UTC). + assert converted == dt + assert str(converted.tzinfo) == "America/Los_Angeles" + assert converted.hour == 9 + + def test_distinct_zones_same_instant_are_not_collapsed(self): + from datetime import datetime, timezone + from zoneinfo import ZoneInfo + + la = datetime(2026, 6, 17, 9, 0, 0, tzinfo=ZoneInfo("America/Los_Angeles")) + utc = datetime(2026, 6, 17, 16, 0, 0, tzinfo=timezone.utc) # same instant + protos = python_values_to_proto_values([la, utc], ValueType.ZONED_TIMESTAMP) + # Same epoch, different zone preserved. + assert ( + protos[0].zoned_timestamp_val.unix_timestamp + == protos[1].zoned_timestamp_val.unix_timestamp + ) + assert protos[0].zoned_timestamp_val.zone == "America/Los_Angeles" + assert protos[1].zoned_timestamp_val.zone == "UTC" + + def test_naive_datetime_treated_as_utc(self): + from datetime import datetime, timezone + + dt = datetime(2026, 6, 17, 12, 0, 0) # naive + protos = python_values_to_proto_values([dt], ValueType.ZONED_TIMESTAMP) + assert protos[0].zoned_timestamp_val.zone == "" + converted = feast_value_type_to_python_type(protos[0]) + assert converted == dt.replace(tzinfo=timezone.utc) + + def test_null_values(self): + protos = python_values_to_proto_values([None], ValueType.ZONED_TIMESTAMP) + assert protos[0] == ProtoValue() + assert feast_value_type_to_python_type(protos[0]) is None + + def test_value_type_round_trips_to_feast_type(self): + from feast.types import ZonedTimestamp, from_value_type + + assert from_value_type(ValueType.ZONED_TIMESTAMP) == ZonedTimestamp + assert ZonedTimestamp.to_value_type() == ValueType.ZONED_TIMESTAMP diff --git a/skills/references/feature-definitions.md b/skills/references/feature-definitions.md index dc6764e94c7..d9040e089c0 100644 --- a/skills/references/feature-definitions.md +++ b/skills/references/feature-definitions.md @@ -42,7 +42,7 @@ customer = Entity(name="customer_id", join_keys=["customer_id"]) ### Type System -**Scalar types** (from `feast.types`): `Float32`, `Float64`, `Int32`, `Int64`, `String`, `Bool`, `Bytes`, `UnixTimestamp` +**Scalar types** (from `feast.types`): `Float32`, `Float64`, `Int32`, `Int64`, `String`, `Bool`, `Bytes`, `UnixTimestamp`, `ZonedTimestamp` **Collection types**: `Array(T)` where T is a scalar type (e.g., `Array(Float32)` for embeddings)