patchdiff 0.3.6__py3-none-any.whl → 0.3.8__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.
- patchdiff/__init__.py +5 -2
- patchdiff/apply.py +2 -0
- patchdiff/diff.py +32 -25
- patchdiff/pointer.py +13 -14
- patchdiff/produce.py +660 -0
- {patchdiff-0.3.6.dist-info → patchdiff-0.3.8.dist-info}/METADATA +53 -2
- patchdiff-0.3.8.dist-info/RECORD +10 -0
- patchdiff-0.3.6.dist-info/RECORD +0 -9
- {patchdiff-0.3.6.dist-info → patchdiff-0.3.8.dist-info}/WHEEL +0 -0
patchdiff/__init__.py
CHANGED
patchdiff/apply.py
CHANGED
patchdiff/diff.py
CHANGED
|
@@ -136,36 +136,43 @@ def diff_lists(input: List, output: List, ptr: Pointer) -> Tuple[List, List]:
|
|
|
136
136
|
|
|
137
137
|
def diff_dicts(input: Dict, output: Dict, ptr: Pointer) -> Tuple[List, List]:
|
|
138
138
|
ops, rops = [], []
|
|
139
|
-
input_keys = set(input.keys())
|
|
140
|
-
output_keys = set(output.keys())
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
139
|
+
input_keys = set(input.keys()) if input else set()
|
|
140
|
+
output_keys = set(output.keys()) if output else set()
|
|
141
|
+
if input_only := input_keys - output_keys:
|
|
142
|
+
for key in input_only:
|
|
143
|
+
key_ptr = ptr.append(key)
|
|
144
|
+
ops.append({"op": "remove", "path": key_ptr})
|
|
145
|
+
rops.insert(0, {"op": "add", "path": key_ptr, "value": input[key]})
|
|
146
|
+
if output_only := output_keys - input_keys:
|
|
147
|
+
for key in output_only:
|
|
148
|
+
key_ptr = ptr.append(key)
|
|
149
|
+
ops.append(
|
|
150
|
+
{
|
|
151
|
+
"op": "add",
|
|
152
|
+
"path": key_ptr,
|
|
153
|
+
"value": output[key],
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
rops.insert(0, {"op": "remove", "path": key_ptr})
|
|
157
|
+
if common := input_keys & output_keys:
|
|
158
|
+
for key in common:
|
|
159
|
+
key_ops, key_rops = diff(input[key], output[key], ptr.append(key))
|
|
160
|
+
ops.extend(key_ops)
|
|
161
|
+
key_rops.extend(rops)
|
|
162
|
+
rops = key_rops
|
|
158
163
|
return ops, rops
|
|
159
164
|
|
|
160
165
|
|
|
161
166
|
def diff_sets(input: Set, output: Set, ptr: Pointer) -> Tuple[List, List]:
|
|
162
167
|
ops, rops = [], []
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
168
|
+
if input_only := input - output:
|
|
169
|
+
for value in input_only:
|
|
170
|
+
ops.append({"op": "remove", "path": ptr.append(value)})
|
|
171
|
+
rops.insert(0, {"op": "add", "path": ptr.append("-"), "value": value})
|
|
172
|
+
if output_only := output - input:
|
|
173
|
+
for value in output_only:
|
|
174
|
+
ops.append({"op": "add", "path": ptr.append("-"), "value": value})
|
|
175
|
+
rops.insert(0, {"op": "remove", "path": ptr.append(value)})
|
|
169
176
|
return ops, rops
|
|
170
177
|
|
|
171
178
|
|
patchdiff/pointer.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
|
-
from typing import Any, Hashable, Iterable
|
|
4
|
+
from typing import Any, Hashable, Iterable
|
|
5
5
|
|
|
6
6
|
from .types import Diffable
|
|
7
7
|
|
|
@@ -20,6 +20,8 @@ def escape(token: str) -> str:
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
class Pointer:
|
|
23
|
+
__slots__ = ("tokens",)
|
|
24
|
+
|
|
23
25
|
def __init__(self, tokens: Iterable[Hashable] | None = None) -> None:
|
|
24
26
|
if tokens is None:
|
|
25
27
|
tokens = []
|
|
@@ -39,26 +41,23 @@ class Pointer:
|
|
|
39
41
|
def __hash__(self) -> int:
|
|
40
42
|
return hash(self.tokens)
|
|
41
43
|
|
|
42
|
-
def __eq__(self, other:
|
|
43
|
-
if
|
|
44
|
+
def __eq__(self, other: Any) -> bool:
|
|
45
|
+
if other.__class__ != self.__class__:
|
|
44
46
|
return False
|
|
45
47
|
return self.tokens == other.tokens
|
|
46
48
|
|
|
47
|
-
def evaluate(self, obj: Diffable) ->
|
|
49
|
+
def evaluate(self, obj: Diffable) -> tuple[Diffable, Hashable, Any]:
|
|
48
50
|
key = ""
|
|
49
51
|
parent = None
|
|
50
52
|
cursor = obj
|
|
51
|
-
|
|
52
|
-
parent = cursor
|
|
53
|
-
if hasattr(parent, "add"): # set
|
|
54
|
-
break
|
|
55
|
-
if hasattr(parent, "append"): # list
|
|
56
|
-
if key == "-":
|
|
57
|
-
break
|
|
53
|
+
if tokens := self.tokens:
|
|
58
54
|
try:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
55
|
+
for key in tokens:
|
|
56
|
+
parent = cursor
|
|
57
|
+
cursor = parent[key]
|
|
58
|
+
except (KeyError, TypeError):
|
|
59
|
+
# KeyError for dicts, TypeError for sets and lists
|
|
60
|
+
pass
|
|
62
61
|
return parent, key, cursor
|
|
63
62
|
|
|
64
63
|
def append(self, token: Hashable) -> "Pointer":
|
patchdiff/produce.py
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
"""Proxy-based patch generation for tracking mutations in real-time.
|
|
2
|
+
|
|
3
|
+
This module provides an alternative to diffing by using proxy objects that
|
|
4
|
+
monitor mutations as they are being made and emit patches immediately.
|
|
5
|
+
This approach is inspired by Immer's proxy-based implementation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
from typing import Any, Callable, Dict, List, Set, Tuple, Union
|
|
12
|
+
|
|
13
|
+
from .pointer import Pointer
|
|
14
|
+
|
|
15
|
+
# Optional observ integration
|
|
16
|
+
try:
|
|
17
|
+
from observ import to_raw as observ_to_raw
|
|
18
|
+
except ImportError: # pragma: no cover
|
|
19
|
+
observ_to_raw = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _add_reader_methods(proxy_class, method_names):
|
|
23
|
+
"""Add simple pass-through reader methods to a proxy class.
|
|
24
|
+
|
|
25
|
+
These methods don't modify the object and just pass through to _data.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def _make_reader(name):
|
|
29
|
+
def reader(self, *args, **kwargs):
|
|
30
|
+
method = getattr(self._data, name)
|
|
31
|
+
return method(*args, **kwargs)
|
|
32
|
+
|
|
33
|
+
reader.__name__ = name
|
|
34
|
+
reader.__qualname__ = f"{proxy_class.__name__}.{name}"
|
|
35
|
+
return reader
|
|
36
|
+
|
|
37
|
+
for method_name in method_names:
|
|
38
|
+
setattr(proxy_class, method_name, _make_reader(method_name))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PatchRecorder:
|
|
42
|
+
"""Records patches as mutations happen on proxy objects."""
|
|
43
|
+
|
|
44
|
+
def __init__(self):
|
|
45
|
+
self.patches: List[Dict] = []
|
|
46
|
+
self.reverse_patches: List[Dict] = []
|
|
47
|
+
|
|
48
|
+
def record_add(
|
|
49
|
+
self, path: Pointer, value: Any, reverse_path: Pointer = None
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Record an add operation.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
path: The path for the add operation
|
|
55
|
+
value: The value being added
|
|
56
|
+
reverse_path: Optional path for the reverse (remove) operation.
|
|
57
|
+
If not provided, uses the same path. This is needed
|
|
58
|
+
for sets where add uses "/-" but remove needs "/value".
|
|
59
|
+
"""
|
|
60
|
+
self.patches.append({"op": "add", "path": path, "value": value})
|
|
61
|
+
self.reverse_patches.insert(
|
|
62
|
+
0, {"op": "remove", "path": reverse_path if reverse_path else path}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def record_remove(
|
|
66
|
+
self, path: Pointer, old_value: Any, reverse_path: Pointer | None = None
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Record a remove operation.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
path: The path where the item is being removed
|
|
72
|
+
old_value: The value being removed
|
|
73
|
+
reverse_path: Optional path for the reverse (add) operation.
|
|
74
|
+
If not provided, uses the same path. This is needed
|
|
75
|
+
for lists where remove uses "/index" but add needs "/-"
|
|
76
|
+
when removing from the end.
|
|
77
|
+
"""
|
|
78
|
+
self.patches.append({"op": "remove", "path": path})
|
|
79
|
+
self.reverse_patches.insert(
|
|
80
|
+
0,
|
|
81
|
+
{
|
|
82
|
+
"op": "add",
|
|
83
|
+
"path": reverse_path if reverse_path else path,
|
|
84
|
+
"value": old_value,
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def record_replace(self, path: Pointer, old_value: Any, new_value: Any) -> None:
|
|
89
|
+
"""Record a replace operation, but only if the value actually changed."""
|
|
90
|
+
if old_value == new_value:
|
|
91
|
+
return # Skip no-op replacements
|
|
92
|
+
self.patches.append({"op": "replace", "path": path, "value": new_value})
|
|
93
|
+
self.reverse_patches.insert(
|
|
94
|
+
0, {"op": "replace", "path": path, "value": old_value}
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class DictProxy:
|
|
99
|
+
"""Proxy for dict objects that tracks mutations and generates patches."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, data: Dict, recorder: PatchRecorder, path: Pointer):
|
|
102
|
+
self._data = data
|
|
103
|
+
self._recorder = recorder
|
|
104
|
+
self._path = path
|
|
105
|
+
self._proxies = {}
|
|
106
|
+
|
|
107
|
+
def _wrap(self, key: Any, value: Any) -> Any:
|
|
108
|
+
"""Wrap nested structures in proxies using duck typing."""
|
|
109
|
+
# Check cache first - it's faster than hasattr() calls
|
|
110
|
+
if key in self._proxies:
|
|
111
|
+
return self._proxies[key]
|
|
112
|
+
|
|
113
|
+
# Use duck typing to support observ reactive objects and other proxies
|
|
114
|
+
if hasattr(value, "keys"): # dict-like
|
|
115
|
+
proxy = DictProxy(value, self._recorder, self._path.append(key))
|
|
116
|
+
self._proxies[key] = proxy
|
|
117
|
+
return proxy
|
|
118
|
+
elif hasattr(value, "append"): # list-like
|
|
119
|
+
proxy = ListProxy(value, self._recorder, self._path.append(key))
|
|
120
|
+
self._proxies[key] = proxy
|
|
121
|
+
return proxy
|
|
122
|
+
elif hasattr(value, "add") and hasattr(value, "discard"): # set-like
|
|
123
|
+
proxy = SetProxy(value, self._recorder, self._path.append(key))
|
|
124
|
+
self._proxies[key] = proxy
|
|
125
|
+
return proxy
|
|
126
|
+
return value
|
|
127
|
+
|
|
128
|
+
def __getitem__(self, key: Any) -> Any:
|
|
129
|
+
value = self._data[key]
|
|
130
|
+
return self._wrap(key, value)
|
|
131
|
+
|
|
132
|
+
def __setitem__(self, key: Any, value: Any) -> None:
|
|
133
|
+
path = self._path.append(key)
|
|
134
|
+
if key in self._data:
|
|
135
|
+
old_value = self._data[key]
|
|
136
|
+
self._recorder.record_replace(path, old_value, value)
|
|
137
|
+
else:
|
|
138
|
+
self._recorder.record_add(path, value)
|
|
139
|
+
self._data[key] = value
|
|
140
|
+
# Invalidate proxy cache for this key
|
|
141
|
+
if key in self._proxies:
|
|
142
|
+
del self._proxies[key]
|
|
143
|
+
|
|
144
|
+
def __delitem__(self, key: Any) -> None:
|
|
145
|
+
old_value = self._data[key]
|
|
146
|
+
path = self._path.append(key)
|
|
147
|
+
self._recorder.record_remove(path, old_value)
|
|
148
|
+
del self._data[key]
|
|
149
|
+
# Invalidate proxy cache for this key
|
|
150
|
+
if key in self._proxies:
|
|
151
|
+
del self._proxies[key]
|
|
152
|
+
|
|
153
|
+
def get(self, key: Any, default=None):
|
|
154
|
+
if key in self._data:
|
|
155
|
+
return self[key]
|
|
156
|
+
return default
|
|
157
|
+
|
|
158
|
+
def pop(self, key: Any, default=None):
|
|
159
|
+
if key in self._data:
|
|
160
|
+
old_value = self._data[key]
|
|
161
|
+
path = self._path.append(key)
|
|
162
|
+
self._recorder.record_remove(path, old_value)
|
|
163
|
+
result = self._data.pop(key)
|
|
164
|
+
# Invalidate proxy cache for this key
|
|
165
|
+
if key in self._proxies:
|
|
166
|
+
del self._proxies[key]
|
|
167
|
+
return result
|
|
168
|
+
elif default:
|
|
169
|
+
return default
|
|
170
|
+
else:
|
|
171
|
+
raise KeyError(key)
|
|
172
|
+
|
|
173
|
+
def setdefault(self, key: Any, default=None):
|
|
174
|
+
if key not in self._data:
|
|
175
|
+
self[key] = default
|
|
176
|
+
return default
|
|
177
|
+
return self[key]
|
|
178
|
+
|
|
179
|
+
def update(self, *args, **kwargs):
|
|
180
|
+
# Collect all key-value pairs to update
|
|
181
|
+
items = []
|
|
182
|
+
if args:
|
|
183
|
+
other = args[0]
|
|
184
|
+
if hasattr(other, "items"):
|
|
185
|
+
items.extend(other.items())
|
|
186
|
+
else:
|
|
187
|
+
items.extend(other)
|
|
188
|
+
items.extend(kwargs.items())
|
|
189
|
+
|
|
190
|
+
# Generate patches and update data
|
|
191
|
+
for key, value in items:
|
|
192
|
+
path = self._path.append(key)
|
|
193
|
+
if key in self._data:
|
|
194
|
+
old_value = self._data[key]
|
|
195
|
+
self._recorder.record_replace(path, old_value, value)
|
|
196
|
+
else:
|
|
197
|
+
self._recorder.record_add(path, value)
|
|
198
|
+
self._data[key] = value
|
|
199
|
+
# Invalidate proxy cache for this key
|
|
200
|
+
if key in self._proxies:
|
|
201
|
+
del self._proxies[key]
|
|
202
|
+
|
|
203
|
+
def clear(self):
|
|
204
|
+
# Generate patches for all keys and clear data
|
|
205
|
+
for key, value in list(self._data.items()):
|
|
206
|
+
path = self._path.append(key)
|
|
207
|
+
self._recorder.record_remove(path, value)
|
|
208
|
+
self._data.clear()
|
|
209
|
+
self._proxies.clear()
|
|
210
|
+
|
|
211
|
+
def popitem(self):
|
|
212
|
+
key, value = self._data.popitem()
|
|
213
|
+
path = self._path.append(key)
|
|
214
|
+
self._recorder.record_remove(path, value)
|
|
215
|
+
# Invalidate proxy cache for this key
|
|
216
|
+
if key in self._proxies:
|
|
217
|
+
del self._proxies[key]
|
|
218
|
+
return key, value
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# Add simple reader methods to DictProxy
|
|
222
|
+
_add_reader_methods(
|
|
223
|
+
DictProxy,
|
|
224
|
+
[
|
|
225
|
+
"__len__",
|
|
226
|
+
"__contains__",
|
|
227
|
+
"__repr__",
|
|
228
|
+
"__iter__",
|
|
229
|
+
"__reversed__",
|
|
230
|
+
"keys",
|
|
231
|
+
"values",
|
|
232
|
+
"items",
|
|
233
|
+
"copy",
|
|
234
|
+
],
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class ListProxy:
|
|
239
|
+
"""Proxy for list objects that tracks mutations and generates patches."""
|
|
240
|
+
|
|
241
|
+
def __init__(self, data: List, recorder: PatchRecorder, path: Pointer):
|
|
242
|
+
self._data = data
|
|
243
|
+
self._recorder = recorder
|
|
244
|
+
self._path = path
|
|
245
|
+
self._proxies = {}
|
|
246
|
+
|
|
247
|
+
def _wrap(self, index: int, value: Any) -> Any:
|
|
248
|
+
"""Wrap nested structures in proxies using duck typing."""
|
|
249
|
+
# Check cache first - it's faster than hasattr() calls
|
|
250
|
+
if index in self._proxies:
|
|
251
|
+
return self._proxies[index]
|
|
252
|
+
|
|
253
|
+
# Use duck typing to support observ reactive objects and other proxies
|
|
254
|
+
if hasattr(value, "keys"): # dict-like
|
|
255
|
+
proxy = DictProxy(value, self._recorder, self._path.append(index))
|
|
256
|
+
self._proxies[index] = proxy
|
|
257
|
+
return proxy
|
|
258
|
+
elif hasattr(value, "append"): # list-like
|
|
259
|
+
proxy = ListProxy(value, self._recorder, self._path.append(index))
|
|
260
|
+
self._proxies[index] = proxy
|
|
261
|
+
return proxy
|
|
262
|
+
elif hasattr(value, "add") and hasattr(value, "discard"): # set-like
|
|
263
|
+
proxy = SetProxy(value, self._recorder, self._path.append(index))
|
|
264
|
+
self._proxies[index] = proxy
|
|
265
|
+
return proxy
|
|
266
|
+
return value
|
|
267
|
+
|
|
268
|
+
def __getitem__(self, index: Union[int, slice]) -> Any:
|
|
269
|
+
value = self._data[index]
|
|
270
|
+
if isinstance(index, slice):
|
|
271
|
+
# Wrap each element in the slice so nested mutations are tracked
|
|
272
|
+
start, stop, step = index.indices(len(self._data))
|
|
273
|
+
indices = range(start, stop, step)
|
|
274
|
+
return [self._wrap(i, self._data[i]) for i in indices]
|
|
275
|
+
# Resolve negative indices to positive for consistent caching and paths
|
|
276
|
+
if index < 0:
|
|
277
|
+
index = len(self._data) + index
|
|
278
|
+
return self._wrap(index, value)
|
|
279
|
+
|
|
280
|
+
def __setitem__(self, index: Union[int, slice], value: Any) -> None:
|
|
281
|
+
if isinstance(index, slice):
|
|
282
|
+
# Handle slice assignment with proper patch generation
|
|
283
|
+
start, stop, step = index.indices(len(self._data))
|
|
284
|
+
|
|
285
|
+
if step != 1:
|
|
286
|
+
# Step slices must have same length
|
|
287
|
+
old_values = self._data[index]
|
|
288
|
+
if len(old_values) != len(value):
|
|
289
|
+
raise ValueError(
|
|
290
|
+
f"attempt to assign sequence of size {len(value)} "
|
|
291
|
+
f"to extended slice of size {len(old_values)}"
|
|
292
|
+
)
|
|
293
|
+
# Replace each element in the stepped slice
|
|
294
|
+
for i, (idx, new_val) in enumerate(
|
|
295
|
+
zip(range(start, stop, step), value)
|
|
296
|
+
):
|
|
297
|
+
path = self._path.append(idx)
|
|
298
|
+
old_val = self._data[idx]
|
|
299
|
+
self._recorder.record_replace(path, old_val, new_val)
|
|
300
|
+
self._data[idx] = new_val
|
|
301
|
+
else:
|
|
302
|
+
# Contiguous slice - can change length
|
|
303
|
+
old_values = list(self._data[start:stop])
|
|
304
|
+
new_values = list(value)
|
|
305
|
+
|
|
306
|
+
# Perform the slice assignment
|
|
307
|
+
self._data[start:stop] = new_values
|
|
308
|
+
|
|
309
|
+
# Generate patches for the changes
|
|
310
|
+
old_len = len(old_values)
|
|
311
|
+
new_len = len(new_values)
|
|
312
|
+
|
|
313
|
+
# Replace common elements
|
|
314
|
+
for i in range(min(old_len, new_len)):
|
|
315
|
+
if old_values[i] != new_values[i]:
|
|
316
|
+
path = self._path.append(start + i)
|
|
317
|
+
self._recorder.record_replace(
|
|
318
|
+
path, old_values[i], new_values[i]
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Add new elements if new slice is longer
|
|
322
|
+
if new_len > old_len:
|
|
323
|
+
for i in range(old_len, new_len):
|
|
324
|
+
path = self._path.append(start + i)
|
|
325
|
+
self._recorder.record_add(path, new_values[i])
|
|
326
|
+
|
|
327
|
+
# Remove extra elements if new slice is shorter
|
|
328
|
+
elif new_len < old_len:
|
|
329
|
+
# Remove from end to start to maintain correct indices
|
|
330
|
+
for i in range(old_len - 1, new_len - 1, -1):
|
|
331
|
+
path = self._path.append(start + i)
|
|
332
|
+
self._recorder.record_remove(path, old_values[i])
|
|
333
|
+
|
|
334
|
+
# Invalidate all proxy caches as indices may have shifted
|
|
335
|
+
self._proxies.clear()
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
# Resolve negative indices to positive for correct paths
|
|
339
|
+
if index < 0:
|
|
340
|
+
index = len(self._data) + index
|
|
341
|
+
path = self._path.append(index)
|
|
342
|
+
old_value = self._data[index]
|
|
343
|
+
self._recorder.record_replace(path, old_value, value)
|
|
344
|
+
self._data[index] = value
|
|
345
|
+
# Invalidate proxy cache for this index
|
|
346
|
+
if index in self._proxies:
|
|
347
|
+
del self._proxies[index]
|
|
348
|
+
|
|
349
|
+
def __delitem__(self, index: Union[int, slice]) -> None:
|
|
350
|
+
if isinstance(index, slice):
|
|
351
|
+
# Handle slice deletion with proper patch generation
|
|
352
|
+
start, stop, step = index.indices(len(self._data))
|
|
353
|
+
|
|
354
|
+
if step != 1:
|
|
355
|
+
# For step slices, delete from end to start to maintain indices
|
|
356
|
+
indices = list(range(start, stop, step))
|
|
357
|
+
for idx in reversed(indices):
|
|
358
|
+
old_value = self._data[idx]
|
|
359
|
+
path = self._path.append(idx)
|
|
360
|
+
self._recorder.record_remove(path, old_value)
|
|
361
|
+
del self._data[idx]
|
|
362
|
+
else:
|
|
363
|
+
# Contiguous slice - delete from end to start
|
|
364
|
+
old_values = list(self._data[start:stop])
|
|
365
|
+
for i in range(len(old_values) - 1, -1, -1):
|
|
366
|
+
old_value = old_values[i]
|
|
367
|
+
path = self._path.append(start + i)
|
|
368
|
+
self._recorder.record_remove(path, old_value)
|
|
369
|
+
del self._data[start:stop]
|
|
370
|
+
|
|
371
|
+
# Invalidate all proxy caches as indices shifted
|
|
372
|
+
self._proxies.clear()
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
# Resolve negative indices to positive for correct paths
|
|
376
|
+
if index < 0:
|
|
377
|
+
index = len(self._data) + index
|
|
378
|
+
old_value = self._data[index]
|
|
379
|
+
path = self._path.append(index)
|
|
380
|
+
self._recorder.record_remove(path, old_value)
|
|
381
|
+
del self._data[index]
|
|
382
|
+
# Invalidate all proxy caches as indices shift
|
|
383
|
+
self._proxies.clear()
|
|
384
|
+
|
|
385
|
+
def append(self, value: Any) -> None:
|
|
386
|
+
# Forward patch uses "-" (append to end), reverse patch uses actual index
|
|
387
|
+
forward_path = self._path.append("-")
|
|
388
|
+
reverse_path = self._path.append(len(self._data))
|
|
389
|
+
self._recorder.record_add(forward_path, value, reverse_path)
|
|
390
|
+
self._data.append(value)
|
|
391
|
+
|
|
392
|
+
def insert(self, index: int, value: Any) -> None:
|
|
393
|
+
# Use the index for insertion
|
|
394
|
+
path = self._path.append(index)
|
|
395
|
+
self._recorder.record_add(path, value)
|
|
396
|
+
self._data.insert(index, value)
|
|
397
|
+
# Invalidate all proxy caches as indices shift
|
|
398
|
+
self._proxies.clear()
|
|
399
|
+
|
|
400
|
+
def pop(self, index: int = -1) -> Any:
|
|
401
|
+
if index < 0:
|
|
402
|
+
index = len(self._data) + index
|
|
403
|
+
old_value = self._data[index]
|
|
404
|
+
path = self._path.append(index)
|
|
405
|
+
# If popping from the end, the reverse (add) operation should use "-" to append
|
|
406
|
+
# rather than a specific index, since the index may not exist when reversing
|
|
407
|
+
is_last = index == len(self._data) - 1
|
|
408
|
+
reverse_path = self._path.append("-") if is_last else None
|
|
409
|
+
self._recorder.record_remove(path, old_value, reverse_path)
|
|
410
|
+
result = self._data.pop(index)
|
|
411
|
+
# Invalidate all proxy caches as indices shift
|
|
412
|
+
self._proxies.clear()
|
|
413
|
+
return result
|
|
414
|
+
|
|
415
|
+
def remove(self, value: Any) -> None:
|
|
416
|
+
index = self._data.index(value)
|
|
417
|
+
del self[index]
|
|
418
|
+
|
|
419
|
+
def clear(self) -> None:
|
|
420
|
+
# Generate patches for all elements (from end to start for correct indices)
|
|
421
|
+
# All reverse patches use "-" to append, since we're restoring to an empty list
|
|
422
|
+
reverse_path = self._path.append("-")
|
|
423
|
+
for i in range(len(self._data) - 1, -1, -1):
|
|
424
|
+
path = self._path.append(i)
|
|
425
|
+
self._recorder.record_remove(path, self._data[i], reverse_path)
|
|
426
|
+
self._data.clear()
|
|
427
|
+
self._proxies.clear()
|
|
428
|
+
|
|
429
|
+
def extend(self, values):
|
|
430
|
+
# Generate patches and extend data
|
|
431
|
+
values_list = list(values)
|
|
432
|
+
start_index = len(self._data)
|
|
433
|
+
for i, value in enumerate(values_list):
|
|
434
|
+
forward_path = self._path.append("-")
|
|
435
|
+
reverse_path = self._path.append(start_index + i)
|
|
436
|
+
self._recorder.record_add(forward_path, value, reverse_path)
|
|
437
|
+
self._data.extend(values_list)
|
|
438
|
+
|
|
439
|
+
def reverse(self) -> None:
|
|
440
|
+
"""Reverse the list in place and generate appropriate patches."""
|
|
441
|
+
n = len(self._data)
|
|
442
|
+
# Reverse the underlying data
|
|
443
|
+
self._data.reverse()
|
|
444
|
+
# Generate patches for each changed position
|
|
445
|
+
# After reverse, element at position i came from position n-1-i
|
|
446
|
+
for i in range(n):
|
|
447
|
+
old_value = self._data[n - 1 - i]
|
|
448
|
+
new_value = self._data[i]
|
|
449
|
+
if old_value != new_value:
|
|
450
|
+
path = self._path.append(i)
|
|
451
|
+
self._recorder.record_replace(path, old_value, new_value)
|
|
452
|
+
# Invalidate all proxy caches as positions changed
|
|
453
|
+
self._proxies.clear()
|
|
454
|
+
|
|
455
|
+
def sort(self, *args, **kwargs) -> None:
|
|
456
|
+
"""Sort the list in place and generate appropriate patches."""
|
|
457
|
+
# Record the old state
|
|
458
|
+
old_list = list(self._data)
|
|
459
|
+
# Sort the underlying data
|
|
460
|
+
self._data.sort(*args, **kwargs)
|
|
461
|
+
# Generate patches for each changed position
|
|
462
|
+
for i in range(len(self._data)):
|
|
463
|
+
if i < len(old_list) and old_list[i] != self._data[i]:
|
|
464
|
+
path = self._path.append(i)
|
|
465
|
+
self._recorder.record_replace(path, old_list[i], self._data[i])
|
|
466
|
+
# Invalidate all proxy caches as positions changed
|
|
467
|
+
self._proxies.clear()
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# Add simple reader methods to ListProxy
|
|
471
|
+
_add_reader_methods(
|
|
472
|
+
ListProxy,
|
|
473
|
+
[
|
|
474
|
+
"__len__",
|
|
475
|
+
"__contains__",
|
|
476
|
+
"__repr__",
|
|
477
|
+
"__iter__",
|
|
478
|
+
"__reversed__",
|
|
479
|
+
"index",
|
|
480
|
+
"count",
|
|
481
|
+
"copy",
|
|
482
|
+
],
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
class SetProxy:
|
|
487
|
+
"""Proxy for set objects that tracks mutations and generates patches."""
|
|
488
|
+
|
|
489
|
+
def __init__(self, data: Set, recorder: PatchRecorder, path: Pointer):
|
|
490
|
+
self._data = data
|
|
491
|
+
self._recorder = recorder
|
|
492
|
+
self._path = path
|
|
493
|
+
|
|
494
|
+
def add(self, value: Any) -> None:
|
|
495
|
+
if value not in self._data:
|
|
496
|
+
path = self._path.append("-")
|
|
497
|
+
reverse_path = self._path.append(value)
|
|
498
|
+
self._recorder.record_add(path, value, reverse_path)
|
|
499
|
+
self._data.add(value)
|
|
500
|
+
|
|
501
|
+
def remove(self, value: Any) -> None:
|
|
502
|
+
path = self._path.append(value)
|
|
503
|
+
self._recorder.record_remove(path, value)
|
|
504
|
+
self._data.remove(value)
|
|
505
|
+
|
|
506
|
+
def discard(self, value: Any) -> None:
|
|
507
|
+
if value in self._data:
|
|
508
|
+
path = self._path.append(value)
|
|
509
|
+
self._recorder.record_remove(path, value)
|
|
510
|
+
self._data.discard(value)
|
|
511
|
+
|
|
512
|
+
def pop(self) -> Any:
|
|
513
|
+
value = self._data.pop()
|
|
514
|
+
path = self._path.append(value)
|
|
515
|
+
self._recorder.record_remove(path, value)
|
|
516
|
+
return value
|
|
517
|
+
|
|
518
|
+
def clear(self) -> None:
|
|
519
|
+
# Generate patches for all values and clear data
|
|
520
|
+
for value in list(self._data):
|
|
521
|
+
path = self._path.append(value)
|
|
522
|
+
self._recorder.record_remove(path, value)
|
|
523
|
+
self._data.clear()
|
|
524
|
+
|
|
525
|
+
def update(self, *others):
|
|
526
|
+
# Generate patches and update data
|
|
527
|
+
for other in others:
|
|
528
|
+
for value in other:
|
|
529
|
+
if value not in self._data:
|
|
530
|
+
path = self._path.append("-")
|
|
531
|
+
reverse_path = self._path.append(value)
|
|
532
|
+
self._recorder.record_add(path, value, reverse_path)
|
|
533
|
+
self._data.add(value)
|
|
534
|
+
|
|
535
|
+
def __ior__(self, other):
|
|
536
|
+
"""Implement |= operator (union update)."""
|
|
537
|
+
for value in other:
|
|
538
|
+
self.add(value)
|
|
539
|
+
return self
|
|
540
|
+
|
|
541
|
+
def __iand__(self, other):
|
|
542
|
+
"""Implement &= operator (intersection update)."""
|
|
543
|
+
# Remove values not in other
|
|
544
|
+
values_to_remove = [v for v in self._data if v not in other]
|
|
545
|
+
for value in values_to_remove:
|
|
546
|
+
self.remove(value)
|
|
547
|
+
return self
|
|
548
|
+
|
|
549
|
+
def __isub__(self, other):
|
|
550
|
+
"""Implement -= operator (difference update)."""
|
|
551
|
+
# Remove values that are in other
|
|
552
|
+
for value in other:
|
|
553
|
+
if value in self._data:
|
|
554
|
+
self.remove(value)
|
|
555
|
+
return self
|
|
556
|
+
|
|
557
|
+
def __ixor__(self, other):
|
|
558
|
+
"""Implement ^= operator (symmetric difference update)."""
|
|
559
|
+
# Add values from other that aren't in self, remove values that are in both
|
|
560
|
+
for value in other:
|
|
561
|
+
if value in self._data:
|
|
562
|
+
self.remove(value)
|
|
563
|
+
else:
|
|
564
|
+
self.add(value)
|
|
565
|
+
return self
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
# Add simple reader methods to SetProxy
|
|
569
|
+
_add_reader_methods(
|
|
570
|
+
SetProxy,
|
|
571
|
+
[
|
|
572
|
+
"__len__",
|
|
573
|
+
"__contains__",
|
|
574
|
+
"__repr__",
|
|
575
|
+
"__iter__",
|
|
576
|
+
"union",
|
|
577
|
+
"intersection",
|
|
578
|
+
"difference",
|
|
579
|
+
"symmetric_difference",
|
|
580
|
+
"isdisjoint",
|
|
581
|
+
"issubset",
|
|
582
|
+
"issuperset",
|
|
583
|
+
"copy",
|
|
584
|
+
],
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def produce(
|
|
589
|
+
base: Any, recipe: Callable[[Any], None], in_place: bool = False
|
|
590
|
+
) -> Tuple[Any, List[Dict], List[Dict]]:
|
|
591
|
+
"""
|
|
592
|
+
Produce a new state by applying mutations, tracking patches along the way.
|
|
593
|
+
|
|
594
|
+
This is an alternative to the diff() function that uses proxy objects to
|
|
595
|
+
track mutations in real-time instead of comparing before/after snapshots.
|
|
596
|
+
|
|
597
|
+
Args:
|
|
598
|
+
base: The base object to mutate (dict, list, or set)
|
|
599
|
+
recipe: A function that receives a proxy-wrapped draft and mutates it
|
|
600
|
+
in_place: If True, mutate the original object directly (useful for
|
|
601
|
+
reactive objects like observ). If False (default), operate
|
|
602
|
+
on a deep copy and leave the original unchanged.
|
|
603
|
+
|
|
604
|
+
Returns:
|
|
605
|
+
A tuple of (result, patches, reverse_patches) where:
|
|
606
|
+
- result: The mutated object (same as base if in_place=True)
|
|
607
|
+
- patches: List of patches representing the mutations
|
|
608
|
+
- reverse_patches: List of patches to reverse the mutations
|
|
609
|
+
|
|
610
|
+
Example:
|
|
611
|
+
>>> base = {"count": 0, "items": []}
|
|
612
|
+
>>> def increment(draft):
|
|
613
|
+
... draft["count"] += 1
|
|
614
|
+
... draft["items"].append("new")
|
|
615
|
+
>>> result, patches, reverse = produce(base, increment)
|
|
616
|
+
>>> print(result)
|
|
617
|
+
{"count": 1, "items": ["new"]}
|
|
618
|
+
>>> print(patches)
|
|
619
|
+
[{"op": "replace", "path": "/count", "value": 1},
|
|
620
|
+
{"op": "add", "path": "/items/-", "value": "new"}]
|
|
621
|
+
|
|
622
|
+
Example with in_place=True for reactive objects:
|
|
623
|
+
>>> from observ import reactive
|
|
624
|
+
>>> state = reactive({"count": 0})
|
|
625
|
+
>>> result, patches, reverse = produce(state, lambda d: d.__setitem__("count", 5), in_place=True)
|
|
626
|
+
>>> # state["count"] is now 5, and watchers were triggered
|
|
627
|
+
"""
|
|
628
|
+
if in_place:
|
|
629
|
+
# Mutate the original object directly
|
|
630
|
+
# Don't unwrap or copy - use the base object as-is
|
|
631
|
+
draft = base
|
|
632
|
+
else:
|
|
633
|
+
# Unwrap observ reactive objects to get the underlying data
|
|
634
|
+
# Use observ's to_raw() function if available
|
|
635
|
+
if observ_to_raw is not None:
|
|
636
|
+
base = observ_to_raw(base)
|
|
637
|
+
|
|
638
|
+
# Create a deep copy of the base object
|
|
639
|
+
draft = copy.deepcopy(base)
|
|
640
|
+
|
|
641
|
+
# Create a patch recorder
|
|
642
|
+
recorder = PatchRecorder()
|
|
643
|
+
|
|
644
|
+
# Wrap the draft in a proxy using duck typing (similar to diff())
|
|
645
|
+
# This allows compatibility with observ reactive objects and other proxies
|
|
646
|
+
path = Pointer()
|
|
647
|
+
if hasattr(draft, "keys"): # dict-like
|
|
648
|
+
proxy = DictProxy(draft, recorder, path)
|
|
649
|
+
elif hasattr(draft, "append"): # list-like
|
|
650
|
+
proxy = ListProxy(draft, recorder, path)
|
|
651
|
+
elif hasattr(draft, "add"): # set-like
|
|
652
|
+
proxy = SetProxy(draft, recorder, path)
|
|
653
|
+
else:
|
|
654
|
+
raise TypeError(f"Unsupported type for produce: {type(draft)}")
|
|
655
|
+
|
|
656
|
+
# Call the recipe function with the proxy
|
|
657
|
+
recipe(proxy)
|
|
658
|
+
|
|
659
|
+
# Return the mutated draft and the patches
|
|
660
|
+
return draft, recorder.patches, recorder.reverse_patches
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: patchdiff
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.8
|
|
4
4
|
Summary: MIT
|
|
5
5
|
Project-URL: Homepage, https://github.com/fork-tongue/patchdiff
|
|
6
6
|
Author-email: Korijn van Golen <korijn@gmail.com>, Berend Klein Haneveld <berendkleinhaneveld@gmail.com>
|
|
7
|
-
Requires-Python: >=3.
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
8
|
Description-Content-Type: text/markdown
|
|
9
9
|
|
|
10
10
|
[](https://badge.fury.io/py/patchdiff)
|
|
@@ -52,3 +52,54 @@ print(to_json(ops, indent=4))
|
|
|
52
52
|
# }
|
|
53
53
|
# ]
|
|
54
54
|
```
|
|
55
|
+
|
|
56
|
+
## Proxy-based patch generation
|
|
57
|
+
|
|
58
|
+
For better performance, `produce()` can be used which generates patches by tracking mutations on a proxy object (inspired by [Immer](https://immerjs.github.io/immer/produce)):
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from patchdiff import produce
|
|
62
|
+
|
|
63
|
+
base = {"count": 0, "items": [1, 2, 3]}
|
|
64
|
+
|
|
65
|
+
def recipe(draft):
|
|
66
|
+
"""Mutate the draft object - changes are tracked automatically."""
|
|
67
|
+
draft["count"] = 5
|
|
68
|
+
draft["items"].append(4)
|
|
69
|
+
draft["new_field"] = "hello"
|
|
70
|
+
|
|
71
|
+
result, patches, reverse_patches = produce(base, recipe)
|
|
72
|
+
|
|
73
|
+
# base is unchanged (immutable by default)
|
|
74
|
+
assert base == {"count": 0, "items": [1, 2, 3]}
|
|
75
|
+
|
|
76
|
+
# result contains the changes
|
|
77
|
+
assert result == {"count": 5, "items": [1, 2, 3, 4], "new_field": "hello"}
|
|
78
|
+
|
|
79
|
+
# patches describe what changed
|
|
80
|
+
print(patches)
|
|
81
|
+
# [
|
|
82
|
+
# {"op": "replace", "path": "/count", "value": 5},
|
|
83
|
+
# {"op": "add", "path": "/items/-", "value": 4},
|
|
84
|
+
# {"op": "add", "path": "/new_field", "value": "hello"}
|
|
85
|
+
# ]
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
When immutability is not needed, it is possible to apply the ops directly, improving performance even further by not having to make a `deepcopy` of the given state.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from observ import reactive
|
|
92
|
+
from patchdiff import produce
|
|
93
|
+
|
|
94
|
+
state = reactive({"count": 0})
|
|
95
|
+
|
|
96
|
+
# Mutate in place and get patches for undo/redo
|
|
97
|
+
result, patches, reverse = produce(
|
|
98
|
+
state,
|
|
99
|
+
lambda draft: draft.update({"count": 5}),
|
|
100
|
+
in_place=True,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
assert result is state # Same object
|
|
104
|
+
assert state["count"] == 5 # State was mutated, watchers triggered
|
|
105
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
patchdiff/__init__.py,sha256=15flrOoL84AMf5VVQr1PVunxuZNLeRTfClXzJJgFOx0,192
|
|
2
|
+
patchdiff/apply.py,sha256=zxjJ4sCivcy4WOZulQGfv2iduVeY0xnjlvZJkHIZhtY,1405
|
|
3
|
+
patchdiff/diff.py,sha256=-Aj-8NGE1HRoLxVYNK1dFofACaY3fCCsIJo6ypkrl5U,6827
|
|
4
|
+
patchdiff/pointer.py,sha256=2h5pWNin_TUhfDaib4UpYvuUBFT4WXAOmytYO0qsFWU,1777
|
|
5
|
+
patchdiff/produce.py,sha256=SamyDqbCAXC_6heSCmL-jMKLt1kWzBIrGaSMBlc3q8g,24318
|
|
6
|
+
patchdiff/serialize.py,sha256=N0S9e0P49TBMb7ghM--h13MsF59ybiscjZ_auAErTq8,295
|
|
7
|
+
patchdiff/types.py,sha256=BVKXOl3tnQOOml3VI_epTrLn79agi6sD5vNr2acC-yE,77
|
|
8
|
+
patchdiff-0.3.8.dist-info/METADATA,sha256=cKrY_-pLivR7Rpg3pGQC1i8LbQSFONtTHbdK5rXJ8-o,3130
|
|
9
|
+
patchdiff-0.3.8.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
10
|
+
patchdiff-0.3.8.dist-info/RECORD,,
|
patchdiff-0.3.6.dist-info/RECORD
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
patchdiff/__init__.py,sha256=uQAwiWM9rTop0zZC6Spn0y_v74cfAzuhCxcEFOrAaXY,110
|
|
2
|
-
patchdiff/apply.py,sha256=25TfOTZTYFIjy70mBus5yCzmbJRawbguDffv_4yHjqE,1366
|
|
3
|
-
patchdiff/diff.py,sha256=JPERnHGR0oyV_E-Q4GDnjrg2dfofCrGcfrrJbNfd50c,6489
|
|
4
|
-
patchdiff/pointer.py,sha256=5bcOYUGPJ-bo1dEWBcQpXVsjhV46EJtDyG3RPw0VwTI,1812
|
|
5
|
-
patchdiff/serialize.py,sha256=N0S9e0P49TBMb7ghM--h13MsF59ybiscjZ_auAErTq8,295
|
|
6
|
-
patchdiff/types.py,sha256=BVKXOl3tnQOOml3VI_epTrLn79agi6sD5vNr2acC-yE,77
|
|
7
|
-
patchdiff-0.3.6.dist-info/METADATA,sha256=Xw0FpdVK-G5SZTtfQv_J3cj4AnySJt5A_OF7thAT0tU,1634
|
|
8
|
-
patchdiff-0.3.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
9
|
-
patchdiff-0.3.6.dist-info/RECORD,,
|
|
File without changes
|