Skip to content

Commit 5e9522a

Browse files
Ambient Code Botclaude
authored andcommitted
test: Add unit tests and fix BackendFactory bug for online stores, compute engines, and transformations
Add 9 new test files with 74 unit tests covering previously untested modules to improve the test-to-source ratio. Bug fix: - Fix BackendFactory.infer_from_entity_df where `not entity_df` crashes for pandas DataFrames with ValueError("The truth value of a DataFrame is ambiguous"). Reorder conditions to check isinstance first, making the isinstance(entity_df, pd.DataFrame) check reachable. Coverage added: - Online Stores (2 files, 23 tests): helpers, base class - Compute Engines (5 files, 37 tests): topological sort, backend factory, local engine/jobs, materialization - Transformations (3 files, 14 tests): factory resolution, SQL transformation, mode enum All tests are pure unit tests with no infrastructure dependencies. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Ambient Code Bot <bot@ambient-code.local>
1 parent 4ea123d commit 5e9522a

File tree

12 files changed

+704
-2
lines changed

12 files changed

+704
-2
lines changed

sdk/python/feast/infra/compute_engines/backends/factory.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@ def from_name(name: str) -> DataFrameBackend:
2424
@staticmethod
2525
def infer_from_entity_df(entity_df) -> Optional[DataFrameBackend]:
2626
if (
27-
not entity_df
28-
or isinstance(entity_df, pyarrow.Table)
27+
entity_df is None
2928
or isinstance(entity_df, pd.DataFrame)
29+
or isinstance(entity_df, pyarrow.Table)
30+
or (isinstance(entity_df, str) and not entity_df)
3031
):
3132
return PandasBackend()
3233

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
import pandas as pd
4+
import pyarrow
5+
import pytest
6+
7+
from feast.infra.compute_engines.backends.factory import BackendFactory
8+
from feast.infra.compute_engines.backends.pandas_backend import PandasBackend
9+
10+
11+
class TestBackendFactoryFromName:
12+
def test_pandas_backend(self):
13+
backend = BackendFactory.from_name("pandas")
14+
assert isinstance(backend, PandasBackend)
15+
16+
@patch(
17+
"feast.infra.compute_engines.backends.factory.BackendFactory._get_polars_backend"
18+
)
19+
def test_polars_backend(self, mock_get_polars):
20+
mock_backend = MagicMock()
21+
mock_get_polars.return_value = mock_backend
22+
23+
result = BackendFactory.from_name("polars")
24+
assert result is mock_backend
25+
26+
def test_unsupported_name_raises(self):
27+
with pytest.raises(ValueError, match="Unsupported backend name"):
28+
BackendFactory.from_name("dask")
29+
30+
31+
class TestBackendFactoryInferFromEntityDf:
32+
def test_pandas_dataframe_returns_pandas_backend(self):
33+
"""A non-empty pandas DataFrame is detected via isinstance check."""
34+
df = pd.DataFrame({"a": [1, 2]})
35+
backend = BackendFactory.infer_from_entity_df(df)
36+
assert isinstance(backend, PandasBackend)
37+
38+
def test_empty_pandas_dataframe_returns_pandas_backend(self):
39+
"""An empty pandas DataFrame returns PandasBackend."""
40+
df = pd.DataFrame()
41+
backend = BackendFactory.infer_from_entity_df(df)
42+
assert isinstance(backend, PandasBackend)
43+
44+
def test_pyarrow_table(self):
45+
table = pyarrow.table({"a": [1, 2]})
46+
backend = BackendFactory.infer_from_entity_df(table)
47+
assert isinstance(backend, PandasBackend)
48+
49+
def test_none_input(self):
50+
backend = BackendFactory.infer_from_entity_df(None)
51+
assert isinstance(backend, PandasBackend)
52+
53+
def test_empty_string_input(self):
54+
backend = BackendFactory.infer_from_entity_df("")
55+
assert isinstance(backend, PandasBackend)
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from unittest.mock import MagicMock
2+
3+
from feast.infra.compute_engines.base import ComputeEngine
4+
from feast.infra.compute_engines.local.compute import (
5+
LocalComputeEngine,
6+
LocalComputeEngineConfig,
7+
)
8+
9+
10+
class TestLocalComputeEngineConfig:
11+
def test_default_type(self):
12+
config = LocalComputeEngineConfig()
13+
assert config.type == "local"
14+
15+
def test_default_backend_none(self):
16+
config = LocalComputeEngineConfig()
17+
assert config.backend is None
18+
19+
def test_custom_backend(self):
20+
config = LocalComputeEngineConfig(backend="polars")
21+
assert config.backend == "polars"
22+
23+
24+
class TestLocalComputeEngine:
25+
def _make_engine(self):
26+
return LocalComputeEngine(
27+
repo_config=MagicMock(),
28+
offline_store=MagicMock(),
29+
online_store=MagicMock(),
30+
)
31+
32+
def test_is_compute_engine(self):
33+
engine = self._make_engine()
34+
assert isinstance(engine, ComputeEngine)
35+
36+
def test_update_is_noop(self):
37+
engine = self._make_engine()
38+
# Should not raise
39+
engine.update(
40+
project="test",
41+
views_to_delete=[],
42+
views_to_keep=[],
43+
entities_to_delete=[],
44+
entities_to_keep=[],
45+
)
46+
47+
def test_teardown_is_noop(self):
48+
engine = self._make_engine()
49+
# Should not raise
50+
engine.teardown_infra(
51+
project="test",
52+
fvs=[],
53+
entities=[],
54+
)
55+
56+
def test_stores_config(self):
57+
repo_config = MagicMock()
58+
offline = MagicMock()
59+
online = MagicMock()
60+
engine = LocalComputeEngine(
61+
repo_config=repo_config,
62+
offline_store=offline,
63+
online_store=online,
64+
)
65+
assert engine.repo_config is repo_config
66+
assert engine.offline_store is offline
67+
assert engine.online_store is online
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from unittest.mock import MagicMock
2+
3+
import pytest
4+
5+
from feast.infra.common.materialization_job import MaterializationJobStatus
6+
from feast.infra.compute_engines.local.job import (
7+
LocalMaterializationJob,
8+
LocalRetrievalJob,
9+
)
10+
11+
12+
class TestLocalMaterializationJob:
13+
def test_status(self):
14+
job = LocalMaterializationJob(
15+
job_id="test-job-1",
16+
status=MaterializationJobStatus.SUCCEEDED,
17+
)
18+
assert job.status() == MaterializationJobStatus.SUCCEEDED
19+
20+
def test_job_id(self):
21+
job = LocalMaterializationJob(
22+
job_id="test-job-1",
23+
status=MaterializationJobStatus.RUNNING,
24+
)
25+
assert job.job_id() == "test-job-1"
26+
27+
def test_error_none_by_default(self):
28+
job = LocalMaterializationJob(
29+
job_id="test-job-1",
30+
status=MaterializationJobStatus.SUCCEEDED,
31+
)
32+
assert job.error() is None
33+
34+
def test_error_stored(self):
35+
err = RuntimeError("something failed")
36+
job = LocalMaterializationJob(
37+
job_id="test-job-1",
38+
status=MaterializationJobStatus.ERROR,
39+
error=err,
40+
)
41+
assert job.error() is err
42+
43+
def test_should_not_be_retried(self):
44+
job = LocalMaterializationJob(
45+
job_id="test-job-1",
46+
status=MaterializationJobStatus.ERROR,
47+
)
48+
assert job.should_be_retried() is False
49+
50+
def test_url_is_none(self):
51+
job = LocalMaterializationJob(
52+
job_id="test-job-1",
53+
status=MaterializationJobStatus.SUCCEEDED,
54+
)
55+
assert job.url() is None
56+
57+
58+
class TestLocalRetrievalJob:
59+
def test_full_feature_names(self):
60+
job = LocalRetrievalJob(
61+
plan=None,
62+
context=MagicMock(),
63+
full_feature_names=True,
64+
)
65+
assert job.full_feature_names is True
66+
67+
def test_full_feature_names_false(self):
68+
job = LocalRetrievalJob(
69+
plan=None,
70+
context=MagicMock(),
71+
full_feature_names=False,
72+
)
73+
assert job.full_feature_names is False
74+
75+
def test_error_none_by_default(self):
76+
job = LocalRetrievalJob(plan=None, context=MagicMock())
77+
assert job.error() is None
78+
79+
def test_error_stored(self):
80+
err = ValueError("bad data")
81+
job = LocalRetrievalJob(plan=None, context=MagicMock(), error=err)
82+
assert job.error() is err
83+
84+
def test_on_demand_feature_views_default_empty(self):
85+
job = LocalRetrievalJob(plan=None, context=MagicMock())
86+
assert job.on_demand_feature_views == []
87+
88+
def test_metadata_default_none(self):
89+
job = LocalRetrievalJob(plan=None, context=MagicMock())
90+
assert job.metadata is None
91+
92+
def test_to_remote_storage_raises(self):
93+
job = LocalRetrievalJob(plan=None, context=MagicMock())
94+
with pytest.raises(NotImplementedError, match="Remote storage"):
95+
job.to_remote_storage()
96+
97+
def test_to_sql_raises(self):
98+
job = LocalRetrievalJob(plan=None, context=MagicMock())
99+
with pytest.raises(NotImplementedError, match="SQL generation"):
100+
job.to_sql()
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from datetime import datetime
2+
from unittest.mock import MagicMock
3+
4+
from feast.infra.common.materialization_job import (
5+
MaterializationJobStatus,
6+
MaterializationTask,
7+
)
8+
9+
10+
class TestMaterializationJobStatus:
11+
def test_all_statuses_defined(self):
12+
expected = {
13+
"WAITING",
14+
"RUNNING",
15+
"AVAILABLE",
16+
"ERROR",
17+
"CANCELLING",
18+
"CANCELLED",
19+
"SUCCEEDED",
20+
"PAUSED",
21+
"RETRYING",
22+
}
23+
actual = {s.name for s in MaterializationJobStatus}
24+
assert actual == expected
25+
26+
27+
class TestMaterializationTask:
28+
def test_creation(self):
29+
mock_fv = MagicMock()
30+
task = MaterializationTask(
31+
project="my_project",
32+
feature_view=mock_fv,
33+
start_time=datetime(2024, 1, 1),
34+
end_time=datetime(2024, 1, 2),
35+
)
36+
assert task.project == "my_project"
37+
assert task.feature_view is mock_fv
38+
assert task.start_time == datetime(2024, 1, 1)
39+
assert task.end_time == datetime(2024, 1, 2)
40+
41+
def test_default_only_latest(self):
42+
mock_fv = MagicMock()
43+
task = MaterializationTask(
44+
project="p",
45+
feature_view=mock_fv,
46+
start_time=datetime(2024, 1, 1),
47+
end_time=datetime(2024, 1, 2),
48+
)
49+
assert task.only_latest is True
50+
51+
def test_default_disable_event_timestamp(self):
52+
mock_fv = MagicMock()
53+
task = MaterializationTask(
54+
project="p",
55+
feature_view=mock_fv,
56+
start_time=datetime(2024, 1, 1),
57+
end_time=datetime(2024, 1, 2),
58+
)
59+
assert task.disable_event_timestamp is False
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from unittest.mock import MagicMock
2+
3+
from feast.infra.compute_engines.algorithms.topo import (
4+
topological_sort,
5+
topological_sort_multiple,
6+
)
7+
from feast.infra.compute_engines.dag.node import DAGNode
8+
9+
10+
def _make_node(name, inputs=None):
11+
"""Create a mock DAGNode."""
12+
node = MagicMock(spec=DAGNode)
13+
node.name = name
14+
node.inputs = inputs or []
15+
return node
16+
17+
18+
class TestTopologicalSort:
19+
def test_single_node(self):
20+
root = _make_node("root")
21+
result = topological_sort(root)
22+
assert len(result) == 1
23+
assert result[0] is root
24+
25+
def test_linear_chain(self):
26+
"""A -> B -> C should produce [A, B, C]."""
27+
a = _make_node("A")
28+
b = _make_node("B", inputs=[a])
29+
c = _make_node("C", inputs=[b])
30+
31+
result = topological_sort(c)
32+
assert len(result) == 3
33+
# Dependencies must come before dependents
34+
assert result.index(a) < result.index(b)
35+
assert result.index(b) < result.index(c)
36+
37+
def test_diamond_dependency(self):
38+
"""
39+
A → B
40+
A → C
41+
B,C → D
42+
Should visit A before B and C, and B/C before D.
43+
"""
44+
a = _make_node("A")
45+
b = _make_node("B", inputs=[a])
46+
c = _make_node("C", inputs=[a])
47+
d = _make_node("D", inputs=[b, c])
48+
49+
result = topological_sort(d)
50+
assert len(result) == 4
51+
assert result.index(a) < result.index(b)
52+
assert result.index(a) < result.index(c)
53+
assert result.index(b) < result.index(d)
54+
assert result.index(c) < result.index(d)
55+
56+
def test_no_duplicates(self):
57+
"""Shared dependencies should appear only once."""
58+
shared = _make_node("shared")
59+
b = _make_node("B", inputs=[shared])
60+
c = _make_node("C", inputs=[shared])
61+
root = _make_node("root", inputs=[b, c])
62+
63+
result = topological_sort(root)
64+
assert result.count(shared) == 1
65+
66+
67+
class TestTopologicalSortMultiple:
68+
def test_multiple_roots_no_overlap(self):
69+
r1 = _make_node("root1")
70+
r2 = _make_node("root2")
71+
72+
result = topological_sort_multiple([r1, r2])
73+
assert len(result) == 2
74+
assert r1 in result
75+
assert r2 in result
76+
77+
def test_multiple_roots_with_shared_dep(self):
78+
shared = _make_node("shared")
79+
r1 = _make_node("root1", inputs=[shared])
80+
r2 = _make_node("root2", inputs=[shared])
81+
82+
result = topological_sort_multiple([r1, r2])
83+
assert len(result) == 3
84+
assert result.count(shared) == 1
85+
assert result.index(shared) < result.index(r1)
86+
assert result.index(shared) < result.index(r2)
87+
88+
def test_empty_roots(self):
89+
result = topological_sort_multiple([])
90+
assert result == []

0 commit comments

Comments
 (0)