Coverage for src/navdict/changed.py: 23%

169 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-10-01 16:40 +0200

1from navdict.navdict import NavDict 

2 

3 

4class ChangeTracker: 

5 """Tracks whether any changes have been made""" 

6 

7 def __init__(self): 

8 self._changed = False 

9 

10 def mark_changed(self): 

11 self._changed = True 

12 

13 def reset(self): 

14 self._changed = False 

15 

16 @property 

17 def changed(self): 

18 return self._changed 

19 

20 

21class MutableProxy: 

22 """ 

23 A proxy that wraps mutable objects (lists, dicts, sets, etc.) and 

24 tracks when any mutating operation is performed on them. 

25 """ 

26 

27 # Methods that mutate the object (not exhaustive, but covers common cases) 

28 MUTATING_METHODS = { 

29 "list": { 

30 "append", 

31 "extend", 

32 "insert", 

33 "remove", 

34 "pop", 

35 "clear", 

36 "sort", 

37 "reverse", 

38 "__setitem__", 

39 "__delitem__", 

40 "__iadd__", 

41 }, 

42 "dict": {"__setitem__", "__delitem__", "pop", "popitem", "clear", "update", "setdefault"}, 

43 "set": { 

44 "add", 

45 "remove", 

46 "discard", 

47 "pop", 

48 "clear", 

49 "update", 

50 "intersection_update", 

51 "difference_update", 

52 "symmetric_difference_update", 

53 "__ior__", 

54 "__iand__", 

55 "__ixor__", 

56 "__isub__", 

57 }, 

58 } 

59 

60 def __init__(self, obj, tracker): 

61 """ 

62 Args: 

63 obj: The object to wrap (list, dict, set, etc.) 

64 tracker: A ChangeTracker instance to notify on changes 

65 """ 

66 # Use object.__setattr__ to avoid triggering our own __setattr__ 

67 object.__setattr__(self, "_obj", obj) 

68 object.__setattr__(self, "_tracker", tracker) 

69 object.__setattr__(self, "_obj_type", type(obj).__name__) 

70 

71 def __getattr__(self, name): 

72 """Intercept attribute access""" 

73 # Get the actual attribute from the wrapped object 

74 attr = getattr(self._obj, name) 

75 

76 # If it's a method, wrap it 

77 if callable(attr): 

78 return self._wrap_method(name, attr) 

79 

80 # If it's a mutable object, wrap it too (for nested structures) 

81 if isinstance(attr, (list, dict, set)): 

82 return MutableProxy(attr, self._tracker) 

83 

84 return attr 

85 

86 def _wrap_method(self, method_name, method): 

87 """Wrap a method to track changes""" 

88 

89 def wrapper(*args, **kwargs): 

90 # Call the actual method 

91 result = method(*args, **kwargs) 

92 

93 # Check if this method mutates the object 

94 mutating_methods = self.MUTATING_METHODS.get(self._obj_type, set()) 

95 if method_name in mutating_methods: 

96 self._tracker.mark_changed() 

97 

98 # If the result is mutable, wrap it too 

99 if isinstance(result, (list, dict, set)): 

100 return MutableProxy(result, self._tracker) 

101 

102 return result 

103 

104 return wrapper 

105 

106 def __setattr__(self, name, value): 

107 """Intercept attribute setting""" 

108 if name.startswith("_"): 

109 # Internal attributes 

110 object.__setattr__(self, name, value) 

111 else: 

112 setattr(self._obj, name, value) 

113 self._tracker.mark_changed() 

114 

115 def __setitem__(self, key, value): 

116 """Handle obj[key] = value""" 

117 self._obj[key] = value 

118 self._tracker.mark_changed() 

119 

120 def __delitem__(self, key): 

121 """Handle del obj[key]""" 

122 del self._obj[key] 

123 self._tracker.mark_changed() 

124 

125 def __getitem__(self, key): 

126 """Handle obj[key]""" 

127 result = self._obj[key] 

128 # Wrap mutable results 

129 if isinstance(result, (list, dict, set)): 

130 return MutableProxy(result, self._tracker) 

131 return result 

132 

133 def __repr__(self): 

134 return f"MutableProxy({self._obj!r})" 

135 

136 def __str__(self): 

137 return str(self._obj) 

138 

139 def __len__(self): 

140 return len(self._obj) 

141 

142 def __iter__(self): 

143 return iter(self._obj) 

144 

145 def unwrap(self): 

146 """Get the original object""" 

147 return self._obj 

148 

149 

150class ChangeTrackingDict(NavDict): 

151 """ 

152 A dictionary that tracks changes to itself and any mutable values 

153 stored within it (including nested structures). 

154 """ 

155 

156 def __init__(self, *args, **kwargs): 

157 super().__init__(*args, **kwargs) 

158 object.__setattr__(self, "_tracker", ChangeTracker()) 

159 

160 def __setitem__(self, key, value): 

161 super().__setitem__(key, value) 

162 self._tracker.mark_changed() 

163 

164 def __delitem__(self, key): 

165 super().__delitem__(key) 

166 self._tracker.mark_changed() 

167 

168 def __getitem__(self, key): 

169 value = super().__getitem__(key) 

170 

171 # Wrap mutable values in a proxy 

