Repository: great-expectations/great_expectations
Pull request #11263 (2025-06-23 15:17:53)

[MAINTENANCE] Bump ruff to 0.12.0


- [ ] Description of PR changes above includes a link to [an existing GitHub issue](https://github.com/great-expectations/great_expectations/issues)
- [ ] PR title is prefixed with one of: [BUGFIX], [FEATURE], [DOCS], [MAINTENANCE], [CONTRIB], [MINORBUMP]
- [ ] Code is linted - run `invoke lint` (uses `ruff format` + `ruff check`)
- [ ] Appropriate tests and docs have been updated

For more information about contributing, visit our [community resources](https://docs.greatexpectations.io/docs/core/introduction/community_resources#contribute-code-or-documentation).

After you submit your PR, keep the page open and **monitor the statuses of the various checks made by our continuous integration process at the bottom of the page. Please fix any issues that come up** and [reach out on Slack](https://greatexpectations.io/slack) if you need help. Thanks for contributing!


netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy Preview for *niobium-lead-7998* processing.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 64865be2cf1a0313ae22a8557115cf4630c2f5ef |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/projects/niobium-lead-7998/deploys/68597024a160d1000858262f |

Patch:
diff --git a/assets/scripts/build_gallery.py b/assets/scripts/build_gallery.py
index d7d7aa48c..a49403264 100644
--- a/assets/scripts/build_gallery.py
+++ b/assets/scripts/build_gallery.py
@@ -626,7 +626,7 @@ def build_gallery(  # noqa: C901 - 17
 
     # Iterate the gallery_info_by_backend dict and write the backend-specific files
     for _backend, gallery_info in gallery_info_by_backend.items():
-        if gallery_info_by_backend[_backend]:
+        if gallery_info:
             with open(f"{_backend}_{backend_outfile_suffix}.json", "w") as outfile:
                 json.dump(gallery_info, outfile, indent=4)
 
diff --git a/great_expectations/core/batch.py b/great_expectations/core/batch.py
index f1ff1cd5c..af0520837 100644
--- a/great_expectations/core/batch.py
+++ b/great_expectations/core/batch.py
@@ -416,6 +416,10 @@ class BatchRequestBase(SerializableDictDot):
 
         return self.id == other.id
 
+    @override
+    def __hash__(self) -> int:
+        return hash(self.id)
+
     @override
     def __repr__(self) -> str:
         """
diff --git a/great_expectations/core/expectation_suite.py b/great_expectations/core/expectation_suite.py
index 747175db9..9d4df5ff6 100644
--- a/great_expectations/core/expectation_suite.py
+++ b/great_expectations/core/expectation_suite.py
@@ -340,7 +340,8 @@ class ExpectationSuite(SerializableDictDot):
             "Please use ExpectationSuite.expectations instead."
         )
 
-    def __eq__(self, other):  # type: ignore[explicit-override] # FIXME
+    @override
+    def __eq__(self, other):
         """ExpectationSuite equality ignores instance identity, relying only on properties."""
         if not isinstance(other, self.__class__):
             # Delegate comparison to the other instance's __eq__.
@@ -354,6 +355,17 @@ class ExpectationSuite(SerializableDictDot):
             )
         )
 
+    @override
+    def __hash__(self) -> int:
+        return hash(
+            (
+                self.name,
+                tuple(sorted(hash(exp) for exp in self.expectations)),
+                tuple(sorted(self.suite_parameters.items())) if self.suite_parameters else (),
+                tuple(sorted(self.meta.items())) if self.meta else (),
+            )
+        )
+
     def __ne__(self, other):  # type: ignore[explicit-override] # FIXME
         # By using the == operator, the returned NotImplemented is handled correctly.
         return not self == other
@@ -683,7 +695,7 @@ class ExpectationSuiteSchema(Schema):
     def clean_empty(self, data: _TExpectationSuite) -> _TExpectationSuite:
         if isinstance(data, ExpectationSuite):
             # We are hitting this TypeVar narrowing mypy bug: https://github.com/python/mypy/issues/10817
-            data = self._clean_empty_suite(data)  # type: ignore[assignment] # FIXME CoP
+            data = self._clean_empty_suite(data)
         elif isinstance(data, dict):
             data = self._clean_empty_dict(data)
         return data
diff --git a/great_expectations/core/expectation_validation_result.py b/great_expectations/core/expectation_validation_result.py
index 816e81e6e..2725e29b3 100644
--- a/great_expectations/core/expectation_validation_result.py
+++ b/great_expectations/core/expectation_validation_result.py
@@ -113,7 +113,8 @@ class ExpectationValidationResult(SerializableDictDot):
         }
         self.rendered_content = rendered_content
 
-    def __eq__(self, other):  # type: ignore[explicit-override] # FIXME
+    @override
+    def __eq__(self, other):
         """ExpectationValidationResult equality ignores instance identity, relying only on properties."""  # noqa: E501 # FIXME CoP
         # NOTE: JPC - 20200213 - need to spend some time thinking about whether we want to
         # consistently allow dict as a comparison alternative in situations like these...
@@ -156,6 +157,32 @@ class ExpectationValidationResult(SerializableDictDot):
             # if invalid comparisons are attempted, the objects are not equal.
             return False
 
+    @override
+    def __hash__(self) -> int:
+        """Overrides the default implementation"""
+        # note that it is possible for two results to be equal but have different hashes
+        # this is because during comparison we only compare common keys
+        if self.result:
+            result_hash = hash(tuple(sorted(self.result.items())))
+        else:
+            result_hash = hash(None)
+
+        # Handle expectation_config hash
+        if self.expectation_config:
+            config_hash = hash(self.expectation_config)
+        else:
+            config_hash = hash(None)
+
+        return hash(
+            (
+                self.success,
+                config_hash,
+                result_hash,
+                tuple(sorted(self.meta.items())) if self.meta else (),
+                tuple(sorted(self.exception_info.items())) if self.exception_info else (),
+            )
+        )
+
     def __ne__(self, other):  # type: ignore[explicit-override] # FIXME
         # Negated implementation of '__eq__'. TODO the method should be deleted when it will coincide with __eq__.  # noqa: E501 # FIXME CoP
         # return not self == other
@@ -516,6 +543,18 @@ class ExpectationSuiteValidationResult(SerializableDictDot):
             )
         )
 
+    @override
+    def __hash__(self) -> int:
+        return hash(
+            (
+                self.success,
+                tuple(sorted(hash(result) for result in self.results)),
+                tuple(sorted(self.suite_parameters.items())) if self.suite_parameters else (),
+                tuple(sorted(self.statistics.items())) if self.statistics else (),
+                tuple(sorted(self.meta.items())) if self.meta else (),
+            )
+        )
+
     def __repr__(self):  # type: ignore[explicit-override] # FIXME
         return json.dumps(self.to_json_dict(), indent=2)
 
diff --git a/great_expectations/expectations/expectation.py b/great_expectations/expectations/expectation.py
index 653f45235..d7494dd67 100644
--- a/great_expectations/expectations/expectation.py
+++ b/great_expectations/expectations/expectation.py
@@ -370,26 +370,49 @@ class Expectation(pydantic.BaseModel, metaclass=MetaExpectation):
 
         return False
 
-    @override
-    def __eq__(self, other: object) -> bool:
-        if not isinstance(other, Expectation):
-            return False
+    def _to_normalized_self_dict(self) -> dict:
+        """Helper method to get normalized dictionary representation for equality and hashing.
 
+        This method:
+        1. Excludes rendered_content (derived property)
+        2. Normalizes notes and meta (falsiness is equivalent)
+        3. Returns a consistent dictionary representation
+        """
         # rendered_content is derived from the rest of the expectation, and can/should
         # be excluded from equality checks
         exclude: set[str] = {"rendered_content"}
 
         self_dict = self.dict(exclude=exclude)
-        other_dict = other.dict(exclude=exclude)
 
         # Simplify notes and meta equality - falsiness is equivalent
         for attr in ("notes", "meta"):
             self_val = self_dict.pop(attr, None) or None
-            other_val = other_dict.pop(attr, None) or None
-            if self_val != other_val:
-                return False
+            self_dict[attr] = self_val
+
+        return self_dict
+
+    @override
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, Expectation):
+            return False
+
+        return self._to_normalized_self_dict() == other._to_normalized_self_dict()
+
+    @override
+    def __hash__(self) -> int:
+        def make_hashable(obj):
+            """Convert unhashable types to hashable ones recursively."""
+            if isinstance(obj, (str, int, float, bool, type(None))):
+                return obj
+            elif isinstance(obj, list):
+                return tuple(make_hashable(item) for item in obj)
+            elif isinstance(obj, dict):
+                return tuple(sorted((k, make_hashable(v)) for k, v in obj.items()))
+            else:
+                return str(obj)
 
-        return self_dict == other_dict
+        normalized_dict = self._to_normalized_self_dict()
+        return hash(make_hashable(normalized_dict))
 
     @pydantic.validator("result_format")
     def _validate_result_format(cls, result_format: ResultFormat | dict) -> ResultFormat | dict:
diff --git a/great_expectations/expectations/expectation_configuration.py b/great_expectations/expectations/expectation_configuration.py
index b10128523..43e5f4bf8 100644
--- a/great_expectations/expectations/expectation_configuration.py
+++ b/great_expectations/expectations/expectation_configuration.py
@@ -391,7 +391,8 @@ class ExpectationConfiguration(SerializableDictDot):
 
         return False
 
-    def __eq__(self, other):  # type: ignore[explicit-override] # FIXME
+    @override
+    def __eq__(self, other):
         """ExpectationConfiguration equality does include meta, but ignores instance identity."""
         if not isinstance(other, self.__class__):
             # Delegate comparison to the other instance's __eq__.
@@ -408,6 +409,30 @@ class ExpectationConfiguration(SerializableDictDot):
             )
         )
 
+    @override
+    def __hash__(self) -> int:
+        this_kwargs: dict = convert_to_json_serializable(self.kwargs)
+        this_meta: dict = convert_to_json_serializable(self.meta)
+
+        def make_hashable(obj):
+            """Convert unhashable types to hashable ones recursively."""
+            if isinstance(obj, (str, int, float, bool, type(None))):
+                return obj
+            elif isinstance(obj, list):
+                return tuple(make_hashable(item) for item in obj)
+            elif isinstance(obj, dict):
+                return tuple(sorted((k, make_hashable(v)) for k, v in obj.items()))
+            else:
+                return str(obj)
+
+        return hash(
+            (
+                self.type,
+                make_hashable(this_kwargs),
+                make_hashable(this_meta),
+            )
+        )
+
     def __ne__(self, other):  # type: ignore[explicit-override] # FIXME
         # By using the == operator, the returned NotImplemented is handled correctly.
         return not self == other
diff --git a/great_expectations/render/components.py b/great_expectations/render/components.py
index 9f4a4e6a6..a0ac4a6e6 100644
--- a/great_expectations/render/components.py
+++ b/great_expectations/render/components.py
@@ -133,12 +133,17 @@ class RenderedContent:
         """
         return {}
 
-    def __eq__(self, other):  # type: ignore[explicit-override] # FIXME
+    @override
+    def __eq__(self, other):
         if not isinstance(other, self.__class__):
             # Delegate comparison to the other instance's __eq__.
             return NotImplemented
         return self.to_json_dict() == other.to_json_dict()
 
+    @override
+    def __hash__(self) -> int:
+        return hash(str(sorted(self.to_json_dict())))
+
     @classmethod
     def rendered_content_list_to_json(cls, list_, check_dicts=False):
         result_list = []
@@ -494,9 +499,15 @@ class RenderedStringTemplateContent(RenderedComponentContent):
         )
         return string
 
-    def __eq__(self, other):  # type: ignore[explicit-override] # FIXME
+    @override
+    def __eq__(self, other):
         return str(self) == str(other)
 
+    @override
+    def __hash__(self) -> int:
+        """Overrides the default implementation"""
+        return hash(str(self))
+
 
 class RenderedBulletListContent(RenderedComponentContent):
     def __init__(
diff --git a/great_expectations/render/renderer/renderer.py b/great_expectations/render/renderer/renderer.py
index 5eaab8450..caa25e56e 100644
--- a/great_expectations/render/renderer/renderer.py
+++ b/great_expectations/render/renderer/renderer.py
@@ -43,6 +43,10 @@ class Renderer:
         # Renderers do not have any state, so they are equal if they are the same class
         return type(self) is type(other)
 
+    @override
+    def __hash__(self) -> int:
+        return hash(type(self))
+
     def serialize(self) -> dict:
         # Necessary to enable proper serialization within an Action (and additionally, within a Checkpoint)  # noqa: E501 # FIXME CoP
         # TODO: Renderers should be ported over to Pydantic to prevent this fork in logic
diff --git a/great_expectations/render/renderer_configuration.py b/great_expectations/render/renderer_configuration.py
index 0a790d6f8..ee46989a8 100644
--- a/great_expectations/render/renderer_configuration.py
+++ b/great_expectations/render/renderer_configuration.py
@@ -329,7 +329,11 @@ class RendererConfiguration(pydantic_generics.GenericModel, Generic[RendererPara
             elif isinstance(other, dict):
                 return self.dict() == other
             else:
-                return self == other
+                return NotImplemented
+
+        @override
+        def __hash__(self) -> int:
+            return hash(tuple(sorted(self.dict().items())))
 
     @staticmethod
     def _get_renderer_value_base_model_type(
diff --git a/great_expectations/validator/validation_graph.py b/great_expectations/validator/validation_graph.py
index 4ed57e4d3..cc94a238f 100644
--- a/great_expectations/validator/validation_graph.py
+++ b/great_expectations/validator/validation_graph.py
@@ -104,6 +104,10 @@ class ValidationGraph:
         """Supports comparing two "ValidationGraph" objects."""
         return self.edge_ids == other.edge_ids
 
+    @override
+    def __hash__(self) -> int:
+        return hash(frozenset(self.edge_ids))
+
     @property
     def edges(self) -> List[MetricEdge]:
         """Returns "MetricEdge" objects, contained within this "ValidationGraph" object (as list)."""  # noqa: E501 # FIXME CoP
@@ -440,3 +444,13 @@ class ExpectationValidationGraph:
             for metric_id, metric_info_item in metric_info.items()
             if metric_id in graph_metric_ids
         }
+
+    @override
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, ExpectationValidationGraph):
+            return False
+        return self.configuration == other.configuration and self.graph == other.graph
+
+    @override
+    def __hash__(self) -> int:
+        return hash((self.configuration, self.graph))
diff --git a/pyproject.toml b/pyproject.toml
index 083042194..0c7c2f7fa 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -302,6 +302,8 @@ lint.ignore = [
     # https://beta.ruff.rs/docs/rules/#pyupgrade-up
     "UP006", # use-pep585-annotation
     "UP007", # use-pep604-annotation
+    "UP035", # TODO: remove once min version of pydantic supports using collections.abc types
+    "UP045", # Use `X | None` for type annotations - >2k occurrences, required for backward compatibliity
     # https://beta.ruff.rs/docs/rules/#flake8-type-checking-tch
     # minimal cost for standard lib imports; keep this disabled
     "TC003", # typing-only-standard-library-import
@@ -313,7 +315,6 @@ lint.ignore = [
     # https://beta.ruff.rs/docs/rules/#flake8-pyi-pyi
     "PYI053", # string-or-bytes-too-long - causes mypy to fail on some of our type stubs
     "PYI054", # numeric-literal-too-long - causes mypy to fail on some of our type stubs
-    "UP035", # TODO: remove once min version of pydantic supports using collections.abc types
     # https://beta.ruff.rs/docs/rules/#flake8-bugbear-b
     # TODO: enable these
     "B904", # raise-without-from-inside-except
@@ -335,9 +336,8 @@ lint.ignore = [
     "SIM202", # negate-not-equal-op
     "SIM210", # if-expr-with-true-false
     "SIM112", # uncapitalized-environment-variables
-
-
-
+    # https://docs.astral.sh/ruff/rules/#convention-plc
+    "PLC0415", # `import` should be at the top-level of a file - used frequently for compatibility
 ]
 extend-exclude = [
     "docs/docusaurus/versioned_docs/version-0.18",
diff --git a/reqs/requirements-dev-contrib.txt b/reqs/requirements-dev-contrib.txt
index 472017f5f..ac3498544 100644
--- a/reqs/requirements-dev-contrib.txt
+++ b/reqs/requirements-dev-contrib.txt
@@ -2,5 +2,5 @@ adr-tools-python==1.0.3
 invoke>=2.0.0
 mypy==1.15.0
 pre-commit>=2.21.0
-ruff==0.11.12
+ruff==0.12.0
 tomli>=2.0.1


Test patch:
diff --git a/tests/core/test_batch.py b/tests/core/test_batch.py
index 61a8fcabf..be775bf6d 100644
--- a/tests/core/test_batch.py
+++ b/tests/core/test_batch.py
@@ -231,3 +231,111 @@ def test_get_batch_request_from_acceptable_arguments_block_partitioner_sampler_b
     assert "partitioner_method" not in actual.batch_spec_passthrough
     assert "partitioner_kwargs" not in actual.batch_spec_passthrough
     assert isinstance(actual, BatchRequest)
+
+
+@pytest.mark.unit
+def test_batch_request_hash_consistency_with_equality():
+    batch1 = BatchRequest(
+        datasource_name="test_datasource",
+        data_connector_name="test_connector",
+        data_asset_name="test_asset",
+    )
+    batch2 = BatchRequest(
+        datasource_name="test_datasource",
+        data_connector_name="test_connector",
+        data_asset_name="test_asset",
+    )
+
+    assert batch1 == batch2
+    assert hash(batch1) == hash(batch2)
+
+
+@pytest.mark.unit
+def test_batch_request_hash_different_for_different_attributes():
+    batch1 = BatchRequest(
+        datasource_name="test_datasource_1",
+        data_connector_name="test_connector",
+        data_asset_name="test_asset",
+    )
+    batch2 = BatchRequest(
+        datasource_name="test_datasource_2",
+        data_connector_name="test_connector",
+        data_asset_name="test_asset",
+    )
+
+    assert batch1 != batch2
+    assert hash(batch1) != hash(batch2)
+
+
+@pytest.mark.unit
+def test_batch_request_hash_stable_across_runs():
+    batch = BatchRequest(
+        datasource_name="test_datasource",
+        data_connector_name="test_connector",
+        data_asset_name="test_asset",
+    )
+
+    hash1 = hash(batch)
+    hash2 = hash(batch)
+    hash3 = hash(batch)
+
+    assert hash1 == hash2 == hash3
+
+
+@pytest.mark.unit
+def test_runtime_batch_request_hash_consistency_with_equality():
+    batch1 = RuntimeBatchRequest(
+        datasource_name="test_datasource",
+        data_connector_name="test_connector",
+        data_asset_name="test_asset",
+        runtime_parameters={"path": "test_path"},
+        batch_identifiers={"test": "identifier"},
+    )
+    batch2 = RuntimeBatchRequest(
+        datasource_name="test_datasource",
+        data_connector_name="test_connector",
+        data_asset_name="test_asset",
+        runtime_parameters={"path": "test_path"},
+        batch_identifiers={"test": "identifier"},
+    )
+
+    assert batch1 == batch2
+    assert hash(batch1) == hash(batch2)
+
+
+@pytest.mark.unit
+def test_runtime_batch_request_hash_different_for_different_runtime_parameters():
+    batch1 = RuntimeBatchRequest(
+        datasource_name="test_datasource",
+        data_connector_name="test_connector",
+        data_asset_name="test_asset",
+        runtime_parameters={"path": "test_path_1"},
+        batch_identifiers={"test": "identifier"},
+    )
+    batch2 = RuntimeBatchRequest(
+        datasource_name="test_datasource",
+        data_connector_name="test_connector",
+        data_asset_name="test_asset",
+        runtime_parameters={"path": "test_path_2"},
+        batch_identifiers={"test": "identifier"},
+    )
+
+    assert batch1 != batch2
+    assert hash(batch1) != hash(batch2)
+
+
+@pytest.mark.unit
+def test_runtime_batch_request_hash_stable_across_runs():
+    batch = RuntimeBatchRequest(
+        datasource_name="test_datasource",
+        data_connector_name="test_connector",
+        data_asset_name="test_asset",
+        runtime_parameters={"path": "test_path"},
+        batch_identifiers={"test": "identifier"},
+    )
+
+    hash1 = hash(batch)
+    hash2 = hash(batch)
+    hash3 = hash(batch)
+
+    assert hash1 == hash2 == hash3
diff --git a/tests/core/test_expectation_configuration.py b/tests/core/test_expectation_configuration.py
index a557184b7..79410e075 100644
--- a/tests/core/test_expectation_configuration.py
+++ b/tests/core/test_expectation_configuration.py
@@ -171,3 +171,58 @@ def test_expectation_configuration_to_json_dict(config1, config4, config8):
         "meta": {"notes": "This is another expectation."},
         "type": "expect_column_values_to_be_in_set",
     }
+
+
+class TestExpectationConfigurationHash:
+    @pytest.mark.unit
+    def test_hash_consistency_with_equality(self, config1, config2):
+        assert config1 == config2
+        assert hash(config1) == hash(config2)
+
+    @pytest.mark.unit
+    def test_hash_different_for_different_types(self):
+        config1 = ExpectationConfiguration(
+            type="expect_column_values_to_not_be_null", kwargs={"column": "test_column"}
+        )
+        config2 = ExpectationConfiguration(
+            type="expect_column_values_to_be_between", kwargs={"column": "test_column"}
+        )
+
+        assert config1 != config2
+        assert hash(config1) != hash(config2)
+
+    @pytest.mark.unit
+    def test_hash_different_for_different_kwargs(self):
+        config1 = ExpectationConfiguration(
+            type="expect_column_values_to_not_be_null", kwargs={"column": "test_column_1"}
+        )
+        config2 = ExpectationConfiguration(
+            type="expect_column_values_to_not_be_null", kwargs={"column": "test_column_2"}
+        )
+
+        assert config1 != config2
+        assert hash(config1) != hash(config2)
+
+    @pytest.mark.unit
+    def test_hash_different_for_different_meta(self):
+        config1 = ExpectationConfiguration(
+            type="expect_column_values_to_not_be_null",
+            kwargs={"column": "test_column"},
+            meta={"test": "value1"},
+        )
+        config2 = ExpectationConfiguration(
+            type="expect_column_values_to_not_be_null",
+            kwargs={"column": "test_column"},
+            meta={"test": "value2"},
+        )
+
+        assert config1 != config2
+        assert hash(config1) != hash(config2)
+
+    @pytest.mark.unit
+    def test_hash_stable_across_runs(self, config1):
+        hash1 = hash(config1)
+        hash2 = hash(config1)
+        hash3 = hash(config1)
+
+        assert hash1 == hash2 == hash3
diff --git a/tests/core/test_expectation_suite.py b/tests/core/test_expectation_suite.py
index 6c447f9c3..b0ed3146f 100644
--- a/tests/core/test_expectation_suite.py
+++ b/tests/core/test_expectation_suite.py
@@ -1462,3 +1462,84 @@ def test_is_fresh_ignores_expectation_ordering(in_memory_runtime_context):
         diagnostics = suite.is_fresh()
 
     assert diagnostics.success is True
+
+
+class TestExpectationSuiteHash:
+    @pytest.mark.unit
+    def test_hash_consistency_with_equality(self, empty_data_context):
+        expectation1 = ExpectColumnValuesToNotBeNull(column="test_column")
+        expectation2 = ExpectColumnValuesToNotBeNull(column="test_column")
+
+        suite1 = ExpectationSuite(name="test_suite")
+        suite1.add_expectation(expectation1)
+
+        suite2 = ExpectationSuite(name="test_suite")
+        suite2.add_expectation(expectation2)
+
+        assert suite1 == suite2
+        assert hash(suite1) == hash(suite2)
+
+    @pytest.mark.unit
+    def test_hash_different_for_different_names(self, empty_data_context):
+        expectation = ExpectColumnValuesToNotBeNull(column="test_column")
+
+        suite1 = ExpectationSuite(name="test_suite_1")
+        suite1.add_expectation(expectation)
+
+        suite2 = ExpectationSuite(name="test_suite_2")
+        suite2.add_expectation(expectation)
+
+        assert suite1 != suite2
+        assert hash(suite1) != hash(suite2)
+
+    @pytest.mark.unit
+    def test_hash_different_for_different_expectations(self, empty_data_context):
+        expectation1 = ExpectColumnValuesToNotBeNull(column="test_column_1")
+        expectation2 = ExpectColumnValuesToNotBeNull(column="test_column_2")
+
+        suite1 = ExpectationSuite(name="test_suite")
+        suite1.add_expectation(expectation1)
+
+        suite2 = ExpectationSuite(name="test_suite")
+        suite2.add_expectation(expectation2)
+
+        assert suite1 != suite2
+        assert hash(suite1) != hash(suite2)
+
+    @pytest.mark.unit
+    def test_hash_different_for_different_meta(self, empty_data_context):
+        expectation = ExpectColumnValuesToNotBeNull(column="test_column")
+
+        suite1 = ExpectationSuite(name="test_suite", meta={"test": "value1"})
+        suite1.add_expectation(expectation)
+
+        suite2 = ExpectationSuite(name="test_suite", meta={"test": "value2"})
+        suite2.add_expectation(expectation)
+
+        assert suite1 != suite2
+        assert hash(suite1) != hash(suite2)
+
+    @pytest.mark.unit
+    def test_hash_different_for_different_suite_parameters(self, empty_data_context):
+        expectation = ExpectColumnValuesToNotBeNull(column="test_column")
+
+        suite1 = ExpectationSuite(name="test_suite", suite_parameters={"param1": "value1"})
+        suite1.add_expectation(expectation)
+
+        suite2 = ExpectationSuite(name="test_suite", suite_parameters={"param1": "value2"})
+        suite2.add_expectation(expectation)
+
+        assert suite1 != suite2
+        assert hash(suite1) != hash(suite2)
+
+    @pytest.mark.unit
+    def test_hash_stable_across_runs(self, empty_data_context):
+        expectation = ExpectColumnValuesToNotBeNull(column="test_column")
+        suite = ExpectationSuite(name="test_suite")
+        suite.add_expectation(expectation)
+
+        hash1 = hash(suite)
+        hash2 = hash(suite)
+        hash3 = hash(suite)
+
+        assert hash1 == hash2 == hash3
diff --git a/tests/core/test_expectation_validation_result.py b/tests/core/test_expectation_validation_result.py
index 47639d44e..4f5a17be6 100644
--- a/tests/core/test_expectation_validation_result.py
+++ b/tests/core/test_expectation_validation_result.py
@@ -493,3 +493,172 @@ class TestSerialization:
             {"index": "b", "value": 2},
             {"index": "c", "value": 4},
         ]
+
+
+class TestExpectationValidationResultHash:
+    @pytest.mark.unit
+    def test_hash_consistency_with_equality(self):
+        config1 = ExpectationConfiguration(
+            type="expect_column_values_to_not_be_null", kwargs={"column": "test_column"}
+        )
+        config2 = ExpectationConfiguration(
+            type="expect_column_values_to_not_be_null", kwargs={"column": "test_column"}
+        )
+
+        result1 = ExpectationValidationResult(
+            success=True,
+            expectation_config=config1,
+            result={"observed_value": 100},
+            meta={"test": "value"},
+            exception_info={"raised_exception": False},
+        )
+
+        result2 = ExpectationValidationResult(
+            success=True,
+            expectation_config=config2,
+            result={"observed_value": 100},
+            meta={"test": "value"},
+            exception_info={"raised_exception": False},
+        )
+
+        assert result1 == result2
+        assert hash(result1) == hash(result2)
+
+    @pytest.mark.unit
+    def test_hash_different_for_different_success(self):
+        config = ExpectationConfiguration(
+            type="expect_column_values_to_not_be_null", kwargs={"column": "test_column"}
+        )
+
+        result1 = ExpectationValidationResult(
+            success=True, expectation_config=config, result={"observed_value": 100}
+        )
+
+        result2 = ExpectationValidationResult(
+            success=False, expectation_config=config, result={"observed_value": 100}
+        )
+
+        assert result1 != result2
+        assert hash(result1) != hash(result2)
+
+    @pytest.mark.unit
+    def test_hash_different_for_different_results(self):
+        config = ExpectationConfiguration(
+            type="expect_column_values_to_not_be_null", kwargs={"column": "test_column"}
+        )
+
+        result1 = ExpectationValidationResult(
+            success=True, expectation_config=config, result={"observed_value": 100}
+        )
+
+        result2 = ExpectationValidationResult(
+            success=True, expectation_config=config, result={"observed_value": 200}
+        )
+
+        assert result1 != result2
+        assert hash(result1) != hash(result2)
+
+    @pytest.mark.unit
+    def test_hash_stable_across_runs(self):
+        config = ExpectationConfiguration(
+            type="expect_column_values_to_not_be_null", kwargs={"column": "test_column"}
+        )
+
+        result = ExpectationValidationResult(
+            success=True,
+            expectation_config=config,
+            result={"observed_value": 100},
+            meta={"test": "value"},
+            exception_info={"raised_exception": False},
+        )
+
+        hash1 = hash(result)
+        hash2 = hash(result)
+        hash3 = hash(result)
+
+        assert hash1 == hash2 == hash3
+
+
+class TestExpectationSuiteValidationResultHash:
+    @pytest.mark.unit
+    def test_hash_consistency_with_equality(self):
+        config = ExpectationConfiguration(
+            type="expect_column_values_to_not_be_null", kwargs={"column": "test_column"}
+        )
+
+        evr = ExpectationValidationResult(
+            success=True, expectation_config=config, result={"observed_value": 100}
+        )
+
+        result1 = ExpectationSuiteValidationResult(
+            suite_name="test_suite",
+            success=True,
+            results=[evr],
+            suite_parameters={"param": "value"},
+            statistics={"evaluated_expectations": 1},
+            meta={"test": "value"},
+        )
+
+        result2 = ExpectationSuiteValidationResult(
+            suite_name="test_suite",
+            success=True,
+            results=[evr],
+            suite_parameters={"param": "value"},
+            statistics={"evaluated_expectations": 1},
+            meta={"test": "value"},
+        )
+
+        assert result1 == result2
+        assert hash(result1) == hash(result2)
+
+    @pytest.mark.unit
+    def test_hash_different_for_different_success(self):
+        config = ExpectationConfiguration(
+            type="expect_column_values_to_not_be_null", kwargs={"column": "test_column"}
+        )
+
+        evr = ExpectationValidationResult(
+            success=True, expectation_config=config, result={"observed_value": 100}
+        )
+
+        result1 = ExpectationSuiteValidationResult(
+            suite_name="test_suite",
+            success=True,
+            results=[evr],
+            suite_parameters={"param": "value"},
+        )
+
+        result2 = ExpectationSuiteValidationResult(
+            suite_name="test_suite",
+            success=False,
+            results=[evr],
+            suite_parameters={"param": "value"},
+        )
+
+        assert result1 != result2
+        assert hash(result1) != hash(result2)
+
+    @pytest.mark.unit
+    def test_hash_stable_across_runs(self):
+        config = ExpectationConfiguration(
+            type="expect_column_values_to_not_be_null", kwargs={"column": "test_column"}
+        )
+
+        evr = ExpectationValidationResult(
+            success=True, expectation_config=config, result={"observed_value": 100}
+        )
+
+        result = ExpectationSuiteValidationResult(
+            suite_name="test_suite",
+            success=True,
+            results=[evr],
+            suite_parameters={"param": "value"},
+            statistics={"evaluated_expectations": 1},
+            meta={"test": "value"},
+        )
+
+        hash1 = hash(result)
+        hash2 = hash(result)
+        hash3 = hash(result)
+
+        assert hash1 == hash2 == hash3
diff --git a/tests/expectations/test_expectation.py b/tests/expectations/test_expectation.py
index 6880533be..3ed2368a5 100644
--- a/tests/expectations/test_expectation.py
+++ b/tests/expectations/test_expectation.py
@@ -599,3 +599,67 @@ class TestCustomAnnotatedFields:
     def test_value_set_field_converts_to_list(self, input_value: Any, expected_type: type) -> None:
         expectation = _SampleExpectation(mostly=1, value_set=input_value)
         assert isinstance(expectation.value_set, expected_type)
+
+
+class TestExpectationHash:
+    @pytest.mark.unit
+    def test_hash_consistency_with_equality(self):
+        expectation1 = gxe.ExpectColumnValuesToNotBeNull(
+            column="test_column", mostly=0.95, meta={"test": "value"}, notes="test notes"
+        )
+        expectation2 = gxe.ExpectColumnValuesToNotBeNull(
+            column="test_column", mostly=0.95, meta={"test": "value"}, notes="test notes"
+        )
+
+        assert expectation1 == expectation2
+        assert hash(expectation1) == hash(expectation2)
+
+    @pytest.mark.unit
+    def test_hash_different_for_different_columns(self):
+        expectation1 = gxe.ExpectColumnValuesToNotBeNull(column="test_column_1")
+        expectation2 = gxe.ExpectColumnValuesToNotBeNull(column="test_column_2")
+
+        assert expectation1 != expectation2
+        assert hash(expectation1) != hash(expectation2)
+
+    @pytest.mark.unit
+    def test_hash_different_for_different_mostly(self):
+        expectation1 = gxe.ExpectColumnValuesToNotBeNull(column="test_column", mostly=0.95)
+        expectation2 = gxe.ExpectColumnValuesToNotBeNull(column="test_column", mostly=0.90)
+
+        assert expectation1 != expectation2
+        assert hash(expectation1) != hash(expectation2)
+
+    @pytest.mark.unit
+    def test_hash_different_for_different_meta(self):
+        expectation1 = gxe.ExpectColumnValuesToNotBeNull(
+            column="test_column", meta={"test": "value1"}
+        )
+        expectation2 = gxe.ExpectColumnValuesToNotBeNull(
+            column="test_column", meta={"test": "value2"}
+        )
+
+        assert expectation1 != expectation2
+        assert hash(expectation1) != hash(expectation2)
+
+    @pytest.mark.unit
+    def test_hash_excludes_rendered_content(self):
+        expectation1 = gxe.ExpectColumnValuesToNotBeNull(column="test_column")
+        expectation2 = gxe.ExpectColumnValuesToNotBeNull(column="test_column")
+
+        expectation1.render()
+
+        assert expectation1 == expectation2
+        assert hash(expectation1) == hash(expectation2)
+
+    @pytest.mark.unit
+    def test_hash_stable_across_runs(self):
+        expectation = gxe.ExpectColumnValuesToNotBeNull(
+            column="test_column", mostly=0.95, meta={"test": "value"}, notes="test notes"
+        )
+
+        hash1 = hash(expectation)
+        hash2 = hash(expectation)
+        hash3 = hash(expectation)
+
+        assert hash1 == hash2 == hash3
diff --git a/tests/render/test_render.py b/tests/render/test_render.py
index 1553c807f..f54699eb8 100644
--- a/tests/render/test_render.py
+++ b/tests/render/test_render.py
@@ -413,3 +413,52 @@ def test_render_string_template():
     )
 
     assert res == expected
+
+
+def test_rendered_content_hash_consistency_with_equality():
+    from great_expectations.render.components import RenderedContent
+
+    content1 = RenderedContent()
+    content2 = RenderedContent()
+
+    assert content1 == content2
+    assert hash(content1) == hash(content2)
+
+
+def test_rendered_string_template_content_hash_consistency_with_equality():
+    from great_expectations.render.components import RenderedStringTemplateContent
+
+    template1 = {"template": "Hello $name", "params": {"name": "World"}}
+    template2 = {"params": {"name": "World"}, "template": "Hello $name"}
+
+    content1 = RenderedStringTemplateContent(string_template=template1)
+    content2 = RenderedStringTemplateContent(string_template=template2)
+
+    assert content1 == content2
+    assert hash(content1) == hash(content2)
+
+
+def test_rendered_string_template_content_hash_different_for_different_templates():
+    from great_expectations.render.components import RenderedStringTemplateContent
+
+    template1 = {"template": "Hello $name", "params": {"name": "World"}}
+    template2 = {"template": "Hello $name", "params": {"name": "Universe"}}
+
+    content1 = RenderedStringTemplateContent(string_template=template1)
+    content2 = RenderedStringTemplateContent(string_template=template2)
+
+    assert content1 != content2
+    assert hash(content1) != hash(content2)
+
+
+def test_rendered_string_template_content_hash_stable_across_runs():
+    from great_expectations.render.components import RenderedStringTemplateContent
+
+    template = {"template": "Hello $name", "params": {"name": "World"}}
+    content = RenderedStringTemplateContent(string_template=template)
+
+    hash1 = hash(content)
+    hash2 = hash(content)
+    hash3 = hash(content)
+
+    assert hash1 == hash2 == hash3
diff --git a/tests/render/test_renderer.py b/tests/render/test_renderer.py
index 02e1b5fd8..b43b3ed5c 100644
--- a/tests/render/test_renderer.py
+++ b/tests/render/test_renderer.py
@@ -99,3 +99,35 @@ def test__get_column_list_from_evrs(titanic_profiled_evrs_1):
         "Survived",
         "SexCode",
     ]
+
+
+@pytest.mark.unit
+def test_renderer_hash_consistency_with_equality():
+    renderer1 = Renderer()
+    renderer2 = Renderer()
+
+    assert renderer1 == renderer2
+    assert hash(renderer1) == hash(renderer2)
+
+
+@pytest.mark.unit
+def test_renderer_hash_different_for_different_types():
+    class TestRenderer(Renderer):
+        pass
+
+    renderer1 = Renderer()
+    renderer2 = TestRenderer()
+
+    assert renderer1 != renderer2
+    assert hash(renderer1) != hash(renderer2)
+
+
+@pytest.mark.unit
+def test_renderer_hash_stable_across_runs():
+    renderer = Renderer()
+
+    hash1 = hash(renderer)
+    hash2 = hash(renderer)
+    hash3 = hash(renderer)
+
+    assert hash1 == hash2 == hash3
diff --git a/tests/validator/test_validation_graph.py b/tests/validator/test_validation_graph.py
index 175f5f9b1..3b9c2c0ed 100644
--- a/tests/validator/test_validation_graph.py
+++ b/tests/validator/test_validation_graph.py
@@ -479,6 +479,109 @@ def test_progress_bar_config(
         assert mock_tqdm.call_args[1]["disable"] is are_progress_bars_disabled
 
 
+@pytest.mark.unit
+def test_validation_graph_hash_consistency_with_equality():
+    class DummyExecutionEngine:
+        pass
+
+    execution_engine = cast("ExecutionEngine", DummyExecutionEngine)
+
+    graph1 = ValidationGraph(execution_engine=execution_engine)
+    graph2 = ValidationGraph(execution_engine=execution_engine)
+
+    assert graph1 == graph2
+    assert hash(graph1) == hash(graph2)
+
+
+@pytest.mark.unit
+def test_validation_graph_hash_different_for_different_edges(metric_edge):
+    class DummyExecutionEngine:
+        pass
+
+    execution_engine = cast("ExecutionEngine", DummyExecutionEngine)
+
+    graph1 = ValidationGraph(execution_engine=execution_engine)
+    graph2 = ValidationGraph(execution_engine=execution_engine, edges=[metric_edge])
+
+    assert graph1 != graph2
+    assert hash(graph1) != hash(graph2)
+
+
+@pytest.mark.unit
+def test_validation_graph_hash_stable_across_runs():
+    class DummyExecutionEngine:
+        pass
+
+    execution_engine = cast("ExecutionEngine", DummyExecutionEngine)
+
+    graph = ValidationGraph(execution_engine=execution_engine)
+
+    hash1 = hash(graph)
+    hash2 = hash(graph)
+    hash3 = hash(graph)
+
+    assert hash1 == hash2 == hash3
+
+
+@pytest.mark.unit
+def test_expectation_validation_graph_hash_consistency_with_equality(
+    expect_column_values_to_be_unique_expectation_config,
+    validation_graph_with_no_edges,
+):
+    graph1 = ExpectationValidationGraph(
+        configuration=expect_column_values_to_be_unique_expectation_config,
+        graph=validation_graph_with_no_edges,
+    )
+    graph2 = ExpectationValidationGraph(
+        configuration=expect_column_values_to_be_unique_expectation_config,
+        graph=validation_graph_with_no_edges,
+    )
+
+    assert graph1 == graph2
+    assert hash(graph1) == hash(graph2)
+
+
+@pytest.mark.unit
+def test_expectation_validation_graph_hash_different_for_different_configurations(
+    validation_graph_with_no_edges,
+):
+    config1 = ExpectationConfiguration(
+        type="expect_column_values_to_not_be_null", kwargs={"column": "test_column_1"}
+    )
+    config2 = ExpectationConfiguration(
+        type="expect_column_values_to_not_be_null", kwargs={"column": "test_column_2"}
+    )
+
+    graph1 = ExpectationValidationGraph(
+        configuration=config1,
+        graph=validation_graph_with_no_edges,
+    )
+    graph2 = ExpectationValidationGraph(
+        configuration=config2,
+        graph=validation_graph_with_no_edges,
+    )
+
+    assert graph1 != graph2
+    assert hash(graph1) != hash(graph2)
+
+
+@pytest.mark.unit
+def test_expectation_validation_graph_hash_stable_across_runs(
+    expect_column_values_to_be_unique_expectation_config,
+    validation_graph_with_no_edges,
+):
+    graph = ExpectationValidationGraph(
+        configuration=expect_column_values_to_be_unique_expectation_config,
+        graph=validation_graph_with_no_edges,
+    )
+
+    hash1 = hash(graph)
+    hash2 = hash(graph)
+    hash3 = hash(graph)
+
+    assert hash1 == hash2 == hash3
+
+
 if __name__ == "__main__":
     argv: list = sys.argv[1:]