⬅ src/addict_tracking_changes/addict.py source

1 # This file is part of addict-tracking-changes
2 #
3 # Copyright (c) [2023] by Webworks, MonalLabs.
4 # This file is released under the MIT License.
5 #
6 # Author(s): Webworks, Monal Labs.
7  
8 # MIT License
9  
10 # Permission is hereby granted, free of charge, to any person obtaining a copy
11 # of this software and associated documentation files (the "Software"), to deal
12 # in the Software without restriction, including without limitation the rights
13 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14 # copies of the Software, and to permit persons to whom the Software is
15 # furnished to do so, subject to the following conditions:
16 #
17 # The above copyright notice and this permission notice shall be included in all
18 # copies or substantial portions of the Software.
19 #
20 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26 # SOFTWARE.
27  
28  
29 import copy
30  
31  
32 def isnamedtupleinstance(x):
33 t = type(x)
34 b = t.__bases__
35 if len(b) != 1 or b[0] != tuple:
36 return False
37 f = getattr(t, "_fields", None)
38 if not isinstance(f, tuple):
39 return False
40 return all(type(n) == str for n in f)
41  
42  
43 def get_changed_history_list(arritems, tprefix="", path_guards=None):
44 for idx, aitem in enumerate(arritems):
45 if isinstance(aitem, Dict):
46 yield from aitem.get_changed_history(
47 tprefix + f"/{idx}", path_guards=path_guards
48 )
49 elif isinstance(aitem, list):
50 yield from get_changed_history_list(
51 aitem, tprefix + f"/{idx}", path_guards=path_guards
52 )
53 else:
54 # list are by default tracked for changes
55 yield tprefix + f"/{idx}"
56  
57  
58 def clear_changed_history_list(arritems):
59 for _idx, aitem in enumerate(arritems):
60 if isinstance(aitem, Dict):
61 aitem.clear_changed_history()
62 elif isinstance(aitem, list):
63 clear_changed_history_list(aitem)
64 else:
65 pass
66  
67  
68 class Dict(dict):
69 def __init__(__self, *args, **kwargs):
70 object.__setattr__(__self, "__parent", kwargs.pop("__parent", None))
71 object.__setattr__(__self, "__key", kwargs.pop("__key", None))
72 object.__setattr__(__self, "__frozen", False)
73 object.__setattr__(
74 __self, "__track_changes", kwargs.pop("track_changes", False)
75 )
76 # to track __getattr__/__getitem chain of calls
77 object.__setattr__(__self, "__breadcrumb_parent_dict", None)
78 object.__setattr__(__self, "__breadcrumb_parent_dict_key", None)
79  
80 # __track_changes specifies tracking is on
81 if object.__getattribute__(__self, "__track_changes"):
82 object.__setattr__(__self, "__tracker", set())
83 for arg in args:
84 if not arg:
85 continue
86 elif isinstance(arg, dict):
87 for key, val in arg.items():
88 __self[key] = __self._hook(val)
89 elif isinstance(arg, tuple) and (not isinstance(arg[0], tuple)):
90 __self[arg[0]] = __self._hook(arg[1])
91 else:
92 for key, val in iter(arg):
93 __self[key] = __self._hook(val)
94  
95 for key, val in kwargs.items():
96 __self[key] = __self._hook(val)
97  
98 def __setattr__(self, name, value):
99 if hasattr(self.__class__, name):
100 raise AttributeError(
101 "'Dict' object attribute " "'{0}' is read-only".format(name)
102 )
103 else:
104 self[name] = value # this invokes __setitem__
105  
  • C901 'Dict.__setitem__' is too complex (11)
