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
« prev ^ index » next coverage.py v7.8.2, created at 2025-10-01 16:40 +0200
1from navdict.navdict import NavDict
4class ChangeTracker:
5 """Tracks whether any changes have been made"""
7 def __init__(self):
8 self._changed = False
10 def mark_changed(self):
11 self._changed = True
13 def reset(self):
14 self._changed = False
16 @property
17 def changed(self):
18 return self._changed
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 """
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 }
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__)
71 def __getattr__(self, name):
72 """Intercept attribute access"""
73 # Get the actual attribute from the wrapped object
74 attr = getattr(self._obj, name)
76 # If it's a method, wrap it
77 if callable(attr):
78 return self._wrap_method(name, attr)
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)
84 return attr
86 def _wrap_method(self, method_name, method):
87 """Wrap a method to track changes"""
89 def wrapper(*args, **kwargs):
90 # Call the actual method
91 result = method(*args, **kwargs)
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()
98 # If the result is mutable, wrap it too
99 if isinstance(result, (list, dict, set)):
100 return MutableProxy(result, self._tracker)
102 return result
104 return wrapper
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()
115 def __setitem__(self, key, value):
116 """Handle obj[key] = value"""
117 self._obj[key] = value
118 self._tracker.mark_changed()
120 def __delitem__(self, key):
121 """Handle del obj[key]"""
122 del self._obj[key]
123 self._tracker.mark_changed()
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
133 def __repr__(self):
134 return f"MutableProxy({self._obj!r})"
136 def __str__(self):
137 return str(self._obj)
139 def __len__(self):
140 return len(self._obj)
142 def __iter__(self):
143 return iter(self._obj)
145 def unwrap(self):
146 """Get the original object"""
147 return self._obj
150class ChangeTrackingDict(NavDict):
151 """
152 A dictionary that tracks changes to itself and any mutable values
153 stored within it (including nested structures).
154 """
156 def __init__(self, *args, **kwargs):
157 super().__init__(*args, **kwargs)
158 object.__setattr__(self, "_tracker", ChangeTracker())
160 def __setitem__(self, key, value):
161 super().__setitem__(key, value)
162 self._tracker.mark_changed()
164 def __delitem__(self, key):
165 super().__delitem__(key)
166 self._tracker.mark_changed()
168 def __getitem__(self, key):
169 value = super().__getitem__(key)
171 # Wrap mutable values in a proxy
172 if isinstance(value, (list, dict, set)):
173 return MutableProxy(value, self._tracker)
175 return value
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
183 @property
184 def changed(self):
185 """Check if the dictionary or any of its values have changed"""
186 return self._tracker.changed
188 def reset_tracking(self):
189 """Reset the change tracking flag"""
190 self._tracker.reset()
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
198 def popitem(self):
199 result = super().popitem()
200 self._tracker.mark_changed()
201 return result
203 def clear(self):
204 super().clear()
205 self._tracker.mark_changed()
207 def update(self, *args, **kwargs):
208 super().update(*args, **kwargs)
209 self._tracker.mark_changed()
211 def setdefault(self, key, default=None):
212 result = super().setdefault(key, default)
213 self._tracker.mark_changed()
214 return result
217# ============================================================================
218# EXAMPLES AND TESTS
219# ============================================================================
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)
226 # Example 1: Basic usage
227 print("\n1. Basic dictionary operations:")
228 d = ChangeTrackingDict()
229 print(f" Initial changed status: {d.changed}")
231 d["name"] = "Alice"
232 print(f" After d['name'] = 'Alice': {d.changed}")
234 d.reset_tracking()
235 print(f" After reset: {d.changed}")
237 # Example 2: List mutations
238 print("\n2. Tracking list mutations:")
239 d = ChangeTrackingDict()
240 d["items"] = [1, 2, 3]
241 d.reset_tracking()
243 print(f" Initial: {d['items']}, changed: {d.changed}")
245 d["items"].append(4)
246 print(f" After append(4): {d['items']}, changed: {d.changed}")
248 d.reset_tracking()
249 d["items"].extend([5, 6])
250 print(f" After extend([5,6]): {d['items']}, changed: {d.changed}")
252 d.reset_tracking()
253 d["items"][0] = 999
254 print(f" After [0] = 999: {d['items']}, changed: {d.changed}")
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()
262 print(f" Initial changed: {d.changed}")
264 d["data"]["users"].append({"name": "Charlie"})
265 print(f" After appending to nested list: {d.changed}")
267 d.reset_tracking()
268 d["data"]["count"] = 3
269 print(f" After adding to nested dict: {d.changed}")
271 d.reset_tracking()
272 d["data"]["users"][1]["name"] = "Bill"
273 print(f" After changing name in 'users' list: {d.changed}")
275 # Example 4: Set operations
276 print("\n4. Tracking set mutations:")
277 d = ChangeTrackingDict()
278 d["tags"] = {"python", "programming"}
279 d.reset_tracking()
281 print(f" Initial: {d['tags']}, changed: {d.changed}")
283 d["tags"].add("coding")
284 print(f" After add('coding'): {d['tags']}, changed: {d.changed}")
286 d.reset_tracking()
287 d["tags"].remove("coding")
288 print(f" After remove('coding'): {d['tags']}, changed: {d.changed}")
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()
296 print(f" Initial changed: {d.changed}")
298 length = len(d["items"])
299 print(f" After len(): changed = {d.changed}")
301 for item in d["items"]:
302 pass
303 print(f" After iteration: changed = {d.changed}")
305 contains = 3 in d["items"]
306 print(f" After 'in' check: changed = {d.changed}")
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()
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']}")