From 1c1a232f2cd3a329b783d2b6adc9d9c5642fdb02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=B0brahim=20Sait=20Akar=C3=A7e=C5=9Fme?= <72226290+saitakarcesme@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:08:26 +0200 Subject: [PATCH 1/2] Preserve dictionary key casing in case-insensitive diff paths --- CHANGELOG.md | 1 + deepdiff/diff.py | 6 ++++-- tests/test_diff_text.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9282c421..cf413292 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # DeepDiff Change log - Unreleased + - Preserve original string key casing in reported paths when `ignore_string_case=True`, so path exclusions can use the input keys (#515). - 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..e3a26282 100755 --- a/deepdiff/diff.py +++ b/deepdiff/diff.py @@ -813,13 +813,15 @@ def _diff_dict( continue parents_ids_added = add_to_frozen_set(parents_ids, item_id) + # Case normalization is for matching, not for paths into the input objects. + preserve_case = self.ignore_string_case and isinstance(key1, str) and isinstance(key2, str) # Go one level deeper next_level = level.branch_deeper( t1[key1], t2[key2], child_relationship_class=rel_class, - child_relationship_param=key, - child_relationship_param2=key, + child_relationship_param=key1 if preserve_case else key, + child_relationship_param2=key2 if preserve_case else key, ) self._diff(next_level, parents_ids_added, local_tree=local_tree) diff --git a/tests/test_diff_text.py b/tests/test_diff_text.py index cfeda768..6ea5a943 100755 --- a/tests/test_diff_text.py +++ b/tests/test_diff_text.py @@ -369,6 +369,36 @@ def test_string_dict_key_ignore_case(self): result = {} assert result == ddiff + def test_ignore_case_preserves_original_dictionary_paths(self): + t1 = {'User': {'AboutMe': 1}} + t2 = {'user': {'aboutme': 2}} + diff = DeepDiff(t1, t2, ignore_string_case=True) + assert diff == {'values_changed': { + "root['User']['AboutMe']": {'old_value': 1, 'new_value': 2} + }} + tree = DeepDiff(t1, t2, ignore_string_case=True, view='tree') + change = next(iter(tree['values_changed'])) + assert change.path() == "root['User']['AboutMe']" + assert change.path(use_t2=True) == "root['user']['aboutme']" + + @pytest.mark.parametrize('path_option', ['exclude_paths', 'exclude_regex_paths']) + def test_ignore_case_excludes_original_dictionary_path(self, path_option): + t1 = {'User': {'AboutMe': 1}} + t2 = {'User': {'AboutMe': 2}} + path = "root['User']['AboutMe']" + if path_option == 'exclude_regex_paths': + path = r"root\['User'\]\['AboutMe'\]$" + assert not DeepDiff(t1, t2, ignore_string_case=True, **{path_option: [path]}) + + def test_ignore_case_preserves_paths_for_nested_additions_and_removals(self): + t1 = {'User': {'Keep': 1, 'Removed': 2}} + t2 = {'user': {'keep': 1, 'Added': 3}} + diff = DeepDiff(t1, t2, ignore_string_case=True, threshold_to_diff_deeper=0) + assert diff == { + 'dictionary_item_added': ["root['User']['Added']"], + 'dictionary_item_removed': ["root['User']['Removed']"], + } + def test_string_list_ignore_case(self): t1 = ['AboutMe', 'ALIAS'] t2 = ['aboutme', 'alias'] From 9ec4391e6d05524ad1de6d6b1146442bc3da9d29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=B0brahim=20Sait=20Akar=C3=A7e=C5=9Fme?= <72226290+saitakarcesme@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:32:18 +0200 Subject: [PATCH 2/2] Cover original-key paths in verbose and bidirectional delta output --- CHANGELOG.md | 2 +- tests/test_delta.py | 13 +++++++++++++ tests/test_diff_text.py | 14 ++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf413292..a7683525 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # DeepDiff Change log - Unreleased - - Preserve original string key casing in reported paths when `ignore_string_case=True`, so path exclusions can use the input keys (#515). + - Preserve original string key casing in paths for matched dictionary keys when `ignore_string_case=True` (#515). `verbose_level=2` and Delta include the second input's spelling in `new_path` for value and type changes. Forward and reverse Delta application use the respective input paths without renaming keys whose casing is ignored. Exact and regex exclusions can use the original paths when comparison reaches those keys; the existing `threshold_to_diff_deeper` shortcut is unchanged. Numeric and bytes key normalization is unchanged. - 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/tests/test_delta.py b/tests/test_delta.py index 38cac165..7ace0adb 100644 --- a/tests/test_delta.py +++ b/tests/test_delta.py @@ -28,6 +28,19 @@ class TestBasicsOfDelta: + @pytest.mark.parametrize('new_value', [2, 'two']) + def test_ignore_case_delta_uses_original_paths_in_both_directions(self, new_value): + t1 = {'User': {'AboutMe': 1}} + t2 = {'user': {'aboutme': new_value}} + diff = DeepDiff(t1, t2, ignore_string_case=True) + delta = Delta(diff, bidirectional=True, raise_errors=True) + + # Ignored casing differences do not rename keys in either input. + assert t1 + delta == {'User': {'AboutMe': new_value}} + assert t2 - delta == {'user': {'aboutme': 1}} + assert t1 == {'User': {'AboutMe': 1}} + assert t2 == {'user': {'aboutme': new_value}} + def test_from_null_delta_json(self): t1 = None t2 = [1, 2, 3, 5] diff --git a/tests/test_diff_text.py b/tests/test_diff_text.py index 6ea5a943..7fc33ffd 100755 --- a/tests/test_diff_text.py +++ b/tests/test_diff_text.py @@ -390,6 +390,20 @@ def test_ignore_case_excludes_original_dictionary_path(self, path_option): path = r"root\['User'\]\['AboutMe'\]$" assert not DeepDiff(t1, t2, ignore_string_case=True, **{path_option: [path]}) + @pytest.mark.parametrize('new_value,change_type', [(2, 'values_changed'), ('two', 'type_changes')]) + def test_ignore_case_verbose_paths_use_each_dictionary_spelling(self, new_value, change_type): + t1 = {'User': {'AboutMe': 1}} + t2 = {'user': {'aboutme': new_value}} + diff = DeepDiff(t1, t2, ignore_string_case=True, verbose_level=2) + expected = { + 'old_value': 1, + 'new_value': new_value, + 'new_path': "root['user']['aboutme']", + } + if change_type == 'type_changes': + expected.update(old_type=int, new_type=str) + assert diff == {change_type: {"root['User']['AboutMe']": expected}} + def test_ignore_case_preserves_paths_for_nested_additions_and_removals(self): t1 = {'User': {'Keep': 1, 'Removed': 2}} t2 = {'user': {'keep': 1, 'Added': 3}}