Skip to content

Commit 34e22af

Browse files
committed
Preserve moved-item diffs over standard replacements
1 parent b639fec commit 34e22af

File tree

4 files changed

+106
-28
lines changed

4 files changed

+106
-28
lines changed

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,18 @@
1717

1818
Tested on Python 3.9+ and PyPy3.
1919

20+
### Detect moved items in lists
21+
22+
DeepDiff reports items that only change position in an ordered iterable under
23+
the ``iterable_item_moved`` key:
24+
25+
```python
26+
>>> from deepdiff import DeepDiff
27+
>>> DeepDiff([1, 2, 3, 4], [4, 2, 3, 1], verbose_level=2)
28+
{'iterable_item_moved': {'root[0]': {'new_path': 'root[3]', 'value': 1},
29+
'root[3]': {'new_path': 'root[0]', 'value': 4}}}
30+
```
31+
2032
- **[Documentation](https://zepworks.com/deepdiff/8.6.0/)**
2133

2234
## What is new?

deepdiff/diff.py

Lines changed: 75 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -896,8 +896,9 @@ def _diff_iterable_in_order(self, level, parents_ids=frozenset(), _original_type
896896
child_relationship_class=child_relationship_class,
897897
local_tree=local_tree_pass,
898898
)
899+
has_moves = bool(local_tree_pass['iterable_item_moved'])
899900
# Sometimes DeepDiff's old iterable diff does a better job than DeepDiff
900-
if len(local_tree_pass) > 1:
901+
if len(local_tree_pass) > 1 and not has_moves:
901902
local_tree_pass2 = TreeResult()
902903
self._diff_by_forming_pairs_and_comparing_one_by_one(
903904
level,
@@ -910,6 +911,8 @@ def _diff_iterable_in_order(self, level, parents_ids=frozenset(), _original_type
910911
local_tree_pass = local_tree_pass2
911912
else:
912913
self._iterable_opcodes[level.path(force=FORCE_DEFAULT)] = opcodes_with_values
914+
else:
915+
self._iterable_opcodes[level.path(force=FORCE_DEFAULT)] = opcodes_with_values
913916
for report_type, levels in local_tree_pass.items():
914917
if levels:
915918
self.tree[report_type] |= levels
@@ -1015,32 +1018,28 @@ def _diff_ordered_iterable_by_difflib(
10151018

10161019
opcodes = seq.get_opcodes()
10171020
opcodes_with_values = []
1021+
replace_opcodes: List[Opcode] = []
10181022

1019-
# TODO: this logic should be revisted so we detect reverse operations
1020-
# like when a replacement happens at index X and a reverse replacement happens at index Y
1021-
# in those cases we have a "iterable_item_moved" operation.
10221023
for tag, t1_from_index, t1_to_index, t2_from_index, t2_to_index in opcodes:
10231024
if tag == 'equal':
1024-
opcodes_with_values.append(Opcode(
1025-
tag, t1_from_index, t1_to_index, t2_from_index, t2_to_index,
1026-
))
1025+
opcodes_with_values.append(
1026+
Opcode(tag, t1_from_index, t1_to_index, t2_from_index, t2_to_index)
1027+
)
10271028
continue
1028-
# print('{:7} t1[{}:{}] --> t2[{}:{}] {!r:>8} --> {!r}'.format(
1029-
# tag, t1_from_index, t1_to_index, t2_from_index, t2_to_index, level.t1[t1_from_index:t1_to_index], level.t2[t2_from_index:t2_to_index]))
10301029

1031-
opcodes_with_values.append(Opcode(
1032-
tag, t1_from_index, t1_to_index, t2_from_index, t2_to_index,
1033-
old_values = level.t1[t1_from_index: t1_to_index],
1034-
new_values = level.t2[t2_from_index: t2_to_index],
1035-
))
1030+
opcode = Opcode(
1031+
tag,
1032+
t1_from_index,
1033+
t1_to_index,
1034+
t2_from_index,
1035+
t2_to_index,
1036+
old_values=level.t1[t1_from_index:t1_to_index],
1037+
new_values=level.t2[t2_from_index:t2_to_index],
1038+
)
1039+
opcodes_with_values.append(opcode)
10361040

10371041
if tag == 'replace':
1038-
self._diff_by_forming_pairs_and_comparing_one_by_one(
1039-
level, local_tree=local_tree, parents_ids=parents_ids,
1040-
_original_type=_original_type, child_relationship_class=child_relationship_class,
1041-
t1_from_index=t1_from_index, t1_to_index=t1_to_index,
1042-
t2_from_index=t2_from_index, t2_to_index=t2_to_index,
1043-
)
1042+
replace_opcodes.append(opcode)
10441043
elif tag == 'delete':
10451044
for index, x in enumerate(level.t1[t1_from_index:t1_to_index]):
10461045
change_level = level.branch_deeper(
@@ -1061,6 +1060,62 @@ def _diff_ordered_iterable_by_difflib(
10611060
child_relationship_param2=index + t2_from_index,
10621061
)
10631062
self._report_result('iterable_item_added', change_level, local_tree=local_tree)
1063+
1064+
used: Set[int] = set()
1065+
for i, opcode_a in enumerate(replace_opcodes):
1066+
if i in used:
1067+
continue
1068+
for j in range(i + 1, len(replace_opcodes)):
1069+
opcode_b = replace_opcodes[j]
1070+
if j in used:
1071+
continue
1072+
if (
1073+
opcode_a.old_values == opcode_b.new_values
1074+
and opcode_a.new_values == opcode_b.old_values
1075+
and len(opcode_a.old_values or []) == len(opcode_b.old_values or [])
1076+
):
1077+
length = len(opcode_a.old_values or [])
1078+
for offset in range(length):
1079+
val_a = opcode_a.old_values[offset]
1080+
new_index_a = opcode_b.t2_from_index + offset
1081+
change_level = level.branch_deeper(
1082+
val_a,
1083+
val_a,
1084+
child_relationship_class=child_relationship_class,
1085+
child_relationship_param=opcode_a.t1_from_index + offset,
1086+
child_relationship_param2=new_index_a,
1087+
)
1088+
self._report_result('iterable_item_moved', change_level, local_tree=local_tree)
1089+
1090+
val_b = opcode_b.old_values[offset]
1091+
new_index_b = opcode_a.t2_from_index + offset
1092+
change_level = level.branch_deeper(
1093+
val_b,
1094+
val_b,
1095+
child_relationship_class=child_relationship_class,
1096+
child_relationship_param=opcode_b.t1_from_index + offset,
1097+
child_relationship_param2=new_index_b,
1098+
)
1099+
self._report_result('iterable_item_moved', change_level, local_tree=local_tree)
1100+
1101+
used.update({i, j})
1102+
break
1103+
1104+
for idx, opcode in enumerate(replace_opcodes):
1105+
if idx in used:
1106+
continue
1107+
self._diff_by_forming_pairs_and_comparing_one_by_one(
1108+
level,
1109+
local_tree=local_tree,
1110+
parents_ids=parents_ids,
1111+
_original_type=_original_type,
1112+
child_relationship_class=child_relationship_class,
1113+
t1_from_index=opcode.t1_from_index,
1114+
t1_to_index=opcode.t1_to_index,
1115+
t2_from_index=opcode.t2_from_index,
1116+
t2_to_index=opcode.t2_to_index,
1117+
)
1118+
10641119
return opcodes_with_values
10651120

10661121

docs/basics.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,14 @@ List difference
106106
>>> pprint (ddiff, indent = 2)
107107
{'iterable_item_removed': {"root[4]['b'][2]": 3, "root[4]['b'][3]": 4}}
108108

109+
List item moved
110+
>>> t1 = [1, 2, 3, 4]
111+
>>> t2 = [4, 2, 3, 1]
112+
>>> pprint(DeepDiff(t1, t2, verbose_level=2), indent=2)
113+
{ 'iterable_item_moved': {
114+
'root[0]': {'new_path': 'root[3]', 'value': 1},
115+
'root[3]': {'new_path': 'root[0]', 'value': 4}}}
116+
109117
List that contains dictionary:
110118
>>> t1 = {1:1, 2:2, 3:3, 4:{"a":"hello", "b":[1, 2, {1:1, 2:2}]}}
111119
>>> t2 = {1:1, 2:2, 3:3, 4:{"a":"hello", "b":[1, 2, {1:3}]}}

tests/test_diff_text.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1819,14 +1819,17 @@ def test_list_item_removed_from_the_middle(self):
18191819
assert {"root[4]"} == diff.affected_paths
18201820
assert {4} == diff.affected_root_keys
18211821

1822-
# TODO: we need to support reporting that items have been swapped
1823-
# def test_item_moved(self):
1824-
# # currently all the items in the list need to be hashables
1825-
# t1 = [1, 2, 3, 4]
1826-
# t2 = [4, 2, 3, 1]
1827-
# diff = DeepDiff(t1, t2)
1828-
# result = {} # it should show that those items are swapped.
1829-
# assert result == diff
1822+
def test_item_moved(self):
1823+
t1 = [1, 2, 3, 4]
1824+
t2 = [4, 2, 3, 1]
1825+
diff = DeepDiff(t1, t2, verbose_level=2)
1826+
result = {
1827+
'iterable_item_moved': {
1828+
'root[0]': {'new_path': 'root[3]', 'value': 1},
1829+
'root[3]': {'new_path': 'root[0]', 'value': 4},
1830+
}
1831+
}
1832+
assert result == diff
18301833

18311834
def test_list_item_values_replace_in_the_middle(self):
18321835
t1 = [0, 1, 2, 3, 'bye', 5, 6, 7, 8, 'a', 'b', 'c']

0 commit comments

Comments
 (0)