106 def __setitem__(self, name, value):
107 isFrozen = hasattr(self, "__frozen") and object.__getattribute__(
108 self, "__frozen"
109 )
110 if isFrozen and name not in super(Dict, self).keys():
111 raise KeyError(name)
112  
113 isTracked = False
114 try:
115 if object.__getattribute__(self, "__track_changes"):
116 object.__getattribute__(self, "__tracker").add((name))
117  
118 curr_dict = self
119  
120 while True:
121 if object.__getattribute__(curr_dict, "__breadcrumb_parent_dict"):
122 parent_dict = object.__getattribute__(
123 curr_dict, "__breadcrumb_parent_dict"
124 )
125 parent_key = object.__getattribute__(
126 curr_dict, "__breadcrumb_parent_dict_key"
127 )
128 if object.__getattribute__(parent_dict, "__track_changes"):
129 object.__getattribute__(parent_dict, "__tracker").add(
130 (parent_key)
131 )
132  
133 # erase the breadcrumbs
134 object.__setattr__(curr_dict, "__breadcrumb_parent_dict", None)
135 object.__setattr__(curr_dict, "__breadcrumb_parent_dict_key", None)
136 # visit the parent
137 curr_dict = parent_dict
138 else:
139 break
  • F841 Local variable 'e' is assigned to but never used
