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 CHANGED
@@ -1,5 +1,8 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("patchdiff")
4
+
1
5
  from .apply import apply, iapply
2
6
  from .diff import diff
7
+ from .produce import produce
3
8
  from .serialize import to_json
4
-
5
- __version__ = "0.3.4"
patchdiff/apply.py CHANGED
@@ -6,6 +6,8 @@ from .types import Diffable
6
6
 
7
7
  def iapply(obj: Diffable, patches: List[Dict]) -> Diffable:
8
8
  """Apply a set of patches to an object, in-place"""
9
+ if not patches:
10
+ return obj
9
11
  for patch in patches:
10
12
  ptr = patch["path"]
11
13
  op = patch["op"]
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
- for key in input_keys - output_keys:
142
- ops.append({"op": "remove", "path": ptr.append(key)})
143
- rops.insert(0, {"op": "add", "path": ptr.append(key), "value": input[key]})
144
- for key in output_keys - input_keys:
145
- ops.append(
146
- {
147
- "op": "add",
148
- "path": ptr.append(key),
149
- "value": output[key],
150
- }
151
- )
152
- rops.insert(0, {"op": "remove", "path": ptr.append(key)})
153
- for key in input_keys & output_keys:
154
- key_ops, key_rops = diff(input[key], output[key], ptr.append(key))
155
- ops.extend(key_ops)
156
- key_rops.extend(rops)
157
- rops = key_rops
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
- for value in input - output:
164
- ops.append({"op": "remove", "path": ptr.append(value)})
165
- rops.insert(0, {"op": "add", "path": ptr.append("-"), "value": value})
166
- for value in output - input:
167
- ops.append({"op": "add", "path": ptr.append("-"), "value": value})
168
- rops.insert(0, {"op": "remove", "path": ptr.append(value)})
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, Tuple
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: "Pointer") -> bool:
43
- if not isinstance(other, self.__class__):
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) -> Tuple[Diffable, Hashable, Any]:
49
+ def evaluate(self, obj: Diffable) -> tuple[Diffable, Hashable, Any]:
48
50
  key = ""
49
51
  parent = None
50
52
  cursor = obj
51
- for key in self.tokens:
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
- cursor = parent[key]
60
- except KeyError:
61
- break
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.6
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.8
7
+ Requires-Python: >=3.9
8
8
  Description-Content-Type: text/markdown
9
9
 
10
10
  [![PyPI version](https://badge.fury.io/py/patchdiff.svg)](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,,
@@ -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,,