From 24e21bfa1692328eaa92298ff4ed329e8552518a Mon Sep 17 00:00:00 2001 From: Larry Singleton <166439969+larrysingleton007@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:35:48 -0500 Subject: [PATCH 1/3] fix: Return False from __eq__ on cross-type comparison Every registry object's __eq__ did `if not isinstance(other, X): raise TypeError("Comparisons should only involve X class objects.")`. Raising from __eq__ on an operand of a different type is wrong: comparing objects of different types should be False, not an error. As a result, changing a feature view's data source type and re-applying over an existing registry crashed with `TypeError: Comparisons should only involve class objects.` Replace the raise with `return False` across all 35 affected __eq__ methods, matching PushSource.__eq__ which already handles a cross-type comparison this way. Update the LabelView equality test (it asserted the removed TypeError) and add cross-type regression tests for DataSource, Entity, and policy objects, all asserting False rather than a raise. Signed-off-by: Larry Singleton <166439969+larrysingleton007@users.noreply.github.com> --- sdk/python/feast/aggregation/__init__.py | 2 +- sdk/python/feast/base_feature_view.py | 4 +-- sdk/python/feast/data_source.py | 14 +++------- sdk/python/feast/entity.py | 2 +- sdk/python/feast/feature_service.py | 4 +-- sdk/python/feast/feature_view.py | 4 +-- .../infra/offline_stores/bigquery_source.py | 4 +-- .../athena_offline_store/athena_source.py | 4 +-- .../couchbase_source.py | 4 +-- .../mssql_offline_store/mssqlserver_source.py | 4 +-- .../oracle_offline_store/oracle_source.py | 4 +-- .../postgres_offline_store/postgres_source.py | 4 +-- .../contrib/ray_offline_store/ray_source.py | 2 +- .../trino_offline_store/trino_source.py | 4 +-- .../feast/infra/offline_stores/file_source.py | 2 +- .../infra/offline_stores/redshift_source.py | 4 +-- .../infra/offline_stores/snowflake_source.py | 4 +-- sdk/python/feast/labeling/label_view.py | 2 +- sdk/python/feast/on_demand_feature_view.py | 4 +-- sdk/python/feast/permissions/permission.py | 2 +- sdk/python/feast/permissions/policy.py | 16 +++-------- sdk/python/feast/project.py | 2 +- sdk/python/feast/project_metadata.py | 4 +-- sdk/python/feast/saved_dataset.py | 4 +-- sdk/python/feast/stream_feature_view.py | 2 +- .../transformation/pandas_transformation.py | 4 +-- .../transformation/python_transformation.py | 4 +-- .../transformation/ray_transformation.py | 4 +-- .../substrait_transformation.py | 4 +-- .../tests/unit/permissions/test_policy.py | 7 +++++ sdk/python/tests/unit/test_data_sources.py | 27 +++++++++++++++++++ sdk/python/tests/unit/test_entity.py | 7 +++++ sdk/python/tests/unit/test_label_view.py | 5 ++-- 33 files changed, 79 insertions(+), 89 deletions(-) diff --git a/sdk/python/feast/aggregation/__init__.py b/sdk/python/feast/aggregation/__init__.py index 464e49edd9b..d0865c0dabd 100644 --- a/sdk/python/feast/aggregation/__init__.py +++ b/sdk/python/feast/aggregation/__init__.py @@ -90,7 +90,7 @@ def from_proto(cls, agg_proto: AggregationProto): def __eq__(self, other): if not isinstance(other, Aggregation): - raise TypeError("Comparisons should only involve Aggregations.") + return False if ( self.column != other.column diff --git a/sdk/python/feast/base_feature_view.py b/sdk/python/feast/base_feature_view.py index 170beb73609..b657f505f1e 100644 --- a/sdk/python/feast/base_feature_view.py +++ b/sdk/python/feast/base_feature_view.py @@ -172,9 +172,7 @@ def _schema_or_udf_changed(self, other: "BaseFeatureView") -> bool: def __eq__(self, other): if not isinstance(other, BaseFeatureView): - raise TypeError( - "Comparisons should only involve BaseFeatureView class objects." - ) + return False if ( self.name != other.name diff --git a/sdk/python/feast/data_source.py b/sdk/python/feast/data_source.py index a84c0d0bf9b..6981ec03abd 100644 --- a/sdk/python/feast/data_source.py +++ b/sdk/python/feast/data_source.py @@ -279,7 +279,7 @@ def __eq__(self, other): return False if not isinstance(other, DataSource): - raise TypeError("Comparisons should only involve DataSource class objects.") + return False if ( self.name != other.name @@ -499,9 +499,7 @@ def __init__( def __eq__(self, other): if not isinstance(other, KafkaSource): - raise TypeError( - "Comparisons should only involve KafkaSource class objects." - ) + return False if not super().__eq__(other): return False @@ -639,9 +637,7 @@ def get_table_column_names_and_types( def __eq__(self, other): if not isinstance(other, RequestSource): - raise TypeError( - "Comparisons should only involve RequestSource class objects." - ) + return False if not super().__eq__(other): return False @@ -801,9 +797,7 @@ def __init__( def __eq__(self, other): if not isinstance(other, KinesisSource): - raise TypeError( - "Comparisons should only involve KinesisSource class objects." - ) + return False if not super().__eq__(other): return False diff --git a/sdk/python/feast/entity.py b/sdk/python/feast/entity.py index bcfa170a96d..bbd13c83e48 100644 --- a/sdk/python/feast/entity.py +++ b/sdk/python/feast/entity.py @@ -126,7 +126,7 @@ def __hash__(self) -> int: def __eq__(self, other): if not isinstance(other, Entity): - raise TypeError("Comparisons should only involve Entity class objects.") + return False if ( self.name != other.name diff --git a/sdk/python/feast/feature_service.py b/sdk/python/feast/feature_service.py index ed4692f6a76..5cc61372e0f 100644 --- a/sdk/python/feast/feature_service.py +++ b/sdk/python/feast/feature_service.py @@ -293,9 +293,7 @@ def __hash__(self): def __eq__(self, other): if not isinstance(other, FeatureService): - raise TypeError( - "Comparisons should only involve FeatureService class objects." - ) + return False if ( self.name != other.name diff --git a/sdk/python/feast/feature_view.py b/sdk/python/feast/feature_view.py index a5d3c8d9537..6d1a77b1114 100644 --- a/sdk/python/feast/feature_view.py +++ b/sdk/python/feast/feature_view.py @@ -395,9 +395,7 @@ def _schema_or_udf_changed(self, other: "BaseFeatureView") -> bool: def __eq__(self, other): if not isinstance(other, FeatureView): - raise TypeError( - "Comparisons should only involve FeatureView class objects." - ) + return False if not super().__eq__(other): return False diff --git a/sdk/python/feast/infra/offline_stores/bigquery_source.py b/sdk/python/feast/infra/offline_stores/bigquery_source.py index 5fdc29a19fb..d1ad9e8417a 100644 --- a/sdk/python/feast/infra/offline_stores/bigquery_source.py +++ b/sdk/python/feast/infra/offline_stores/bigquery_source.py @@ -97,9 +97,7 @@ def __hash__(self): def __eq__(self, other): if not isinstance(other, BigQuerySource): - raise TypeError( - "Comparisons should only involve BigQuerySource class objects." - ) + return False return ( super().__eq__(other) diff --git a/sdk/python/feast/infra/offline_stores/contrib/athena_offline_store/athena_source.py b/sdk/python/feast/infra/offline_stores/contrib/athena_offline_store/athena_source.py index 157ca35933f..94d635b2ad8 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/athena_offline_store/athena_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/athena_offline_store/athena_source.py @@ -115,9 +115,7 @@ def __hash__(self): def __eq__(self, other): if not isinstance(other, AthenaSource): - raise TypeError( - "Comparisons should only involve AthenaSource class objects." - ) + return False return ( super().__eq__(other) diff --git a/sdk/python/feast/infra/offline_stores/contrib/couchbase_offline_store/couchbase_source.py b/sdk/python/feast/infra/offline_stores/contrib/couchbase_offline_store/couchbase_source.py index 10b9863daed..3702e566759 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/couchbase_offline_store/couchbase_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/couchbase_offline_store/couchbase_source.py @@ -93,9 +93,7 @@ def __hash__(self): def __eq__(self, other): if not isinstance(other, CouchbaseColumnarSource): - raise TypeError( - "Comparisons should only involve CouchbaseColumnarSource class objects." - ) + return False return ( super().__eq__(other) diff --git a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py index ce1107730eb..7590f987ba7 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/mssql_offline_store/mssqlserver_source.py @@ -170,9 +170,7 @@ def __init__( def __eq__(self, other): if not isinstance(other, MsSqlServerSource): - raise TypeError( - "Comparisons should only involve SqlServerSource class objects." - ) + return False return ( self.name == other.name diff --git a/sdk/python/feast/infra/offline_stores/contrib/oracle_offline_store/oracle_source.py b/sdk/python/feast/infra/offline_stores/contrib/oracle_offline_store/oracle_source.py index cb2de89538e..cf45ec5454b 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/oracle_offline_store/oracle_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/oracle_offline_store/oracle_source.py @@ -105,9 +105,7 @@ def __init__( def __eq__(self, other): if not isinstance(other, OracleSource): - raise TypeError( - "Comparisons should only involve OracleSource class objects." - ) + return False return ( self.name == other.name diff --git a/sdk/python/feast/infra/offline_stores/contrib/postgres_offline_store/postgres_source.py b/sdk/python/feast/infra/offline_stores/contrib/postgres_offline_store/postgres_source.py index 272b8ad0474..ca693fc578a 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/postgres_offline_store/postgres_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/postgres_offline_store/postgres_source.py @@ -77,9 +77,7 @@ def __hash__(self): def __eq__(self, other): if not isinstance(other, PostgreSQLSource): - raise TypeError( - "Comparisons should only involve PostgreSQLSource class objects." - ) + return False return ( super().__eq__(other) diff --git a/sdk/python/feast/infra/offline_stores/contrib/ray_offline_store/ray_source.py b/sdk/python/feast/infra/offline_stores/contrib/ray_offline_store/ray_source.py index a8bfa2af2ef..0da9fe4f9dd 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/ray_offline_store/ray_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/ray_offline_store/ray_source.py @@ -228,7 +228,7 @@ def get_table_query_string(self) -> str: def __eq__(self, other): if not isinstance(other, RaySource): - raise TypeError("Comparisons should only involve RaySource class objects.") + return False base_eq = super().__eq__(other) if not base_eq: return False diff --git a/sdk/python/feast/infra/offline_stores/contrib/trino_offline_store/trino_source.py b/sdk/python/feast/infra/offline_stores/contrib/trino_offline_store/trino_source.py index d8768b773fb..3225b17eb4e 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/trino_offline_store/trino_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/trino_offline_store/trino_source.py @@ -146,9 +146,7 @@ def __hash__(self): def __eq__(self, other): if not isinstance(other, TrinoSource): - raise TypeError( - "Comparisons should only involve TrinoSource class objects." - ) + return False return ( super().__eq__(other) diff --git a/sdk/python/feast/infra/offline_stores/file_source.py b/sdk/python/feast/infra/offline_stores/file_source.py index 76460a73e5c..0b5dd162cb6 100644 --- a/sdk/python/feast/infra/offline_stores/file_source.py +++ b/sdk/python/feast/infra/offline_stores/file_source.py @@ -97,7 +97,7 @@ def __hash__(self): def __eq__(self, other): if not isinstance(other, FileSource): - raise TypeError("Comparisons should only involve FileSource class objects.") + return False return ( super().__eq__(other) diff --git a/sdk/python/feast/infra/offline_stores/redshift_source.py b/sdk/python/feast/infra/offline_stores/redshift_source.py index 752d3b12cf3..6c40038ceb0 100644 --- a/sdk/python/feast/infra/offline_stores/redshift_source.py +++ b/sdk/python/feast/infra/offline_stores/redshift_source.py @@ -123,9 +123,7 @@ def __hash__(self): def __eq__(self, other): if not isinstance(other, RedshiftSource): - raise TypeError( - "Comparisons should only involve RedshiftSource class objects." - ) + return False return ( super().__eq__(other) diff --git a/sdk/python/feast/infra/offline_stores/snowflake_source.py b/sdk/python/feast/infra/offline_stores/snowflake_source.py index c5a95dd8395..cec00b90513 100644 --- a/sdk/python/feast/infra/offline_stores/snowflake_source.py +++ b/sdk/python/feast/infra/offline_stores/snowflake_source.py @@ -132,9 +132,7 @@ def __hash__(self): def __eq__(self, other): if not isinstance(other, SnowflakeSource): - raise TypeError( - "Comparisons should only involve SnowflakeSource class objects." - ) + return False return ( super().__eq__(other) diff --git a/sdk/python/feast/labeling/label_view.py b/sdk/python/feast/labeling/label_view.py index e2796006422..754417cc290 100644 --- a/sdk/python/feast/labeling/label_view.py +++ b/sdk/python/feast/labeling/label_view.py @@ -221,7 +221,7 @@ def __copy__(self): def __eq__(self, other): if not isinstance(other, LabelView): - raise TypeError("Comparisons should only involve LabelView class objects.") + return False if not super().__eq__(other): return False diff --git a/sdk/python/feast/on_demand_feature_view.py b/sdk/python/feast/on_demand_feature_view.py index bf2d34666cf..3ab188da334 100644 --- a/sdk/python/feast/on_demand_feature_view.py +++ b/sdk/python/feast/on_demand_feature_view.py @@ -459,9 +459,7 @@ def _schema_or_udf_changed(self, other: "BaseFeatureView") -> bool: def __eq__(self, other): if not isinstance(other, OnDemandFeatureView): - raise TypeError( - "Comparisons should only involve OnDemandFeatureView class objects." - ) + return False # Note, no longer evaluating the base feature view layer as ODFVs can have # multiple datasources and a base_feature_view only has one source diff --git a/sdk/python/feast/permissions/permission.py b/sdk/python/feast/permissions/permission.py index 208575195e9..64af612c704 100644 --- a/sdk/python/feast/permissions/permission.py +++ b/sdk/python/feast/permissions/permission.py @@ -84,7 +84,7 @@ def __init__( def __eq__(self, other): if not isinstance(other, Permission): - raise TypeError("Comparisons should only involve Permission class objects.") + return False if ( self.name != other.name diff --git a/sdk/python/feast/permissions/policy.py b/sdk/python/feast/permissions/policy.py index 30cbb73e992..f50ef863e6f 100644 --- a/sdk/python/feast/permissions/policy.py +++ b/sdk/python/feast/permissions/policy.py @@ -80,9 +80,7 @@ def __init__( def __eq__(self, other): if not isinstance(other, RoleBasedPolicy): - raise TypeError( - "Comparisons should only involve RoleBasedPolicy class objects." - ) + return False if sorted(self.roles) != sorted(other.roles): return False @@ -148,9 +146,7 @@ def __init__( def __eq__(self, other): if not isinstance(other, GroupBasedPolicy): - raise TypeError( - "Comparisons should only involve GroupBasedPolicy class objects." - ) + return False if sorted(self.groups) != sorted(other.groups): return False @@ -206,9 +202,7 @@ def __init__( def __eq__(self, other): if not isinstance(other, NamespaceBasedPolicy): - raise TypeError( - "Comparisons should only involve NamespaceBasedPolicy class objects." - ) + return False if sorted(self.namespaces) != sorted(other.namespaces): return False @@ -270,9 +264,7 @@ def __init__( def __eq__(self, other): if not isinstance(other, CombinedGroupNamespacePolicy): - raise TypeError( - "Comparisons should only involve CombinedGroupNamespacePolicy class objects." - ) + return False if sorted(self.groups) != sorted(other.groups) or sorted( self.namespaces diff --git a/sdk/python/feast/project.py b/sdk/python/feast/project.py index b9cbaddcaad..309f6eb76b5 100644 --- a/sdk/python/feast/project.py +++ b/sdk/python/feast/project.py @@ -82,7 +82,7 @@ def __hash__(self) -> int: def __eq__(self, other): if not isinstance(other, Project): - raise TypeError("Comparisons should only involve Project class objects.") + return False if ( self.name != other.name diff --git a/sdk/python/feast/project_metadata.py b/sdk/python/feast/project_metadata.py index 64488a03629..37394a6706b 100644 --- a/sdk/python/feast/project_metadata.py +++ b/sdk/python/feast/project_metadata.py @@ -60,9 +60,7 @@ def __hash__(self) -> int: def __eq__(self, other): if not isinstance(other, ProjectMetadata): - raise TypeError( - "Comparisons should only involve ProjectMetadata class objects." - ) + return False if ( self.project_name != other.project_name diff --git a/sdk/python/feast/saved_dataset.py b/sdk/python/feast/saved_dataset.py index d78c5b4c349..32877bbd017 100644 --- a/sdk/python/feast/saved_dataset.py +++ b/sdk/python/feast/saved_dataset.py @@ -133,9 +133,7 @@ def __hash__(self): def __eq__(self, other): if not isinstance(other, SavedDataset): - raise TypeError( - "Comparisons should only involve SavedDataset class objects." - ) + return False if ( self.name != other.name diff --git a/sdk/python/feast/stream_feature_view.py b/sdk/python/feast/stream_feature_view.py index c2b4625214a..0538345069f 100644 --- a/sdk/python/feast/stream_feature_view.py +++ b/sdk/python/feast/stream_feature_view.py @@ -248,7 +248,7 @@ def _schema_or_udf_changed(self, other: "BaseFeatureView") -> bool: def __eq__(self, other): if not isinstance(other, StreamFeatureView): - raise TypeError("Comparisons should only involve StreamFeatureViews") + return False if not super().__eq__(other): return False diff --git a/sdk/python/feast/transformation/pandas_transformation.py b/sdk/python/feast/transformation/pandas_transformation.py index 6e073c30100..d14f240ebe8 100644 --- a/sdk/python/feast/transformation/pandas_transformation.py +++ b/sdk/python/feast/transformation/pandas_transformation.py @@ -132,9 +132,7 @@ def infer_features( def __eq__(self, other): if not isinstance(other, PandasTransformation): - raise TypeError( - "Comparisons should only involve PandasTransformation class objects." - ) + return False if ( self.udf_string != other.udf_string diff --git a/sdk/python/feast/transformation/python_transformation.py b/sdk/python/feast/transformation/python_transformation.py index 68e9eee95f6..1fd8390f9c3 100644 --- a/sdk/python/feast/transformation/python_transformation.py +++ b/sdk/python/feast/transformation/python_transformation.py @@ -143,9 +143,7 @@ def infer_features( def __eq__(self, other): if not isinstance(other, PythonTransformation): - raise TypeError( - "Comparisons should only involve PythonTransformation class objects." - ) + return False if ( self.udf_string != other.udf_string diff --git a/sdk/python/feast/transformation/ray_transformation.py b/sdk/python/feast/transformation/ray_transformation.py index b592ce8b0d7..1fe9f03fb0a 100644 --- a/sdk/python/feast/transformation/ray_transformation.py +++ b/sdk/python/feast/transformation/ray_transformation.py @@ -270,9 +270,7 @@ def infer_features( def __eq__(self, other): if not isinstance(other, RayTransformation): - raise TypeError( - "Comparisons should only involve RayTransformation class objects." - ) + return False if ( self.udf_string != other.udf_string diff --git a/sdk/python/feast/transformation/substrait_transformation.py b/sdk/python/feast/transformation/substrait_transformation.py index 9d271c95d51..bf821364304 100644 --- a/sdk/python/feast/transformation/substrait_transformation.py +++ b/sdk/python/feast/transformation/substrait_transformation.py @@ -133,9 +133,7 @@ def infer_features( def __eq__(self, other): if not isinstance(other, SubstraitTransformation): - raise TypeError( - "Comparisons should only involve SubstraitTransformation class objects." - ) + return False return ( self.substrait_plan == other.substrait_plan diff --git a/sdk/python/tests/unit/permissions/test_policy.py b/sdk/python/tests/unit/permissions/test_policy.py index 4e78282d4f8..beccaa1d577 100644 --- a/sdk/python/tests/unit/permissions/test_policy.py +++ b/sdk/python/tests/unit/permissions/test_policy.py @@ -42,3 +42,10 @@ def test_role_based_policy(users, required_roles, username, result): assertpy.assert_that(explain).is_equal_to("") else: assertpy.assert_that(len(explain)).is_greater_than(0) + + +def test_policy_eq_cross_type_returns_false(): + """A policy compared to a different type returns False, not TypeError.""" + policy = RoleBasedPolicy(roles=["reader"]) + assert (policy == "not a policy") is False + assert (policy == 42) is False diff --git a/sdk/python/tests/unit/test_data_sources.py b/sdk/python/tests/unit/test_data_sources.py index e6375fc3a00..e2a928f7bb9 100644 --- a/sdk/python/tests/unit/test_data_sources.py +++ b/sdk/python/tests/unit/test_data_sources.py @@ -306,3 +306,30 @@ def test_redshift_fully_qualified_table_name(source_kwargs, expected_name): ) assert redshift_source.redshift_options.fully_qualified_table_name == expected_name + + +def test_data_source_eq_cross_type_returns_false(): + """A DataSource compared to a different type returns ``False``, never ``TypeError``. + + Regression: ``__eq__`` used to ``raise TypeError("Comparisons should only involve + class objects.")`` on a cross-type comparison, so changing a feature view's + source type and re-``apply()``-ing over an existing registry crashed. Comparing + objects of different types must return ``False``, matching ``PushSource.__eq__``. + """ + file_source = FileSource(name="src", path="/tmp/x.parquet", timestamp_field="ts") + snowflake_source = SnowflakeSource( + name="src", database="D", schema="S", table="T", timestamp_field="ts" + ) + + # Cross-type comparison is False in both directions, not a raise. + assert (file_source == snowflake_source) is False + assert (snowflake_source == file_source) is False + + # Comparison against a non-DataSource operand is also False, not a raise. + assert (file_source == "not a data source") is False + assert (file_source == 42) is False + + # Same-type equality still holds for two independently-built equal instances. + assert file_source == FileSource( + name="src", path="/tmp/x.parquet", timestamp_field="ts" + ) diff --git a/sdk/python/tests/unit/test_entity.py b/sdk/python/tests/unit/test_entity.py index b36f363a6ff..f9192309891 100644 --- a/sdk/python/tests/unit/test_entity.py +++ b/sdk/python/tests/unit/test_entity.py @@ -88,3 +88,10 @@ def test_entity_with_value_type_no_warning(): warnings.simplefilter("error") entity = Entity(name="my-entity", value_type=ValueType.STRING) assert entity.value_type == ValueType.STRING + + +def test_entity_eq_cross_type_returns_false(): + """Entity compared to a different type returns False, not TypeError.""" + entity = Entity(name="my-entity", value_type=ValueType.STRING) + assert (entity == "not an entity") is False + assert (entity == 42) is False diff --git a/sdk/python/tests/unit/test_label_view.py b/sdk/python/tests/unit/test_label_view.py index 77a752dba12..f08aac72dbb 100644 --- a/sdk/python/tests/unit/test_label_view.py +++ b/sdk/python/tests/unit/test_label_view.py @@ -175,8 +175,9 @@ def test_equality_detects_differences(self): def test_equality_type_check(self): lv = _sample_label_view() - with pytest.raises(TypeError): - lv == "not a label view" + # Cross-type comparison returns False, not TypeError. + assert (lv == "not a label view") is False + assert (lv == 42) is False def test_hash_by_name(self): lv1 = _sample_label_view() From 3d5a6dfa44778dc4ac88b11f234b28085430c8eb Mon Sep 17 00:00:00 2001 From: Larry Singleton <166439969+larrysingleton007@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:21:00 -0500 Subject: [PATCH 2/3] test: Add parametrized cross-type __eq__ coverage across the object model Assert that cross-type comparison returns False (never raises) for the importable core types touched by the sweep: Entity, Project, Aggregation, Permission, all four policies, the Kafka/Kinesis/Request and File/BigQuery/Redshift/Snowflake sources, FeatureView, and FeatureService. Complements the existing per-type tests (DataSource, Entity, LabelView, RoleBasedPolicy) and raises patch coverage on the sweep. Contrib sources and optional-dependency transformations are omitted since the unit env can't import them. Signed-off-by: Larry Singleton <166439969+larrysingleton007@users.noreply.github.com> --- sdk/python/tests/unit/test_eq_cross_type.py | 94 +++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 sdk/python/tests/unit/test_eq_cross_type.py diff --git a/sdk/python/tests/unit/test_eq_cross_type.py b/sdk/python/tests/unit/test_eq_cross_type.py new file mode 100644 index 00000000000..571923d61fe --- /dev/null +++ b/sdk/python/tests/unit/test_eq_cross_type.py @@ -0,0 +1,94 @@ +"""Cross-type ``__eq__`` regression tests (see #6636). + +Comparing two Feast registry objects of different types must return ``False`` +instead of raising ``TypeError``. This exercises the shared +``if not isinstance(other, X): return False`` guard across the importable core +object model in one place; the per-type tests for ``DataSource``, ``Entity``, +``LabelView``, and ``RoleBasedPolicy`` live in their own modules. + +The contrib offline sources (athena, couchbase, mssql, oracle, postgres, ray, +trino) and the optional-dependency transformations are intentionally omitted: +the unit environment does not install their drivers, so they cannot be imported +here. Their ``__eq__`` follows the identical, mechanical pattern. +""" + +from datetime import timedelta + +import pytest + +from feast import Entity, FeatureService, FeatureView, Project +from feast.aggregation import Aggregation +from feast.data_format import ProtoFormat +from feast.data_source import KafkaSource, KinesisSource, RequestSource +from feast.field import Field +from feast.infra.offline_stores.bigquery_source import BigQuerySource +from feast.infra.offline_stores.file_source import FileSource +from feast.infra.offline_stores.redshift_source import RedshiftSource +from feast.infra.offline_stores.snowflake_source import SnowflakeSource +from feast.permissions.permission import Permission +from feast.permissions.policy import ( + CombinedGroupNamespacePolicy, + GroupBasedPolicy, + NamespaceBasedPolicy, + RoleBasedPolicy, +) +from feast.types import Int64 + + +def _instances(): + """One instance of each importable type touched by the __eq__ sweep.""" + return { + "Entity": Entity(name="e"), + "Project": Project(name="proj"), + "Aggregation": Aggregation(column="c", function="sum"), + "Permission": Permission(name="perm"), + "RoleBasedPolicy": RoleBasedPolicy(roles=["reader"]), + "GroupBasedPolicy": GroupBasedPolicy(groups=["g"]), + "NamespaceBasedPolicy": NamespaceBasedPolicy(namespaces=["n"]), + "CombinedGroupNamespacePolicy": CombinedGroupNamespacePolicy( + groups=["g"], namespaces=["n"] + ), + "FileSource": FileSource( + name="fs", path="/tmp/x.parquet", timestamp_field="ts" + ), + "BigQuerySource": BigQuerySource( + name="bq", table="p.d.t", timestamp_field="ts" + ), + "RedshiftSource": RedshiftSource(name="rs", table="t", timestamp_field="ts"), + "SnowflakeSource": SnowflakeSource( + name="sf", database="D", schema="S", table="T", timestamp_field="ts" + ), + "KafkaSource": KafkaSource( + name="ks", + kafka_bootstrap_servers="s", + message_format=ProtoFormat("cp"), + topic="t", + timestamp_field="ts", + ), + "KinesisSource": KinesisSource( + name="kn", + region="r", + record_format=ProtoFormat("cp"), + stream_name="s", + timestamp_field="ts", + ), + "RequestSource": RequestSource( + name="rq", schema=[Field(name="f", dtype=Int64)] + ), + "FeatureView": FeatureView(name="fv", ttl=timedelta(days=1)), + "FeatureService": FeatureService(name="svc", features=[]), + } + + +_CASES = list(_instances().items()) + + +@pytest.mark.parametrize("name,obj", _CASES, ids=[n for n, _ in _CASES]) +def test_eq_cross_type_returns_false(name, obj): + # A different-typed operand must compare False, never raise (#6636). + assert (obj == object()) is False + assert (obj == "not a feast object") is False + # __ne__ derives from __eq__, so it must be the inverse. + assert (obj != object()) is True + # The isinstance guard must not break same-object equality. + assert (obj == obj) is True From d2fc30ba3112258c460863ba6f1ca0f9f549f00c Mon Sep 17 00:00:00 2001 From: Larry Singleton <166439969+larrysingleton007@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:26:05 -0500 Subject: [PATCH 3/3] fix: Guard SparkSource, Feature, and data format __eq__ against cross-type comparison SparkSource.__eq__ passed the shared DataSource base check for any DataSource subclass and then accessed spark-only attributes, so comparing against a FileSource with a matching name raised AttributeError instead of returning False (reported on #6636 when swapping a FeatureView's source from FileSource to SparkSource). Feature.__eq__ and the FileFormat/StreamFormat __eq__ accessed attributes of the other operand unguarded and failed the same way; they raised AttributeError rather than the TypeError pattern, which is why the original sweep missed them. Signed-off-by: Larry Singleton <166439969+larrysingleton007@users.noreply.github.com> --- sdk/python/feast/data_format.py | 4 +++ sdk/python/feast/feature.py | 2 ++ .../spark_offline_store/spark_source.py | 9 +++-- sdk/python/tests/unit/test_eq_cross_type.py | 34 +++++++++++++++++-- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/sdk/python/feast/data_format.py b/sdk/python/feast/data_format.py index 409c1500f88..b58af65ca99 100644 --- a/sdk/python/feast/data_format.py +++ b/sdk/python/feast/data_format.py @@ -33,6 +33,8 @@ def to_proto(self): pass def __eq__(self, other): + if not isinstance(other, FileFormat): + return False return self.to_proto() == other.to_proto() @classmethod @@ -95,6 +97,8 @@ def to_proto(self): pass def __eq__(self, other): + if not isinstance(other, StreamFormat): + return False return self.to_proto() == other.to_proto() @classmethod diff --git a/sdk/python/feast/feature.py b/sdk/python/feast/feature.py index db629d677a8..df8f860acd8 100644 --- a/sdk/python/feast/feature.py +++ b/sdk/python/feast/feature.py @@ -50,6 +50,8 @@ def __init__( self._labels = labels def __eq__(self, other): + if not isinstance(other, Feature): + return False if self.name != other.name or self.dtype != other.dtype: return False return True diff --git a/sdk/python/feast/infra/offline_stores/contrib/spark_offline_store/spark_source.py b/sdk/python/feast/infra/offline_stores/contrib/spark_offline_store/spark_source.py index 24dce0c4e0b..a9c2b7e98c9 100644 --- a/sdk/python/feast/infra/offline_stores/contrib/spark_offline_store/spark_source.py +++ b/sdk/python/feast/infra/offline_stores/contrib/spark_offline_store/spark_source.py @@ -262,8 +262,13 @@ def _load_dataframe_from_path(self, spark_session): return reader.load(self.path) def __eq__(self, other): - base_eq = super().__eq__(other) - if not base_eq: + # Guard before the spark-specific attribute access below: the base + # DataSource.__eq__ accepts any DataSource subclass, so a cross-type + # comparison (e.g. against a FileSource with a matching name) would + # otherwise raise AttributeError on `other.table` (#6636). + if not isinstance(other, SparkSource): + return False + if not super().__eq__(other): return False return ( self.table == other.table diff --git a/sdk/python/tests/unit/test_eq_cross_type.py b/sdk/python/tests/unit/test_eq_cross_type.py index 571923d61fe..7a87dd68f10 100644 --- a/sdk/python/tests/unit/test_eq_cross_type.py +++ b/sdk/python/tests/unit/test_eq_cross_type.py @@ -6,10 +6,13 @@ object model in one place; the per-type tests for ``DataSource``, ``Entity``, ``LabelView``, and ``RoleBasedPolicy`` live in their own modules. -The contrib offline sources (athena, couchbase, mssql, oracle, postgres, ray, +Most contrib offline sources (athena, couchbase, mssql, oracle, postgres, ray, trino) and the optional-dependency transformations are intentionally omitted: the unit environment does not install their drivers, so they cannot be imported -here. Their ``__eq__`` follows the identical, mechanical pattern. +here. Their ``__eq__`` follows the identical, mechanical pattern. SparkSource +is the exception — its module imports without pyspark, and its ``__eq__`` +accesses spark-only attributes after the shared ``DataSource`` base check, so +it gets a dedicated cross-subclass test below. """ from datetime import timedelta @@ -18,8 +21,9 @@ from feast import Entity, FeatureService, FeatureView, Project from feast.aggregation import Aggregation -from feast.data_format import ProtoFormat +from feast.data_format import ParquetFormat, ProtoFormat from feast.data_source import KafkaSource, KinesisSource, RequestSource +from feast.feature import Feature from feast.field import Field from feast.infra.offline_stores.bigquery_source import BigQuerySource from feast.infra.offline_stores.file_source import FileSource @@ -33,12 +37,16 @@ RoleBasedPolicy, ) from feast.types import Int64 +from feast.value_type import ValueType def _instances(): """One instance of each importable type touched by the __eq__ sweep.""" return { "Entity": Entity(name="e"), + "Feature": Feature(name="f", dtype=ValueType.INT64), + "ParquetFormat": ParquetFormat(), + "ProtoFormat": ProtoFormat("com.example.Msg"), "Project": Project(name="proj"), "Aggregation": Aggregation(column="c", function="sum"), "Permission": Permission(name="perm"), @@ -92,3 +100,23 @@ def test_eq_cross_type_returns_false(name, obj): assert (obj != object()) is True # The isinstance guard must not break same-object equality. assert (obj == obj) is True + + +def test_spark_source_vs_file_source_eq(): + # Reported on #6636: swapping a FeatureView's source from FileSource to + # SparkSource. The two sources share the base DataSource fields, so + # SparkSource.__eq__ used to pass the base check and then raise + # AttributeError on FileSource's missing `table`. Both directions must + # simply compare False. + spark_source = pytest.importorskip( + "feast.infra.offline_stores.contrib.spark_offline_store.spark_source" + ) + SparkSource = spark_source.SparkSource + + spark = SparkSource(name="src", table="t", timestamp_field="ts") + file = FileSource(name="src", path="/tmp/x.parquet", timestamp_field="ts") + + assert (spark == file) is False + assert (file == spark) is False + assert (spark == object()) is False + assert (spark == SparkSource(name="src", table="t", timestamp_field="ts")) is True