140 except AttributeError as e:
141 # skip the __track_changes not present attribute error.
142 # which happens when Dict object is pickled/unpickled.
143 pass
144  
145 super(Dict, self).__setitem__(name, value)
146 try:
147 p = object.__getattribute__(self, "__parent")
148 key = object.__getattribute__(self, "__key")
149 except AttributeError:
150 p = None
151 key = None
152 if p is not None:
153 p[key] = self
154 object.__delattr__(self, "__parent")
155 object.__delattr__(self, "__key")
156 return isTracked
157  
158 # may introduce in future if required
159 # def __add__(self, other):
160 # raise NotImplementedError("add operation not supported")
161  
162 @classmethod
163 def _hook(cls, item):
164 if isinstance(item, dict):
165 return cls(item)
166 elif isinstance(item, (list, tuple)) and not isnamedtupleinstance(item):
167 return type(item)(cls._hook(elem) for elem in item)
168 return item
169  
170 def __getattr__(self, item):
171 # _getattr is called when item is already present in the dict
172 # but it may not be part of the tracker; removed via clear_changed_history
173 child_item = super(Dict, self).__getitem__(item)
174 if isinstance(child_item, type(self)):
175 object.__setattr__(child_item, "__breadcrumb_parent_dict", self)
176 object.__setattr__(child_item, "__breadcrumb_parent_dict_key", item)
177  
178 return child_item
179  
180 def __getitem__(self, key):
181 if object.__getattribute__(self, "__track_changes"):
182 object.__getattribute__(self, "__tracker").add((key))
183 return self.__getattr__(key)
184  
185 def __missing__(self, name):
186 if object.__getattribute__(self, "__frozen"):
187 raise KeyError(name)
188 return self.__class__(
189 __parent=self,
190 __key=name,
191 track_changes=object.__getattribute__(self, "__track_changes"),
192 )
193  
194 def __delitem__(self, key):
195 if object.__getattribute__(self, "__track_changes"):
196 # key may have removed with clear_changed_history
197 if key in object.__getattribute__(self, "__tracker"):
198 object.__getattribute__(self, "__tracker").remove(key)
199 return super(Dict, self).__delitem__(key)
200  
201 # Phasing out pop for now
202 # use del mydict['key'] expression to delete
203 # def pop(self, key, default=None):
204 # """
205 # called when pop myd[a] is called
206 # """
207  
208 # try:
209 # if object.__getattribute__(self, "__track_changes"):
210 # object.__getattribute__(self, "__tracker").remove(key)
211 # except:
212 # logging.info("Fatal error: cannot remove item from tracker")
213 # pass
214 # return super(Dict, self).pop(key, default)
215  
216 def __delattr__(self, name):
217 del self[name]
218  
219 def to_dict(self):
220 base = {}
221 for key, value in self.items():
222 if isinstance(value, type(self)):
223 base[key] = value.to_dict()
224 elif isinstance(value, (list, tuple)):
225 base[key] = type(value)(
226 item.to_dict() if isinstance(item, type(self)) else item
227 for item in value
228 )
229 else:
230 base[key] = value
231 return base
232  
233 def __copy__(self):
234 raise NotImplementedError("Shallow copy is not supported for this class.")
235  
236 def __deepcopy__(self, memo):
237 other = self.__class__()
238 memo[id(self)] = other
239 for key, value in self.items():
240 other[copy.deepcopy(key, memo)] = copy.deepcopy(value, memo)
241 return other
242  
243 # ================================================================
244 # Not removed; not added yet feature. Might fold in
245 # if usage is clear
246  
247 # def update(self, *args, **kwargs):
248 # other = {}
249 # if args:
250 # if len(args) > 1:
251 # raise TypeError()
252 # other.update(args[0])
253 # other.update(kwargs)
254 # for k, v in other.items():
255 # if (
256 # (k not in self)
257 # or (not isinstance(self[k], dict))
258 # or (not isinstance(v, dict))
259 # ):
260 # self[k] = v
261 # else:
262 # self[k].update(v)
263  
264 # def __getnewargs__(self):
265 # return tuple(self.items())
266  
267 # def __getstate__(self):
268 # return self
269  
270 # def __setstate__(self, state):
271 # self.update(state)
272  
273 # def __or__(self, other):
274 # #print ("__or__")
275 # if not isinstance(other, (Dict, dict)):
276 # return NotImplemented
277 # new = Dict(self)
278 # new.update(other)
279 # return new
280  
281 # def __ror__(self, other):
282 # if not isinstance(other, (Dict, dict)):
283 # return NotImplemented
284 # new = Dict(other)
285 # new.update(self)
286 # return new
287  
288 # def __ior__(self, other):
289 # self.update(other)
290 # return self
291  
292 # def setdefault(self, key, default=None):
293 # if key in self:
294 # return self[key]
295 # else:
296 # self[key] = default
297 # return default
298  
299 # ============================== end =============================
300 def freeze(self, shouldFreeze=True):
301 object.__setattr__(self, "__frozen", shouldFreeze)
302 for _key, val in self.items():
303 if isinstance(val, Dict):
304 val.freeze(shouldFreeze)
305  
306 def unfreeze(self):
307 self.freeze(False)
308  
309 # as of now; don't see need for get
310 # however --> keeping the option open
311 # to be included if needed
312 # def get(self, key, default=None):
313 # #print(f"Getting key '{key}'")
314 # return super().get(key, default)
315  
316 def get_changed_history(self, prefix="", path_guards=None):
317 if super().__getattribute__("__track_changes") is False:
318 return
319  
320 for key, value in self.items():
321 if key in super().__getattribute__("__tracker"):
322 if isinstance(value, type(self)):
323 if not path_guards or prefix + "/" + str(key) not in path_guards:
324 yield from value.get_changed_history(
325 prefix + "/" + str(key), path_guards=path_guards
326 )
327  
328 elif isinstance(value, list):
329 yield from get_changed_history_list(
330 value, prefix + "/" + str(key), path_guards=path_guards
331 )
332 else:
333 yield prefix + "/" + key
334  
335 def clear_changed_history(self):
336 if super().__getattribute__("__track_changes") is False:
337 return
338 for _key, value in self.items():
339 if isinstance(value, type(self)):
340 value.clear_changed_history()
341 elif isinstance(value, list):
342 clear_changed_history_list(value)
343 super().__getattribute__("__tracker").clear()
344  
345 def set_tracker(self, track_changes=False):
346 """
347 pickle/unpickle forgets about trackers and frozenness
348 """
349 for _key, value in self.items():
350 if isinstance(value, type(self)):
351 value.set_tracker(self)
352 object.__setattr__(self, "__frozen", False)
353 object.__setattr__(self, "__track_changes", track_changes)
354 if track_changes:
355 object.__setattr__(self, "__tracker", set())
356  
357  
358 def walker(adict, ppath="", guards=None):
359 for key, value in adict.items():
360 if guards:
361 if f"{ppath}/{key}" in guards:
362 yield (f"{ppath}/{key}", value)
363 continue # stop at the guard
364 if isinstance(value, Dict):
365 yield from walker(value, ppath + f"/{key}", guards=guards)
366 else:
367 yield (f"{ppath}/{key}", value)
368 pass