172 if isinstance(value, (list, dict, set)): 

173 return MutableProxy(value, self._tracker) 

174 

175 return value 

176 

177 def get(self, key, default=None): 

178 """Override get() to also wrap mutable values""" 

179 if key in self: 

180 return self[key] 

181 return default 

182 

183 @property 

184 def changed(self): 

185 """Check if the dictionary or any of its values have changed""" 

186 return self._tracker.changed 

187 

188 def reset_tracking(self): 

189 """Reset the change tracking flag""" 

190 self._tracker.reset() 

191 

192 # Override other mutating methods 

193 def pop(self, *args, **kwargs): 

194 result = super().pop(*args, **kwargs) 

195 self._tracker.mark_changed() 

196 return result 

197 

198 def popitem(self): 

199 result = super().popitem() 

200 self._tracker.mark_changed() 

201 return result 

202 

203 def clear(self): 

204 super().clear() 

205 self._tracker.mark_changed() 

206 

207 def update(self, *args, **kwargs): 

208 super().update(*args, **kwargs) 

209 self._tracker.mark_changed() 

210 

211 def setdefault(self, key, default=None): 

212 result = super().setdefault(key, default) 

213 self._tracker.mark_changed() 

214 return result 

215 

216 

217# ============================================================================ 

218# EXAMPLES AND TESTS 

219# ============================================================================ 

220 

221if __name__ == "__main__": 221 ↛ 222line 221 didn't jump to line 222 because the condition on line 221 was never true

222 print("=" * 60) 

223 print("Change Tracking Dictionary with Proxy Pattern") 

224 print("=" * 60) 

225 

226 # Example 1: Basic usage 

227 print("\n1. Basic dictionary operations:") 

228 d = ChangeTrackingDict() 

229 print(f" Initial changed status: {d.changed}") 

230 

231 d["name"] = "Alice" 

232 print(f" After d['name'] = 'Alice': {d.changed}") 

233 

234 d.reset_tracking() 

235 print(f" After reset: {d.changed}") 

236 

237 # Example 2: List mutations 

238 print("\n2. Tracking list mutations:") 

239 d = ChangeTrackingDict() 

240 d["items"] = [1, 2, 3] 

241 d.reset_tracking() 

242 

243 print(f" Initial: {d['items']}, changed: {d.changed}") 

244 

245 d["items"].append(4) 

246 print(f" After append(4): {d['items']}, changed: {d.changed}") 

247 

248 d.reset_tracking() 

249 d["items"].extend([5, 6]) 

250 print(f" After extend([5,6]): {d['items']}, changed: {d.changed}") 

251 

252 d.reset_tracking() 

253 d["items"][0] = 999 

254 print(f" After [0] = 999: {d['items']}, changed: {d.changed}") 

255 

256 # Example 3: Nested structures 

257 print("\n3. Tracking nested structures:") 

258 d = ChangeTrackingDict() 

259 d["data"] = {"users": [{"name": "Alice"}, {"name": "Bob"}]} 

260 d.reset_tracking() 

261 

262 print(f" Initial changed: {d.changed}") 

263 

264 d["data"]["users"].append({"name": "Charlie"}) 

265 print(f" After appending to nested list: {d.changed}") 

266 

267 d.reset_tracking() 

268 d["data"]["count"] = 3 

269 print(f" After adding to nested dict: {d.changed}") 

270 

271 d.reset_tracking() 

272 d["data"]["users"][1]["name"] = "Bill" 

273 print(f" After changing name in 'users' list: {d.changed}") 

274 

275 # Example 4: Set operations 

276 print("\n4. Tracking set mutations:") 

277 d = ChangeTrackingDict() 

278 d["tags"] = {"python", "programming"} 

279 d.reset_tracking() 

280 

281 print(f" Initial: {d['tags']}, changed: {d.changed}") 

282 

283 d["tags"].add("coding") 

284 print(f" After add('coding'): {d['tags']}, changed: {d.changed}") 

285 

286 d.reset_tracking() 

287 d["tags"].remove("coding") 

288 print(f" After remove('coding'): {d['tags']}, changed: {d.changed}") 

289 

290 # Example 5: Non-mutating operations don't trigger changes 

291 print("\n5. Non-mutating operations:") 

292 d = ChangeTrackingDict() 

293 d["items"] = [1, 2, 3, 4, 5] 

294 d.reset_tracking() 

295 

296 print(f" Initial changed: {d.changed}") 

297 

298 length = len(d["items"]) 

299 print(f" After len(): changed = {d.changed}") 

300 

301 for item in d["items"]: 

302 pass 

303 print(f" After iteration: changed = {d.changed}") 

304 

305 contains = 3 in d["items"] 

306 print(f" After 'in' check: changed = {d.changed}") 

307 

308 # Example 6: Complex nested manipulation 

309 print("\n6. Complex nested example:") 

310 d = ChangeTrackingDict() 

311 d["config"] = {"settings": {"theme": "dark", "plugins": ["linter", "formatter"]}} 

312 d.reset_tracking() 

313 

314 print(f" Initial changed: {d.changed}") 

315 d["config"]["settings"]["plugins"].append("debugger") 

316 print(f" After deeply nested append: {d.changed}") 

317 print(f" Final plugins: {d['config']['settings']['plugins']}")