From 0ba58493c2106a2e886f7b49cecd40c812d81fb8 Mon Sep 17 00:00:00 2001 From: Joaquin Hui Gomez <132194176+joaquinhuigomez@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:47:23 +0100 Subject: [PATCH] fix: Recognize Parquet list types in pa_to_feast_value_type pa_to_feast_value_type only parsed the "list" spelling. Parquet files written by pandas/pyarrow read back as "list", so schema inference (feast apply) and write_to_offline_store raised KeyError: 'list' for Array(String), Array(Bytes) and Array(UnixTimestamp) columns in a FileSource. The hard-coded "list" fallback keys only covered some element types, and two of them ("str", "bytes") are names pyarrow never prints. large_list<...>, "not null" elements and element-named nested lists failed the same way. Parse every list spelling with one pattern, recurse into the element type as before, and drop the fallback keys. _parse_pa_type_str, which rebuilds nested list types for write_to_offline_store, uses the same helper. Signed-off-by: Joaquin Hui Gomez <132194176+joaquinhuigomez@users.noreply.github.com> --- .../infra/offline_stores/offline_utils.py | 6 +- sdk/python/feast/type_map.py | 26 +++--- sdk/python/tests/unit/test_type_map.py | 79 +++++++++++++++++++ 3 files changed, 98 insertions(+), 13 deletions(-) diff --git a/sdk/python/feast/infra/offline_stores/offline_utils.py b/sdk/python/feast/infra/offline_stores/offline_utils.py index ef6d0584296..bac8b625b83 100644 --- a/sdk/python/feast/infra/offline_stores/offline_utils.py +++ b/sdk/python/feast/infra/offline_stores/offline_utils.py @@ -20,7 +20,7 @@ from feast.infra.offline_stores.offline_store import OfflineStore from feast.infra.registry.base_registry import BaseRegistry from feast.repo_config import RepoConfig -from feast.type_map import feast_value_type_to_pa +from feast.type_map import _pa_list_inner_type_str, feast_value_type_to_pa from feast.utils import ( _get_requested_feature_views_to_features_dict, _get_requested_on_demand_feature_views, @@ -273,8 +273,8 @@ def get_offline_store_from_config(offline_store_config: Any) -> OfflineStore: def _parse_pa_type_str(pa_type_str: str) -> pa.DataType: """Parse a PyArrow type string to preserve inner element types for nested lists.""" pa_type_str = pa_type_str.strip() - if pa_type_str.startswith("list"): - inner = pa_type_str[len("list ValueType: _FIXED_SIZE_LIST_PREFIX = "fixed_size_list" +) + + +def _pa_list_inner_type_str(pa_type_as_str: str) -> Optional[str]: + """Return the element type string of a PyArrow list type string, else None.""" + match = _PA_LIST_TYPE_PATTERN.fullmatch(pa_type_as_str) + return match.group(1) if match else None + def pa_to_feast_value_type(pa_type_as_str: str) -> ValueType: is_list = False @@ -1618,11 +1631,11 @@ def pa_to_feast_value_type(pa_type_as_str: str) -> ValueType: if closing != -1: pa_type_as_str = f"list" - if pa_type_as_str.startswith("list ValueType: "binary": ValueType.BYTES, "bool": ValueType.BOOL, "null": ValueType.NULL, - "list": ValueType.DOUBLE_LIST, - "list": ValueType.INT64_LIST, - "list": ValueType.INT32_LIST, - "list": ValueType.STRING_LIST, - "list": ValueType.BOOL_LIST, - "list": ValueType.BYTES_LIST, - "list": ValueType.FLOAT_LIST, } value_type = type_map[pa_type_as_str] diff --git a/sdk/python/tests/unit/test_type_map.py b/sdk/python/tests/unit/test_type_map.py index e939d1a2ca2..41c41b98fb1 100644 --- a/sdk/python/tests/unit/test_type_map.py +++ b/sdk/python/tests/unit/test_type_map.py @@ -1977,6 +1977,85 @@ def test_pa_to_feast_value_type_nested(self): ) +class TestPyArrowListTypeStrings: + """PyArrow spells list types several ways; Parquet round-trips (pyarrow >= 13) + name the child field ``element`` instead of ``item``. Schema inference and + ``write_to_offline_store`` used to raise ``KeyError: 'list'``. + """ + + @pytest.mark.parametrize( + "pa_type_str, expected", + [ + ("list", ValueType.STRING_LIST), + ("list", ValueType.BYTES_LIST), + ("list", ValueType.INT64_LIST), + ("list", ValueType.DOUBLE_LIST), + ("list", ValueType.UNIX_TIMESTAMP_LIST), + ("list>", ValueType.STRUCT_LIST), + ("large_list", ValueType.STRING_LIST), + ("large_list", ValueType.BYTES_LIST), + ("list", ValueType.INT64_LIST), + ("list", ValueType.STRING_LIST), + ("list", ValueType.JSON_LIST), + ("list>", ValueType.VALUE_LIST), + ("list not null>", ValueType.VALUE_LIST), + ("large_list>", ValueType.VALUE_LIST), + ], + ) + def test_pa_to_feast_value_type_list_spellings(self, pa_type_str, expected): + assert pa_to_feast_value_type(pa_type_str) == expected + + @pytest.mark.parametrize( + "pa_type_str, expected", + [ + ( + "list>", + pyarrow.list_(pyarrow.list_(pyarrow.int64())), + ), + ( + "list not null>", + pyarrow.list_(pyarrow.list_(pyarrow.string())), + ), + ( + "large_list>", + pyarrow.list_(pyarrow.list_(pyarrow.float64())), + ), + ], + ) + def test_parse_pa_type_str_list_spellings(self, pa_type_str, expected): + from feast.infra.offline_stores.offline_utils import _parse_pa_type_str + + assert _parse_pa_type_str(pa_type_str) == expected + + def test_file_source_parquet_list_columns(self, tmp_path): + from feast.infra.offline_stores.file_source import FileSource + from feast.infra.offline_stores.offline_utils import ( + get_pyarrow_schema_from_batch_source, + ) + from feast.repo_config import RepoConfig + + path = tmp_path / "features.parquet" + pd.DataFrame( + {"tags": [["a", "b"], ["c"]], "nested": [[[1, 2]], [[3]]]} + ).to_parquet(path) + source = FileSource(path=str(path), timestamp_field="event_timestamp") + config = RepoConfig( + project="test", registry=str(tmp_path / "registry.db"), provider="local" + ) + + column_types = dict(source.get_table_column_names_and_types(config)) + assert column_types["tags"] == "list" + to_value_type = source.source_datatype_to_feast_value_type() + assert to_value_type(column_types["tags"]) == ValueType.STRING_LIST + assert to_value_type(column_types["nested"]) == ValueType.VALUE_LIST + + pa_schema, _ = get_pyarrow_schema_from_batch_source(config, source) + assert pa_schema.field("tags").type == pyarrow.list_(pyarrow.string()) + assert pa_schema.field("nested").type == pyarrow.list_( + pyarrow.list_(pyarrow.int64()) + ) + + class TestEmptyArrayAsNull: """Regression tests for https://github.com/feast-dev/feast/issues/6255 Ensure that an empty numpy array in a scalar feature column is treated as