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,153 @@
|
|
|
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 conflict resolution."""
|
|
11
|
+
|
|
12
|
+
from .utils import get_path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UnresolvedConflictsException(Exception):
|
|
16
|
+
"""Exception raised in case of an unresolveable conflict.
|
|
17
|
+
|
|
18
|
+
Exception raised in case of conflicts, that can not be resolved using
|
|
19
|
+
the provided actions in the automated merging process.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, unresolved_conflicts):
|
|
23
|
+
"""Initialize the UnresolvedConflictsException.
|
|
24
|
+
|
|
25
|
+
:param unresolved_conflicts: list of unresolved conflicts.
|
|
26
|
+
dictdiffer.conflict.Conflict objects.
|
|
27
|
+
"""
|
|
28
|
+
self.message = (
|
|
29
|
+
"The unresolved conflicts are stored in the *content* "
|
|
30
|
+
"attribute of this exception or in the "
|
|
31
|
+
"*unresolved_conflicts* attribute of the "
|
|
32
|
+
"dictdiffer.merge.Merger object."
|
|
33
|
+
)
|
|
34
|
+
self.content = unresolved_conflicts
|
|
35
|
+
|
|
36
|
+
def __repr__(self):
|
|
37
|
+
"""Return the object representation."""
|
|
38
|
+
return self.message
|
|
39
|
+
|
|
40
|
+
def __str__(self):
|
|
41
|
+
"""Return the string representation."""
|
|
42
|
+
return self.message
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NoFurtherResolutionException(Exception):
|
|
46
|
+
"""Exception raised to stop the automated resolution process.
|
|
47
|
+
|
|
48
|
+
Raised in case that the automatic conflict resolution process should stop
|
|
49
|
+
trying more general keys.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Resolver:
|
|
56
|
+
"""Class handling the conflict resolution process.
|
|
57
|
+
|
|
58
|
+
Presents the given conflicts to actions designed to solve them.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, actions, additional_info=None):
|
|
62
|
+
"""Initialize the Resolver.
|
|
63
|
+
|
|
64
|
+
:param action: dict object containing the necessary resolution
|
|
65
|
+
functions
|
|
66
|
+
:param additional_info: any additional information required by the
|
|
67
|
+
actions
|
|
68
|
+
"""
|
|
69
|
+
self.actions = actions
|
|
70
|
+
self.additional_info = additional_info
|
|
71
|
+
|
|
72
|
+
self.unresolved_conflicts = []
|
|
73
|
+
|
|
74
|
+
def _auto_resolve(self, conflict):
|
|
75
|
+
"""Try to auto resolve conflicts.
|
|
76
|
+
|
|
77
|
+
Method trying to auto resolve conflicts in case that the perform the
|
|
78
|
+
same amendment.
|
|
79
|
+
"""
|
|
80
|
+
if conflict.first_patch == conflict.second_patch:
|
|
81
|
+
conflict.take = "f"
|
|
82
|
+
return True
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
def _find_conflicting_path(self, conflict):
|
|
86
|
+
"""Return the shortest path commown to two patches."""
|
|
87
|
+
p1p = get_path(conflict.first_patch)
|
|
88
|
+
p2p = get_path(conflict.second_patch)
|
|
89
|
+
|
|
90
|
+
# This returns the shortest path
|
|
91
|
+
return p1p if len(p1p) <= len(p2p) else p2p
|
|
92
|
+
|
|
93
|
+
def _consecutive_slices(self, iterable):
|
|
94
|
+
"""Build a list of consecutive slices of a given path.
|
|
95
|
+
|
|
96
|
+
>>> r = Resolver(None, None)
|
|
97
|
+
>>> list(r._consecutive_slices([1, 2, 3]))
|
|
98
|
+
[[1, 2, 3], [1, 2], [1]]
|
|
99
|
+
"""
|
|
100
|
+
return (iterable[:i] for i in reversed(range(1, len(iterable) + 1)))
|
|
101
|
+
|
|
102
|
+
def resolve_conflicts(self, first_patches, second_patches, conflicts):
|
|
103
|
+
"""Convert the given conflicts to the actions.
|
|
104
|
+
|
|
105
|
+
The method, will map the conflicts to an actions based on the path of
|
|
106
|
+
the conflict. In case that the resolution attempt is not successful, it
|
|
107
|
+
will strip the last element of the path and try again, until the
|
|
108
|
+
resolution is just not possible.
|
|
109
|
+
|
|
110
|
+
:param first_patches: list of dictdiffer.diff patches
|
|
111
|
+
:param second_patches: list of dictdiffer.diff patches
|
|
112
|
+
:param conflicts: list of Conflict objects
|
|
113
|
+
"""
|
|
114
|
+
for conflict in conflicts:
|
|
115
|
+
conflict_path = self._find_conflicting_path(conflict)
|
|
116
|
+
|
|
117
|
+
if self._auto_resolve(conflict):
|
|
118
|
+
continue
|
|
119
|
+
# Let's do some cascading here
|
|
120
|
+
for sub_path in self._consecutive_slices(conflict_path):
|
|
121
|
+
try:
|
|
122
|
+
if self.actions[sub_path](
|
|
123
|
+
conflict, first_patches, second_patches, self.additional_info
|
|
124
|
+
):
|
|
125
|
+
break
|
|
126
|
+
except NoFurtherResolutionException:
|
|
127
|
+
self.unresolved_conflicts.append(conflict)
|
|
128
|
+
break
|
|
129
|
+
except KeyError:
|
|
130
|
+
pass
|
|
131
|
+
else:
|
|
132
|
+
# The conflict could not be resolved
|
|
133
|
+
self.unresolved_conflicts.append(conflict)
|
|
134
|
+
|
|
135
|
+
if self.unresolved_conflicts:
|
|
136
|
+
raise UnresolvedConflictsException(self.unresolved_conflicts)
|
|
137
|
+
|
|
138
|
+
def manual_resolve_conflicts(self, picks):
|
|
139
|
+
"""Resolve manually the conflicts.
|
|
140
|
+
|
|
141
|
+
This method resolves conflicts that could not be resolved in an
|
|
142
|
+
automatic way. The picks parameter utilized the *take* attribute of the
|
|
143
|
+
Conflict objects.
|
|
144
|
+
|
|
145
|
+
:param picks: list of 'f' or 's' strings, utilizing the *take*
|
|
146
|
+
parameter of each Conflict object
|
|
147
|
+
"""
|
|
148
|
+
if len(picks) != len(self.unresolved_conflicts):
|
|
149
|
+
raise UnresolvedConflictsException(self.unresolved_conflicts)
|
|
150
|
+
for pick, conflict in zip(picks, self.unresolved_conflicts):
|
|
151
|
+
conflict.take = pick
|
|
152
|
+
|
|
153
|
+
self.unresolved_conflicts = []
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# This file is part of Dictdiffer.
|
|
2
|
+
#
|
|
3
|
+
# Copyright (C) 2021 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
|
+
"""Define helper functions for testing."""
|
|
9
|
+
|
|
10
|
+
from pprint import pformat
|
|
11
|
+
|
|
12
|
+
from . import diff
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def assert_no_diff(*args, **kwargs):
|
|
16
|
+
"""Compare two dictionary/list/set objects and raise error on difference.
|
|
17
|
+
|
|
18
|
+
When there is a difference, this will print a formatted diff.
|
|
19
|
+
This is especially useful for pytest.
|
|
20
|
+
|
|
21
|
+
Usage example:
|
|
22
|
+
|
|
23
|
+
>>> from dictdiffer.testing import assert_no_diff
|
|
24
|
+
>>> result = {'a': 'b'}
|
|
25
|
+
>>> expected = {'a': 'c'}
|
|
26
|
+
>>> assert_no_diff(result, expected)
|
|
27
|
+
Traceback (most recent call last):
|
|
28
|
+
File "<stdin>", line 1, in <module>
|
|
29
|
+
File "<stdin>", line 14, in assert_no_diff
|
|
30
|
+
AssertionError: [('change', 'a', ('b', 'c'))]
|
|
31
|
+
|
|
32
|
+
:param args: Positional arguments to the ``diff`` function.
|
|
33
|
+
:param second: Named arguments to the ``diff`` function.
|
|
34
|
+
"""
|
|
35
|
+
d = [d for d in diff(*args, **kwargs)]
|
|
36
|
+
assert not d, pformat(d)
|
|
@@ -0,0 +1,53 @@
|
|
|
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 handle the unification of patches after the merge."""
|
|
10
|
+
|
|
11
|
+
from .utils import get_path, nested_hash
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Unifier:
|
|
15
|
+
"""Class handling the unification process after the merge."""
|
|
16
|
+
|
|
17
|
+
def unify(self, first_patches, second_patches, conflicts):
|
|
18
|
+
"""Unify two lists of patches into one.
|
|
19
|
+
|
|
20
|
+
Takes into account their appearance in the given list of conflicts.
|
|
21
|
+
|
|
22
|
+
:param first_patches: list of dictdiffer.diff patches
|
|
23
|
+
:param second_patches: list of dictdiffer.diff patches
|
|
24
|
+
:param conflicts: list of Conflict objects
|
|
25
|
+
"""
|
|
26
|
+
self.unified_patches = []
|
|
27
|
+
self._build_index(conflicts)
|
|
28
|
+
|
|
29
|
+
sorted_patches = sorted(first_patches + second_patches, key=get_path)
|
|
30
|
+
|
|
31
|
+
for patch in sorted_patches:
|
|
32
|
+
conflict = self._index.get(nested_hash(patch))
|
|
33
|
+
|
|
34
|
+
# Apply only the patches that were taken as part of conflict
|
|
35
|
+
# resolution.
|
|
36
|
+
if conflict:
|
|
37
|
+
if conflict.take_patch() != patch:
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
self.unified_patches.append(patch)
|
|
41
|
+
|
|
42
|
+
return self.unified_patches
|
|
43
|
+
|
|
44
|
+
def _build_index(self, conflicts):
|
|
45
|
+
"""Create a dictionary attribute mapping patches to conflicts.
|
|
46
|
+
|
|
47
|
+
Creates a dictionary attribute mapping the tuplefied version of each
|
|
48
|
+
patch to it's containing Conflict object.
|
|
49
|
+
"""
|
|
50
|
+
self._index = {}
|
|
51
|
+
for conflict in conflicts:
|
|
52
|
+
self._index[nested_hash(conflict.first_patch)] = conflict
|
|
53
|
+
self._index[nested_hash(conflict.second_patch)] = conflict
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# This file is part of Dictdiffer.
|
|
2
|
+
#
|
|
3
|
+
# Copyright (C) 2015 CERN.
|
|
4
|
+
# Copyright (C) 2017, 2019 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
|
+
"""Utils gathers helper functions, classes for the dictdiffer module."""
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from collections.abc import MutableMapping, MutableSequence
|
|
15
|
+
from itertools import zip_longest
|
|
16
|
+
|
|
17
|
+
EPSILON = sys.float_info.epsilon
|
|
18
|
+
|
|
19
|
+
DICT_TYPES = (MutableMapping,)
|
|
20
|
+
LIST_TYPES = (MutableSequence,)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RemovedObject:
|
|
24
|
+
"""Removed object placeholder."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WildcardDict(dict):
|
|
28
|
+
"""Provide possibility to use special wildcard keys to access values.
|
|
29
|
+
|
|
30
|
+
Those wildcards are:
|
|
31
|
+
*: wildcard for everything that follows
|
|
32
|
+
+: wildcard for anything on the same path level
|
|
33
|
+
The intended use case of this are dictionaries, that utilize tuples as
|
|
34
|
+
keys.
|
|
35
|
+
|
|
36
|
+
>>> from dictdiffer.utils import WildcardDict
|
|
37
|
+
>>> w = WildcardDict({('foo', '*'): '* card',
|
|
38
|
+
... ('banana', '+'): '+ card'})
|
|
39
|
+
>>> w[ ('foo', 'bar', 'baz') ]
|
|
40
|
+
'* card'
|
|
41
|
+
>>> w[ ('banana', 'apple') ]
|
|
42
|
+
'+ card'
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, values=None):
|
|
46
|
+
"""Set lookup key indices.
|
|
47
|
+
|
|
48
|
+
:param values: a dictionary
|
|
49
|
+
"""
|
|
50
|
+
super().__init__()
|
|
51
|
+
self.star_keys = set()
|
|
52
|
+
self.plus_keys = set()
|
|
53
|
+
|
|
54
|
+
if values is not None:
|
|
55
|
+
for key, value in values.items():
|
|
56
|
+
self.__setitem__(key, value)
|
|
57
|
+
|
|
58
|
+
def __getitem__(self, key):
|
|
59
|
+
"""Return the value corresponding to the key, regarding wildcards.
|
|
60
|
+
|
|
61
|
+
If the key doesn't exit it tries the '+' wildcard and then the
|
|
62
|
+
'*' wildcard.
|
|
63
|
+
|
|
64
|
+
>>> w = WildcardDict({('foo', '*'): '* card',
|
|
65
|
+
... ('banana', '+'): '+ card'})
|
|
66
|
+
>>> w[ ('foo', 'bar') ]
|
|
67
|
+
'* card'
|
|
68
|
+
>>> w[ ('foo', 'bar', 'baz') ]
|
|
69
|
+
'* card'
|
|
70
|
+
>>> w[ ('banana', 'apple') ]
|
|
71
|
+
'+ card'
|
|
72
|
+
>>> w[ ('banana', 'apple', 'mango') ]
|
|
73
|
+
Traceback (most recent call last):
|
|
74
|
+
...
|
|
75
|
+
KeyError
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
return super().__getitem__(key)
|
|
79
|
+
except KeyError:
|
|
80
|
+
if key[:-1] in self.plus_keys:
|
|
81
|
+
return super().__getitem__(key[:-1] + ("+",))
|
|
82
|
+
for _key in [key[:-i] for i in range(1, len(key) + 1)]:
|
|
83
|
+
if _key in self.star_keys:
|
|
84
|
+
return super().__getitem__(_key + ("*",))
|
|
85
|
+
raise KeyError
|
|
86
|
+
|
|
87
|
+
def __setitem__(self, key, value):
|
|
88
|
+
"""Set the item for a given key (path)."""
|
|
89
|
+
super().__setitem__(key, value)
|
|
90
|
+
|
|
91
|
+
if key[-1] == "+":
|
|
92
|
+
self.plus_keys.add(key[:-1])
|
|
93
|
+
if key[-1] == "*":
|
|
94
|
+
self.star_keys.add(key[:-1])
|
|
95
|
+
|
|
96
|
+
def query_path(self, key):
|
|
97
|
+
"""Return the key (path) that matches the queried key.
|
|
98
|
+
|
|
99
|
+
>>> w = WildcardDict({('foo', '*'): 'banana'})
|
|
100
|
+
>>> w.query_path(('foo', 'bar', 'baz'))
|
|
101
|
+
('foo', '*')
|
|
102
|
+
"""
|
|
103
|
+
if key in self:
|
|
104
|
+
return key
|
|
105
|
+
if key[:-1] in self.plus_keys:
|
|
106
|
+
return key[:-1] + ("+",)
|
|
107
|
+
for _key in [key[:-i] for i in range(1, len(key) + 1)]:
|
|
108
|
+
if _key in self.star_keys:
|
|
109
|
+
return _key + ("*",)
|
|
110
|
+
|
|
111
|
+
raise KeyError
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class PathLimit:
|
|
115
|
+
"""Class to limit recursion depth during the dictdiffer.diff execution."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, path_limits=[], final_key=None):
|
|
118
|
+
"""Initialize a dictionary structure to determine a path limit.
|
|
119
|
+
|
|
120
|
+
:param path_limits: list of keys (tuples) determining the path limits
|
|
121
|
+
:param final_key: the key used in the dictionary to determin if the
|
|
122
|
+
path is final
|
|
123
|
+
|
|
124
|
+
>>> pl = PathLimit( [('foo', 'bar')] , final_key='!@#$%FINAL')
|
|
125
|
+
>>> pl.dict
|
|
126
|
+
{'foo': {'bar': {'!@#$%FINAL': True}}}
|
|
127
|
+
"""
|
|
128
|
+
self.final_key = final_key if final_key else "!@#$FINAL"
|
|
129
|
+
self.dict = {}
|
|
130
|
+
for key_path in path_limits:
|
|
131
|
+
containing = self.dict
|
|
132
|
+
for key in key_path:
|
|
133
|
+
try:
|
|
134
|
+
containing = containing[key]
|
|
135
|
+
except KeyError:
|
|
136
|
+
containing[key] = {}
|
|
137
|
+
containing = containing[key]
|
|
138
|
+
|
|
139
|
+
containing[self.final_key] = True
|
|
140
|
+
|
|
141
|
+
def path_is_limit(self, key_path):
|
|
142
|
+
"""Query the PathLimit object if the given key_path is a limit.
|
|
143
|
+
|
|
144
|
+
>>> pl = PathLimit( [('foo', 'bar')] , final_key='!@#$%FINAL')
|
|
145
|
+
>>> pl.path_is_limit( ('foo', 'bar') )
|
|
146
|
+
True
|
|
147
|
+
"""
|
|
148
|
+
containing = self.dict
|
|
149
|
+
for key in key_path:
|
|
150
|
+
try:
|
|
151
|
+
containing = containing[key]
|
|
152
|
+
except KeyError:
|
|
153
|
+
try:
|
|
154
|
+
containing = containing["*"]
|
|
155
|
+
except KeyError:
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
return containing.get(self.final_key, False)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def create_dotted_node(node):
|
|
162
|
+
"""Create the *dotted node* notation for the dictdiffer.diff patches.
|
|
163
|
+
|
|
164
|
+
>>> create_dotted_node( ['foo', 'bar', 'baz'] )
|
|
165
|
+
'foo.bar.baz'
|
|
166
|
+
"""
|
|
167
|
+
if all(map(lambda x: isinstance(x, str), node)):
|
|
168
|
+
return ".".join(node)
|
|
169
|
+
else:
|
|
170
|
+
return list(node)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def get_path(patch):
|
|
174
|
+
"""Return the path for a given dictdiffer.diff patch."""
|
|
175
|
+
if patch[1] != "":
|
|
176
|
+
keys = patch[1].split(".") if isinstance(patch[1], str) else patch[1]
|
|
177
|
+
else:
|
|
178
|
+
keys = []
|
|
179
|
+
keys = keys + [patch[2][0][0]] if patch[0] != "change" else keys
|
|
180
|
+
return tuple(keys)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def is_super_path(path1, path2):
|
|
184
|
+
"""Check if one path is the super path of the other.
|
|
185
|
+
|
|
186
|
+
Super path means, that the n values in tuple are equal to the first n of m
|
|
187
|
+
vales in tuple b.
|
|
188
|
+
|
|
189
|
+
>>> is_super_path( ('foo', 'bar'), ('foo', 'bar') )
|
|
190
|
+
True
|
|
191
|
+
|
|
192
|
+
>>> is_super_path( ('foo', 'bar'), ('foo', 'bar', 'baz') )
|
|
193
|
+
True
|
|
194
|
+
|
|
195
|
+
>>> is_super_path( ('foo', 'bar'), ('foo', 'apple', 'banana') )
|
|
196
|
+
False
|
|
197
|
+
"""
|
|
198
|
+
return all(map(lambda x: x[0] == x[1] or x[0] is None, zip_longest(path1, path2)))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def nested_hash(obj):
|
|
202
|
+
"""Create a hash of nested, mutable data structures.
|
|
203
|
+
|
|
204
|
+
It shall be noted, that the uniqeness of those hashes in general cases is
|
|
205
|
+
not assured but it should be enough for the cases occurring during the
|
|
206
|
+
merging process.
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
return hash(obj)
|
|
210
|
+
except TypeError:
|
|
211
|
+
if isinstance(obj, (list, tuple)):
|
|
212
|
+
return hash(tuple(map(nested_hash, obj)))
|
|
213
|
+
elif isinstance(obj, set):
|
|
214
|
+
return hash(tuple(map(nested_hash, sorted(obj))))
|
|
215
|
+
elif isinstance(obj, dict):
|
|
216
|
+
return hash(tuple(map(nested_hash, sorted(obj.items()))))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def dot_lookup(source, lookup, parent=False):
|
|
220
|
+
"""Allow you to reach dictionary items with string or list lookup.
|
|
221
|
+
|
|
222
|
+
Recursively find value by lookup key split by '.'.
|
|
223
|
+
|
|
224
|
+
>>> from dictdiffer.utils import dot_lookup
|
|
225
|
+
>>> dot_lookup({'a': {'b': 'hello'}}, 'a.b')
|
|
226
|
+
'hello'
|
|
227
|
+
|
|
228
|
+
If parent argument is True, returns the parent node of matched
|
|
229
|
+
object.
|
|
230
|
+
|
|
231
|
+
>>> dot_lookup({'a': {'b': 'hello'}}, 'a.b', parent=True)
|
|
232
|
+
{'b': 'hello'}
|
|
233
|
+
|
|
234
|
+
If node is empty value, returns the whole dictionary object.
|
|
235
|
+
|
|
236
|
+
>>> dot_lookup({'a': {'b': 'hello'}}, '')
|
|
237
|
+
{'a': {'b': 'hello'}}
|
|
238
|
+
|
|
239
|
+
"""
|
|
240
|
+
if lookup is None or lookup == "" or lookup == []:
|
|
241
|
+
return source
|
|
242
|
+
|
|
243
|
+
value = source
|
|
244
|
+
if isinstance(lookup, str):
|
|
245
|
+
keys = lookup.split(".")
|
|
246
|
+
elif isinstance(lookup, list):
|
|
247
|
+
keys = lookup
|
|
248
|
+
else:
|
|
249
|
+
raise TypeError("lookup must be string or list")
|
|
250
|
+
|
|
251
|
+
if parent:
|
|
252
|
+
keys = keys[:-1]
|
|
253
|
+
|
|
254
|
+
for key in keys:
|
|
255
|
+
if isinstance(value, list):
|
|
256
|
+
key = int(key)
|
|
257
|
+
value = value[key]
|
|
258
|
+
return value
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def are_different(first, second, tolerance, absolute_tolerance=None):
|
|
262
|
+
"""Check if 2 values are different.
|
|
263
|
+
|
|
264
|
+
In case of numerical values, the tolerance is used to check if the values
|
|
265
|
+
are different.
|
|
266
|
+
In all other cases, the difference is straight forward.
|
|
267
|
+
"""
|
|
268
|
+
if first == second:
|
|
269
|
+
# values are same - simple case
|
|
270
|
+
return False
|
|
271
|
+
|
|
272
|
+
first_is_nan, second_is_nan = bool(first != first), bool(second != second)
|
|
273
|
+
|
|
274
|
+
if first_is_nan or second_is_nan:
|
|
275
|
+
# two 'NaN' values are not different (see issue #114)
|
|
276
|
+
return not (first_is_nan and second_is_nan)
|
|
277
|
+
elif isinstance(first, (int, float)) and isinstance(second, (int, float)):
|
|
278
|
+
# two numerical values are compared with tolerance
|
|
279
|
+
return not isclose(
|
|
280
|
+
first,
|
|
281
|
+
second,
|
|
282
|
+
rel_tol=tolerance or 0,
|
|
283
|
+
abs_tol=absolute_tolerance or 0,
|
|
284
|
+
)
|
|
285
|
+
# we got different values
|
|
286
|
+
return True
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def strip_removed_objects(obj):
|
|
290
|
+
"""Strip all the values that are instances of ``RemovedObject``."""
|
|
291
|
+
if isinstance(obj, LIST_TYPES):
|
|
292
|
+
return [
|
|
293
|
+
strip_removed_objects(value)
|
|
294
|
+
for value in obj
|
|
295
|
+
if not isinstance(value, RemovedObject)
|
|
296
|
+
]
|
|
297
|
+
if isinstance(obj, DICT_TYPES):
|
|
298
|
+
new_dict = {}
|
|
299
|
+
for key, value in obj.items():
|
|
300
|
+
if isinstance(value, RemovedObject):
|
|
301
|
+
continue
|
|
302
|
+
clean_value = strip_removed_objects(value)
|
|
303
|
+
new_dict[key] = clean_value
|
|
304
|
+
return new_dict
|
|
305
|
+
return obj
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def isclose(a, b, rel_tol=1e-09, abs_tol=0.0):
|
|
309
|
+
"""Verify if two values are close to each other."""
|
|
310
|
+
return abs(a - b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pl-dict-diff
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Dictdiffer is a library that helps you to diff and patch dictionaries.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: Invenio Collaboration
|
|
8
|
+
Author-email: info@inveniosoftware.org
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Provides-Extra: all
|
|
20
|
+
Provides-Extra: numpy
|
|
21
|
+
Provides-Extra: tests
|
|
22
|
+
Requires-Dist: numpy (>=2.4.6,<3) ; extra == "all"
|
|
23
|
+
Requires-Dist: numpy (>=2.4.6,<3) ; extra == "numpy"
|
|
24
|
+
Requires-Dist: pytest (>=9.1.1,<10) ; extra == "all"
|
|
25
|
+
Requires-Dist: pytest (>=9.1.1,<10) ; extra == "tests"
|
|
26
|
+
Project-URL: Homepage, https://github.com/inspirehep/dictdiffer
|
|
27
|
+
Project-URL: Repository, https://github.com/inspirehep/dictdiffer
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Dictdiffer
|
|
32
|
+
Dictdiffer is a helper module that helps you to diff and patch dictionaries.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
Requires Python 3.11 or newer.
|
|
36
|
+
|
|
37
|
+
Install using pip
|
|
38
|
+
```bash
|
|
39
|
+
pip install git+https://github.com/inspirehep/dictdiffer.git
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
Let's start with an example on how to find the diff between two dictionaries using :func:`.diff` method:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from inspire_dictdiffer import diff, patch, swap, revert
|
|
47
|
+
|
|
48
|
+
first = {
|
|
49
|
+
"title": "hello",
|
|
50
|
+
"fork_count": 20,
|
|
51
|
+
"stargazers": ["/users/20", "/users/30"],
|
|
52
|
+
"settings": {
|
|
53
|
+
"assignees": [100, 101, 201],
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
second = {
|
|
58
|
+
"title": "hellooo",
|
|
59
|
+
"fork_count": 20,
|
|
60
|
+
"stargazers": ["/users/20", "/users/30", "/users/40"],
|
|
61
|
+
"settings": {
|
|
62
|
+
"assignees": [100, 101, 202],
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
result = diff(first, second)
|
|
67
|
+
|
|
68
|
+
assert list(result) == [
|
|
69
|
+
('change', ['settings', 'assignees', 2], (201, 202)),
|
|
70
|
+
('add', 'stargazers', [(2, '/users/40')]),
|
|
71
|
+
('change', 'title', ('hello', 'hellooo'))]
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Now we can apply the diff result with `.patch()` method:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
result = diff(first, second)
|
|
79
|
+
patched = patch(result, first)
|
|
80
|
+
|
|
81
|
+
assert patched == second
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Also we can swap the diff result with `.swap()` method:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
result = diff(first, second)
|
|
88
|
+
swapped = swap(result)
|
|
89
|
+
|
|
90
|
+
assert list(swapped) == [
|
|
91
|
+
('change', ['settings', 'assignees', 2], (202, 201)),
|
|
92
|
+
('remove', 'stargazers', [(2, '/users/40')]),
|
|
93
|
+
('change', 'title', ('hellooo', 'hello'))]
|
|
94
|
+
```
|
|
95
|
+
Let's revert the last changes:
|
|
96
|
+
```python
|
|
97
|
+
result = diff(first, second)
|
|
98
|
+
reverted = revert(result, patched)
|
|
99
|
+
assert reverted == first
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
A tolerance can be used to consider closed values as equal.
|
|
103
|
+
The tolerance parameter only applies for int and float.
|
|
104
|
+
Let's try with a tolerance of 10% with the values 10 and 10.5:
|
|
105
|
+
```python
|
|
106
|
+
first = {'a': 10.0}
|
|
107
|
+
second = {'a': 10.5}
|
|
108
|
+
result = diff(first, second, tolerance=0.1)
|
|
109
|
+
|
|
110
|
+
assert list(result) == []
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Now with a tolerance of 1%:
|
|
114
|
+
```python
|
|
115
|
+
result = diff(first, second, tolerance=0.01)
|
|
116
|
+
|
|
117
|
+
assert list(result) == ('change', 'a', (10.0, 10.5))
|
|
118
|
+
```
|
|
119
|
+
## Testing
|
|
120
|
+
Running the test suite is as simple as:
|
|
121
|
+
```bash
|
|
122
|
+
poetry install --with dev --extras numpy
|
|
123
|
+
poetry run pytest
|
|
124
|
+
```
|
|
125
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
inspire_dictdiffer/__init__.py,sha256=5ML-eb_IdJVR3JPQ0cbw8mqJlUsyi0XnIoR-EZQczpw,15170
|
|
2
|
+
inspire_dictdiffer/conflict.py,sha256=q9Ia9yNMo6_IBIiy62q8gJmEbP1bVVabUDandVMBgwM,2401
|
|
3
|
+
inspire_dictdiffer/merge.py,sha256=04CaFpKJj4tM3j_NaYNoutZYdAX-IRuOf7kKMGZ3Ol0,5119
|
|
4
|
+
inspire_dictdiffer/resolve.py,sha256=WRT5uaHbsz6dejTCZMvqwdaXRFg33VY-oUlhuUmuaoM,5373
|
|
5
|
+
inspire_dictdiffer/testing.py,sha256=tOyY-8uu-sZ-do-mD8w7PZv-ksObohpnEsrELrG637o,1122
|
|
6
|
+
inspire_dictdiffer/unify.py,sha256=l39HpLoI8aYv6LG62Gp39vdp6U1GvxeSvr7clZwscJE,1777
|
|
7
|
+
inspire_dictdiffer/utils.py,sha256=Ia2R33-OUpsBl1yC9IDEIgYk7k_k_BYJ2ChEhJAgUoc,9562
|
|
8
|
+
inspire_dictdiffer/version.py,sha256=gnhfK7jNsnB4dCOPVrnkLhkLHWUM6RZcGykwbNCvlHI,146
|
|
9
|
+
pl_dict_diff-0.0.1.dist-info/licenses/LICENSE,sha256=H54Fs7mm-tJMxnRVV4vN_T6JsJolxFNppSVCA7vivdM,1398
|
|
10
|
+
pl_dict_diff-0.0.1.dist-info/METADATA,sha256=q4B2_ajXRONIYzbymi4RICy7KIrD6VtXspxmI3czwcA,3244
|
|
11
|
+
pl_dict_diff-0.0.1.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
|
|
12
|
+
pl_dict_diff-0.0.1.dist-info/RECORD,,
|