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/patchdiff/produce.py b/patchdiff/produce.py index f4cb365..e37b085 100644 --- a/patchdiff/produce.py +++ b/patchdiff/produce.py @@ -98,6 +98,9 @@ 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): self._data = data self._recorder = recorder @@ -217,6 +220,21 @@ 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 + # Add simple reader methods to DictProxy _add_reader_methods( @@ -226,18 +244,32 @@ 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__", + "__eq__", + "__ne__", + "__or__", + "__ror__", ], ) +# Skipped dict methods: +# - fromkeys: classmethod, not relevant for proxy instances +# - __class_getitem__: typing support (dict[str, int]), not relevant for instances +# - __lt__, __le__, __gt__, __ge__: dicts don't support ordering comparisons 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): self._data = data self._recorder = recorder @@ -466,6 +498,31 @@ 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 + # Add simple reader methods to ListProxy _add_reader_methods( @@ -474,18 +531,34 @@ 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__", + "__eq__", + "__ne__", + "__lt__", + "__le__", + "__gt__", + "__ge__", + "__add__", + "__mul__", + "__rmul__", ], ) +# Skipped list methods: +# - __class_getitem__: typing support (list[int]), not relevant for instances 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): self._data = data self._recorder = recorder @@ -564,6 +637,31 @@ 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) + # Add simple reader methods to SetProxy _add_reader_methods( @@ -581,8 +679,26 @@ def __ixor__(self, other): "issubset", "issuperset", "copy", + "__str__", + "__format__", + "__eq__", + "__ne__", + "__le__", + "__lt__", + "__ge__", + "__gt__", + "__or__", + "__ror__", + "__and__", + "__rand__", + "__sub__", + "__rsub__", + "__xor__", + "__rxor__", ], ) +# Skipped set methods: +# - __class_getitem__: typing support (set[int]), not relevant for instances def produce( diff --git a/tests/test_apply.py b/tests/test_apply.py index d38c63a..a5df072 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -34,6 +34,29 @@ 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, + } + assert a == b + + ops, rops = diff(a, b) + + assert not ops + assert not rops + + c = apply(a, ops) + assert c == b + + d = apply(b, rops) + assert a == d + + def test_add_remove_list(): a = [] b = [1] diff --git a/tests/test_produce_core.py b/tests/test_produce_core.py index 9dff110..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): @@ -793,3 +794,78 @@ 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. + +# 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) + + +# 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]) +} + + +class TestProxyApiCompleteness: + """Verify proxy classes implement all methods of their base types.""" + + def test_dict_proxy_api_completeness(self): + 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." + ) + + def test_list_proxy_api_completeness(self): + 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." + ) + + def test_set_proxy_api_completeness(self): + 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." + ) diff --git a/tests/test_produce_dict.py b/tests/test_produce_dict.py index f785a82..f81504c 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 = {} @@ -567,6 +601,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..cd11301 100644 --- a/tests/test_produce_list.py +++ b/tests/test_produce_list.py @@ -982,3 +982,160 @@ 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_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] + + def recipe(draft): + new = draft + [3, 4] # noqa: RUF005 + 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..89e8208 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)