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.
@@ -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
+ )