Skip to content

Commit d12fbfd

Browse files
feat: Unify transformations
Signed-off-by: Francisco Javier Arceo <farceo@redhat.com>
1 parent 837e34f commit d12fbfd

6 files changed

Lines changed: 143 additions & 93 deletions

File tree

sdk/python/feast/feature_store.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -966,21 +966,25 @@ def apply(
966966

967967
# Handle dual registration for online_enabled FeatureViews
968968
online_enabled_views = [
969-
view for view in views_to_update
970-
if hasattr(view, 'online_enabled') and view.online_enabled
969+
view
970+
for view in views_to_update
971+
if hasattr(view, "online_enabled") and view.online_enabled
971972
]
972973

973974
for fv in online_enabled_views:
974975
# Create OnDemandFeatureView for online serving with same transformation
975-
if hasattr(fv, 'feature_transformation') and fv.feature_transformation:
976+
if hasattr(fv, "feature_transformation") and fv.feature_transformation:
976977
# Create ODFV with same transformation logic
977978
online_fv = OnDemandFeatureView(
978979
name=f"{fv.name}_online",
979980
sources=fv.source_views or [], # Use source views for ODFV
980981
schema=fv.schema or [],
981982
feature_transformation=fv.feature_transformation, # Same transformation!
982983
description=f"Online serving for {fv.name}",
983-
tags=dict(fv.tags or {}, **{"generated_from": fv.name, "dual_registration": "true"}),
984+
tags=dict(
985+
fv.tags or {},
986+
**{"generated_from": fv.name, "dual_registration": "true"},
987+
),
984988
owner=fv.owner,
985989
)
986990

sdk/python/feast/feature_view.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,10 @@ def __init__(
171171
schema = schema or []
172172
self.mode = mode
173173
# Don't override feature_transformation if it's already set by subclass (e.g., BatchFeatureView)
174-
if not hasattr(self, 'feature_transformation') or self.feature_transformation is None:
174+
if (
175+
not hasattr(self, "feature_transformation")
176+
or self.feature_transformation is None
177+
):
175178
self.feature_transformation = feature_transformation
176179
self.when = when
177180
self.online_enabled = online_enabled

sdk/python/feast/transformation/base.py

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
1+
from __future__ import annotations
2+
13
import functools
24
from abc import ABC
3-
from typing import Any, Callable, Dict, List, Optional, Union
5+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
46

57
import dill
68

9+
from feast.entity import Entity
10+
from feast.field import Field
11+
12+
if TYPE_CHECKING:
13+
from feast.data_source import RequestSource
14+
from feast.feature_view import FeatureView, FeatureViewProjection
715
from feast.protos.feast.core.Transformation_pb2 import (
816
SubstraitTransformationV2 as SubstraitTransformationProto,
917
)
@@ -15,8 +23,6 @@
1523
get_transformation_class_from_type,
1624
)
1725
from feast.transformation.mode import TransformationMode, TransformationTiming
18-
from feast.entity import Entity
19-
from feast.field import Field
2026

2127
# Online compatibility constants
2228
ONLINE_COMPATIBLE_MODES = {"python", "pandas"}
@@ -139,7 +145,9 @@ def transformation(
139145
mode: Union[TransformationMode, str], # Support both enum and string
140146
when: Optional[str] = None,
141147
online: Optional[bool] = None,
142-
sources: Optional[List[Union["FeatureView", "FeatureViewProjection", "RequestSource"]]] = None,
148+
sources: Optional[
149+
List[Union["FeatureView", "FeatureViewProjection", "RequestSource"]]
150+
] = None,
143151
schema: Optional[List[Field]] = None,
144152
entities: Optional[List[Entity]] = None,
145153
name: Optional[str] = None,
@@ -160,19 +168,20 @@ def decorator(user_function):
160168
else:
161169
mode_str = mode.lower() # Normalize to lowercase
162170
try:
163-
mode_enum = TransformationMode(mode_str)
171+
TransformationMode(mode_str) # Validate mode string
164172
except ValueError:
165173
valid_modes = [m.value for m in TransformationMode]
166174
raise ValueError(f"Invalid mode '{mode}'. Valid options: {valid_modes}")
167175

168176
# Validate timing if provided
169-
timing_enum = None
170177
if when is not None:
171178
try:
172-
timing_enum = TransformationTiming(when.lower())
179+
TransformationTiming(when.lower()) # Validate timing string
173180
except ValueError:
174181
valid_timings = [t.value for t in TransformationTiming]
175-
raise ValueError(f"Invalid timing '{when}'. Valid options: {valid_timings}")
182+
raise ValueError(
183+
f"Invalid timing '{when}'. Valid options: {valid_timings}"
184+
)
176185

177186
# Validate online compatibility
178187
if online and not is_online_compatible(mode_str):
@@ -196,19 +205,29 @@ def decorator(user_function):
196205
)
197206

198207
# If FeatureView parameters are provided, create and return FeatureView
199-
if any(param is not None for param in [when, online, sources, schema, entities]):
208+
if any(
209+
param is not None for param in [when, online, sources, schema, entities]
210+
):
200211
# Import FeatureView here to avoid circular imports
201212
from feast.feature_view import FeatureView
202213

203214
# Validate required parameters when creating FeatureView
204215
if when is None:
205-
raise ValueError("'when' parameter is required when creating FeatureView")
216+
raise ValueError(
217+
"'when' parameter is required when creating FeatureView"
218+
)
206219
if online is None:
207-
raise ValueError("'online' parameter is required when creating FeatureView")
220+
raise ValueError(
221+
"'online' parameter is required when creating FeatureView"
222+
)
208223
if sources is None:
209-
raise ValueError("'sources' parameter is required when creating FeatureView")
224+
raise ValueError(
225+
"'sources' parameter is required when creating FeatureView"
226+
)
210227
if schema is None:
211-
raise ValueError("'schema' parameter is required when creating FeatureView")
228+
raise ValueError(
229+
"'schema' parameter is required when creating FeatureView"
230+
)
212231

213232
# Handle source parameter correctly for FeatureView constructor
214233
if not sources:
@@ -219,9 +238,12 @@ def decorator(user_function):
219238
else:
220239
# Multiple sources - pass as list (must be List[FeatureView])
221240
from feast.feature_view import FeatureView as FV
241+
222242
for src in sources:
223-
if not isinstance(src, (FV, type(src).__name__ == 'FeatureView')):
224-
raise ValueError("Multiple sources must be FeatureViews, not DataSources")
243+
if not isinstance(src, (FV, type(src).__name__ == "FeatureView")):
244+
raise ValueError(
245+
"Multiple sources must be FeatureViews, not DataSources"
246+
)
225247
source_param = sources
226248

227249
# Create FeatureView with transformation

sdk/python/feast/transformation/mode.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ class TransformationMode(Enum):
1212

1313

1414
class TransformationTiming(Enum):
15-
ON_READ = "on_read" # Execute during get_online_features()
16-
ON_WRITE = "on_write" # Execute during materialization, cache results
17-
BATCH = "batch" # Scheduled batch processing
18-
STREAMING = "streaming" # Real-time stream processing
15+
ON_READ = "on_read" # Execute during get_online_features()
16+
ON_WRITE = "on_write" # Execute during materialization, cache results
17+
BATCH = "batch" # Scheduled batch processing
18+
STREAMING = "streaming" # Real-time stream processing

sdk/python/tests/unit/test_dual_registration.py

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,16 @@
55
as both batch FeatureViews and OnDemandFeatureViews for serving.
66
"""
77

8-
import pytest
9-
from unittest.mock import Mock, patch, MagicMock
8+
from unittest.mock import Mock, patch
9+
10+
from feast.entity import Entity
1011
from feast.feature_store import FeatureStore
1112
from feast.feature_view import FeatureView
12-
from feast.on_demand_feature_view import OnDemandFeatureView
13-
from feast.transformation.base import transformation, Transformation
14-
from feast.transformation.mode import TransformationMode
1513
from feast.field import Field
16-
from feast.types import Float64, Int64
17-
from feast.entity import Entity
1814
from feast.infra.offline_stores.file_source import FileSource
15+
from feast.on_demand_feature_view import OnDemandFeatureView
16+
from feast.transformation.base import Transformation, transformation
17+
from feast.types import Float64
1918

2019

2120
class TestDualRegistration:
@@ -29,9 +28,7 @@ def test_online_enabled_creates_odfv(self):
2928

3029
# Create transformation
3130
test_transformation = Transformation(
32-
mode="python",
33-
udf=lambda x: x,
34-
udf_string="lambda x: x"
31+
mode="python", udf=lambda x: x, udf_string="lambda x: x"
3532
)
3633

3734
fv = FeatureView(
@@ -41,15 +38,15 @@ def test_online_enabled_creates_odfv(self):
4138
schema=[Field(name="feature1", dtype=Float64)],
4239
feature_transformation=test_transformation,
4340
when="on_write",
44-
online_enabled=True
41+
online_enabled=True,
4542
)
4643

4744
# Mock registry and provider
4845
mock_registry = Mock()
4946
mock_provider = Mock()
5047

5148
# Create FeatureStore instance with mocked initialization
52-
with patch.object(FeatureStore, '__init__', return_value=None):
49+
with patch.object(FeatureStore, "__init__", return_value=None):
5350
fs = FeatureStore()
5451
fs._registry = mock_registry
5552
fs._provider = mock_provider
@@ -89,15 +86,17 @@ def capture_feature_view(view, project, commit):
8986
generated_odfv = None
9087

9188
for view in applied_views:
92-
if isinstance(view, FeatureView) and not isinstance(view, OnDemandFeatureView):
89+
if isinstance(view, FeatureView) and not isinstance(
90+
view, OnDemandFeatureView
91+
):
9392
original_fv = view
9493
elif isinstance(view, OnDemandFeatureView):
9594
generated_odfv = view
9695

9796
# Verify original FV
9897
assert original_fv is not None
9998
assert original_fv.name == "test_fv"
100-
assert original_fv.online_enabled == True
99+
assert original_fv.online_enabled
101100
assert original_fv.feature_transformation is not None
102101

103102
# Verify generated ODFV
@@ -119,12 +118,12 @@ def test_no_dual_registration_when_online_disabled(self):
119118
source=mock_source,
120119
entities=[driver],
121120
schema=[Field(name="feature1", dtype=Float64)],
122-
online_enabled=False # Disabled
121+
online_enabled=False, # Disabled
123122
)
124123

125124
# Mock FeatureStore
126125
# Create FeatureStore instance with mocked initialization
127-
with patch.object(FeatureStore, '__init__', return_value=None):
126+
with patch.object(FeatureStore, "__init__", return_value=None):
128127
fs = FeatureStore()
129128
fs.config = Mock()
130129
fs.config.project = "test_project"
@@ -134,7 +133,9 @@ def test_no_dual_registration_when_online_disabled(self):
134133
fs._make_inferences = Mock()
135134

136135
applied_views = []
137-
fs._registry.apply_feature_view.side_effect = lambda view, project, commit: applied_views.append(view)
136+
fs._registry.apply_feature_view.side_effect = (
137+
lambda view, project, commit: applied_views.append(view)
138+
)
138139
fs._registry.apply_entity = Mock()
139140
fs._registry.apply_data_source = Mock()
140141
fs._registry.apply_feature_service = Mock()
@@ -168,7 +169,7 @@ def test_no_dual_registration_without_transformation(self):
168169

169170
# Mock FeatureStore
170171
# Create FeatureStore instance with mocked initialization
171-
with patch.object(FeatureStore, '__init__', return_value=None):
172+
with patch.object(FeatureStore, "__init__", return_value=None):
172173
fs = FeatureStore()
173174
fs.config = Mock()
174175
fs.config.project = "test_project"
@@ -178,7 +179,9 @@ def test_no_dual_registration_without_transformation(self):
178179
fs._make_inferences = Mock()
179180

180181
applied_views = []
181-
fs._registry.apply_feature_view.side_effect = lambda view, project, commit: applied_views.append(view)
182+
fs._registry.apply_feature_view.side_effect = (
183+
lambda view, project, commit: applied_views.append(view)
184+
)
182185
fs._registry.apply_entity = Mock()
183186
fs._registry.apply_data_source = Mock()
184187
fs._registry.apply_feature_service = Mock()
@@ -201,7 +204,9 @@ def test_enhanced_decorator_with_dual_registration(self):
201204
driver = Entity(name="driver", join_keys=["driver_id"])
202205

203206
# Create FeatureView using enhanced decorator with dummy source
204-
dummy_source = FileSource(path="test.parquet", timestamp_field="event_timestamp")
207+
dummy_source = FileSource(
208+
path="test.parquet", timestamp_field="event_timestamp"
209+
)
205210

206211
@transformation(
207212
mode="python",
@@ -210,19 +215,19 @@ def test_enhanced_decorator_with_dual_registration(self):
210215
sources=[dummy_source],
211216
schema=[Field(name="doubled", dtype=Float64)],
212217
entities=[driver],
213-
name="doubling_transform"
218+
name="doubling_transform",
214219
)
215220
def doubling_transform(inputs):
216221
return [{"doubled": inp.get("value", 0) * 2} for inp in inputs]
217222

218223
# Verify it's a FeatureView with the right properties
219224
assert isinstance(doubling_transform, FeatureView)
220-
assert doubling_transform.online_enabled == True
225+
assert doubling_transform.online_enabled
221226
assert doubling_transform.feature_transformation is not None
222227

223228
# Mock FeatureStore and apply
224229
# Create FeatureStore instance with mocked initialization
225-
with patch.object(FeatureStore, '__init__', return_value=None):
230+
with patch.object(FeatureStore, "__init__", return_value=None):
226231
fs = FeatureStore()
227232
fs.config = Mock()
228233
fs.config.project = "test_project"
@@ -232,7 +237,9 @@ def doubling_transform(inputs):
232237
fs._make_inferences = Mock()
233238

234239
applied_views = []
235-
fs._registry.apply_feature_view.side_effect = lambda view, project, commit: applied_views.append(view)
240+
fs._registry.apply_feature_view.side_effect = (
241+
lambda view, project, commit: applied_views.append(view)
242+
)
236243
fs._registry.apply_entity = Mock()
237244
fs._registry.apply_data_source = Mock()
238245
fs._registry.apply_feature_service = Mock()
@@ -249,7 +256,9 @@ def doubling_transform(inputs):
249256
assert len(applied_views) == 2
250257

251258
# Verify the ODFV has the same transformation
252-
odfv = next((v for v in applied_views if isinstance(v, OnDemandFeatureView)), None)
259+
odfv = next(
260+
(v for v in applied_views if isinstance(v, OnDemandFeatureView)), None
261+
)
253262
assert odfv is not None
254263
assert odfv.name == "doubling_transform_online"
255264

@@ -261,4 +270,4 @@ def doubling_transform(inputs):
261270
odfv_udf = odfv.feature_transformation.udf
262271

263272
assert original_udf(test_input) == expected_output
264-
assert odfv_udf(test_input) == expected_output
273+
assert odfv_udf(test_input) == expected_output

0 commit comments

Comments
 (0)