From e33cf4404d14fdafb0ed013c87fb06c53b905568 Mon Sep 17 00:00:00 2001 From: Berend Klein Haneveld Date: Mon, 16 Mar 2026 21:41:18 +0100 Subject: [PATCH 01/11] Fix proxy missing methods: proxied iterators, operators, and comparisons DictProxy: - values() and items() now return proxied nested objects (was broken: iterating values/items of dicts with nested containers didn't track mutations) - Add __ior__ (|=), __or__ (|), __ror__, __eq__, __ne__, __bool__ ListProxy: - __iter__ and __reversed__ now return proxied nested objects (was broken: for-loop over lists with nested containers didn't track mutations) - Add __iadd__ (+=), __imul__ (*=), __add__ (+), __radd__, __mul__ (*), __rmul__, __eq__, __ne__, __lt__, __le__, __gt__, __ge__, __bool__ SetProxy: - Add difference_update, intersection_update, symmetric_difference_update - Add __or__ (|), __ror__, __and__ (&), __rand__, __sub__ (-), __rsub__, __xor__ (^), __rxor__, __eq__, __ne__, __le__, __lt__, __ge__, __gt__, __bool__ All proxies: add __str__ and __format__ as reader pass-throughs. Add comments documenting skipped methods and why. Co-Authored-By: Claude Opus 4.6 --- patchdiff/produce.py | 198 ++++++++++++++++++++++++++++++++++++- tests/test_produce_dict.py | 92 +++++++++++++++++ tests/test_produce_list.py | 144 +++++++++++++++++++++++++++ tests/test_produce_set.py | 145 +++++++++++++++++++++++++++ 4 files changed, 575 insertions(+), 4 deletions(-) diff --git a/patchdiff/produce.py b/patchdiff/produce.py index f4cb365..e20cb5f 100644 --- a/patchdiff/produce.py +++ b/patchdiff/produce.py @@ -217,6 +217,38 @@ def popitem(self): del self._proxies[key] return key, value + def values(self): + """Return proxied values so nested mutations are tracked.""" + for key in self._data: + yield self._wrap(key, self._data[key]) + + def items(self): + """Return (key, proxied_value) pairs so nested mutations are tracked.""" + for key in self._data: + yield key, self._wrap(key, self._data[key]) + + def __ior__(self, other): + """Implement |= operator (merge update).""" + self.update(other) + return self + + def __or__(self, other): + """Implement | operator (merge), returns a new dict.""" + return self._data | other + + def __ror__(self, other): + """Implement reverse | operator, returns a new dict.""" + return other | self._data + + def __eq__(self, other): + return self._data == other + + def __ne__(self, other): + return self._data != other + + def __bool__(self): + return bool(self._data) + # Add simple reader methods to DictProxy _add_reader_methods( @@ -226,13 +258,21 @@ def popitem(self): "__contains__", "__repr__", "__iter__", + # __reversed__ returns keys (not values), so pass-through is fine "__reversed__", "keys", - "values", - "items", + # values() and items() are implemented as custom methods above + # to return proxied nested objects "copy", + "__str__", + "__format__", ], ) +# Skipped dict methods: +# - fromkeys: classmethod, not relevant for proxy instances +# - __class_getitem__: typing support (dict[str, int]), not relevant for instances +# - __hash__: dicts are unhashable, same for proxy +# - __lt__, __le__, __gt__, __ge__: dicts don't support ordering comparisons class ListProxy: @@ -466,6 +506,68 @@ def sort(self, *args, **kwargs) -> None: # Invalidate all proxy caches as positions changed self._proxies.clear() + def __iter__(self): + """Iterate over list elements, wrapping nested structures in proxies.""" + for i in range(len(self._data)): + yield self._wrap(i, self._data[i]) + + def __reversed__(self): + """Iterate in reverse, wrapping nested structures in proxies.""" + for i in range(len(self._data) - 1, -1, -1): + yield self._wrap(i, self._data[i]) + + def __iadd__(self, other): + """Implement += operator (in-place extend).""" + self.extend(other) + return self + + def __imul__(self, n): + """Implement *= operator (in-place repeat).""" + if n <= 0: + self.clear() + elif n > 1: + original = list(self._data) + for _ in range(n - 1): + self.extend(original) + return self + + def __add__(self, other): + """Implement + operator, returns a new list.""" + return self._data + other + + def __radd__(self, other): + """Implement reverse + operator, returns a new list.""" + return other + self._data + + def __mul__(self, n): + """Implement * operator, returns a new list.""" + return self._data * n + + def __rmul__(self, n): + """Implement reverse * operator, returns a new list.""" + return self._data * n + + def __eq__(self, other): + return self._data == other + + def __ne__(self, other): + return self._data != other + + def __lt__(self, other): + return self._data < other + + def __le__(self, other): + return self._data <= other + + def __gt__(self, other): + return self._data > other + + def __ge__(self, other): + return self._data >= other + + def __bool__(self): + return bool(self._data) + # Add simple reader methods to ListProxy _add_reader_methods( @@ -474,13 +576,18 @@ def sort(self, *args, **kwargs) -> None: "__len__", "__contains__", "__repr__", - "__iter__", - "__reversed__", + # __iter__ and __reversed__ are implemented as custom methods above + # to return proxied nested objects "index", "count", "copy", + "__str__", + "__format__", ], ) +# Skipped list methods: +# - __class_getitem__: typing support (list[int]), not relevant for instances +# - __hash__: lists are unhashable, same for proxy class SetProxy: @@ -564,6 +671,84 @@ def __ixor__(self, other): self.add(value) return self + def difference_update(self, *others): + """Remove all elements found in others.""" + for other in others: + for value in other: + if value in self._data: + self.remove(value) + + def intersection_update(self, *others): + """Keep only elements found in all others.""" + # Compute the intersection first, then remove what's not in it + keep = self._data.copy() + for other in others: + keep &= set(other) + values_to_remove = [v for v in self._data if v not in keep] + for value in values_to_remove: + self.remove(value) + + def symmetric_difference_update(self, other): + """Update with symmetric difference.""" + for value in other: + if value in self._data: + self.remove(value) + else: + self.add(value) + + def __or__(self, other): + """Implement | operator (union), returns a new set.""" + return self._data | other + + def __ror__(self, other): + """Implement reverse | operator, returns a new set.""" + return other | self._data + + def __and__(self, other): + """Implement & operator (intersection), returns a new set.""" + return self._data & other + + def __rand__(self, other): + """Implement reverse & operator, returns a new set.""" + return other & self._data + + def __sub__(self, other): + """Implement - operator (difference), returns a new set.""" + return self._data - other + + def __rsub__(self, other): + """Implement reverse - operator, returns a new set.""" + return other - self._data + + def __xor__(self, other): + """Implement ^ operator (symmetric difference), returns a new set.""" + return self._data ^ other + + def __rxor__(self, other): + """Implement reverse ^ operator, returns a new set.""" + return other ^ self._data + + def __eq__(self, other): + return self._data == other + + def __ne__(self, other): + return self._data != other + + def __le__(self, other): + return self._data <= other + + def __lt__(self, other): + return self._data < other + + def __ge__(self, other): + return self._data >= other + + def __gt__(self, other): + return self._data > other + + def __bool__(self): + return bool(self._data) + # Add simple reader methods to SetProxy _add_reader_methods( @@ -581,8 +766,13 @@ def __ixor__(self, other): "issubset", "issuperset", "copy", + "__str__", + "__format__", ], ) +# Skipped set methods: +# - __class_getitem__: typing support (set[int]), not relevant for instances +# - __hash__: sets are unhashable, same for proxy def produce( diff --git a/tests/test_produce_dict.py b/tests/test_produce_dict.py index f785a82..0b80496 100644 --- a/tests/test_produce_dict.py +++ b/tests/test_produce_dict.py @@ -567,6 +567,98 @@ def recipe(draft): assert result == {"a": 1} +def test_dict_values_returns_proxied_nested(): + """Test that values() returns proxied nested objects.""" + base = {"a": {"x": 1}, "b": {"x": 2}} + + def recipe(draft): + for v in draft.values(): + v["x"] = 99 + + result, patches, _reverse = produce(base, recipe) + + assert result == {"a": {"x": 99}, "b": {"x": 99}} + assert len(patches) == 2 + + +def test_dict_items_returns_proxied_nested(): + """Test that items() returns proxied nested objects.""" + base = {"a": {"x": 1}, "b": {"x": 2}} + + def recipe(draft): + for k, v in draft.items(): + v["x"] = 99 + + result, patches, _reverse = produce(base, recipe) + + assert result == {"a": {"x": 99}, "b": {"x": 99}} + assert len(patches) == 2 + + +def test_dict_ior_operator(): + """Test |= operator (merge update) on dict proxy.""" + base = {"a": 1} + + def recipe(draft): + draft |= {"b": 2, "c": 3} + + result, patches, _reverse = produce(base, recipe) + + assert result == {"a": 1, "b": 2, "c": 3} + assert len(patches) == 2 + + +def test_dict_or_operator(): + """Test | operator (merge) on dict proxy returns new dict.""" + base = {"a": 1} + + def recipe(draft): + merged = draft | {"b": 2} + assert isinstance(merged, dict) + assert merged == {"a": 1, "b": 2} + + result, patches, _reverse = produce(base, recipe) + + assert patches == [] # No mutations to draft + + +def test_dict_eq(): + """Test __eq__ on dict proxy.""" + base = {"a": 1, "b": 2} + + def recipe(draft): + assert draft == {"a": 1, "b": 2} + assert not (draft == {"a": 1}) + + produce(base, recipe) + + +def test_dict_ne(): + """Test __ne__ on dict proxy.""" + base = {"a": 1} + + def recipe(draft): + assert draft != {"b": 2} + assert not (draft != {"a": 1}) + + produce(base, recipe) + + +def test_dict_bool(): + """Test __bool__ on dict proxy.""" + base_empty = {} + base_full = {"a": 1} + + def recipe_empty(draft): + assert not draft + + def recipe_full(draft): + assert draft + + produce(base_empty, recipe_empty) + produce(base_full, recipe_full) + + def test_dict_get_none_explicit(): """Test get() with explicit None default.""" base = {"a": 1} diff --git a/tests/test_produce_list.py b/tests/test_produce_list.py index 6e455e9..946e92e 100644 --- a/tests/test_produce_list.py +++ b/tests/test_produce_list.py @@ -982,3 +982,147 @@ def recipe(draft): result, _patches, _reverse = produce(base, recipe) assert result == [] + + +def test_list_iter_returns_proxied_nested(): + """Test that __iter__ returns proxied nested objects.""" + base = [{"x": 1}, {"x": 2}] + + def recipe(draft): + for item in draft: + item["x"] = 99 + + result, patches, _reverse = produce(base, recipe) + + assert result == [{"x": 99}, {"x": 99}] + assert len(patches) == 2 + + +def test_list_reversed_returns_proxied_nested(): + """Test that __reversed__ returns proxied nested objects.""" + base = [{"x": 1}, {"x": 2}] + + def recipe(draft): + for item in reversed(draft): + item["x"] = 99 + + result, patches, _reverse = produce(base, recipe) + + assert result == [{"x": 99}, {"x": 99}] + assert len(patches) == 2 + + +def test_list_iadd_operator(): + """Test += operator (in-place add, like extend) on list proxy.""" + base = [1, 2] + + def recipe(draft): + draft += [3, 4] + + result, patches, _reverse = produce(base, recipe) + + assert result == [1, 2, 3, 4] + assert len(patches) == 2 + + +def test_list_imul_operator(): + """Test *= operator (in-place repeat) on list proxy.""" + base = [1, 2] + + def recipe(draft): + draft *= 3 + + result, patches, _reverse = produce(base, recipe) + + assert result == [1, 2, 1, 2, 1, 2] + assert len(patches) == 4 # 4 new elements added + + +def test_list_add_operator(): + """Test + operator returns new list, not a proxy.""" + base = [1, 2] + + def recipe(draft): + new = draft + [3, 4] + assert isinstance(new, list) + assert new == [1, 2, 3, 4] + + result, patches, _reverse = produce(base, recipe) + + assert patches == [] + + +def test_list_mul_operator(): + """Test * operator returns new list, not a proxy.""" + base = [1, 2] + + def recipe(draft): + new = draft * 3 + assert isinstance(new, list) + assert new == [1, 2, 1, 2, 1, 2] + + result, patches, _reverse = produce(base, recipe) + + assert patches == [] + + +def test_list_rmul_operator(): + """Test reverse * operator (int * list) returns new list.""" + base = [1, 2] + + def recipe(draft): + new = 3 * draft + assert isinstance(new, list) + assert new == [1, 2, 1, 2, 1, 2] + + result, patches, _reverse = produce(base, recipe) + + assert patches == [] + + +def test_list_eq(): + """Test __eq__ on list proxy.""" + base = [1, 2, 3] + + def recipe(draft): + assert draft == [1, 2, 3] + assert not (draft == [1, 2]) + + produce(base, recipe) + + +def test_list_ne(): + """Test __ne__ on list proxy.""" + base = [1, 2, 3] + + def recipe(draft): + assert draft != [1, 2] + assert not (draft != [1, 2, 3]) + + produce(base, recipe) + + +def test_list_bool(): + """Test __bool__ on list proxy.""" + + def recipe_empty(draft): + assert not draft + + def recipe_full(draft): + assert draft + + produce([], recipe_empty) + produce([1], recipe_full) + + +def test_list_lt_le_gt_ge(): + """Test comparison operators on list proxy.""" + base = [1, 2, 3] + + def recipe(draft): + assert draft < [1, 2, 4] + assert draft <= [1, 2, 3] + assert draft > [1, 2, 2] + assert draft >= [1, 2, 3] + + produce(base, recipe) diff --git a/tests/test_produce_set.py b/tests/test_produce_set.py index dda53b6..c34c0f6 100644 --- a/tests/test_produce_set.py +++ b/tests/test_produce_set.py @@ -535,3 +535,148 @@ def recipe(draft): result, _patches, _reverse = produce(base, recipe) assert result == base + + +def test_set_difference_update_method(): + """Test difference_update() method on set proxy.""" + base = {1, 2, 3, 4} + + def recipe(draft): + draft.difference_update({2, 4}) + + result, patches, _reverse = produce(base, recipe) + + assert result == {1, 3} + assert len(patches) == 2 + + +def test_set_intersection_update_method(): + """Test intersection_update() method on set proxy.""" + base = {1, 2, 3, 4} + + def recipe(draft): + draft.intersection_update({2, 3, 5}) + + result, patches, _reverse = produce(base, recipe) + + assert result == {2, 3} + assert len(patches) == 2 # Removed 1 and 4 + + +def test_set_symmetric_difference_update_method(): + """Test symmetric_difference_update() method on set proxy.""" + base = {1, 2, 3} + + def recipe(draft): + draft.symmetric_difference_update({2, 3, 4}) + + result, patches, _reverse = produce(base, recipe) + + assert result == {1, 4} + assert len(patches) == 3 # Removed 2, 3, added 4 + + +def test_set_or_operator(): + """Test | operator (union) returns new set, not a proxy.""" + base = {1, 2, 3} + + def recipe(draft): + new = draft | {3, 4, 5} + assert isinstance(new, set) + assert new == {1, 2, 3, 4, 5} + + result, patches, _reverse = produce(base, recipe) + + assert patches == [] + + +def test_set_and_operator(): + """Test & operator (intersection) returns new set.""" + base = {1, 2, 3} + + def recipe(draft): + new = draft & {2, 3, 4} + assert isinstance(new, set) + assert new == {2, 3} + + result, patches, _reverse = produce(base, recipe) + + assert patches == [] + + +def test_set_sub_operator(): + """Test - operator (difference) returns new set.""" + base = {1, 2, 3} + + def recipe(draft): + new = draft - {2, 4} + assert isinstance(new, set) + assert new == {1, 3} + + result, patches, _reverse = produce(base, recipe) + + assert patches == [] + + +def test_set_xor_operator(): + """Test ^ operator (symmetric difference) returns new set.""" + base = {1, 2, 3} + + def recipe(draft): + new = draft ^ {2, 3, 4} + assert isinstance(new, set) + assert new == {1, 4} + + result, patches, _reverse = produce(base, recipe) + + assert patches == [] + + +def test_set_eq(): + """Test __eq__ on set proxy.""" + base = {1, 2, 3} + + def recipe(draft): + assert draft == {1, 2, 3} + assert not (draft == {1, 2}) + + produce(base, recipe) + + +def test_set_ne(): + """Test __ne__ on set proxy.""" + base = {1, 2, 3} + + def recipe(draft): + assert draft != {1, 2} + assert not (draft != {1, 2, 3}) + + produce(base, recipe) + + +def test_set_bool(): + """Test __bool__ on set proxy.""" + + def recipe_empty(draft): + assert not draft + + def recipe_full(draft): + assert draft + + produce(set(), recipe_empty) + produce({1}, recipe_full) + + +def test_set_le_lt_ge_gt(): + """Test comparison operators on set proxy (subset/superset).""" + base = {1, 2, 3} + + def recipe(draft): + assert draft <= {1, 2, 3, 4} # subset + assert draft <= {1, 2, 3} # equal is also <= + assert draft < {1, 2, 3, 4} # proper subset + assert not (draft < {1, 2, 3}) # not proper subset of equal + assert draft >= {1, 2} # superset + assert draft > {1, 2} # proper superset + + produce(base, recipe) From 42df436efd3e128e68484ae2762b2f092cb39194 Mon Sep 17 00:00:00 2001 From: Berend Klein Haneveld Date: Mon, 16 Mar 2026 21:56:28 +0100 Subject: [PATCH 02/11] Move dunders to _add_reader_methods, remove __bool__ __eq__, __ne__, and comparison dunders work fine via _add_reader_methods since it uses setattr on the class. __bool__ is not needed since Python falls back to __len__ for truthiness, and __len__ is already proxied. Co-Authored-By: Claude Opus 4.6 --- patchdiff/produce.py | 65 ++++++++++---------------------------------- 1 file changed, 14 insertions(+), 51 deletions(-) diff --git a/patchdiff/produce.py b/patchdiff/produce.py index e20cb5f..31e8338 100644 --- a/patchdiff/produce.py +++ b/patchdiff/produce.py @@ -240,15 +240,6 @@ def __ror__(self, other): """Implement reverse | operator, returns a new dict.""" return other | self._data - def __eq__(self, other): - return self._data == other - - def __ne__(self, other): - return self._data != other - - def __bool__(self): - return bool(self._data) - # Add simple reader methods to DictProxy _add_reader_methods( @@ -266,6 +257,8 @@ def __bool__(self): "copy", "__str__", "__format__", + "__eq__", + "__ne__", ], ) # Skipped dict methods: @@ -547,27 +540,6 @@ def __rmul__(self, n): """Implement reverse * operator, returns a new list.""" return self._data * n - def __eq__(self, other): - return self._data == other - - def __ne__(self, other): - return self._data != other - - def __lt__(self, other): - return self._data < other - - def __le__(self, other): - return self._data <= other - - def __gt__(self, other): - return self._data > other - - def __ge__(self, other): - return self._data >= other - - def __bool__(self): - return bool(self._data) - # Add simple reader methods to ListProxy _add_reader_methods( @@ -583,6 +555,12 @@ def __bool__(self): "copy", "__str__", "__format__", + "__eq__", + "__ne__", + "__lt__", + "__le__", + "__gt__", + "__ge__", ], ) # Skipped list methods: @@ -728,27 +706,6 @@ def __rxor__(self, other): """Implement reverse ^ operator, returns a new set.""" return other ^ self._data - def __eq__(self, other): - return self._data == other - - def __ne__(self, other): - return self._data != other - - def __le__(self, other): - return self._data <= other - - def __lt__(self, other): - return self._data < other - - def __ge__(self, other): - return self._data >= other - - def __gt__(self, other): - return self._data > other - - def __bool__(self): - return bool(self._data) - # Add simple reader methods to SetProxy _add_reader_methods( @@ -768,6 +725,12 @@ def __bool__(self): "copy", "__str__", "__format__", + "__eq__", + "__ne__", + "__le__", + "__lt__", + "__ge__", + "__gt__", ], ) # Skipped set methods: From b49072ae5e98d8a6f0398a8efa9584295a7cefa6 Mon Sep 17 00:00:00 2001 From: Berend Klein Haneveld Date: Mon, 16 Mar 2026 22:05:37 +0100 Subject: [PATCH 03/11] Fix proxies being incorrectly hashable, remove spurious __radd__ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since __eq__ is added via setattr (not in the class body), Python doesn't automatically set __hash__ = None. Set it explicitly on all three proxy classes so they're unhashable like the types they proxy. Also remove __radd__ from ListProxy — list doesn't support reverse add. Co-Authored-By: Claude Opus 4.6 --- patchdiff/produce.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/patchdiff/produce.py b/patchdiff/produce.py index 31e8338..19cfc75 100644 --- a/patchdiff/produce.py +++ b/patchdiff/produce.py @@ -98,6 +98,8 @@ def record_replace(self, path: Pointer, old_value: Any, new_value: Any) -> None: class DictProxy: """Proxy for dict objects that tracks mutations and generates patches.""" + __hash__ = None # dicts are unhashable + def __init__(self, data: Dict, recorder: PatchRecorder, path: Pointer): self._data = data self._recorder = recorder @@ -264,13 +266,14 @@ def __ror__(self, other): # Skipped dict methods: # - fromkeys: classmethod, not relevant for proxy instances # - __class_getitem__: typing support (dict[str, int]), not relevant for instances -# - __hash__: dicts are unhashable, same for proxy # - __lt__, __le__, __gt__, __ge__: dicts don't support ordering comparisons class ListProxy: """Proxy for list objects that tracks mutations and generates patches.""" + __hash__ = None # lists are unhashable + def __init__(self, data: List, recorder: PatchRecorder, path: Pointer): self._data = data self._recorder = recorder @@ -528,10 +531,6 @@ def __add__(self, other): """Implement + operator, returns a new list.""" return self._data + other - def __radd__(self, other): - """Implement reverse + operator, returns a new list.""" - return other + self._data - def __mul__(self, n): """Implement * operator, returns a new list.""" return self._data * n @@ -565,12 +564,13 @@ def __rmul__(self, n): ) # Skipped list methods: # - __class_getitem__: typing support (list[int]), not relevant for instances -# - __hash__: lists are unhashable, same for proxy class SetProxy: """Proxy for set objects that tracks mutations and generates patches.""" + __hash__ = None # sets are unhashable + def __init__(self, data: Set, recorder: PatchRecorder, path: Pointer): self._data = data self._recorder = recorder @@ -735,7 +735,6 @@ def __rxor__(self, other): ) # Skipped set methods: # - __class_getitem__: typing support (set[int]), not relevant for instances -# - __hash__: sets are unhashable, same for proxy def produce( From 1cce14ee280c051a801c44161e352abb15323ab4 Mon Sep 17 00:00:00 2001 From: Berend Klein Haneveld Date: Mon, 16 Mar 2026 22:08:14 +0100 Subject: [PATCH 04/11] Move non-mutating operator dunders to _add_reader_methods Replace explicit one-liner delegations with _add_reader_methods entries: - DictProxy: __or__, __ror__ - ListProxy: __add__, __mul__, __rmul__ - SetProxy: __or__, __ror__, __and__, __rand__, __sub__, __rsub__, __xor__, __rxor__ Co-Authored-By: Claude Opus 4.6 --- patchdiff/produce.py | 64 +++++++++----------------------------------- 1 file changed, 13 insertions(+), 51 deletions(-) diff --git a/patchdiff/produce.py b/patchdiff/produce.py index 19cfc75..7b8ee7c 100644 --- a/patchdiff/produce.py +++ b/patchdiff/produce.py @@ -234,14 +234,6 @@ def __ior__(self, other): self.update(other) return self - def __or__(self, other): - """Implement | operator (merge), returns a new dict.""" - return self._data | other - - def __ror__(self, other): - """Implement reverse | operator, returns a new dict.""" - return other | self._data - # Add simple reader methods to DictProxy _add_reader_methods( @@ -261,6 +253,8 @@ def __ror__(self, other): "__format__", "__eq__", "__ne__", + "__or__", + "__ror__", ], ) # Skipped dict methods: @@ -527,18 +521,6 @@ def __imul__(self, n): self.extend(original) return self - def __add__(self, other): - """Implement + operator, returns a new list.""" - return self._data + other - - def __mul__(self, n): - """Implement * operator, returns a new list.""" - return self._data * n - - def __rmul__(self, n): - """Implement reverse * operator, returns a new list.""" - return self._data * n - # Add simple reader methods to ListProxy _add_reader_methods( @@ -560,6 +542,9 @@ def __rmul__(self, n): "__le__", "__gt__", "__ge__", + "__add__", + "__mul__", + "__rmul__", ], ) # Skipped list methods: @@ -674,37 +659,6 @@ def symmetric_difference_update(self, other): else: self.add(value) - def __or__(self, other): - """Implement | operator (union), returns a new set.""" - return self._data | other - - def __ror__(self, other): - """Implement reverse | operator, returns a new set.""" - return other | self._data - - def __and__(self, other): - """Implement & operator (intersection), returns a new set.""" - return self._data & other - - def __rand__(self, other): - """Implement reverse & operator, returns a new set.""" - return other & self._data - - def __sub__(self, other): - """Implement - operator (difference), returns a new set.""" - return self._data - other - - def __rsub__(self, other): - """Implement reverse - operator, returns a new set.""" - return other - self._data - - def __xor__(self, other): - """Implement ^ operator (symmetric difference), returns a new set.""" - return self._data ^ other - - def __rxor__(self, other): - """Implement reverse ^ operator, returns a new set.""" - return other ^ self._data # Add simple reader methods to SetProxy @@ -731,6 +685,14 @@ def __rxor__(self, other): "__lt__", "__ge__", "__gt__", + "__or__", + "__ror__", + "__and__", + "__rand__", + "__sub__", + "__rsub__", + "__xor__", + "__rxor__", ], ) # Skipped set methods: From 403c6b74b6cf311d340d038632d59f715a1947d7 Mon Sep 17 00:00:00 2001 From: Berend Klein Haneveld Date: Mon, 16 Mar 2026 22:19:59 +0100 Subject: [PATCH 05/11] Add tests for uncovered proxy cache invalidation and imul edge case - DictProxy.pop(): test that popping a key with a cached proxy invalidates it - DictProxy.popitem(): same for popitem - ListProxy.__imul__: test *= 0 (clears the list) produce.py is now at 100% coverage. Co-Authored-By: Claude Opus 4.6 --- tests/test_produce_dict.py | 34 ++++++++++++++++++++++++++++++++++ tests/test_produce_list.py | 13 +++++++++++++ 2 files changed, 47 insertions(+) diff --git a/tests/test_produce_dict.py b/tests/test_produce_dict.py index 0b80496..2e58670 100644 --- a/tests/test_produce_dict.py +++ b/tests/test_produce_dict.py @@ -102,6 +102,23 @@ def recipe(draft): assert patches[0]["op"] == "remove" +def test_dict_pop_invalidates_proxy_cache(): + """Test that pop() invalidates the proxy cache for nested structures.""" + base = {"nested": {"a": 1}, "other": 2} + + def recipe(draft): + # Access nested to populate the proxy cache + _ = draft["nested"]["a"] + # Pop the key that has a cached proxy + draft.pop("nested") + + result, patches, _reverse = produce(base, recipe) + + assert result == {"other": 2} + assert len(patches) == 1 + assert patches[0]["op"] == "remove" + + def test_dict_update(): """Test dict.update() operation.""" base = {"a": 1} @@ -281,6 +298,23 @@ def recipe(draft): assert patches[0]["op"] == "remove" +def test_dict_popitem_invalidates_proxy_cache(): + """Test that popitem() invalidates the proxy cache for nested structures.""" + base = {"a": {"x": 1}} + + def recipe(draft): + # Access nested to populate the proxy cache + _ = draft["a"]["x"] + # popitem removes the only key which has a cached proxy + draft.popitem() + + result, patches, _reverse = produce(base, recipe) + + assert result == {} + assert len(patches) == 1 + assert patches[0]["op"] == "remove" + + def test_dict_popitem_empty(): """Test popitem() on empty dict raises KeyError.""" base = {} diff --git a/tests/test_produce_list.py b/tests/test_produce_list.py index 946e92e..e947309 100644 --- a/tests/test_produce_list.py +++ b/tests/test_produce_list.py @@ -1038,6 +1038,19 @@ def recipe(draft): assert len(patches) == 4 # 4 new elements added +def test_list_imul_zero(): + """Test *= 0 clears the list.""" + base = [1, 2, 3] + + def recipe(draft): + draft *= 0 + + result, patches, _reverse = produce(base, recipe) + + assert result == [] + assert len(patches) == 3 # 3 elements removed + + def test_list_add_operator(): """Test + operator returns new list, not a proxy.""" base = [1, 2] From 168a60f7edfb0c89e6c04b57dd677bcd433c7ed4 Mon Sep 17 00:00:00 2001 From: Berend Klein Haneveld Date: Mon, 16 Mar 2026 22:33:41 +0100 Subject: [PATCH 06/11] Add proxy API completeness tests for forward compatibility Tests compare dir(Proxy) against dir(base_type) and fail if a method exists on the base type that isn't on the proxy and isn't in the explicit SKIPPED set. This catches new methods added in future Python versions. Skipped entries that don't apply to a given Python version are silently ignored, so the tests work across Python 3.9-3.14. Co-Authored-By: Claude Opus 4.6 --- tests/test_produce_core.py | 76 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/test_produce_core.py b/tests/test_produce_core.py index 9dff110..c63edd2 100644 --- a/tests/test_produce_core.py +++ b/tests/test_produce_core.py @@ -793,3 +793,79 @@ def recipe(draft): assert result["level1"]["sibling"][0]["a"] == 10 assert len(patches) >= 7 + + +# -- Proxy API completeness tests -- +# These tests ensure that proxy classes cover all methods of their base types. +# If a new Python version adds a method to dict/list/set, the corresponding +# test will fail. To fix it, either: +# 1. Add the method name to SKIPPED below (if it doesn't need proxying), or +# 2. Implement it on the proxy class. + +from patchdiff.produce import DictProxy, ListProxy, SetProxy + +# Methods inherited from object that are not part of the container API +_OBJECT_INTERNALS = { + "__class__", + "__delattr__", + "__dir__", + "__doc__", + "__getattribute__", + "__getstate__", + "__init__", + "__init_subclass__", + "__new__", + "__reduce__", + "__reduce_ex__", + "__setattr__", + "__sizeof__", + "__subclasshook__", +} + + +def _unhandled_methods(proxy_cls, base_cls, skipped): + """Return methods on base_cls that are missing from proxy_cls and not in skipped.""" + base_methods = set(dir(base_cls)) - _OBJECT_INTERNALS + proxy_methods = set(dir(proxy_cls)) - _OBJECT_INTERNALS + return (base_methods - proxy_methods) - set(skipped) + + +class TestProxyApiCompleteness: + """Verify proxy classes implement all methods of their base types.""" + + # Methods intentionally not implemented on the proxy classes. + # If a new Python version adds a method to dict/list/set, the test will + # fail. To fix, either implement the method or add it here. + DICT_SKIPPED = { + "fromkeys", # classmethod, not relevant for proxy instances + "__class_getitem__", # typing support (dict[str, int]) + } + + LIST_SKIPPED = { + "__class_getitem__", # typing support (list[int]) + } + + SET_SKIPPED = { + "__class_getitem__", # typing support (set[int]) + } + + def test_dict_proxy_api_completeness(self): + unhandled = _unhandled_methods(DictProxy, dict, self.DICT_SKIPPED) + assert not unhandled, ( + f"DictProxy is missing methods from dict: {sorted(unhandled)}. " + f"Either implement them on DictProxy or add to DICT_SKIPPED." + ) + + def test_list_proxy_api_completeness(self): + unhandled = _unhandled_methods(ListProxy, list, self.LIST_SKIPPED) + assert not unhandled, ( + f"ListProxy is missing methods from list: {sorted(unhandled)}. " + f"Either implement them on ListProxy or add to LIST_SKIPPED." + ) + + def test_set_proxy_api_completeness(self): + unhandled = _unhandled_methods(SetProxy, set, self.SET_SKIPPED) + assert not unhandled, ( + f"SetProxy is missing methods from set: {sorted(unhandled)}. " + f"Either implement them on SetProxy or add to SET_SKIPPED." + ) From ed764a6565315afc84ff3494ebe4cd83bb9df4b8 Mon Sep 17 00:00:00 2001 From: Berend Klein Haneveld Date: Mon, 16 Mar 2026 22:40:08 +0100 Subject: [PATCH 07/11] Fix ruff lint warnings in test files - Move DictProxy/ListProxy/SetProxy import to top of file (E402) - Convert class attributes to module-level constants (RUF012) - Prefix unused `result` variables with underscore (RUF059) - Add noqa for intentional list concatenation in operator test (RUF005) Co-Authored-By: Claude Opus 4.6 --- tests/test_produce_core.py | 46 +++++++++++++++++++------------------- tests/test_produce_dict.py | 2 +- tests/test_produce_list.py | 8 +++---- tests/test_produce_set.py | 8 +++---- 4 files changed, 32 insertions(+), 32 deletions(-) diff --git a/tests/test_produce_core.py b/tests/test_produce_core.py index c63edd2..4572b2d 100644 --- a/tests/test_produce_core.py +++ b/tests/test_produce_core.py @@ -3,6 +3,7 @@ import pytest from patchdiff import apply, produce +from patchdiff.produce import DictProxy, ListProxy, SetProxy def assert_patches_work(base, recipe): @@ -802,8 +803,6 @@ def recipe(draft): # 1. Add the method name to SKIPPED below (if it doesn't need proxying), or # 2. Implement it on the proxy class. -from patchdiff.produce import DictProxy, ListProxy, SetProxy - # Methods inherited from object that are not part of the container API _OBJECT_INTERNALS = { "__class__", @@ -830,42 +829,43 @@ def _unhandled_methods(proxy_cls, base_cls, skipped): return (base_methods - proxy_methods) - set(skipped) -class TestProxyApiCompleteness: - """Verify proxy classes implement all methods of their base types.""" +# Methods intentionally not implemented on the proxy classes. +# If a new Python version adds a method to dict/list/set, the test will +# fail. To fix, either implement the method or add it here. +_DICT_SKIPPED = { + "fromkeys", # classmethod, not relevant for proxy instances + "__class_getitem__", # typing support (dict[str, int]) +} - # Methods intentionally not implemented on the proxy classes. - # If a new Python version adds a method to dict/list/set, the test will - # fail. To fix, either implement the method or add it here. - DICT_SKIPPED = { - "fromkeys", # classmethod, not relevant for proxy instances - "__class_getitem__", # typing support (dict[str, int]) - } +_LIST_SKIPPED = { + "__class_getitem__", # typing support (list[int]) +} - LIST_SKIPPED = { - "__class_getitem__", # typing support (list[int]) - } +_SET_SKIPPED = { + "__class_getitem__", # typing support (set[int]) +} - SET_SKIPPED = { - "__class_getitem__", # typing support (set[int]) - } + +class TestProxyApiCompleteness: + """Verify proxy classes implement all methods of their base types.""" def test_dict_proxy_api_completeness(self): - unhandled = _unhandled_methods(DictProxy, dict, self.DICT_SKIPPED) + unhandled = _unhandled_methods(DictProxy, dict, _DICT_SKIPPED) assert not unhandled, ( f"DictProxy is missing methods from dict: {sorted(unhandled)}. " - f"Either implement them on DictProxy or add to DICT_SKIPPED." + f"Either implement them on DictProxy or add to _DICT_SKIPPED." ) def test_list_proxy_api_completeness(self): - unhandled = _unhandled_methods(ListProxy, list, self.LIST_SKIPPED) + unhandled = _unhandled_methods(ListProxy, list, _LIST_SKIPPED) assert not unhandled, ( f"ListProxy is missing methods from list: {sorted(unhandled)}. " - f"Either implement them on ListProxy or add to LIST_SKIPPED." + f"Either implement them on ListProxy or add to _LIST_SKIPPED." ) def test_set_proxy_api_completeness(self): - unhandled = _unhandled_methods(SetProxy, set, self.SET_SKIPPED) + unhandled = _unhandled_methods(SetProxy, set, _SET_SKIPPED) assert not unhandled, ( f"SetProxy is missing methods from set: {sorted(unhandled)}. " - f"Either implement them on SetProxy or add to SET_SKIPPED." + f"Either implement them on SetProxy or add to _SET_SKIPPED." ) diff --git a/tests/test_produce_dict.py b/tests/test_produce_dict.py index 2e58670..f81504c 100644 --- a/tests/test_produce_dict.py +++ b/tests/test_produce_dict.py @@ -651,7 +651,7 @@ def recipe(draft): assert isinstance(merged, dict) assert merged == {"a": 1, "b": 2} - result, patches, _reverse = produce(base, recipe) + _result, patches, _reverse = produce(base, recipe) assert patches == [] # No mutations to draft diff --git a/tests/test_produce_list.py b/tests/test_produce_list.py index e947309..cd11301 100644 --- a/tests/test_produce_list.py +++ b/tests/test_produce_list.py @@ -1056,11 +1056,11 @@ def test_list_add_operator(): base = [1, 2] def recipe(draft): - new = draft + [3, 4] + new = draft + [3, 4] # noqa: RUF005 assert isinstance(new, list) assert new == [1, 2, 3, 4] - result, patches, _reverse = produce(base, recipe) + _result, patches, _reverse = produce(base, recipe) assert patches == [] @@ -1074,7 +1074,7 @@ def recipe(draft): assert isinstance(new, list) assert new == [1, 2, 1, 2, 1, 2] - result, patches, _reverse = produce(base, recipe) + _result, patches, _reverse = produce(base, recipe) assert patches == [] @@ -1088,7 +1088,7 @@ def recipe(draft): assert isinstance(new, list) assert new == [1, 2, 1, 2, 1, 2] - result, patches, _reverse = produce(base, recipe) + _result, patches, _reverse = produce(base, recipe) assert patches == [] diff --git a/tests/test_produce_set.py b/tests/test_produce_set.py index c34c0f6..89e8208 100644 --- a/tests/test_produce_set.py +++ b/tests/test_produce_set.py @@ -585,7 +585,7 @@ def recipe(draft): assert isinstance(new, set) assert new == {1, 2, 3, 4, 5} - result, patches, _reverse = produce(base, recipe) + _result, patches, _reverse = produce(base, recipe) assert patches == [] @@ -599,7 +599,7 @@ def recipe(draft): assert isinstance(new, set) assert new == {2, 3} - result, patches, _reverse = produce(base, recipe) + _result, patches, _reverse = produce(base, recipe) assert patches == [] @@ -613,7 +613,7 @@ def recipe(draft): assert isinstance(new, set) assert new == {1, 3} - result, patches, _reverse = produce(base, recipe) + _result, patches, _reverse = produce(base, recipe) assert patches == [] @@ -627,7 +627,7 @@ def recipe(draft): assert isinstance(new, set) assert new == {1, 4} - result, patches, _reverse = produce(base, recipe) + _result, patches, _reverse = produce(base, recipe) assert patches == [] From 7ad87e3738519af74739b00b1ed4580ab151eb3c Mon Sep 17 00:00:00 2001 From: Berend Klein Haneveld Date: Mon, 16 Mar 2026 22:43:33 +0100 Subject: [PATCH 08/11] Format --- patchdiff/produce.py | 1 - 1 file changed, 1 deletion(-) diff --git a/patchdiff/produce.py b/patchdiff/produce.py index 7b8ee7c..e774fd8 100644 --- a/patchdiff/produce.py +++ b/patchdiff/produce.py @@ -660,7 +660,6 @@ def symmetric_difference_update(self, other): self.add(value) - # Add simple reader methods to SetProxy _add_reader_methods( SetProxy, From 2fdcbfaed8295f96dcef123429c8fd3112c28da5 Mon Sep 17 00:00:00 2001 From: Berend Klein Haneveld Date: Tue, 17 Mar 2026 09:06:32 +0100 Subject: [PATCH 09/11] Add slots to proxy classes --- patchdiff/produce.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/patchdiff/produce.py b/patchdiff/produce.py index e774fd8..e37b085 100644 --- a/patchdiff/produce.py +++ b/patchdiff/produce.py @@ -98,6 +98,7 @@ def record_replace(self, path: Pointer, old_value: Any, new_value: Any) -> None: class DictProxy: """Proxy for dict objects that tracks mutations and generates patches.""" + __slots__ = ("_data", "_path", "_proxies", "_recorder") __hash__ = None # dicts are unhashable def __init__(self, data: Dict, recorder: PatchRecorder, path: Pointer): @@ -266,6 +267,7 @@ def __ior__(self, other): class ListProxy: """Proxy for list objects that tracks mutations and generates patches.""" + __slots__ = ("_data", "_path", "_proxies", "_recorder") __hash__ = None # lists are unhashable def __init__(self, data: List, recorder: PatchRecorder, path: Pointer): @@ -554,6 +556,7 @@ def __imul__(self, n): class SetProxy: """Proxy for set objects that tracks mutations and generates patches.""" + __slots__ = ("_data", "_path", "_recorder") __hash__ = None # sets are unhashable def __init__(self, data: Set, recorder: PatchRecorder, path: Pointer): From c2c99ec4d3759567351df16407c35a3903c4d9ca Mon Sep 17 00:00:00 2001 From: Berend Klein Haneveld Date: Tue, 17 Mar 2026 09:20:49 +0100 Subject: [PATCH 10/11] Fail CI when coverage falls below 100% --- .github/workflows/ci.yml | 2 +- tests/test_apply.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5248321..c5ff2fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: - name: Format run: uv run ruff format --check - name: Test - run: uv run pytest -v --cov=patchdiff --cov-report=term-missing + run: uv run pytest -v --cov=patchdiff --cov-report=term-missing --cov-fail-under=100 build: name: Build and test wheel diff --git a/tests/test_apply.py b/tests/test_apply.py index d38c63a..a4cbfb2 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -34,6 +34,24 @@ def test_apply_list(): assert a == d +def test_apply_empty(): + a = { + "a": [5, 7, 9, {"a", "b", "c"}], + "b": 6, + } + b = { + "a": [5, 7, 9, {"a", "b", "c"}], + "b": 6, + } + ops, rops = diff(a, b) + + c = apply(a, ops) + assert c == b + + d = apply(b, rops) + assert a == d + + def test_add_remove_list(): a = [] b = [1] From 13e07cd970ee9718eeb21ca9f0da246c67e52025 Mon Sep 17 00:00:00 2001 From: Berend Klein Haneveld Date: Tue, 17 Mar 2026 09:22:31 +0100 Subject: [PATCH 11/11] Be a bit more specific in the new test that produces empty patches --- tests/test_apply.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_apply.py b/tests/test_apply.py index a4cbfb2..a5df072 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -43,8 +43,13 @@ def test_apply_empty(): "a": [5, 7, 9, {"a", "b", "c"}], "b": 6, } + assert a == b + ops, rops = diff(a, b) + assert not ops + assert not rops + c = apply(a, ops) assert c == b