diff --git a/CHANGELOG.md b/CHANGELOG.md index 9282c421..08db6643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # DeepDiff Change log - Unreleased + - Fixed custom operators being called for excluded objects, including paths excluded by `include_paths` (#633). - Fixed missing type changes between equal-comparing items inside ordered iterables, e.g. `DeepDiff([2], [2.0])` now reports the `int` → `float` change like `DeepDiff(2, 2.0)` and `DeepDiff({'a': 2}, {'a': 2.0})` already do (issue #605). - v9-1-0 diff --git a/deepdiff/diff.py b/deepdiff/diff.py index 4c7a4e5c..af7fa86f 100755 --- a/deepdiff/diff.py +++ b/deepdiff/diff.py @@ -2135,13 +2135,17 @@ def _diff(self, level, parents_ids=frozenset(), _original_type=None, local_tree= if self._count_diff() is StopIteration: return - if self._use_custom_operator(level): + # Custom operators may report differences even for identical objects. + if level.t1 is level.t2 and not self.custom_operators: return - if level.t1 is level.t2: + if self._skip_this(level): return - if self._skip_this(level): + if self._use_custom_operator(level): + return + + if level.t1 is level.t2: return report_type_change = True diff --git a/deepdiff/docstrings/custom.rst b/deepdiff/docstrings/custom.rst index e2ff1d96..8269436f 100644 --- a/deepdiff/docstrings/custom.rst +++ b/deepdiff/docstrings/custom.rst @@ -133,6 +133,12 @@ if you are considering whether they are fruits or not. In that case, you can pass a *custom_operators* for the job. +DeepDiff checks whether a comparison is excluded before calling custom operators' +``match()`` and ``give_up_diffing()`` methods. These methods are not called for excluded +objects. With ``include_paths``, operators can still be called for the root and ancestors +of an included path, as well as the included subtree. Operators can also inspect identical +objects on included paths. + Custom operators give you a lot of power. In the following examples, we explore various use cases such as: - Making DeepDiff report the L2 Distance of items diff --git a/tests/test_operators.py b/tests/test_operators.py index 98444680..39a069c0 100644 --- a/tests/test_operators.py +++ b/tests/test_operators.py @@ -8,6 +8,90 @@ class TestOperators: + @pytest.mark.parametrize('exclude_options', [ + {'exclude_paths': ['b']}, + {'exclude_paths': ["root['b']"]}, + {'exclude_regex_paths': [r"\['b'\]$"]}, + {'exclude_obj_callback': lambda obj, path: path == "root['b']"}, + ]) + @pytest.mark.parametrize('prevent_default', [False, True]) + def test_custom_operator_skips_excluded_paths(self, exclude_options, prevent_default): + matched_paths = [] + compared_paths = [] + + class CustomClass: + def __init__(self, values): + self.values = values + + class ListMatchOperator(BaseOperator): + def match(self, level): + matched_paths.append(level.path()) + return super().match(level) + + def give_up_diffing(self, level, diff_instance): + compared_paths.append(level.path()) + if level.t1.values == level.t2.values: + diff_instance.custom_report_result('equal_lists', level, {}) + return prevent_default + + shared = CustomClass([1, 2, 3]) + t1 = {'a': shared, 'b': CustomClass([1, 2, 3])} + t2 = {'a': shared, 'b': CustomClass([1, 2, 3, 4])} + + result = DeepDiff(t1, t2, custom_operators=[ListMatchOperator(types=[CustomClass])], **exclude_options) + + assert "root['b']" not in matched_paths + assert compared_paths == ["root['a']"] + assert result == {'equal_lists': {"root['a']": {}}} + + @pytest.mark.parametrize('include_path', ['root[0]', 'root[0][0]']) + @pytest.mark.parametrize('prevent_default', [False, True]) + def test_custom_operator_skips_paths_outside_included_subtree(self, include_path, prevent_default): + matched_paths = [] + compared_paths = [] + + class CustomClass: + def __init__(self, value): + self.value = value + + class RecordingOperator(BaseOperator): + def match(self, level): + matched_paths.append(level.path()) + return super().match(level) + + def give_up_diffing(self, level, diff_instance): + compared_paths.append(level.path()) + if level.t1.value == level.t2.value: + diff_instance.custom_report_result('equal_values', level, {}) + return prevent_default + + shared = CustomClass(1) + t1 = [[shared], CustomClass(2)] + t2 = [[shared], CustomClass(3)] + + result = DeepDiff( + t1, t2, + include_paths=[include_path], + custom_operators=[RecordingOperator(types=[CustomClass])], + ) + + assert matched_paths == ['root', 'root[0]', 'root[0][0]'] + assert compared_paths == ['root[0][0]'] + assert result == {'equal_values': {'root[0][0]': {}}} + + @pytest.mark.parametrize('exclude_options', [ + {'exclude_paths': ['root']}, + {'exclude_types': [dict]}, + ]) + def test_custom_operator_skips_excluded_root(self, exclude_options): + class UnexpectedOperator(BaseOperator): + def match(self, level): + raise AssertionError('Excluded objects should not reach custom operators') + + result = DeepDiff({'a': 1}, {'a': 2}, custom_operators=[UnexpectedOperator()], **exclude_options) + + assert result == {} + def test_custom_operators_prevent_default(self): t1 = { "coordinates": [