pl-dict-diff 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- inspire_dictdiffer/__init__.py +430 -0
- inspire_dictdiffer/conflict.py +80 -0
- inspire_dictdiffer/merge.py +163 -0
- inspire_dictdiffer/resolve.py +153 -0
- inspire_dictdiffer/testing.py +36 -0
- inspire_dictdiffer/unify.py +53 -0
- inspire_dictdiffer/utils.py +310 -0
- inspire_dictdiffer/version.py +4 -0
- pl_dict_diff-0.0.1.dist-info/METADATA +125 -0
- pl_dict_diff-0.0.1.dist-info/RECORD +12 -0
- pl_dict_diff-0.0.1.dist-info/WHEEL +4 -0
- pl_dict_diff-0.0.1.dist-info/licenses/LICENSE +28 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
# This file is part of Dictdiffer.
|
|
2
|
+
#
|
|
3
|
+
# Copyright (C) 2013 Fatih Erikli.
|
|
4
|
+
# Copyright (C) 2013, 2014, 2015, 2016 CERN.
|
|
5
|
+
# Copyright (C) 2017-2019 ETH Zurich, Swiss Data Science Center, Jiri Kuncar.
|
|
6
|
+
#
|
|
7
|
+
# Dictdiffer is free software; you can redistribute it and/or modify
|
|
8
|
+
# it under the terms of the MIT License; see LICENSE file for more
|
|
9
|
+
# details.
|
|
10
|
+
|
|
11
|
+
"""Dictdiffer is a helper module to diff and patch dictionaries."""
|
|
12
|
+
|
|
13
|
+
from collections.abc import Iterable, MutableMapping, MutableSequence, MutableSet
|
|
14
|
+
from copy import deepcopy
|
|
15
|
+
|
|
16
|
+
from .utils import (
|
|
17
|
+
EPSILON,
|
|
18
|
+
PathLimit,
|
|
19
|
+
RemovedObject,
|
|
20
|
+
are_different,
|
|
21
|
+
dot_lookup,
|
|
22
|
+
strip_removed_objects,
|
|
23
|
+
)
|
|
24
|
+
from .version import __version__
|
|
25
|
+
|
|
26
|
+
(ADD, REMOVE, CHANGE) = ("add", "remove", "change")
|
|
27
|
+
|
|
28
|
+
__all__ = (
|
|
29
|
+
"diff",
|
|
30
|
+
"patch",
|
|
31
|
+
"swap",
|
|
32
|
+
"revert",
|
|
33
|
+
"dot_lookup",
|
|
34
|
+
"__version__",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
DICT_TYPES = (MutableMapping,)
|
|
38
|
+
LIST_TYPES = (MutableSequence,)
|
|
39
|
+
SET_TYPES = (MutableSet,)
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
import numpy
|
|
43
|
+
|
|
44
|
+
HAS_NUMPY = True
|
|
45
|
+
LIST_TYPES += (numpy.ndarray,)
|
|
46
|
+
except ImportError: # pragma: no cover
|
|
47
|
+
HAS_NUMPY = False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def diff(
|
|
51
|
+
first,
|
|
52
|
+
second,
|
|
53
|
+
node=None,
|
|
54
|
+
ignore=None,
|
|
55
|
+
path_limit=None,
|
|
56
|
+
expand=False,
|
|
57
|
+
tolerance=EPSILON,
|
|
58
|
+
absolute_tolerance=None,
|
|
59
|
+
dot_notation=True,
|
|
60
|
+
):
|
|
61
|
+
"""Compare two dictionary/list/set objects, and returns a diff result.
|
|
62
|
+
|
|
63
|
+
Return an iterator with differences between two objects. The diff items
|
|
64
|
+
represent addition/deletion/change and the item value is a *deep copy*
|
|
65
|
+
from the corresponding source or destination objects.
|
|
66
|
+
|
|
67
|
+
>>> from dictdiffer import diff
|
|
68
|
+
>>> result = diff({'a': 'b'}, {'a': 'c'})
|
|
69
|
+
>>> list(result)
|
|
70
|
+
[('change', 'a', ('b', 'c'))]
|
|
71
|
+
|
|
72
|
+
The keys can be skipped from difference calculation when they are included
|
|
73
|
+
in ``ignore`` argument of type :class:`collections.Container`.
|
|
74
|
+
|
|
75
|
+
>>> list(diff({'a': 1, 'b': 2}, {'a': 3, 'b': 4}, ignore=set(['a'])))
|
|
76
|
+
[('change', 'b', (2, 4))]
|
|
77
|
+
>>> class IgnoreCase(set):
|
|
78
|
+
... def __contains__(self, key):
|
|
79
|
+
... return set.__contains__(self, str(key).lower())
|
|
80
|
+
>>> list(diff({'a': 1, 'b': 2}, {'A': 3, 'b': 4}, ignore=IgnoreCase('a')))
|
|
81
|
+
[('change', 'b', (2, 4))]
|
|
82
|
+
|
|
83
|
+
The difference calculation can be limitted to certain path:
|
|
84
|
+
|
|
85
|
+
>>> list(diff({}, {'a': {'b': 'c'}}))
|
|
86
|
+
[('add', '', [('a', {'b': 'c'})])]
|
|
87
|
+
|
|
88
|
+
>>> from dictdiffer.utils import PathLimit
|
|
89
|
+
>>> list(diff({}, {'a': {'b': 'c'}}, path_limit=PathLimit()))
|
|
90
|
+
[('add', '', [('a', {})]), ('add', 'a', [('b', 'c')])]
|
|
91
|
+
|
|
92
|
+
>>> from dictdiffer.utils import PathLimit
|
|
93
|
+
>>> list(diff({}, {'a': {'b': 'c'}}, path_limit=PathLimit([('a',)])))
|
|
94
|
+
[('add', '', [('a', {'b': 'c'})])]
|
|
95
|
+
|
|
96
|
+
>>> from dictdiffer.utils import PathLimit
|
|
97
|
+
>>> list(diff({}, {'a': {'b': 'c'}},
|
|
98
|
+
... path_limit=PathLimit([('a', 'b')])))
|
|
99
|
+
[('add', '', [('a', {})]), ('add', 'a', [('b', 'c')])]
|
|
100
|
+
|
|
101
|
+
>>> from dictdiffer.utils import PathLimit
|
|
102
|
+
>>> list(diff({'a': {'b': 'c'}}, {'a': {'b': 'c'}}, path_limit=PathLimit([('a',)])))
|
|
103
|
+
[]
|
|
104
|
+
|
|
105
|
+
The patch can be expanded to small units e.g. when adding multiple values:
|
|
106
|
+
|
|
107
|
+
>>> list(diff({'fruits': []}, {'fruits': ['apple', 'mango']}))
|
|
108
|
+
[('add', 'fruits', [(0, 'apple'), (1, 'mango')])]
|
|
109
|
+
|
|
110
|
+
>>> list(diff({'fruits': []}, {'fruits': ['apple', 'mango']}, expand=True))
|
|
111
|
+
[('add', 'fruits', [(0, 'apple')]), ('add', 'fruits', [(1, 'mango')])]
|
|
112
|
+
|
|
113
|
+
>>> list(diff({'a': {'x': 1}}, {'a': {'x': 2}}))
|
|
114
|
+
[('change', 'a.x', (1, 2))]
|
|
115
|
+
|
|
116
|
+
>>> list(diff({'a': {'x': 1}}, {'a': {'x': 2}},
|
|
117
|
+
... dot_notation=False))
|
|
118
|
+
[('change', ['a', 'x'], (1, 2))]
|
|
119
|
+
|
|
120
|
+
:param first: The original dictionary, ``list`` or ``set``.
|
|
121
|
+
:param second: New dictionary, ``list`` or ``set``.
|
|
122
|
+
:param node: Key for comparison that can be used in :func:`dot_lookup`.
|
|
123
|
+
:param ignore: Set of keys that should not be checked.
|
|
124
|
+
:param path_limit: List of path limit tuples or dictdiffer.utils.Pathlimit
|
|
125
|
+
object to limit the diff recursion depth.
|
|
126
|
+
A diff is still performed beyond the path_limit,
|
|
127
|
+
but individual differences will be aggregated up to the path_limit.
|
|
128
|
+
:param expand: Expand the patches.
|
|
129
|
+
:param tolerance: Threshold to consider when comparing two float numbers.
|
|
130
|
+
:param absolute_tolerance: Absolute threshold to consider when comparing
|
|
131
|
+
two float numbers.
|
|
132
|
+
:param dot_notation: Boolean to toggle dot notation on and off.
|
|
133
|
+
|
|
134
|
+
.. versionchanged:: 0.3
|
|
135
|
+
Added *ignore* parameter.
|
|
136
|
+
|
|
137
|
+
.. versionchanged:: 0.4
|
|
138
|
+
Arguments ``first`` and ``second`` can now contain a ``set``.
|
|
139
|
+
|
|
140
|
+
.. versionchanged:: 0.5
|
|
141
|
+
Added *path_limit* parameter.
|
|
142
|
+
Added *expand* paramter.
|
|
143
|
+
Added *tolerance* parameter.
|
|
144
|
+
|
|
145
|
+
.. versionchanged:: 0.7
|
|
146
|
+
Diff items are deep copies from its corresponding objects.
|
|
147
|
+
Argument *ignore* is always converted to a ``set``.
|
|
148
|
+
|
|
149
|
+
.. versionchanged:: 0.8
|
|
150
|
+
Added *dot_notation* parameter.
|
|
151
|
+
"""
|
|
152
|
+
if path_limit is not None and not isinstance(path_limit, PathLimit):
|
|
153
|
+
path_limit = PathLimit(path_limit)
|
|
154
|
+
|
|
155
|
+
if isinstance(ignore, Iterable):
|
|
156
|
+
|
|
157
|
+
def _process_ignore_value(value):
|
|
158
|
+
if isinstance(value, int):
|
|
159
|
+
return (value,)
|
|
160
|
+
elif isinstance(value, list):
|
|
161
|
+
return tuple(value)
|
|
162
|
+
elif not dot_notation and isinstance(value, str):
|
|
163
|
+
return (value,)
|
|
164
|
+
return value
|
|
165
|
+
|
|
166
|
+
ignore = type(ignore)(_process_ignore_value(value) for value in ignore)
|
|
167
|
+
|
|
168
|
+
def dotted(node, default_type=list):
|
|
169
|
+
"""Return dotted notation."""
|
|
170
|
+
if dot_notation and all(
|
|
171
|
+
map(lambda x: isinstance(x, str) and "." not in x, node)
|
|
172
|
+
):
|
|
173
|
+
return ".".join(node)
|
|
174
|
+
else:
|
|
175
|
+
return default_type(node)
|
|
176
|
+
|
|
177
|
+
def _diff_recursive(_first, _second, _node=None):
|
|
178
|
+
_node = _node or []
|
|
179
|
+
|
|
180
|
+
dotted_node = dotted(_node)
|
|
181
|
+
|
|
182
|
+
differ = False
|
|
183
|
+
|
|
184
|
+
if isinstance(_first, DICT_TYPES) and isinstance(_second, DICT_TYPES):
|
|
185
|
+
# dictionaries are not hashable, we can't use sets
|
|
186
|
+
def check(key):
|
|
187
|
+
"""Test if key in current node should be ignored."""
|
|
188
|
+
return ignore is None or (
|
|
189
|
+
dotted(_node + [key], default_type=tuple) not in ignore
|
|
190
|
+
and tuple(_node + [key]) not in ignore
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
intersection = [k for k in _first if k in _second and check(k)]
|
|
194
|
+
addition = [k for k in _second if k not in _first and check(k)]
|
|
195
|
+
deletion = [k for k in _first if k not in _second and check(k)]
|
|
196
|
+
|
|
197
|
+
differ = True
|
|
198
|
+
|
|
199
|
+
elif isinstance(_first, LIST_TYPES) and isinstance(_second, LIST_TYPES):
|
|
200
|
+
len_first = len(_first)
|
|
201
|
+
len_second = len(_second)
|
|
202
|
+
|
|
203
|
+
intersection = list(range(0, min(len_first, len_second)))
|
|
204
|
+
addition = list(range(min(len_first, len_second), len_second))
|
|
205
|
+
deletion = list(reversed(range(min(len_first, len_second), len_first)))
|
|
206
|
+
|
|
207
|
+
differ = True
|
|
208
|
+
|
|
209
|
+
elif isinstance(_first, SET_TYPES) and isinstance(_second, SET_TYPES):
|
|
210
|
+
# Deep copy is not necessary for hashable items.
|
|
211
|
+
addition = _second - _first
|
|
212
|
+
if len(addition):
|
|
213
|
+
yield ADD, dotted_node, [(0, addition)]
|
|
214
|
+
deletion = _first - _second
|
|
215
|
+
if len(deletion):
|
|
216
|
+
yield REMOVE, dotted_node, [(0, deletion)]
|
|
217
|
+
|
|
218
|
+
return # stop here for sets
|
|
219
|
+
|
|
220
|
+
if differ:
|
|
221
|
+
# Compare if object is a dictionary or list.
|
|
222
|
+
#
|
|
223
|
+
# NOTE variables: intersection, addition, deletion contain only
|
|
224
|
+
# hashable types, hence they do not need to be deepcopied.
|
|
225
|
+
#
|
|
226
|
+
# Call again the parent function as recursive if dictionary have
|
|
227
|
+
# child objects. Yields `add` and `remove` flags.
|
|
228
|
+
for key in intersection:
|
|
229
|
+
# if type is not changed,
|
|
230
|
+
# callees again diff function to compare.
|
|
231
|
+
# otherwise, the change will be handled as `change` flag.
|
|
232
|
+
if path_limit and path_limit.path_is_limit(_node + [key]):
|
|
233
|
+
if _first[key] == _second[key]:
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
yield (
|
|
237
|
+
CHANGE,
|
|
238
|
+
_node + [key],
|
|
239
|
+
(deepcopy(_first[key]), deepcopy(_second[key])),
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
recurred = _diff_recursive(
|
|
243
|
+
_first[key],
|
|
244
|
+
_second[key],
|
|
245
|
+
_node=_node + [key],
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
for diffed in recurred:
|
|
249
|
+
yield diffed
|
|
250
|
+
|
|
251
|
+
if addition:
|
|
252
|
+
if path_limit:
|
|
253
|
+
collect = []
|
|
254
|
+
collect_recurred = []
|
|
255
|
+
for key in addition:
|
|
256
|
+
if not isinstance(
|
|
257
|
+
_second[key], SET_TYPES + LIST_TYPES + DICT_TYPES
|
|
258
|
+
):
|
|
259
|
+
collect.append((key, deepcopy(_second[key])))
|
|
260
|
+
elif path_limit.path_is_limit(_node + [key]):
|
|
261
|
+
collect.append((key, deepcopy(_second[key])))
|
|
262
|
+
else:
|
|
263
|
+
collect.append((key, _second[key].__class__()))
|
|
264
|
+
recurred = _diff_recursive(
|
|
265
|
+
_second[key].__class__(),
|
|
266
|
+
_second[key],
|
|
267
|
+
_node=_node + [key],
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
collect_recurred.append(recurred)
|
|
271
|
+
|
|
272
|
+
if expand:
|
|
273
|
+
for key, val in collect:
|
|
274
|
+
yield ADD, dotted_node, [(key, val)]
|
|
275
|
+
else:
|
|
276
|
+
yield ADD, dotted_node, collect
|
|
277
|
+
|
|
278
|
+
for recurred in collect_recurred:
|
|
279
|
+
for diffed in recurred:
|
|
280
|
+
yield diffed
|
|
281
|
+
else:
|
|
282
|
+
if expand:
|
|
283
|
+
for key in addition:
|
|
284
|
+
yield ADD, dotted_node, [(key, deepcopy(_second[key]))]
|
|
285
|
+
else:
|
|
286
|
+
yield (
|
|
287
|
+
ADD,
|
|
288
|
+
dotted_node,
|
|
289
|
+
[
|
|
290
|
+
# for additions, return a list that consist with
|
|
291
|
+
# two-pair tuples.
|
|
292
|
+
(key, deepcopy(_second[key]))
|
|
293
|
+
for key in addition
|
|
294
|
+
],
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if deletion:
|
|
298
|
+
if expand:
|
|
299
|
+
for key in deletion:
|
|
300
|
+
yield REMOVE, dotted_node, [(key, deepcopy(_first[key]))]
|
|
301
|
+
else:
|
|
302
|
+
yield (
|
|
303
|
+
REMOVE,
|
|
304
|
+
dotted_node,
|
|
305
|
+
[
|
|
306
|
+
# for deletions, return the list of removed keys
|
|
307
|
+
# and values.
|
|
308
|
+
(key, deepcopy(_first[key]))
|
|
309
|
+
for key in deletion
|
|
310
|
+
],
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
else:
|
|
314
|
+
# Compare string and numerical types and yield `change` flag.
|
|
315
|
+
if are_different(_first, _second, tolerance, absolute_tolerance):
|
|
316
|
+
yield CHANGE, dotted_node, (deepcopy(_first), deepcopy(_second))
|
|
317
|
+
|
|
318
|
+
return _diff_recursive(first, second, node)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def patch(diff_result, destination, in_place=False):
|
|
322
|
+
"""Patch the diff result to the destination dictionary.
|
|
323
|
+
|
|
324
|
+
:param diff_result: Changes returned by ``diff``.
|
|
325
|
+
:param destination: Structure to apply the changes to.
|
|
326
|
+
:param in_place: By default, destination dictionary is deep copied
|
|
327
|
+
before applying the patch, and the copy is returned.
|
|
328
|
+
Setting ``in_place=True`` means that patch will apply
|
|
329
|
+
the changes directly to and return the destination
|
|
330
|
+
structure.
|
|
331
|
+
"""
|
|
332
|
+
if not in_place:
|
|
333
|
+
destination = deepcopy(destination)
|
|
334
|
+
|
|
335
|
+
def add(node, changes):
|
|
336
|
+
for key, value in changes:
|
|
337
|
+
dest = dot_lookup(destination, node)
|
|
338
|
+
if isinstance(dest, LIST_TYPES):
|
|
339
|
+
dest.insert(key, value)
|
|
340
|
+
elif isinstance(dest, SET_TYPES):
|
|
341
|
+
dest |= value
|
|
342
|
+
else:
|
|
343
|
+
dest[key] = value
|
|
344
|
+
|
|
345
|
+
def change(node, changes):
|
|
346
|
+
dest = dot_lookup(destination, node, parent=True)
|
|
347
|
+
if isinstance(node, str):
|
|
348
|
+
last_node = node.split(".")[-1]
|
|
349
|
+
else:
|
|
350
|
+
last_node = node[-1]
|
|
351
|
+
if isinstance(dest, LIST_TYPES):
|
|
352
|
+
last_node = int(last_node)
|
|
353
|
+
_, value = changes
|
|
354
|
+
dest[last_node] = value
|
|
355
|
+
|
|
356
|
+
def remove(node, changes):
|
|
357
|
+
for key, value in changes:
|
|
358
|
+
dest = dot_lookup(destination, node)
|
|
359
|
+
if isinstance(dest, SET_TYPES):
|
|
360
|
+
dest -= value
|
|
361
|
+
elif isinstance(dest, LIST_TYPES):
|
|
362
|
+
dest[key] = RemovedObject()
|
|
363
|
+
else:
|
|
364
|
+
del dest[key]
|
|
365
|
+
|
|
366
|
+
patchers = {REMOVE: remove, ADD: add, CHANGE: change}
|
|
367
|
+
|
|
368
|
+
for action, node, changes in diff_result:
|
|
369
|
+
patchers[action](node, changes)
|
|
370
|
+
|
|
371
|
+
return strip_removed_objects(destination)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def swap(diff_result):
|
|
375
|
+
"""Swap the diff result.
|
|
376
|
+
|
|
377
|
+
It uses following mapping:
|
|
378
|
+
|
|
379
|
+
- remove -> add
|
|
380
|
+
- add -> remove
|
|
381
|
+
|
|
382
|
+
In addition, swap the changed values for `change` flag.
|
|
383
|
+
|
|
384
|
+
>>> from dictdiffer import swap
|
|
385
|
+
>>> swapped = swap([('add', 'a.b.c', [('a', 'b'), ('c', 'd')])])
|
|
386
|
+
>>> next(swapped)
|
|
387
|
+
('remove', 'a.b.c', [('c', 'd'), ('a', 'b')])
|
|
388
|
+
|
|
389
|
+
>>> swapped = swap([('change', 'a.b.c', ('a', 'b'))])
|
|
390
|
+
>>> next(swapped)
|
|
391
|
+
('change', 'a.b.c', ('b', 'a'))
|
|
392
|
+
|
|
393
|
+
"""
|
|
394
|
+
|
|
395
|
+
def add(node, changes):
|
|
396
|
+
return REMOVE, node, list(reversed(changes))
|
|
397
|
+
|
|
398
|
+
def remove(node, changes):
|
|
399
|
+
return ADD, node, changes
|
|
400
|
+
|
|
401
|
+
def change(node, changes):
|
|
402
|
+
first, second = changes
|
|
403
|
+
return CHANGE, node, (second, first)
|
|
404
|
+
|
|
405
|
+
swappers = {REMOVE: remove, ADD: add, CHANGE: change}
|
|
406
|
+
|
|
407
|
+
for action, node, change in diff_result:
|
|
408
|
+
yield swappers[action](node, change)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def revert(diff_result, destination, in_place=False):
|
|
412
|
+
"""Call swap function to revert patched dictionary object.
|
|
413
|
+
|
|
414
|
+
Usage example:
|
|
415
|
+
|
|
416
|
+
>>> from dictdiffer import diff, revert
|
|
417
|
+
>>> first = {'a': 'b'}
|
|
418
|
+
>>> second = {'a': 'c'}
|
|
419
|
+
>>> revert(diff(first, second), second)
|
|
420
|
+
{'a': 'b'}
|
|
421
|
+
|
|
422
|
+
:param diff_result: Changes returned by ``diff``.
|
|
423
|
+
:param destination: Structure to apply the changes to.
|
|
424
|
+
:param in_place: By default, destination dictionary is deep
|
|
425
|
+
copied before being reverted, and the copy
|
|
426
|
+
is returned. Setting ``in_place=True`` means
|
|
427
|
+
that revert will apply the changes directly to
|
|
428
|
+
and return the destination structure.
|
|
429
|
+
"""
|
|
430
|
+
return patch(swap(diff_result), destination, in_place)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# This file is part of Dictdiffer.
|
|
2
|
+
#
|
|
3
|
+
# Copyright (C) 2015 CERN.
|
|
4
|
+
#
|
|
5
|
+
# Dictdiffer is free software; you can redistribute it and/or modify
|
|
6
|
+
# it under the terms of the MIT License; see LICENSE file for more
|
|
7
|
+
# details.
|
|
8
|
+
|
|
9
|
+
"""Sub module to recognize conflicts in dictdiffer patches."""
|
|
10
|
+
|
|
11
|
+
import itertools
|
|
12
|
+
|
|
13
|
+
from .utils import get_path, is_super_path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Conflict:
|
|
17
|
+
"""Wrapper class to store and handle two conflicting patches."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, patch1, patch2):
|
|
20
|
+
"""Initialize Conflict object.
|
|
21
|
+
|
|
22
|
+
:param patch1: First patch tuple
|
|
23
|
+
:param patch2: Second patch tuple
|
|
24
|
+
"""
|
|
25
|
+
self.first_patch = patch1
|
|
26
|
+
self.second_patch = patch2
|
|
27
|
+
self.take = None
|
|
28
|
+
|
|
29
|
+
def take_patch(self):
|
|
30
|
+
"""Return the patch determined by the *take* attribute."""
|
|
31
|
+
if self.take:
|
|
32
|
+
return self.first_patch if self.take == "f" else self.second_patch
|
|
33
|
+
raise Exception("Take attribute not set.")
|
|
34
|
+
|
|
35
|
+
def __repr__(self):
|
|
36
|
+
"""Return string representation."""
|
|
37
|
+
return "Conflict({0}, {1})".format(self.first_patch, self.second_patch)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ConflictFinder:
|
|
41
|
+
"""Responsible for finding conflicting patches."""
|
|
42
|
+
|
|
43
|
+
def _is_conflict(self, patch1, patch2):
|
|
44
|
+
"""Decide on a conflict between two patches.
|
|
45
|
+
|
|
46
|
+
The conditions are:
|
|
47
|
+
1. The paths are identical
|
|
48
|
+
2. On of the paths is the super path of the other
|
|
49
|
+
|
|
50
|
+
:param patch1: First patch tuple
|
|
51
|
+
:param patch2: First patch tuple
|
|
52
|
+
"""
|
|
53
|
+
path1 = get_path(patch1)
|
|
54
|
+
path2 = get_path(patch2)
|
|
55
|
+
|
|
56
|
+
if path1 == path2:
|
|
57
|
+
return True
|
|
58
|
+
elif is_super_path(path1, path2) and patch1[0] == "remove":
|
|
59
|
+
return True
|
|
60
|
+
elif is_super_path(path2, path1) and patch2[0] == "remove":
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
def find_conflicts(self, first_patches, second_patches):
|
|
66
|
+
"""Find all conflicts between two lists of patches.
|
|
67
|
+
|
|
68
|
+
Iterates over the lists of patches, comparing each patch from list
|
|
69
|
+
one to each patch from list two.
|
|
70
|
+
|
|
71
|
+
:param first_patches: List of patch tuples
|
|
72
|
+
:param second_patches: List of patch tuples
|
|
73
|
+
"""
|
|
74
|
+
self.conflicts = [
|
|
75
|
+
Conflict(patch1, patch2)
|
|
76
|
+
for patch1, patch2 in itertools.product(first_patches, second_patches)
|
|
77
|
+
if self._is_conflict(patch1, patch2)
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
return self.conflicts
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# This file is part of Dictdiffer.
|
|
2
|
+
#
|
|
3
|
+
# Copyright (C) 2015 CERN.
|
|
4
|
+
# Copyright (C) 2017 ETH Zurich, Swiss Data Science Center, Jiri Kuncar.
|
|
5
|
+
#
|
|
6
|
+
# Dictdiffer is free software; you can redistribute it and/or modify
|
|
7
|
+
# it under the terms of the MIT License; see LICENSE file for more
|
|
8
|
+
# details.
|
|
9
|
+
|
|
10
|
+
"""Sub module to handle the merging of dictdiffer patches."""
|
|
11
|
+
|
|
12
|
+
from . import diff
|
|
13
|
+
from .conflict import ConflictFinder
|
|
14
|
+
from .resolve import Resolver, UnresolvedConflictsException
|
|
15
|
+
from .unify import Unifier
|
|
16
|
+
from .utils import PathLimit
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Merger:
|
|
20
|
+
"""Class wrapping steps of the automated merging process.
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
>>> lca = {}
|
|
24
|
+
>>> first = {'foo': 'bar'}
|
|
25
|
+
>>> second = {'bar': 'foo'}
|
|
26
|
+
>>> path_limits = []
|
|
27
|
+
>>> actions = {}
|
|
28
|
+
>>> additional_info = {}
|
|
29
|
+
>>> m = Merger(lca, first, second, actions,
|
|
30
|
+
... path_limits, additional_info)
|
|
31
|
+
>>> try:
|
|
32
|
+
... m.run()
|
|
33
|
+
... except UnresolvedConflictsException:
|
|
34
|
+
... # fix the conflicts
|
|
35
|
+
... m.continue_run()
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
lca,
|
|
41
|
+
first,
|
|
42
|
+
second,
|
|
43
|
+
actions,
|
|
44
|
+
path_limits=[],
|
|
45
|
+
additional_info=None,
|
|
46
|
+
ignore=None,
|
|
47
|
+
):
|
|
48
|
+
"""Initialize the Merger object.
|
|
49
|
+
|
|
50
|
+
:param lca: latest common ancestor of the two diverging data structures
|
|
51
|
+
:param first: first data structure
|
|
52
|
+
:param second: second data structure
|
|
53
|
+
:param path_limits: list of paths, utilized to instantiate a
|
|
54
|
+
dictdiffer.utils.PathLimit object
|
|
55
|
+
:param additional_info: Any object containing additional information
|
|
56
|
+
used by the resolution functions
|
|
57
|
+
:param ignore: Set of keys that should not be merged
|
|
58
|
+
"""
|
|
59
|
+
self.lca = lca
|
|
60
|
+
self.first = first
|
|
61
|
+
self.second = second
|
|
62
|
+
self.path_limit = PathLimit(path_limits)
|
|
63
|
+
self.ignore = ignore
|
|
64
|
+
|
|
65
|
+
self.actions = actions
|
|
66
|
+
self.additional_info = additional_info
|
|
67
|
+
|
|
68
|
+
self.conflict_finder = ConflictFinder()
|
|
69
|
+
|
|
70
|
+
self.resolver = Resolver(self.actions, self.additional_info)
|
|
71
|
+
|
|
72
|
+
self.unifier = Unifier()
|
|
73
|
+
|
|
74
|
+
self.conflicts = []
|
|
75
|
+
self.unresolved_conflicts = []
|
|
76
|
+
|
|
77
|
+
def run(self):
|
|
78
|
+
"""Run the automated merging process.
|
|
79
|
+
|
|
80
|
+
Runs every step necessary for the automated merging process, raising
|
|
81
|
+
an UnresolvedConflictsException in case that the provided resolution
|
|
82
|
+
actions can not solve a given conflict.
|
|
83
|
+
|
|
84
|
+
After every performed step, the results are stored inside attributes of
|
|
85
|
+
the merger object.
|
|
86
|
+
"""
|
|
87
|
+
self.extract_patches()
|
|
88
|
+
self.find_conflicts()
|
|
89
|
+
self.resolve_conflicts()
|
|
90
|
+
|
|
91
|
+
if self.unresolved_conflicts:
|
|
92
|
+
raise UnresolvedConflictsException(self.unresolved_conflicts)
|
|
93
|
+
|
|
94
|
+
self.unify_patches()
|
|
95
|
+
|
|
96
|
+
def continue_run(self, picks):
|
|
97
|
+
"""Continue the merge after an UnresolvedConflictsException.
|
|
98
|
+
|
|
99
|
+
:param picks: a list of 'f' or 's' strings, which utilize the Conflicts
|
|
100
|
+
class *take* attribute
|
|
101
|
+
"""
|
|
102
|
+
self.resolver.manual_resolve_conflicts(picks)
|
|
103
|
+
self.unresolved_conflicts = []
|
|
104
|
+
self.unify_patches()
|
|
105
|
+
|
|
106
|
+
def extract_patches(self):
|
|
107
|
+
"""Extract the patches.
|
|
108
|
+
|
|
109
|
+
Extracts the differences between the *lca* and the *first* and
|
|
110
|
+
*second* data structure and stores them in the attributes
|
|
111
|
+
*first_patches* and *second_patches*.
|
|
112
|
+
"""
|
|
113
|
+
self.first_patches = list(
|
|
114
|
+
diff(
|
|
115
|
+
self.lca,
|
|
116
|
+
self.first,
|
|
117
|
+
path_limit=self.path_limit,
|
|
118
|
+
ignore=self.ignore,
|
|
119
|
+
expand=True,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
self.second_patches = list(
|
|
123
|
+
diff(
|
|
124
|
+
self.lca,
|
|
125
|
+
self.second,
|
|
126
|
+
path_limit=self.path_limit,
|
|
127
|
+
ignore=self.ignore,
|
|
128
|
+
expand=True,
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def find_conflicts(self):
|
|
133
|
+
"""Find conflicts between the tow lists of patches.
|
|
134
|
+
|
|
135
|
+
Finds the conflicts between the two difference lists and stores
|
|
136
|
+
them in the *conflicts* attribute.
|
|
137
|
+
"""
|
|
138
|
+
self.conflicts = self.conflict_finder.find_conflicts(
|
|
139
|
+
self.first_patches, self.second_patches
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def resolve_conflicts(self):
|
|
143
|
+
"""Resolve the conflicts.
|
|
144
|
+
|
|
145
|
+
Runs the automated conflict resolution process.
|
|
146
|
+
Occurring unresolvable conflicts are stored in *unresolved_conflicts*.
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
self.resolver.resolve_conflicts(
|
|
150
|
+
self.first_patches, self.second_patches, self.conflicts
|
|
151
|
+
)
|
|
152
|
+
except UnresolvedConflictsException as e:
|
|
153
|
+
self.unresolved_conflicts = e.content
|
|
154
|
+
|
|
155
|
+
def unify_patches(self):
|
|
156
|
+
"""Unify the patches after the conflict resolution.
|
|
157
|
+
|
|
158
|
+
Unifies the patches after a successful merge and stores them in
|
|
159
|
+
*unified_patches*.
|
|
160
|
+
"""
|
|
161
|
+
self.unified_patches = self.unifier.unify(
|
|
162
|
+
self.first_patches, self.second_patches, self.conflicts
|
|
163
|
+
)
|